Копирование и перенос блоков страницы и уроков GetCourse

Доработка добавляет в редактор страницы GetCourse инструменты для копирования, последовательной вставки и удаления блоков, а также для просмотра файлов, используемых внутри блоков страницы.
Автор доработки и инструкции: канал «GetCourse: код и дизайн»
Доработка распространяется бесплатно. При копировании кода или публикации инструкции, пожалуйста, сохраняйте ссылку на источник:
https://t.me/GetCourse_Code

Версия доработки: 05 августа 2026 года.

⚠️ Скрипт не работает в новом дизайне

✨ Вопросы и сообщения об ошибках, связанных с этой доработкой: https://t.me/GetCourseCode
Установка через расширение User JavaScript and CSS
Доработка устанавливается в браузер пользователя. Она не добавляется в настройки аккаунта GetCourse и не влияет на работу других администраторов. Каждый специалист, которому нужны дополнительные кнопки в редакторе страниц, устанавливает расширение и код отдельно в своём браузере.
Google Chrome
  1. Откройте страницу расширения User JavaScript and CSS в Chrome Web Store
  2. Нажмите «Установить» или «Добавить в Chrome». Подтвердите установку расширения.
  3. Откройте страницу: chrome://extensions/
  4. Найдите User JavaScript and CSS и нажмите «Подробнее».
  5. Включите параметр «Разрешить пользовательские скрипты» или Allow User Scripts.
Microsoft Edge
Расширение можно установить из Chrome Web Store, поскольку Microsoft Edge поддерживает расширения из сторонних магазинов.

  1. Откройте в Edge страницу User JavaScript and CSS в Chrome Web Store
  2. Если браузер покажет предупреждение, нажмите «Разрешить расширения из других магазинов».
  3. Нажмите «Получить», «Установить» или «Добавить в Edge».Подтвердите установку.
  4. Откройте: edge://extensions/
  5. Найдите расширение и откройте его подробные настройки.
  6. Разрешите расширению работать на сайтах GetCourse.
  7. Если доступен параметр Allow User Scripts, включите его.
Яндекс Браузер
Яндекс Браузер разрешает устанавливать расширения из Chrome Web Store.

  1. Откройте в Яндекс Браузере страницу User JavaScript and CSS в Chrome Web Store.
  2. Нажмите «Установить» или «Добавить в Яндекс Браузер». Подтвердите установку.
  3. Откройте меню браузера. Перейдите в раздел «Дополнения» или «Расширения». Убедитесь, что расширение включено.
  4. Разрешите ему работать на страницах GetCourse.
Если код не запускается, откройте настройки расширений, включите режим разработчика и перезапустите браузер.
Mozilla Firefox
Для Firefox существует отдельная версия расширения:

  1. Откройте ссылку в Firefox User JavaScript and CSS для Firefox
  2. Нажмите «Добавить в Firefox».
  3. Подтвердите необходимые разрешения.
  4. Закрепите значок расширения на панели браузера.
Версия для Firefox распространяется через официальный каталог Mozilla Add-ons. На странице дополнения она отмечена как экспериментальная, поэтому названия и расположение отдельных настроек могут отличаться от версии для Chrome.
Как добавить доработку в расширение

