Автор скрипта: moondal

[html]
<div id="episode-planner-container">Загрузка…</div>
<script>
  (function() {
    let plannerData = null;
    let container = null;
    let settingsOpen = false; // открыты ли настройки
    // Запоминаем последние выбранные эпизоды в настройках
    let lastAssignEpisodeId = null; // для "Привязка эпизода к персонажу"
    let lastOrderEpisodeId = null; // для "Порядок игроков"
    function sendMessage(msg) {
      window.parent.postMessage(msg, '*');
    }

    function requestData() {
      sendMessage({
        type: 'getPlannerData'
      });
    }
    // Определяем, мой ли сейчас ход, с учётом персонажей и очереди 3+
    function computeMyTurn(topic, cfg, userId, username) {
      const lastName = (topic.last_username || '').trim();
      const lastLower = lastName.toLowerCase();
      // Собираем множество "моих имён"
      const myNames = new Set();
      if (username) myNames.add(username.trim().toLowerCase());
      if (Array.isArray(cfg.characters)) {
        cfg.characters.forEach(c => {
          if (c && c.name) {
            myNames.add(String(c.name).trim().toLowerCase());
          }
        });
      }
      const amLast = (topic.last_user_id == userId) || myNames.has(lastLower);
      // Если есть очередь 3+ для эпизода
      const order = cfg.orders && cfg.orders[topic.id];
      if (order && Array.isArray(order) && order.length > 1) {
        const orderLower = order
          .map(n => String(n || '').trim().toLowerCase())
          .filter(Boolean);
        if (!orderLower.length) {
          return !amLast;
        }
        const idx = orderLower.indexOf(lastLower);
        if (idx === -1) {
          // если имя последнего не в очереди — fallback по "кто последний"
          return !amLast;
        }
        const next = orderLower[(idx + 1) % orderLower.length];
        return next ? myNames.has(next) : false;
      }
      // Обычный случай 2-х игроков:
      // мой ход, если последним писал НЕ я (ни логином, ни персонажем)
      return !amLast;
    }
    // Явная привязка эпизода к персонажу (через настройки)
    function getCharacterName(tid, cfg) {
      const tidStr = String(tid);
      if (cfg.characters) {
        for (const c of cfg.characters) {
          const list = Array.isArray(c.topics) ? c.topics : [];
          if (list.some(x => String(x) === tidStr)) return c.name;
        }
      }
      return '';
    }
    // Автоопределение персонажа по списку, последнему посту и названию эпизода
    function autoDetectCharacter(topic, cfg, username) {
      const chars = Array.isArray(cfg.characters) ?
        cfg.characters.map(c => c && c.name).filter(Boolean) : [];
      if (!chars.length) return '';
      const last = (topic.last_username || '').trim().toLowerCase();
      if (last) {
        const foundByLast = chars.find(n => n.trim().toLowerCase() === last);
        if (foundByLast) return foundByLast;
      }
      const subj = ' ' + String(topic.subject || '') + ' ';
      for (const n of chars) {
        const pat = n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        const re = new RegExp('(^|[\\s\\[\\(\\{\\-_/|])' + pat + '([\\s\\]\\)\\}\\-_/|]|$)', 'i');
        if (re.test(subj)) return n;
      }
      if (chars.length === 1) return chars[0];
      return '';
    }

    function mergeConfig(cfg, update) {
      if (!update) return cfg;
      if (typeof update.showOnlyMyTurn === 'boolean') cfg.showOnlyMyTurn = update.showOnlyMyTurn;
      if (typeof update.sortDescending === 'boolean') cfg.sortDescending = update.sortDescending;
      if (Array.isArray(update.ignoreTopics)) cfg.ignoreTopics = update.ignoreTopics.slice();
      if (Array.isArray(update.ignoreForums)) cfg.ignoreForums = update.ignoreForums.slice();
      if (update.orders && typeof update.orders === 'object') {
        cfg.orders = cfg.orders || {};
        Object.keys(update.orders).forEach(key => {
          const val = update.orders[key];
          if (val) cfg.orders[key] = val;
          else delete cfg.orders[key];
        });
      }
      if (Array.isArray(update.characters)) {
        cfg.characters = update.characters.map(c => ({
          name: c.name,
          topics: Array.isArray(c.topics) ? c.topics.slice() : [],
          pattern: c.pattern || ''
        }));
      }
      return cfg;
    }

    function updateConfig(update) {
      if (!plannerData) return;
      mergeConfig(plannerData.config, update); // локально
      sendMessage({
        type: 'updateConfig',
        update
      });
      renderUI();
    }

    function ensureStyles() {
      if (document.getElementById('episode-planner-style')) return;
      const css = `
      #planner-wrapper {
        font-family: system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
        font-size: 13px;
        color: inherit;
        max-width: 100%;
        overflow-x: auto;
      }

      /* Панель управления */
      #planner-controls {
        margin-bottom: 6px;
        display: flex;
        flex-wrap: wrap;
        gap: 6px;
      }
      #planner-wrapper button {
        margin: 0;
        padding: 4px 8px;
        font-size: 12px;
        cursor: pointer;
        border-radius: 999px;
        border: 1px solid rgba(0,0,0,.15);
        background: rgba(0,0,0,.02);
        color: inherit;
      }
      #planner-wrapper button:hover {
        background: rgba(0,0,0,.06);
      }

      /* Таблица в стилистике настроек */
      #planner-wrapper table {
        width: 100%;
        border-collapse: separate !important;
        border-spacing: 0 !important;
        margin: 0 0 0.75rem;
        border-radius: 8px;
        overflow: hidden;
        background: rgba(0,0,0,.01);
        border: 1px solid rgba(0,0,0,.15) !important;
        min-width: 520px;
      }
      #planner-wrapper th,
      #planner-wrapper td {
        border: none !important;
        padding: 0.4rem 0.5rem;
        text-align: left;
        font-size: 13px;
      }
      #planner-wrapper th {
        background: rgba(0,0,0,.04);
        border-bottom: 1px solid rgba(0,0,0,.12) !important;
        font-weight: 600;
        white-space: nowrap;
      }
      #planner-wrapper td {
        border-bottom: 1px solid rgba(0,0,0,.06) !important;
      }
      #planner-wrapper tr:last-child td {
        border-bottom: 0 !important;
      }

      #planner-wrapper th:nth-child(3),
      #planner-wrapper td:nth-child(3) {
        text-align: center;
        width: 1%;
        white-space: nowrap;
        padding-left: 0.25rem;
        padding-right: 0.25rem;
      }

      #planner-wrapper .ps-group-row td {
        background: rgba(0,0,0,.03);
        font-weight: 600;
        border-bottom: 1px solid rgba(0,0,0,.12) !important;
      }

      #planner-wrapper tr.my-turn td {
        background: rgba(204,0,0,.05);
      }

      #planner-wrapper a {
        color: inherit;
        text-decoration: none;
      }
      #planner-wrapper a:hover {
        text-decoration: underline;
      }

      #planner-wrapper .ps-sortable {
        cursor: pointer;
        user-select: none;
        white-space: nowrap;
      }
      #planner-wrapper .ps-sort-arrow {
        font-size: 11px;
        margin-left: 4px;
        opacity: .8;
      }

      /* Индикатор статуса (красная точка около названия) */
      #planner-wrapper .ps-status {
        display: inline-block;
        min-width: 1.2em;
        text-align: center;
        font-size: 13px;
      }
      #planner-wrapper .ps-status-my {
        color: #c00;
        font-weight: 600;
      }

      /* Блок настроек */
      #planner-settings {
        border: 1px solid rgba(0,0,0,.15);
        border-radius: 8px;
        padding: 8px;
        margin-bottom: 8px;
        background: rgba(0,0,0,.02);
      }
      #planner-settings h4 {
        margin: 4px 0 4px;
        font-size: 13px;
        font-weight: 600;
      }
      #planner-settings .ps-hint {
        font-size: 11px;
        opacity: .75;
        margin: 0 0 6px 0;
      }
      #planner-settings .ps-row {
        display: flex;
        gap: 8px;
        align-items: center;
        margin-bottom: 6px;
        flex-wrap: wrap;
      }
      #planner-settings label {
        font-size: 12px;
        opacity: .9;
      }

      #planner-settings select,
      #planner-settings input[type="text"] {
        padding: 5px 9px;
        font-size: 13px;
        flex: 1 1 auto;
        border-radius: 999px;
        border: 1px solid rgba(0,0,0,.15);
        background: rgba(0,0,0,.02);
        color: inherit;
        box-sizing: border-box;
        outline: none;
      }
      #planner-settings select:focus,
      #planner-settings input[type="text"]:focus {
        border-color: rgba(0,0,0,.35);
        box-shadow: 0 0 0 2px rgba(0,0,0,.08);
        background: rgba(0,0,0,.015);
      }

      #planner-settings .ps-chips {
        display: flex;
        flex-wrap: wrap;
        gap: 4px;
        margin-top: 4px;
      }
      #planner-settings .ps-chip {
        display: inline-flex;
        align-items: center;
        padding: 2px 6px;
        border-radius: 999px;
        border: 1px solid rgba(0,0,0,.2);
        background: rgba(0,0,0,.03);
        font-size: 12px;
      }
      #planner-settings .ps-chip button {
        border: none;
        background: transparent;
        padding: 0 0 0 4px;
        margin: 0;
        cursor: pointer;
        font-size: 12px;
      }

      /* Планшеты и узкие ноуты */
      @media (max-width: 768px) {
        #planner-wrapper {
          font-size: 12px;
        }
        #planner-wrapper th,
        #planner-wrapper td {
          padding: 0.3rem 0.4rem;
        }
        #planner-controls {
          display: flex;
          flex-wrap: wrap;
          gap: 4px;
        }
        #planner-controls button {
          margin-right: 0;
        }
      }

      /* Телефоны */
      @media (max-width: 600px) {
        #planner-settings .ps-row {
          flex-direction: column;
          align-items: flex-start;
        }
        #planner-settings select,
        #planner-settings input[type="text"] {
          width: 100%;
        }
      }

      @media (max-width: 480px) {
        #planner-settings {
          padding: 6px;
        }
        #planner-settings .ps-row {
          flex-direction: column;
          align-items: flex-start;
        }
        #planner-settings label {
          margin-bottom: 2px;
        }
        #planner-settings select,
        #planner-settings input[type="text"] {
          width: 100%;
        }
        #planner-wrapper th:nth-child(2),
        #planner-wrapper td:nth-child(2) {
          white-space: nowrap;
        }
      }
    `;
      const style = document.createElement('style');
      style.id = 'episode-planner-style';
      style.textContent = css;
      document.head.appendChild(style);
    }
    // форматируем дату как ДД.ММ.ГГГГ
    function formatDateOnly(ts) {
      if (!ts) return '';
      const d = new Date(ts * 1000);
      if (isNaN(d.getTime())) return '';
      const dd = String(d.getDate()).padStart(2, '0');
      const mm = String(d.getMonth() + 1).padStart(2, '0');
      const yy = d.getFullYear();
      return dd + '.' + mm + '.' + yy;
    }
    // Настройки (персонажи, привязка, очереди, игнор)
    function renderSettings(settingsDiv, topics, config, username) {
      settingsDiv.innerHTML = '';

      function getCfg() {
        return (plannerData && plannerData.config) || config || {};
      }
      // список эпизодов
      const topicList = topics.slice().sort(
        (a, b) => (a.subject || '').localeCompare(b.subject || '', 'ru')
      );
      // карта форумов
      const forumMap = {};
      topics.forEach(t => {
        if (t.forum_id == null) return;
        const fidStr = String(t.forum_id);
        if (!forumMap[fidStr]) {
          const name = t.forum_name || t.forum_title || '';
          forumMap[fidStr] = name ? name : ('Раздел #' + fidStr);
        }
      });
      const forumIds = Object.keys(forumMap).sort(
        (a, b) => forumMap[a].localeCompare(forumMap[b], 'ru')
      );
      /* --- Мои персонажи --- */
      const cfgNow1 = getCfg();
      const nameSet = new Set();
      if (Array.isArray(cfgNow1.characters)) {
        cfgNow1.characters.forEach(c => {
          if (c && c.name) nameSet.add(c.name);
        });
      }
      if (!nameSet.size && username) nameSet.add(username);
      const charNames = Array.from(nameSet);
      const hChars = document.createElement('h4');
      hChars.textContent = 'Мои персонажи';
      settingsDiv.appendChild(hChars);
      const hintChars = document.createElement('div');
      hintChars.className = 'ps-hint';
      hintChars.textContent =
        'Укажите имена всех своих персонажей через запятую. Подписки могут быть на одном профиле, но здесь можно перечислить всех героев, чтобы эпизоды группировались корректно.';
      settingsDiv.appendChild(hintChars);
      const rowChars = document.createElement('div');
      rowChars.className = 'ps-row';
      const labelChars = document.createElement('label');
      labelChars.textContent = 'Имена:';
      rowChars.appendChild(labelChars);
      const inputChars = document.createElement('input');
      inputChars.type = 'text';
      inputChars.placeholder = 'Через запятую: Лэм, Фионна...';
      inputChars.value = charNames.join(', ');
      rowChars.appendChild(inputChars);
      settingsDiv.appendChild(rowChars);

      function saveCharacters() {
        const raw = inputChars.value || '';
        const names = raw.split(',')
          .map(s => s.trim())
          .filter(Boolean);
        const cfg = getCfg();
        if (!names.length) {
          updateConfig({
            characters: []
          });
          return;
        }
        const existing = Array.isArray(cfg.characters) ? cfg.characters : [];
        const nextChars = [];
        names.forEach(name => {
          let found = existing.find(c => c && c.name === name);
          if (!found) {
            found = {
              name,
              topics: [],
              pattern: ''
            };
          } else {
            found = {
              name: found.name,
              topics: Array.isArray(found.topics) ?
                found.topics.map(x => String(x)) : [],
              pattern: found.pattern || ''
            };
          }
          nextChars.push(found);
        });
        updateConfig({
          characters: nextChars
        });
      }
      inputChars.addEventListener('change', saveCharacters);
      /* --- Привязка эпизода к персонажу --- */
      const hAssign = document.createElement('h4');
      hAssign.textContent = 'Привязка эпизода к персонажу';
      settingsDiv.appendChild(hAssign);
      const hintAssign = document.createElement('div');
      hintAssign.className = 'ps-hint';
      hintAssign.textContent =
        'Если автоопределение не угадало, выберите эпизод и прямо укажите, какому персонажу он принадлежит. Непривязанные эпизоды попадают в группу «Без персонажа».';
      settingsDiv.appendChild(hintAssign);
      const rowEp = document.createElement('div');
      rowEp.className = 'ps-row';
      const labelEp = document.createElement('label');
      labelEp.textContent = 'Эпизод:';
      rowEp.appendChild(labelEp);
      const selEp = document.createElement('select');
      const optE0 = document.createElement('option');
      optE0.value = '';
      optE0.textContent = '— выбрать эпизод —';
      selEp.appendChild(optE0);
      topicList.forEach(t => {
        const op = document.createElement('option');
        op.value = String(t.id);
        op.textContent = t.subject;
        selEp.appendChild(op);
      });
      rowEp.appendChild(selEp);
      settingsDiv.appendChild(rowEp);
      const rowChar = document.createElement('div');
      rowChar.className = 'ps-row';
      const labelChar = document.createElement('label');
      labelChar.textContent = 'Персонаж:';
      rowChar.appendChild(labelChar);
      const selChar = document.createElement('select');
      const optC0 = document.createElement('option');
      optC0.value = '';
      optC0.textContent = '— Без персонажа —';
      selChar.appendChild(optC0);
      charNames.forEach(n => {
        const op = document.createElement('option');
        op.value = n;
        op.textContent = n;
        selChar.appendChild(op);
      });
      rowChar.appendChild(selChar);
      settingsDiv.appendChild(rowChar);

      function refreshAssignChar() {
        const tid = selEp.value;
        if (!tid) {
          selChar.value = '';
          return;
        }
        const cfg = getCfg();
        const assigned = getCharacterName(tid, cfg);
        selChar.value = assigned || '';
      }
      selEp.onchange = () => {
        lastAssignEpisodeId = selEp.value || null;
        refreshAssignChar();
      };
      selChar.onchange = () => {
        const tid = selEp.value;
        if (!tid) return;
        const chosen = selChar.value; // '' = Без персонажа
        const tidStr = String(tid);
        const cfg = getCfg();
        const existing = Array.isArray(cfg.characters) ? cfg.characters : [];
        const nextChars = existing.map(c => ({
          name: c.name,
          topics: Array.isArray(c.topics) ? c.topics.map(x => String(x)) : [],
          pattern: c.pattern || ''
        }));
        // удалить эпизод из всех персонажей
        nextChars.forEach(c => {
          c.topics = c.topics.filter(x => String(x) !== tidStr);
        });
        // добавить к выбранному персонажу
        if (chosen) {
          let target = nextChars.find(c => c.name === chosen);
          if (!target) {
            target = {
              name: chosen,
              topics: [],
              pattern: ''
            };
            nextChars.push(target);
          }
          if (!target.topics.some(x => String(x) === tidStr)) {
            target.topics.push(tidStr);
          }
        }
        updateConfig({
          characters: nextChars
        });
      };
      if (topicList.length) {
        const initialAssignTid =
          (lastAssignEpisodeId &&
            topicList.some(t => String(t.id) === lastAssignEpisodeId)) ?
          lastAssignEpisodeId :
          String(topicList[0].id);
        selEp.value = initialAssignTid;
        lastAssignEpisodeId = initialAssignTid;
        refreshAssignChar();
      }
      /* --- Порядок игроков (для 3+ участников) --- */
      const hOrder = document.createElement('h4');
      hOrder.textContent = 'Порядок игроков (для 3+ участников)';
      settingsDiv.appendChild(hOrder);
      const hintOrder = document.createElement('div');
      hintOrder.className = 'ps-hint';
      hintOrder.textContent =
        'Для эпизодов с тремя и более игроками можно задать очередь ходов: впишите ники по кругу, в том порядке, в котором участники пишут посты. Сохраните изменения кнопкой справа или клавишей Enter.';
      settingsDiv.appendChild(hintOrder);
      const rowOrdEp = document.createElement('div');
      rowOrdEp.className = 'ps-row';
      const labelOrdEp = document.createElement('label');
      labelOrdEp.textContent = 'Эпизод:';
      rowOrdEp.appendChild(labelOrdEp);
      const selOrdEp = document.createElement('select');
      const optOE0 = document.createElement('option');
      optOE0.value = '';
      optOE0.textContent = '— выбрать эпизод —';
      selOrdEp.appendChild(optOE0);
      topicList.forEach(t => {
        const op = document.createElement('option');
        op.value = String(t.id);
        op.textContent = t.subject;
        selOrdEp.appendChild(op);
      });
      rowOrdEp.appendChild(selOrdEp);
      settingsDiv.appendChild(rowOrdEp);
      const rowOrd = document.createElement('div');
      rowOrd.className = 'ps-row';
      const labelOrd = document.createElement('label');
      labelOrd.textContent = 'Очередь:';
      rowOrd.appendChild(labelOrd);
      const inputOrd = document.createElement('input');
      inputOrd.type = 'text';
      inputOrd.placeholder = 'Ники через запятую';
      rowOrd.appendChild(inputOrd);
      const btnOrderSave = document.createElement('button');
      btnOrderSave.type = 'button';
      btnOrderSave.textContent = 'Сохранить';
      rowOrd.appendChild(btnOrderSave);
      settingsDiv.appendChild(rowOrd);

      function refreshOrderInput() {
        const tid = selOrdEp.value;
        if (!tid) {
          inputOrd.value = '';
          return;
        }
        const cfg = getCfg();
        const orders = (cfg.orders && cfg.orders[tid]) || [];
        if (Array.isArray(orders)) {
          inputOrd.value = orders.join(', ');
        } else {
          inputOrd.value = '';
        }
      }
      selOrdEp.onchange = () => {
        lastOrderEpisodeId = selOrdEp.value || null;
        refreshOrderInput();
      };
      inputOrd.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') {
          e.preventDefault();
          btnOrderSave.click();
        }
      });
      btnOrderSave.onclick = () => {
        const tid = selOrdEp.value;
        if (!tid) return;
        const raw = inputOrd.value || '';
        const arr = raw
          .split(',')
          .map(s => s.trim())
          .filter(Boolean);
        const cfg = getCfg();
        const patch = {};
        // Если очередь из 2+ имён — сохраняем,
        // если 0 или 1 имя — считаем, что очереди нет и удаляем её.
        if (arr.length > 1) {
          patch[tid] = arr;
        } else {
          patch[tid] = null; // явный сигнал "удалить очередь" для этого эпизода
        }
        updateConfig({
          orders: patch
        });
      };
      if (topicList.length) {
        const initialOrderTid =
          (lastOrderEpisodeId &&
            topicList.some(t => String(t.id) === lastOrderEpisodeId)) ?
          lastOrderEpisodeId :
          String(topicList[0].id);
        selOrdEp.value = initialOrderTid;
        lastOrderEpisodeId = initialOrderTid;
        refreshOrderInput();
      }
      /* --- Игнорирование разделов --- */
      const hForums = document.createElement('h4');
      hForums.textContent = 'Игнорирование разделов';
      settingsDiv.appendChild(hForums);
      const hintForums = document.createElement('div');
      hintForums.className = 'ps-hint';
      hintForums.textContent =
        'Если вы не хотите видеть в планировщике эпизоды из некоторых разделов (например, архив или техраздел), добавьте их сюда.';
      settingsDiv.appendChild(hintForums);
      const rowForums = document.createElement('div');
      rowForums.className = 'ps-row';
      const labelForums = document.createElement('label');
      labelForums.textContent = 'Раздел:';
      rowForums.appendChild(labelForums);
      const selForum = document.createElement('select');
      const optF0 = document.createElement('option');
      optF0.value = '';
      optF0.textContent = '— выбрать раздел —';
      selForum.appendChild(optF0);
      forumIds.forEach(fidStr => {
        const op = document.createElement('option');
        op.value = fidStr;
        op.textContent = forumMap[fidStr];
        selForum.appendChild(op);
      });
      rowForums.appendChild(selForum);
      const btnForumAdd = document.createElement('button');
      btnForumAdd.type = 'button';
      btnForumAdd.textContent = 'Добавить';
      btnForumAdd.onclick = () => {
        const v = selForum.value;
        if (!v) return;
        const cfg = getCfg();
        const cur = (cfg.ignoreForums || []).slice();
        if (!cur.some(x => String(x) === v)) {
          cur.push(v);
          updateConfig({
            ignoreForums: cur
          });
        }
      };
      rowForums.appendChild(btnForumAdd);
      settingsDiv.appendChild(rowForums);
      const chipsForums = document.createElement('div');
      chipsForums.className = 'ps-chips';
      (getCfg().ignoreForums || []).forEach(id => {
        const key = String(id);
        const chip = document.createElement('span');
        chip.className = 'ps-chip';
        const label = forumMap[key] || ('Раздел #' + key);
        chip.textContent = label;
        const btnX = document.createElement('button');
        btnX.type = 'button';
        btnX.textContent = '×';
        btnX.onclick = () => {
          const cfg = getCfg();
          const cur = (cfg.ignoreForums || []).filter(x => String(x) !== key);
          updateConfig({
            ignoreForums: cur
          });
        };
        chip.appendChild(btnX);
        chipsForums.appendChild(chip);
      });
      settingsDiv.appendChild(chipsForums);
      /* --- Игнорирование эпизодов --- */
      const hTopics = document.createElement('h4');
      hTopics.textContent = 'Игнорирование эпизодов';
      settingsDiv.appendChild(hTopics);
      const hintTopics = document.createElement('div');
      hintTopics.className = 'ps-hint';
      hintTopics.textContent =
        'Отдельные эпизоды можно скрыть из планировщика, не снимая подписку — они просто не будут появляться в таблице.';
      settingsDiv.appendChild(hintTopics);
      const rowTopics = document.createElement('div');
      rowTopics.className = 'ps-row';
      const labelTopics = document.createElement('label');
      labelTopics.textContent = 'Эпизод:';
      rowTopics.appendChild(labelTopics);
      const selTopic = document.createElement('select');
      const optT0 = document.createElement('option');
      optT0.value = '';
      optT0.textContent = '— выбрать эпизод —';
      selTopic.appendChild(optT0);
      topicList.forEach(t => {
        const op = document.createElement('option');
        op.value = String(t.id);
        op.textContent = t.subject;
        selTopic.appendChild(op);
      });
      rowTopics.appendChild(selTopic);
      const btnTopicAdd = document.createElement('button');
      btnTopicAdd.type = 'button';
      btnTopicAdd.textContent = 'Добавить';
      btnTopicAdd.onclick = () => {
        const v = selTopic.value;
        if (!v) return;
        const cfg = getCfg();
        const cur = (cfg.ignoreTopics || []).slice();
        if (!cur.some(x => String(x) === v)) {
          cur.push(v);
          updateConfig({
            ignoreTopics: cur
          });
        }
      };
      rowTopics.appendChild(btnTopicAdd);
      settingsDiv.appendChild(rowTopics);
      const chipsTopics = document.createElement('div');
      chipsTopics.className = 'ps-chips';
      (getCfg().ignoreTopics || []).forEach(id => {
        const key = String(id);
        const chip = document.createElement('span');
        chip.className = 'ps-chip';
        const topic = topicList.find(t => String(t.id) === key);
        const label = topic ? topic.subject : ('Эпизод #' + key);
        chip.textContent = label;
        const btnX = document.createElement('button');
        btnX.type = 'button';
        btnX.textContent = '×';
        btnX.onclick = () => {
          const cfg = getCfg();
          const cur = (cfg.ignoreTopics || []).filter(x => String(x) !== key);
          updateConfig({
            ignoreTopics: cur
          });
        };
        chip.appendChild(btnX);
        chipsTopics.appendChild(chip);
      });
      settingsDiv.appendChild(chipsTopics);
    }

    function renderUI() {
      if (!plannerData || !container) return;
      ensureStyles();
      const {
        topics,
        config,
        userId,
        username
      } = plannerData;
      container.innerHTML = '';
      const wrapper = document.createElement('div');
      wrapper.id = 'planner-wrapper';
      // панель управления
      const controls = document.createElement('div');
      controls.id = 'planner-controls';
      const btnToggle = document.createElement('button');
      btnToggle.textContent = config.showOnlyMyTurn ? 'Показать все' : 'Только мой ход';
      btnToggle.onclick = () =>
        updateConfig({
          showOnlyMyTurn: !config.showOnlyMyTurn
        });
      controls.append(btnToggle);
      const btnSettings = document.createElement('button');
      btnSettings.textContent = settingsOpen ? 'Скрыть настройки' : 'Настройки';
      btnSettings.onclick = () => {
        settingsOpen = !settingsOpen;
        renderUI();
      };
      controls.append(btnSettings);
      wrapper.appendChild(controls);
      // блок настроек
      const settingsDiv = document.createElement('div');
      settingsDiv.id = 'planner-settings';
      settingsDiv.style.display = settingsOpen ? '' : 'none';
      wrapper.appendChild(settingsDiv);
      if (settingsOpen) {
        renderSettings(settingsDiv, topics, config, username);
      }
      // построение строк таблицы
      const now = Date.now() / 1000;
      let rows = topics
        .filter(t => {
          const tidStr = String(t.id);
          const fidStr = t.forum_id != null ? String(t.forum_id) : '';
          if (config.ignoreTopics &&
            config.ignoreTopics.some(x => String(x) === tidStr)) {
            return false;
          }
          if (fidStr &&
            config.ignoreForums &&
            config.ignoreForums.some(x => String(x) === fidStr)) {
            return false;
          }
          return true;
        })
        .map(t => {
          const days = Math.floor((now - t.last_post_date) / 86400);
          const explicitChar = getCharacterName(t.id, config);
          const autoChar = explicitChar || autoDetectCharacter(t, config, username);
          return {
            id: t.id,
            subject: t.subject,
            last_username: t.last_username,
            last_user_id: t.last_user_id,
            last_post_date: t.last_post_date,
            forum_id: t.forum_id,
            days: days,
            myTurn: computeMyTurn(t, config, userId, username),
            character: autoChar
          };
        });
      if (config.showOnlyMyTurn) {
        rows = rows.filter(r => r.myTurn);
      }
      rows.sort((a, b) =>
        config.sortDescending ?
        b.last_post_date - a.last_post_date :
        a.last_post_date - b.last_post_date
      );
      // группировка по персонажам / "Без персонажа"
      const groups = {};
      rows.forEach(r => {
        const groupName = r.character || 'Без персонажа';
        if (!groups[groupName]) groups[groupName] = [];
        groups[groupName].push(r);
      });
      const groupNames = [];
      if (Array.isArray(config.characters)) {
        config.characters.forEach(c => {
          if (!c || !c.name) return;
          if (groups[c.name] && groups[c.name].length) {
            groupNames.push(c.name);
          }
        });
      }
      if (groups['Без персонажа'] && groups['Без персонажа'].length) {
        groupNames.push('Без персонажа');
      }
      const table = document.createElement('table');
      // шапка таблицы
      const headRow = document.createElement('tr');
      const thSubj = document.createElement('th');
      thSubj.textContent = 'Эпизод';
      headRow.appendChild(thSubj);
      const thDate = document.createElement('th');
      thDate.className = 'ps-sortable';
      thDate.title = 'Сортировать по дате';
      const spanLabel = document.createElement('span');
      spanLabel.textContent = 'Дата';
      const spanArrow = document.createElement('span');
      spanArrow.className = 'ps-sort-arrow';
      spanArrow.textContent = config.sortDescending ? '↓' : '↑';
      thDate.appendChild(spanLabel);
      thDate.appendChild(spanArrow);
      thDate.onclick = () =>
        updateConfig({
          sortDescending: !config.sortDescending
        });
      headRow.appendChild(thDate);
      const thDays = document.createElement('th');
      thDays.title = 'Дней с последнего сообщения';
      thDays.textContent = '⏱';
      headRow.appendChild(thDays);
      const thAuthor = document.createElement('th');
      thAuthor.textContent = 'Последний ход';
      headRow.appendChild(thAuthor);
      table.appendChild(headRow);
      // строки групп и эпизодов
      groupNames.forEach(char => {
        const list = groups[char];
        if (!list || !list.length) return;
        const groupTr = document.createElement('tr');
        groupTr.className = 'ps-group-row';
        const groupTd = document.createElement('td');
        groupTd.colSpan = 4;
        groupTd.textContent = char;
        groupTr.appendChild(groupTd);
        table.appendChild(groupTr);
        list.forEach(row => {
          const tr = document.createElement('tr');
          if (row.myTurn) tr.classList.add('my-turn');
          // Эпизод — + красная точка, если ваш ход
          const td1 = document.createElement('td');
          if (row.myTurn) {
            const dot = document.createElement('span');
            dot.className = 'ps-status ps-status-my';
            dot.textContent = '●';
            dot.title = 'Ваш ход';
            dot.style.marginRight = '4px';
            td1.appendChild(dot);
          }
          const link = document.createElement('a');
          link.href = '/viewtopic.php?id=' + row.id;
          link.target = '_blank';
          link.textContent = row.subject;
          td1.appendChild(link);
          tr.appendChild(td1);
          // Дата (дд.мм.гггг)
          const tdDate = document.createElement('td');
          tdDate.textContent = formatDateOnly(row.last_post_date);
          tr.appendChild(tdDate);
          // Дней
          const tdDays = document.createElement('td');
          tdDays.textContent = row.days;
          tr.appendChild(tdDays);
          // Последний ход
          const tdLast = document.createElement('td');
          tdLast.textContent = row.last_username;
          tr.appendChild(tdLast);
          table.appendChild(tr);
        });
      });
      if (!groupNames.length) {
        const trEmpty = document.createElement('tr');
        const tdEmpty = document.createElement('td');
        tdEmpty.colSpan = 4;
        tdEmpty.textContent = 'Нет эпизодов для отображения.';
        trEmpty.appendChild(tdEmpty);
        table.appendChild(trEmpty);
      }
      wrapper.appendChild(table);
      container.appendChild(wrapper);
    }
    // обработчик ответа от родителя
    function handleMessage(event) {
      const msg = event.data;
      if (!msg || msg.type !== 'plannerData') return;
      if (msg.data && msg.data.error) {
        container.textContent = 'Ошибка: ' + msg.data.error;
        return;
      }
      const data = msg.data || {};
      if (!plannerData) {
        plannerData = data;
      } else {
        plannerData.topics = Array.isArray(data.topics) ? data.topics : [];
        plannerData.userId = data.userId;
        plannerData.username = data.username;
        if (!plannerData.config && data.config) {
          plannerData.config = data.config;
        }
      }
      renderUI();
    }

    function init() {
      container = document.getElementById('episode-planner-container');
      requestData();
    }
    window.addEventListener('message', handleMessage);
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', init);
    } else {
      init();
    }
  })();
</script>
[/html]