Установите расширение одним из способов выше.
Нажмите на значок User JavaScript and CSS рядом с адресной строкой браузера.
Создайте новое правило с помощью кнопки добавления:
Укажите понятное название, например: Копирование блоков GetCourse
В качестве адреса или URL-маски укажите: *
Вставьте код, скопированный ниже, в поле JavaScript. И нажмите кнопку «Сохранить»
Код для браузеров Chrome, Яндекс, Edge можно скопировать ниже
Показать код ⬇️
$(function () {
  'use strict';

  const IMPORT_DELAY = 500;
  const EXPORT_RETRIES = 2;
  const IMPORT_TIMEOUT = 30000;

  const LESSON_SERVICE_PRESETS = new Set([
    'lesson-feedback-block-1',
    'lesson-comment-block-1',
    'lmb1'
  ]);

  const $blockSet = $('.lite-page.block-set.block-set-editor').first();
  const $footer = $('.lite-page-footer-add-block').first();

  if (!$blockSet.length || !$footer.length) {
    return;
  }

  let mediaState = {
    files: [],
    sourceHost: location.hostname,
    title: 'Файлы страницы',
    activeFilter: 'all'
  };

  let modalReturnFocus = null;

  addInterfaceStyles();
  addControlButtons();
  createMediaModal();

  $('.copy-all-blocks-btn').on('click', async function () {
    const $button = $(this);
    const originalText = $button.text();

    const sourceContext = getEditorContext();
    const allBlocks = getBlockDescriptors();
    const transferableBlocks = allBlocks.filter(function (block) {
      return !block.isLessonServiceBlock;
    });
    const serviceBlocks = allBlocks.filter(function (block) {
      return block.isLessonServiceBlock;
    });

    if (!transferableBlocks.length) {
      alert(
        sourceContext === 'lesson'
          ? 'В уроке не найдено контентных блоков для копирования.'
          : 'На странице не найдено блоков для копирования.'
      );
      return;
    }

    setToolbarDisabled(true);

    try {
      const blockCodes = [];
      const blockMeta = [];
      const skippedBlockIds = [];

      for (let index = 0; index < transferableBlocks.length; index++) {
        const block = transferableBlocks[index];

        $button.text(
          `Копирование ${index + 1} из ${transferableBlocks.length}...`
        );

        try {
          const blockCode = await exportBlockCode(block.id);

          blockCodes.push(blockCode);
          blockMeta.push({
            sourceId: block.id,
            preset: block.preset,
            sourceContext: sourceContext,
            isLessonServiceBlock: false
          });
        } catch (blockError) {
          skippedBlockIds.push(String(block.id));
          console.warn(
            `Блок ${block.id} пропущен при экспорте:`,
            blockError
          );
        }
      }

      if (!blockCodes.length) {
        throw new Error(
          'GetCourse не вернул код ни для одного блока.'
        );
      }

      const sourceHost = location.hostname;
      const pageFiles = collectFilesFromCurrentBlocks(
        sourceHost,
        window.accountId || null
      );

      const clipboardData = JSON.stringify({
        type: 'getcourse-lite-blocks',
        version: 7,
        sourceHost: sourceHost,
        sourceAccountId: window.accountId || null,
        sourceContext: sourceContext,
        blocks: blockCodes,
        blockMeta: blockMeta,
        files: pageFiles.map(serializeFile)
      });

      await copyTextToClipboard(clipboardData);

      $button.text(
        `Скопировано: ${blockCodes.length} из ${transferableBlocks.length}; ` +
        `файлов: ${pageFiles.length}`
      );

      if (skippedBlockIds.length) {
        alert(
          `Скопировано блоков: ${blockCodes.length} ` +
          `из ${transferableBlocks.length}.\n\n` +
          `GetCourse не разрешил экспортировать блоки: ` +
          skippedBlockIds.join(', ') +
          `. Они не добавлены в буфер обмена.`
        );
      } else if (serviceBlocks.length && sourceContext === 'lesson') {
        console.info(
          'Служебные блоки урока не копируются:',
          serviceBlocks.map(block => block.preset || block.id)
        );
      }

      setTimeout(function () {
        $button.text(originalText);
      }, 3500);
    } catch (error) {
      console.error('Ошибка копирования блоков:', error);

      alert(
        'Не удалось скопировать все блоки. ' +
        'Подробности ошибки находятся в консоли.'
      );

      $button.text(originalText);
    } finally {
      setToolbarDisabled(false);
    }
  });

  $('.paste-all-blocks-btn').on('click', async function () {
    const $button = $(this);
    const originalText = $button.text();

    const targetContext = getEditorContext();
    const ownerContext = getTargetOwnerContext(targetContext);

    if (
      !ownerContext.ownerId ||
      !/^\d+$/.test(String(ownerContext.ownerId))
    ) {
      alert(
        targetContext === 'lesson'
          ? 'Не удалось определить внутренний ID редактора урока.'
          : 'Не удалось определить ID страницы.'
      );
      return;
    }

    setToolbarDisabled(true);

    try {
      const clipboardText = await navigator.clipboard.readText();
      const clipboardData = parseClipboardData(clipboardText);
      const blockItems = buildClipboardBlockItems(clipboardData);

      if (!blockItems.length) {
        throw new Error(
          'В буфере обмена не найдено кодов блоков.'
        );
      }

      const sourceHost =
        sanitizeHost(clipboardData.sourceHost) || location.hostname;

      let foundFiles = normalizeStoredFiles(
        clipboardData.files,
        sourceHost,
        clipboardData.sourceAccountId
      );

      let insertedCount = 0;
      const skippedImports = [];

      for (let index = 0; index < blockItems.length; index++) {
        const blockItem = blockItems[index];

        $button.text(
          `Вставка ${index + 1} из ${blockItems.length}...`
        );

        if (shouldSkipClipboardBlock(blockItem, targetContext)) {
          skippedImports.push({
            index: index + 1,
            preset: blockItem.meta && blockItem.meta.preset,
            reason: 'служебный блок урока'
          });
          continue;
        }

        try {
          const importData = {
            ownerId: String(ownerContext.ownerId),
            code: blockItem.code
          };

          if (ownerContext.ownerTypeId) {
            importData.ownerTypeId = String(ownerContext.ownerTypeId);
          }

          const response = await importBlockCode(importData);

          const importError = getImportResponseError(response);

          if (importError) {
            throw new Error(importError);
          }

          if (!response || !response.data) {
            throw new Error(
              `GetCourse не вернул данные для блока №${index + 1}`
            );
          }

          const importedBlocks = Array.isArray(response.data)
            ? response.data
            : [response.data];

          for (const importedBlock of importedBlocks) {
            if (
              !importedBlock ||
              !importedBlock.id ||
              typeof importedBlock.html !== 'string'
            ) {
              throw new Error(
                `Получены некорректные данные блока №${index + 1}`
              );
            }

            foundFiles = mergeFiles(
              foundFiles,
              extractGetCourseFiles(
                importedBlock.html,
                sourceHost,
                clipboardData.sourceAccountId
              )
            );

            $blockSet.liteBlockSet(
              'insertBlock',
              importedBlock.id,
              importedBlock.html
            );

            await delay(120);
          }

          insertedCount += 1;
          await delay(IMPORT_DELAY);
        } catch (blockError) {
          const errorMessage = getErrorMessage(blockError);

          skippedImports.push({
            index: index + 1,
            preset:
              blockItem.meta && blockItem.meta.preset
                ? blockItem.meta.preset
                : null,
            reason: errorMessage
          });

          console.warn(
            `Блок №${index + 1} пропущен при импорте:`,
            blockError
          );

          await delay(250);
        }
      }

      await delay(350);

      $button.text(
        `Вставлено: ${insertedCount} из ${blockItems.length}`
      );

      if (insertedCount > 0) {
        showMediaModal({
          files: foundFiles,
          sourceHost: sourceHost,
          title: 'Файлы импортированных блоков'
        });
      }

      if (skippedImports.length) {
        const skippedText = skippedImports
          .map(function (item) {
            const presetText = item.preset
              ? ` (${item.preset})`
              : '';

            return `№${item.index}${presetText}: ${item.reason}`;
          })
          .join('\n');

        alert(
          `Вставлено блоков: ${insertedCount} ` +
          `из ${blockItems.length}.\n\n` +
          `Пропущенные блоки:\n${skippedText}`
        );
      }

      setTimeout(function () {
        $button.text(originalText);
      }, 2500);
    } catch (error) {
      console.error('Ошибка вставки блоков:', error);

      alert(
        'Вставка остановлена: ' +
        (error.message || 'неизвестная ошибка')
      );

      $button.text(originalText);
    } finally {
      setToolbarDisabled(false);
    }
  });

  $('.delete-all-blocks-btn').on('click', async function () {
    const $button = $(this);
    const originalText = $button.text();
    const editorContext = getEditorContext();
    const blocks = getBlockDescriptors()
      .filter(function (block) {
        return !block.isLessonServiceBlock;
      })
      .map(function (block) {
        return block.element;
      });

    if (!blocks.length) {
      alert(
        editorContext === 'lesson'
          ? 'В уроке нет контентных блоков для удаления.'
          : 'На странице нет блоков для удаления.'
      );
      return;
    }

    const confirmed = window.confirm(
      (
        editorContext === 'lesson'
          ? `Удалить все контентные блоки урока?\n\n`
          : `Удалить все блоки на странице?\n\n`
      ) +
      `Количество блоков: ${blocks.length}\n\n` +
      `Перед удалением рекомендуется нажать ` +
      `«Скопировать все блоки».`
    );

    if (!confirmed) {
      return;
    }

    setToolbarDisabled(true);

    try {
      for (let index = 0; index < blocks.length; index++) {
        const blockElement = blocks[index];
        const $block = $(blockElement);

        if (!document.documentElement.contains(blockElement)) {
          continue;
        }

        $button.text(
          `Удаление ${index + 1} из ${blocks.length}...`
        );

        await deleteBlockThroughEditor($block);
        await delay(300);
      }

      $button.text(`Удалено: ${blocks.length}`);

      setTimeout(function () {
        $button.text(originalText);
      }, 2500);
    } catch (error) {
      console.error('Ошибка удаления блоков:', error);

      alert(
        'Удаление остановлено: ' +
        (error.message || 'неизвестная ошибка')
      );

      $button.text(originalText);
    } finally {
      setToolbarDisabled(false);
    }
  });

  $('.show-page-files-btn').on('click', function () {
    const files = collectFilesFromCurrentBlocks(
      location.hostname,
      window.accountId || null
    );

    showMediaModal({
      files: files,
      sourceHost: location.hostname,
      title: 'Файлы на текущей странице'
    });
  });

  $(document).on(
    'click',
    '.gc-page-files-modal-close, .gc-page-files-modal-backdrop',
    function (event) {
      if (
        $(event.target).is('.gc-page-files-modal-dialog') ||
        $(event.target).closest('.gc-page-files-modal-dialog').length
      ) {
        return;
      }

      closeMediaModal();
    }
  );

  $(document).on(
    'click',
    '.gc-page-files-modal-close',
    closeMediaModal
  );

  $(document).on(
    'click',
    '.gc-open-file-storage',
    function () {
      const fileKey = $(this).data('file-key');
      const file = findMediaFile(fileKey);

      if (!file || !file.storageUrl) {
        return;
      }

      window.open(file.storageUrl, '_blank', 'noopener');
    }
  );

  $(document).on(
    'click',
    '.gc-page-files-counter',
    function () {
      const filter = String($(this).data('filter') || 'all');

      mediaState.activeFilter = filter;
      renderMediaModalContents();
    }
  );

  $(document).on(
    'click',
    '.gc-copy-all-file-links',
    async function () {
      const $button = $(this);
      const originalText = $button.text();

      const links = mediaState.files
        .map(function (file) {
          return file.directUrl || null;
        })
        .filter(Boolean);

      if (!links.length) {
        alert(
          'Для найденных файлов не удалось сформировать ' +
          'прямые ссылки с параметрами a, sc и h.'
        );
        return;
      }

      try {
        await copyTextToClipboard(links.join('\n'));

        $button.text(`Скопировано ссылок: ${links.length}`);

      } catch (error) {
        console.error('Ошибка копирования ссылок:', error);
        alert('Не удалось скопировать ссылки на файлы.');
      } finally {
        setTimeout(function () {
          $button.text(originalText);
        }, 2200);
      }
    }
  );

  function addControlButtons() {
    if ($footer.find('.custom-copy-paste-btns').length) {
      return;
    }

    $footer.append(`
      <div class="custom-copy-paste-btns">
        <button
          type="button"
          class="btn btn-success copy-all-blocks-btn"
        >
          Скопировать все блоки
        </button>

        <button
          type="button"
          class="btn btn-warning paste-all-blocks-btn"
        >
          Вставить все блоки
        </button>

        <button
          type="button"
          class="btn btn-info show-page-files-btn"
        >
          Файлы страницы
        </button>

        <button
          type="button"
          class="btn btn-danger delete-all-blocks-btn"
        >
          Удалить все блоки
        </button>
      </div>
    `);
  }

  function addInterfaceStyles() {
    if ($('#gc-page-files-tools-styles').length) {
      return;
    }

    $('head').append(`
      <style id="gc-page-files-tools-styles">
        .custom-copy-paste-btns {
          display: flex;
          flex-wrap: wrap;
          justify-content: center;
          gap: 8px;
          margin-top: 20px;
        }

        .gc-page-files-modal {
          display: none;
          position: fixed;
          inset: 0;
          z-index: 1000000;
        }

        .gc-page-files-modal.is-open {
          display: block;
        }

        .gc-page-files-modal-backdrop {
          position: absolute;
          inset: 0;
          background: rgba(0, 0, 0, 0.48);
        }

        .gc-page-files-modal-dialog {
          position: relative;
          width: min(760px, calc(100% - 30px));
          max-height: calc(100vh - 40px);
          margin: 20px auto;
          overflow: hidden;
          border-radius: 14px;
          background: #fff;
          box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28);
        }

        .gc-page-files-modal-header {
          display: flex;
          align-items: flex-start;
          justify-content: space-between;
          gap: 15px;
          padding: 18px 20px;
          border-bottom: 1px solid #e8e8e8;
        }

        .gc-page-files-modal-title {
          margin: 0;
          font-size: 20px;
          line-height: 1.25;
        }

        .gc-page-files-modal-close {
          padding: 0;
          border: 0;
          background: transparent;
          font-size: 28px;
          line-height: 1;
          cursor: pointer;
        }

        .gc-page-files-modal-body {
          max-height: calc(100vh - 125px);
          overflow: auto;
          padding: 18px 20px 22px;
        }

        .gc-page-files-summary {
          display: grid;
          grid-template-columns: repeat(4, minmax(0, 1fr));
          gap: 8px;
          margin-bottom: 14px;
        }

        .gc-page-files-counter {
          width: 100%;
          padding: 10px;
          border: 1px solid #e5e7eb;
          border-radius: 10px;
          background: #f8fafc;
          color: inherit;
          text-align: center;
          cursor: pointer;
          transition: border-color .15s ease, background .15s ease;
        }

        .gc-page-files-counter:hover {
          border-color: #94a3b8;
          background: #f1f5f9;
        }

        .gc-page-files-counter.is-active {
          border-color: #337ab7;
          background: #eaf4fc;
          box-shadow: inset 0 0 0 1px #337ab7;
        }

        .gc-page-files-counter strong {
          display: block;
          font-size: 19px;
        }

        .gc-page-files-actions {
          display: flex;
          flex-wrap: wrap;
          justify-content: center;
          gap: 8px;
          margin-bottom: 12px;
        }

        .gc-page-files-note {
          margin: 0 0 14px;
          padding: 10px 12px;
          border-radius: 9px;
          background: #fff8dc;
          font-size: 13px;
          line-height: 1.45;
        }

        .gc-page-files-list {
          display: grid;
          gap: 8px;
        }

        .gc-page-file-row {
          display: grid;
          grid-template-columns: 88px minmax(0, 1fr) auto;
          align-items: center;
          gap: 12px;
          padding: 10px 12px;
          border: 1px solid #e5e7eb;
          border-radius: 10px;
        }

        .gc-page-file-preview {
          position: relative;
          display: flex;
          width: 88px;
          height: 62px;
          align-items: center;
          justify-content: center;
          overflow: hidden;
          border: 1px solid #e5e7eb;
          border-radius: 8px;
          background: #f3f4f6;
          color: #6b7280;
          font-size: 11px;
          font-weight: 700;
          text-align: center;
        }

        .gc-page-file-preview img,
        .gc-page-file-preview video {
          position: relative;
          z-index: 2;
          display: block;
          width: 100%;
          height: 100%;
          object-fit: contain;
          background: #f3f4f6;
        }

        .gc-page-file-preview-fallback {
          position: absolute;
          inset: 0;
          z-index: 1;
          display: flex;
          align-items: center;
          justify-content: center;
          padding: 4px;
        }

        .gc-page-file-name {
          overflow-wrap: anywhere;
          font-family: monospace;
          font-size: 12px;
        }

        .gc-page-file-type {
          display: inline-block;
          margin-top: 4px;
          color: #6b7280;
          font-size: 12px;
        }

        .gc-page-file-buttons {
          display: flex;
          flex-wrap: wrap;
          justify-content: flex-end;
          gap: 6px;
        }

        .gc-page-files-empty {
          padding: 22px;
          border: 1px dashed #cbd5e1;
          border-radius: 10px;
          text-align: center;
        }

        @media (max-width: 620px) {
          .gc-page-files-summary {
            grid-template-columns: repeat(2, minmax(0, 1fr));
          }

          .gc-page-file-row {
            grid-template-columns: 72px minmax(0, 1fr);
          }

          .gc-page-file-preview {
            width: 72px;
            height: 54px;
          }

          .gc-page-file-buttons {
            grid-column: 1 / -1;
            justify-content: flex-start;
          }
        }
      </style>
    `);
  }

  function createMediaModal() {
    if ($('#gc-page-files-modal').length) {
      return;
    }

    $('body').append(`
      <div
        id="gc-page-files-modal"
        class="gc-page-files-modal"
        hidden
        inert
      >
        <div class="gc-page-files-modal-backdrop"></div>

        <div
          class="gc-page-files-modal-dialog"
          role="dialog"
          aria-modal="true"
          aria-labelledby="gc-page-files-modal-title"
        >
          <div class="gc-page-files-modal-header">
            <h3
              id="gc-page-files-modal-title"
              class="gc-page-files-modal-title"
            ></h3>

            <button
              type="button"
              class="gc-page-files-modal-close"
              aria-label="Закрыть"
            >
              ×
            </button>
          </div>

          <div class="gc-page-files-modal-body">
            <div class="gc-page-files-summary"></div>
            <div class="gc-page-files-actions"></div>
            <div class="gc-page-files-list"></div>
          </div>
        </div>
      </div>
    `);
  }

  function showMediaModal(options) {
    const files = mergeFiles(options.files || []);
    const sourceHost =
      sanitizeHost(options.sourceHost) || location.hostname;

    mediaState = {
      files: files,
      sourceHost: sourceHost,
      title: options.title || 'Файлы страницы',
      activeFilter: 'all'
    };

    const $modal = $('#gc-page-files-modal');

    $modal
      .find('.gc-page-files-modal-title')
      .text(mediaState.title);

    renderMediaModalContents();

    modalReturnFocus = document.activeElement;

    $modal
      .prop('hidden', false)
      .removeAttr('inert')
      .addClass('is-open');

    window.setTimeout(function () {
      $modal.find('.gc-page-files-modal-close').trigger('focus');
    }, 0);
  }

  function renderMediaModalContents() {
    const files = mediaState.files;
    const counts = {
      image: files.filter(file => file.category === 'image').length,
      video: files.filter(file => file.category === 'video').length,
      other: files.filter(file => file.category === 'other').length
    };

    const activeFilter = mediaState.activeFilter || 'all';
    const visibleFiles = activeFilter === 'all'
      ? files
      : files.filter(function (file) {
        return file.category === activeFilter;
      });

    const directLinksCount = files.filter(function (file) {
      return Boolean(file.directUrl);
    }).length;

    const $modal = $('#gc-page-files-modal');

    $modal.find('.gc-page-files-summary').html(`
      ${renderCounter(
        'all',
        'Все файлы',
        files.length,
        activeFilter === 'all'
      )}
      ${renderCounter(
        'image',
        'Изображения',
        counts.image,
        activeFilter === 'image'
      )}
      ${renderCounter(
        'video',
        'Видео',
        counts.video,
        activeFilter === 'video'
      )}
      ${renderCounter(
        'other',
        'Другие',
        counts.other,
        activeFilter === 'other'
      )}
    `);

    $modal.find('.gc-page-files-actions').html(`
      <button
        type="button"
        class="btn btn-primary gc-copy-all-file-links"
        ${directLinksCount ? '' : 'disabled'}
      >
        Скопировать все ссылки на файлы (${directLinksCount})
      </button>
    `);

    const listHtml = visibleFiles.length
      ? visibleFiles.map(renderFileRow).join('')
      : `
        <div class="gc-page-files-empty">
          В выбранной категории файлы не найдены.
        </div>
      `;

    $modal.find('.gc-page-files-list').html(listHtml);
  }

  function closeMediaModal() {
    const $modal = $('#gc-page-files-modal');
    const activeElement = document.activeElement;

    if (
      activeElement &&
      $modal[0] &&
      $modal[0].contains(activeElement)
    ) {
      if (
        modalReturnFocus &&
        document.documentElement.contains(modalReturnFocus)
      ) {
        modalReturnFocus.focus();
      } else {
        activeElement.blur();
      }
    }

    $modal
      .removeClass('is-open')
      .prop('hidden', true)
      .attr('inert', '');
  }

  function renderCounter(filter, label, count, isActive) {
    return `
      <button
        type="button"
        class="gc-page-files-counter${isActive ? ' is-active' : ''}"
        data-filter="${escapeHtml(filter)}"
        aria-pressed="${isActive ? 'true' : 'false'}"
      >
        <strong>${count}</strong>
        <span>${escapeHtml(label)}</span>
      </button>
    `;
  }

  function renderFileRow(file) {
    return `
      <div class="gc-page-file-row">
        ${renderFilePreview(file)}

        <div>
          <div class="gc-page-file-name">
            ${escapeHtml(file.name || file.hash)}
          </div>
          <span class="gc-page-file-type">
            ${escapeHtml(getCategoryLabel(file.category))}
            ${file.accountId ? ` · аккаунт ${escapeHtml(file.accountId)}` : ''}
            ${file.sc ? ` · sc/${escapeHtml(file.sc)}` : ''}
          </span>
        </div>

        <div class="gc-page-file-buttons">
          ${renderDownloadLink(file)}

          <button
            type="button"
            class="btn btn-sm btn-default gc-open-file-storage"
            data-file-key="${escapeHtml(file.key)}"
            ${file.storageUrl ? '' : 'disabled'}
          >
            В хранилище
          </button>
        </div>
      </div>
    `;
  }

  function renderDownloadLink(file) {
    const downloadUrl = file.directUrl || null;

    if (!downloadUrl) {
      return `
        <button
          type="button"
          class="btn btn-sm btn-success"
          title="В коде страницы отсутствует значение sc"
          disabled
        >
          Нет прямой ссылки
        </button>
      `;
    }

    return `
      <a
        class="btn btn-sm btn-success gc-download-file"
        href="${escapeHtml(downloadUrl)}"
        target="_blank"
        rel="noopener noreferrer"
        title="Открыть файл в новой вкладке"
      >
        Скачать
      </a>
    `;
  }

  function renderFilePreview(file) {
    const previewUrl =
      file.previewUrl || file.directUrl || file.accountDownloadUrl;
    const extension = (file.extension || 'FILE').toUpperCase();

    if (file.category === 'image' && previewUrl) {
      return `
        <div class="gc-page-file-preview">
          <span class="gc-page-file-preview-fallback">IMG</span>
          <img
            src="${escapeHtml(previewUrl)}"
            alt=""
            loading="lazy"
            onerror="this.style.display='none'"
          >
        </div>
      `;
    }

    if (file.category === 'video' && previewUrl) {
      return `
        <div class="gc-page-file-preview">
          <span class="gc-page-file-preview-fallback">VIDEO</span>
          <video
            src="${escapeHtml(previewUrl)}"
            muted
            playsinline
            preload="metadata"
            onerror="this.style.display='none'"
          ></video>
        </div>
      `;
    }

    return `
      <div class="gc-page-file-preview">
        <span class="gc-page-file-preview-fallback">
          ${escapeHtml(extension)}
        </span>
      </div>
    `;
  }

  function getTargetOwnerContext(editorContext) {
    const context = editorContext || getEditorContext();
    let ownerId = null;
    let ownerTypeId = null;

    const blockSetData = $blockSet.data() || {};
    const candidates = [
      blockSetData,
      blockSetData.liteBlockSet,
      blockSetData.pluginLiteBlockSet,
      blockSetData.blockSetEditor
    ];

    Object.keys(blockSetData).forEach(function (key) {
      const value = blockSetData[key];

      if (value && typeof value === 'object') {
        candidates.push(value);
      }
    });

    candidates.forEach(function (candidate) {
      if (!candidate || typeof candidate !== 'object') {
        return;
      }

      const containers = [
        candidate,
        candidate.options,
        candidate.settings,
        candidate._options
      ];

      containers.forEach(function (container) {
        if (!container || typeof container !== 'object') {
          return;
        }

        if (!ownerId && /^\d+$/.test(String(container.ownerId || ''))) {
          ownerId = String(container.ownerId);
        }

        if (
          !ownerTypeId &&
          /^\d+$/.test(String(container.ownerTypeId || ''))
        ) {
          ownerTypeId = String(container.ownerTypeId);
        }
      });
    });

    if (context === 'lesson' && (!ownerId || !ownerTypeId)) {
      const scriptsText = $('script')
        .map(function () {
          return this.textContent || this.innerText || '';
        })
        .get()
        .join('\n');

      const lessonConfigMatch = scriptsText.match(
        /mainSection\s*:\s*["']lessons["'][\s\S]{0,1600}?ownerId\s*:\s*(\d+)[\s\S]{0,500}?ownerTypeId\s*:\s*(\d+)/i
      );

      if (lessonConfigMatch) {
        ownerId = ownerId || lessonConfigMatch[1];
        ownerTypeId = ownerTypeId || lessonConfigMatch[2];
      }
    }

    if (!ownerId) {
      const queryOwnerId = new URLSearchParams(
        window.location.search
      ).get('id');

      if (queryOwnerId && /^\d+$/.test(queryOwnerId)) {
        ownerId = queryOwnerId;
      }
    }

    if (context === 'lesson' && !ownerTypeId) {
      ownerTypeId = '161';
    }

    return {
      ownerId: ownerId,
      ownerTypeId: ownerTypeId
    };
  }

  function getEditorContext() {
    const path = String(location.pathname || '');

    if (
      window.controllerId === 'control/lesson' ||
      /\/(?:pl\/)?teach\/control\/lesson\/view/i.test(path) ||
      $blockSet.find('.lt-lesson').length
    ) {
      return 'lesson';
    }

    return 'page';
  }

  function getBlockDescriptors() {
    const editorContext = getEditorContext();

    return $blockSet
      .children('.lite-block')
      .get()
      .map(function (element) {
        const $block = $(element);
        const $innerBlock = $block.find('.lt-block').first();
        const classes = String(
          $innerBlock.attr('class') || ''
        )
          .split(/\s+/)
          .filter(Boolean);

        const preset = detectBlockPreset(classes);
        const isLessonServiceBlock =
          editorContext === 'lesson' &&
          isLessonServiceBlockElement($innerBlock, preset);

        return {
          id: String($block.data('id') || ''),
          element: element,
          preset: preset,
          isLessonServiceBlock: isLessonServiceBlock
        };
      })
      .filter(function (block) {
        return Boolean(block.id);
      });
  }

  function isLessonServiceBlockElement($innerBlock, preset) {
    if (!$innerBlock || !$innerBlock.length) {
      return false;
    }

    /*
     * В уроках исключаем только штатные служебные блоки.
     * Класс lt-system-block сам по себе не является признаком
     * служебного блока: его также получают пользовательские
     * JavaScript-, CSS-, HTML-блоки, якоря и другие элементы.
     */
    return (
      $innerBlock.is(
        '.lt-lesson-feedback-block, ' +
        '.lt-lesson-comment-block, ' +
        '.lt-lesson-mission-block'
      ) ||
      LESSON_SERVICE_PRESETS.has(String(preset || ''))
    );
  }

  function detectBlockPreset(classes) {
    const knownLessonPreset = classes.find(function (className) {
      return LESSON_SERVICE_PRESETS.has(className);
    });

    if (knownLessonPreset) {
      return knownLessonPreset;
    }

    const ignoredClasses = new Set([
      'lt-block',
      'lt-editing',
      'lt-lesson',
      'lt-raw',
      'lt-system-block',
      'lt-invisible-block',
      'has-mission'
    ]);

    return classes.find(function (className) {
      return (
        !ignoredClasses.has(className) &&
        !className.startsWith('lt-')
      );
    }) || null;
  }

  function createEmptyClipboardData() {
    return {
      blocks: [],
      blockMeta: [],
      files: [],
      sourceHost: null,
      sourceAccountId: null,
      sourceContext: null
    };
  }

  function buildClipboardBlockItems(clipboardData) {
    const metaList = Array.isArray(clipboardData.blockMeta)
      ? clipboardData.blockMeta
      : [];

    return (clipboardData.blocks || [])
      .map(function (block, index) {
        if (
          block &&
          typeof block === 'object' &&
          typeof block.code === 'string'
        ) {
          return {
            code: block.code,
            meta: block.meta || metaList[index] || null
          };
        }

        return {
          code: String(block || '').trim(),
          meta: metaList[index] || null
        };
      })
      .filter(function (item) {
        return Boolean(item.code);
      });
  }

  function shouldSkipClipboardBlock(blockItem, targetContext) {
    const meta = blockItem && blockItem.meta
      ? blockItem.meta
      : null;

    if (meta && meta.isLessonServiceBlock) {
      return true;
    }

    if (
      meta &&
      meta.preset &&
      LESSON_SERVICE_PRESETS.has(String(meta.preset))
    ) {
      return true;
    }

    const code = String(
      blockItem && blockItem.code
        ? blockItem.code
        : ''
    );

    return Array.from(LESSON_SERVICE_PRESETS).some(function (preset) {
      return code.includes(preset);
    });
  }

  function getImportResponseError(response) {
    if (!response) {
      return null;
    }

    const possibleMessages = [
      response.error,
      response.message,
      response.data && response.data.error,
      response.data && response.data.message
    ];

    const message = possibleMessages.find(function (value) {
      return typeof value === 'string' && value.trim();
    });

    if (
      response.success === false ||
      response.status === 'error' ||
      response.result === false
    ) {
      return message || 'GetCourse отклонил импорт блока';
    }

    if (
      message &&
      /not found preset|ошибк|error|не найден/i.test(message)
    ) {
      return message;
    }

    return null;
  }

  function getErrorMessage(error) {
    if (!error) {
      return 'неизвестная ошибка';
    }

    if (typeof error === 'string') {
      return error;
    }

    return String(
      error.message ||
      error.statusText ||
      error.responseText ||
      'неизвестная ошибка'
    );
  }

  function collectFilesFromCurrentBlocks(sourceHost, sourceAccountId) {
    const html = getBlockDescriptors()
      .filter(function (block) {
        return !block.isLessonServiceBlock;
      })
      .map(function (block) {
        return block.element.outerHTML || '';
      })
      .join('\n');

    return extractGetCourseFiles(
      html,
      sourceHost,
      sourceAccountId
    );
  }

  function extractGetCourseFiles(
    source,
    sourceHost,
    sourceAccountId
  ) {
    const normalized = normalizeSourceText(source);
    const files = [];

    let match;

    const thumbnailPattern =
      /fileservice\/file\/thumbnail\/h\/([^\/?#"'<>\\\s)]+)(?:\/s\/[^\/?#"'<>\\\s)]+)?\/a\/(\d+)\/sc\/(\d+)/gi;

    while ((match = thumbnailPattern.exec(normalized))) {
      files.push(
        createFileRecord({
          hash: cleanHash(match[1]),
          accountId: match[2],
          sc: match[3],
          sourceHost: sourceHost
        })
      );
    }

    const downloadPattern =
      /fileservice\/file\/download\/a\/(\d+)\/sc\/(\d+)\/h\/([^\/?#"'<>\\\s)]+)/gi;

    while ((match = downloadPattern.exec(normalized))) {
      files.push(
        createFileRecord({
          hash: cleanHash(match[3]),
          accountId: match[1],
          sc: match[2],
          sourceHost: sourceHost
        })
      );
    }

    const alternateDownloadPattern =
      /fileservice\/file\/download\/h\/([^\/?#"'<>\\\s)]+)\/a\/(\d+)\/sc\/(\d+)/gi;

    while ((match = alternateDownloadPattern.exec(normalized))) {
      files.push(
        createFileRecord({
          hash: cleanHash(match[1]),
          accountId: match[2],
          sc: match[3],
          sourceHost: sourceHost
        })
      );
    }

    const accountDownloadPattern =
      /\/pl\/fileservice\/user\/file\/download\?[^"'<>\s]*\bh=([^&#"'<>\s]+)/gi;

    while ((match = accountDownloadPattern.exec(normalized))) {
      files.push(
        createFileRecord({
          hash: cleanHash(match[1]),
          accountId: sourceAccountId || null,
          sourceHost: sourceHost
        })
      );
    }

    const videoHashPatterns = [
      /(?:file-hash|file_hash)=([^&#"'<>\s]+)/gi,
      /data-hash=["']([^"']+)["']/gi,
      /["'](?:file-hash|file_hash)["']\s*:\s*["']([^"']+)["']/gi
    ];

    videoHashPatterns.forEach(function (pattern) {
      while ((match = pattern.exec(normalized))) {
        const hash = cleanHash(match[1]);

        if (getFileCategory(hash) !== 'video') {
          continue;
        }

        files.push(
          createFileRecord({
            hash: hash,
            accountId: sourceAccountId || null,
            sourceHost: sourceHost
          })
        );
      }
    });

    return countFileOccurrences(files.filter(Boolean));
  }

  function createFileRecord(options) {
    const hash = cleanHash(options.hash);

    if (!hash || !hasFileExtension(hash)) {
      return null;
    }

    const sourceHost = sanitizeHost(options.sourceHost);
    const accountId = options.accountId
      ? String(options.accountId)
      : null;
    const sc = options.sc ? String(options.sc) : null;
    const category = getFileCategory(hash);
    const key = buildFileKey(accountId, hash);

    const directUrl = accountId && sc
      ? (
        'https://fs.getcourse.ru/fileservice/file/download/' +
        `a/${encodeURIComponent(accountId)}/` +
        `sc/${encodeURIComponent(sc)}/` +
        `h/${encodeURIComponent(hash)}`
      )
      : null;

    const accountDownloadUrl = sourceHost
      ? (
        `https://${sourceHost}/pl/fileservice/user/file/download?` +
        `h=${encodeURIComponent(hash)}`
      )
      : null;

    const previewUrl = accountId && sc
      ? (
        'https://fs-thb01.getcourse.ru/fileservice/file/thumbnail/' +
        `h/${encodeURIComponent(hash)}/s/s300x/` +
        `a/${encodeURIComponent(accountId)}/` +
        `sc/${encodeURIComponent(sc)}`
      )
      : null;

    return {
      key: key,
      hash: hash,
      name: resolveOriginalFileName(hash) || hash,
      extension: getExtension(hash),
      category: category,
      accountId: accountId,
      sc: sc,
      sourceHost: sourceHost,
      directUrl: directUrl,
      accountDownloadUrl: accountDownloadUrl,
      previewUrl: previewUrl,
      occurrences: Math.max(1, Number(options.occurrences) || 1),
      storageUrl: sourceHost
        ? buildStorageSearchUrl(sourceHost, hash)
        : null
    };
  }

  function serializeFile(file) {
    return {
      hash: file.hash,
      name: file.name || file.hash,
      accountId: file.accountId || null,
      sc: file.sc || null,
      sourceHost: file.sourceHost || null,
      occurrences: Math.max(1, Number(file.occurrences) || 1)
    };
  }

  function normalizeStoredFiles(
    storedFiles,
    sourceHost,
    sourceAccountId
  ) {
    if (!Array.isArray(storedFiles)) {
      return [];
    }

    return mergeFiles(
      storedFiles.map(function (file) {
        if (!file || !file.hash) {
          return null;
        }

        const normalizedFile = createFileRecord({
          hash: file.hash,
          accountId: file.accountId || sourceAccountId,
          sc: file.sc,
          sourceHost: file.sourceHost || sourceHost,
          occurrences: file.occurrences || 1
        });

        if (normalizedFile && file.name) {
          normalizedFile.name = String(file.name);
        }

        return normalizedFile;
      }).filter(Boolean)
    );
  }

  function mergeFiles(...groups) {
    const filesMap = new Map();

    groups.flat().filter(Boolean).forEach(function (file) {
      const key = file.key || buildFileKey(file.accountId, file.hash);
      const existing = filesMap.get(key);

      if (!existing) {
        filesMap.set(key, {
          ...file,
          key: key,
          occurrences: Math.max(1, Number(file.occurrences) || 1)
        });
        return;
      }

      filesMap.set(key, {
        ...existing,
        ...file,
        key: key,
        name:
          existing.name && existing.name !== existing.hash
            ? existing.name
            : file.name,
        directUrl: existing.directUrl || file.directUrl,
        accountDownloadUrl:
          existing.accountDownloadUrl || file.accountDownloadUrl,
        previewUrl: existing.previewUrl || file.previewUrl,
        storageUrl: existing.storageUrl || file.storageUrl,
        sourceHost: existing.sourceHost || file.sourceHost,
        accountId: existing.accountId || file.accountId,
        sc: existing.sc || file.sc,
        occurrences: Math.max(
          Number(existing.occurrences) || 1,
          Number(file.occurrences) || 1
        )
      });
    });

    return Array.from(filesMap.values()).sort(function (a, b) {
      const categoryOrder = {
        image: 1,
        video: 2,
        other: 3
      };

      return (
        categoryOrder[a.category] - categoryOrder[b.category] ||
        a.hash.localeCompare(b.hash)
      );
    });
  }

  function countFileOccurrences(files) {
    const filesMap = new Map();

    files.filter(Boolean).forEach(function (file) {
      const key = file.key || buildFileKey(file.accountId, file.hash);
      const existing = filesMap.get(key);

      if (!existing) {
        filesMap.set(key, {
          ...file,
          key: key,
          occurrences: Math.max(1, Number(file.occurrences) || 1)
        });
        return;
      }

      filesMap.set(key, {
        ...existing,
        ...file,
        key: key,
        directUrl: existing.directUrl || file.directUrl,
        accountDownloadUrl:
          existing.accountDownloadUrl || file.accountDownloadUrl,
        previewUrl: existing.previewUrl || file.previewUrl,
        storageUrl: existing.storageUrl || file.storageUrl,
        sourceHost: existing.sourceHost || file.sourceHost,
        accountId: existing.accountId || file.accountId,
        sc: existing.sc || file.sc,
        occurrences:
          (Number(existing.occurrences) || 1) +
          (Number(file.occurrences) || 1)
      });
    });

    return Array.from(filesMap.values());
  }

  function buildFileKey(accountId, hash) {
    return String(hash || '').trim().toLowerCase();
  }

  function buildStorageSearchUrl(host, hash) {
    const rule = {
      type: 'rule_by_hash',
      inverted: 0,
      params: {
        value: hash,
        valueMode: null
      },
      maxSize: ''
    };

    const params = new URLSearchParams();

    params.set('FileContext[segment_id]', '');
    params.set('FileContext[rule_string]', JSON.stringify(rule));

    return (
      `https://${host}/pl/fileindex/file/index?` +
      params.toString()
    );
  }

  function getFileDownloadUrl(file) {
    return file.directUrl || null;
  }

  function findMediaFile(fileKey) {
    return mediaState.files.find(function (file) {
      return file.key === String(fileKey);
    });
  }

  function resolveOriginalFileName(hash) {
    const element = document.getElementById(hash);

    if (!element) {
      return null;
    }

    return (
      element.getAttribute('data-filename') ||
      element.textContent ||
      null
    );
  }

  function getFileCategory(fileName) {
    const extension = getExtension(fileName);

    const imageExtensions = new Set([
      'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg',
      'avif', 'bmp', 'ico', 'tif', 'tiff'
    ]);

    const videoExtensions = new Set([
      'mp4', 'm4v', 'mov', 'webm', 'avi', 'mkv',
      'mpeg', 'mpg', 'ogv', '3gp', 'm3u8'
    ]);

    if (imageExtensions.has(extension)) {
      return 'image';
    }

    if (videoExtensions.has(extension)) {
      return 'video';
    }

    return 'other';
  }

  function getCategoryLabel(category) {
    if (category === 'image') {
      return 'Изображение';
    }

    if (category === 'video') {
      return 'Видео';
    }

    return 'Другой файл';
  }

  function getExtension(fileName) {
    const cleanName = String(fileName || '')
      .split('?')[0]
      .split('#')[0];

    const dotIndex = cleanName.lastIndexOf('.');

    return dotIndex >= 0
      ? cleanName.slice(dotIndex + 1).toLowerCase()
      : '';
  }

  function hasFileExtension(fileName) {
    return /^[^?#]+\.[a-z0-9]{1,12}$/i.test(
      String(fileName || '')
    );
  }

  function cleanHash(value) {
    let result = String(value || '').trim();

    try {
      result = decodeURIComponent(result);
    } catch (error) {
      // Оставляем исходное значение.
    }

    return result
      .replace(/&amp;/gi, '&')
      .split('&')[0]
      .split('?')[0]
      .replace(/[);,\]}]+$/g, '')
      .trim();
  }

  function normalizeSourceText(source) {
    const textarea = document.createElement('textarea');

    textarea.innerHTML = String(source || '');

    return textarea.value
      .replace(/\\u002f/gi, '/')
      .replace(/\\x2f/gi, '/')
      .replace(/\\\//g, '/')
      .replace(/&amp;/gi, '&');
  }

  function sanitizeHost(value) {
    if (!value) {
      return null;
    }

    try {
      const url = new URL(
        /^https?:\/\//i.test(String(value))
          ? String(value)
          : `https://${String(value)}`
      );

      return url.hostname;
    } catch (error) {
      return null;
    }
  }

  function parseClipboardData(text) {
    const trimmedText = String(text || '').trim();

    if (!trimmedText) {
      return createEmptyClipboardData();
    }

    try {
      const parsedData = JSON.parse(trimmedText);

      if (
        parsedData &&
        parsedData.type === 'getcourse-lite-blocks' &&
        Array.isArray(parsedData.blocks)
      ) {
        return {
          blocks: parsedData.blocks.filter(Boolean),
          blockMeta: Array.isArray(parsedData.blockMeta)
            ? parsedData.blockMeta
            : [],
          files: Array.isArray(parsedData.files)
            ? parsedData.files
            : [],
          sourceHost: parsedData.sourceHost || null,
          sourceAccountId: parsedData.sourceAccountId || null,
          sourceContext: parsedData.sourceContext || null
        };
      }

      if (Array.isArray(parsedData)) {
        return {
          ...createEmptyClipboardData(),
          blocks: parsedData.filter(Boolean)
        };
      }
    } catch (error) {
      return {
        ...createEmptyClipboardData(),
        blocks: trimmedText
          .split(/\s+/)
          .map(code => code.trim())
          .filter(Boolean)
      };
    }

    return createEmptyClipboardData();
  }

  async function exportBlockCode(blockId) {
    let lastError = null;

    for (let attempt = 1; attempt <= EXPORT_RETRIES; attempt++) {
      try {
        const response = await $.ajax({
          url: '/pl/lite/block/export',
          type: 'GET',
          dataType: 'json',
          cache: false,
          data: {
            id: blockId,
            _: Date.now()
          }
        });

        const blockCode =
          response && response.data && response.data.code
            ? String(response.data.code).trim()
            : '';

        if (blockCode) {
          return blockCode;
        }

        lastError = new Error(
          `GetCourse вернул пустой код блока ${blockId}`
        );
      } catch (error) {
        lastError = error;
      }

      if (attempt < EXPORT_RETRIES) {
        await delay(350);
      }
    }

    throw lastError || new Error(
      `Не удалось получить код блока ${blockId}`
    );
  }

  function importBlockCode(data) {
    return new Promise(function (resolve, reject) {
      let settled = false;

      const finishResolve = function (response) {
        if (settled) {
          return;
        }

        settled = true;
        clearTimeout(timeoutId);
        resolve(response);
      };

      const finishReject = function (error) {
        if (settled) {
          return;
        }

        settled = true;
        clearTimeout(timeoutId);
        reject(
          error instanceof Error
            ? error
            : new Error(String(error || 'Ошибка запроса импорта'))
        );
      };

      const timeoutId = setTimeout(function () {
        finishReject(
          new Error('GetCourse не ответил на запрос импорта за 30 секунд')
        );
      }, IMPORT_TIMEOUT);

      try {
        const request = ajaxCall(
          '/pl/lite/block/import',
          data,
          {},
          function (response) {
            finishResolve(response);
          }
        );

        if (
          request &&
          typeof request.fail === 'function'
        ) {
          request.fail(function (xhr, status, error) {
            const responseMessage =
              xhr && xhr.responseJSON
                ? getImportResponseError(xhr.responseJSON)
                : null;

            finishReject(
              new Error(
                responseMessage ||
                error ||
                status ||
                'Ошибка запроса импорта'
              )
            );
          });
        }
      } catch (error) {
        finishReject(error);
      }
    });
  }

  async function deleteBlockThroughEditor($block) {
    const blockElement = $block[0];
    const $deleteButton = $block
      .find('.lite-block-actions .btn-delete')
      .first();

    if (!$deleteButton.length) {
      throw new Error(
        `Не найдена кнопка удаления у блока ` +
        `${$block.data('id') || ''}`
      );
    }

    const originalConfirm = window.confirm;

    try {
      window.confirm = function () {
        return true;
      };

      $deleteButton.trigger('click');
      await delay(150);
      confirmGetCourseDeleteModal();
      await waitForBlockRemoval(blockElement, 15000);
    } finally {
      window.confirm = originalConfirm;
    }
  }

  function confirmGetCourseDeleteModal() {
    const $modal = $('.modal:visible').last();

    if (!$modal.length) {
      return;
    }

    let $confirmButton = $modal
      .find('.btn-danger, .btn-primary')
      .filter(function () {
        return /удалить|да|ок|подтвердить/i.test(
          $(this).text().trim()
        );
      })
      .first();

    if (!$confirmButton.length) {
      $confirmButton = $modal
        .find('.modal-footer .btn-danger')
        .first();
    }

    if (!$confirmButton.length) {
      $confirmButton = $modal
        .find('.modal-footer .btn-primary')
        .first();
    }

    if ($confirmButton.length) {
      $confirmButton.trigger('click');
    }
  }

  function waitForBlockRemoval(blockElement, timeout = 15000) {
    return new Promise(function (resolve, reject) {
      const startedAt = Date.now();

      const timer = setInterval(function () {
        const blockRemoved =
          !document.documentElement.contains(blockElement);

        if (blockRemoved) {
          clearInterval(timer);
          resolve();
          return;
        }

        if (Date.now() - startedAt >= timeout) {
          clearInterval(timer);

          reject(
            new Error(
              'Редактор не подтвердил удаление одного из блоков.'
            )
          );
        }
      }, 100);
    });
  }

  async function copyTextToClipboard(text) {
    try {
      await navigator.clipboard.writeText(text);
      return;
    } catch (clipboardError) {
      const $textarea = $('<textarea>')
        .val(text)
        .css({
          position: 'fixed',
          left: '-9999px',
          top: '0',
          opacity: '0'
        })
        .appendTo('body');

      $textarea[0].focus();
      $textarea[0].select();

      const copied = document.execCommand('copy');

      $textarea.remove();

      if (!copied) {
        throw clipboardError;
      }
    }
  }

  function setToolbarDisabled(disabled) {
    $('.copy-all-blocks-btn, ' +
      '.paste-all-blocks-btn, ' +
      '.show-page-files-btn, ' +
      '.delete-all-blocks-btn')
      .prop('disabled', disabled);
  }

  function escapeHtml(value) {
    return String(value || '')
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#039;');
  }

  function delay(milliseconds) {
    return new Promise(function (resolve) {
      setTimeout(resolve, milliseconds);
    });
  }
});

Код для браузера Firefox можно скопировать из файла https://docs.google.com/document/d/1OLS4IhWqcc6iBBgFCutuwsKEoH3e6T55lcD_Ujt2jBM/edit?usp=sharing
Никакие дополнительные параметры включать не нужно. Вернитесь в редактор GetCourse и обновите страницу.

После обновления в нижней части редактора должны появиться кнопки:

  • Скопировать все блоки
  • Вставить все блоки
  • Удалить все блоки
  • Файлы страницы

Доработка работает только в том браузере и профиле браузера, где она установлена.
Что делают кнопки:
Скопировать все блоки
Последовательно получает коды импорта блоков и помещает их в буфер обмена в том порядке, в котором блоки расположены на странице.
Во время копирования на кнопке отображается текущий прогресс.
Во время копирования лучше не уходить с страницы, иначе может не скопироваться и выдать ошибку, копирование происходит быстро, чаще до одной минуты.

Если GetCourse не разрешит экспортировать отдельный блок, скрипт повторит запрос. Если получить код всё равно не удастся, проблемный блок будет пропущен, а остальные блоки продолжат копироваться. Бывает автором блока запрещается его экспортировать, особенно это бывает в шаблонах.
Вставить все блоки
Получает сохранённые коды из буфера обмена и последовательно вставляет блоки на открытую страницу. Вставка блоков может занять чаще до 5 минут.

Следующий блок начинает импортироваться только после обработки предыдущего. Это помогает сохранить исходный порядок блоков и уменьшает вероятность ошибок редактора.
Перед вставкой убедитесь, что в буфере обмена находятся данные, полученные кнопкой «Скопировать все блоки».

Обратите внимание, что если вы переносите блоки с одного аккаунта GetCourse на другой аккаунт, то картинки, видео и другие загруженные файлы в файловое хранилище не переносятся. Это связано с политикой GetCourse.

Для этого была сделана следующая кнопка "Файлы страницы"
Файлы страницы
Открывает окно со списком уникальных файлов, найденных внутри блоков текущей страницы.
Один и тот же файл может использоваться в нескольких блоках, но в списке он отображается только один раз. Уникальность определяется по пути или хэшу файла.

Системные изображения интерфейса GetCourse, аватары пользователей и элементы административного меню в список не добавляются.
Фильтрация файлов
В верхней части окна расположены карточки:

  • Все файлы
  • Изображения
  • Видео
  • Другие

Нажмите на нужную карточку, чтобы оставить в списке только соответствующий тип файлов.
Чтобы снова увидеть весь список, нажмите «Все файлы».

Число на карточке показывает количество уникальных файлов соответствующего типа, а не количество их упоминаний в блоках.
Кнопка «Скопировать все ссылки на файлы»
Кнопка копирует в буфер обмена прямые ссылки на файлы в формате:
https://fs.getcourse.ru/fileservice/file/download/a/ID-АККАУНТА/sc/НОМЕР/h/ИМЯ-ФАЙЛА

Копируются только те ссылки, для которых в коде страницы удалось определить все необходимые части адреса: ID исходного аккаунта, значение sc и путь файла. Например, для видео нет прямых ссылок, поэтому их нужно будет скачать перейдя по кнопке "В хранилище"

Если для некоторых файлов GetCourse не указал значение sc, полноценная прямая ссылка для них не формируется. Такие файлы не попадут в скопированный список ссылок.

После копирования ссылки можно вставить в текстовый документ, таблицу, сообщение или адресную строку браузера.
Кнопка «Скачать»
Кнопка «Скачать» открывает прямую ссылку на файл в новой вкладке браузера.

GetCourse или браузер могут открыть изображение, видео или другой файл для просмотра вместо автоматического скачивания. Это нормальное поведение и зависит от заголовков, с которыми сервер отдаёт файл.
Кнопка «В хранилище»
Кнопка открывает файловое хранилище исходного аккаунта GetCourse с выборкой по пути конкретного файла.
Она полезна, когда нужно:
  • проверить наличие файла
  • посмотреть информацию о нём
  • скачать файл средствами самого GetCourse
  • найти его расположение в файловом хранилище
  • загрузить файл в другой аккаунт вручную
Почему файл может не найтись в файловом хранилище
Файл может отсутствовать в результатах выборки по нескольким причинам:

  • открыто файловое хранилище не исходного, а другого аккаунта;
  • файл был удалён из хранилища;
  • в HTML сохранился только хэш видео без полного адреса;
  • файл защищён настройками доступа исходного аккаунта.

Импорт блоков переносит структуру и настройки блоков, но не создаёт копии файлов в файловом хранилище другого аккаунта. Необходимые изображения, видео и документы следует скачать из исходного аккаунта, загрузить в новый аккаунт и затем заменить файлы в импортированных блоках.
Кнопка «Удалить все блоки»
Удаляет блоки страницы по очереди через штатные элементы управления редактора GetCourse.

Перед удалением появляется подтверждение. Перед использованием кнопки обязательно нажмите «Скопировать все блоки» или создайте копию страницы стандартными средствами GetCourse.
Возможные ошибки
«Не удалось получить код блока»
GetCourse не вернул код импорта конкретного блока.

Причиной могут быть ограничения блока, временная ошибка сервера, особый тип блока или отсутствие разрешения на экспорт. Скрипт повторяет запрос, после чего может пропустить этот блок и продолжить копирование остальных.

Проверьте указанный ID блока и при необходимости перенесите его вручную.

«Не удалось скопировать все блоки»
Операция экспорта была прервана. Возможные причины:
  • нестабильное интернет-соединение
  • истекла административная сессия
  • GetCourse временно не отвечает
  • открыт не редактор страницы
  • одновременно запущена другая операция
  • установлено несколько версий скрипта
  • сторонний JavaScript влияет на работу редактора

Обновите страницу, убедитесь, что установлена только одна версия скрипта, и повторите копирование.

Браузер запретил доступ к буферу обмена
Разрешите сайту GetCourse использовать буфер обмена в настройках браузера.

Кнопку необходимо нажимать вручную. Браузер может блокировать чтение или запись буфера обмена, если действие было запущено не непосредственным нажатием пользователя.

«В буфере обмена не найдено кодов блоков»
В буфере нет данных, созданных кнопкой «Скопировать все блоки».

Вернитесь на исходную страницу, снова скопируйте блоки и не копируйте другой текст перед вставкой.

Вставка остановилась на одном из блоков
GetCourse не смог импортировать конкретный код или редактор не успел обработать результат.

Обновите страницу и проверьте, какие блоки уже были вставлены. Не запускайте повторную вставку поверх уже добавленных блоков без предварительной проверки, иначе часть блоков может продублироваться.

Не отображаются изображения после импорта
Файл принадлежит файловому хранилищу другого аккаунта. GetCourse может ограничивать использование таких файлов на страницах нового аккаунта.

Скачайте файл из исходного аккаунта, загрузите его в файловое хранилище нового аккаунта и замените изображение или ссылку в настройках блока.

Кнопка «Скачать» открывает файл, но не загружает его
Это не ошибка скрипта. Сервер GetCourse передаёт файл браузеру для просмотра, а не как обязательное вложение для скачивания.

Сохраните файл вручную через контекстное меню браузера или сочетание Ctrl + S либо Command + S.

У файла отображается «Нет прямой ссылки»

В коде блока не хватает части адреса, необходимой для формирования прямой ссылки, чаще всего значения sc.

Попробуйте найти файл через его хэш в файловом хранилище исходного аккаунта, кнопкой «В хранилище» или открыть настройки самого блока.
Чего делать не следует
  • Не устанавливайте одновременно несколько версий скрипта.
  • Не нажимайте кнопки несколько раз подряд, пока предыдущая операция не завершилась.
  • Не обновляйте и не закрывайте страницу во время копирования, вставки или удаления.
  • Не удаляйте блоки до создания резервной копии.
  • Не вставляйте блоки повторно на страницу, не проверив результат предыдущей вставки.
  • Не рассчитывайте, что изображения и видео автоматически перенесутся вместе с блоками.
  • Не удаляйте файлы из исходного аккаунта, пока не убедитесь, что они загружены и заменены в новом аккаунте.
  • Не используйте инструмент на странице, которая не открыта в режиме редактирования.
  • Не передавайте прямые ссылки пользователям, если файлы содержат закрытые или персональные материалы.
ВАЖНО

Это неофициальная пользовательская доработка, которая не является частью стандартного функционала GetCourse и не поддерживается технической поддержкой платформы.


Перед использованием:


  • проверьте работу на тестовой странице;
  • создайте копию исходной страницы;
  • не запускайте удаление блоков без резервной копии;
  • дождитесь завершения текущей операции, прежде чем нажимать другую кнопку.

После обновления редактора или внутренних механизмов GetCourse отдельные функции доработки могут перестать работать и потребовать обновления.

Автор доработки и инструкции: канал «GetCourse: код и дизайн»
Доработка распространяется бесплатно. При копировании кода или публикации инструкции, пожалуйста, сохраняйте ссылку на источник:
https://t.me/GetCourse_Code

Версия доработки: 05 августа 2026 года.

⚠️ Скрипт не работает в новом дизайне

✨ Вопросы и сообщения об ошибках, связанных с этой доработкой: https://t.me/GetCourseCode
Made on
Tilda