$(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(/&/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(/&/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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function delay(milliseconds) {
return new Promise(function (resolve) {
setTimeout(resolve, milliseconds);
});
}
});
Это неофициальная пользовательская доработка, которая не является частью стандартного функционала GetCourse и не поддерживается технической поддержкой платформы.
Перед использованием:
После обновления редактора или внутренних механизмов GetCourse отдельные функции доработки могут перестать работать и потребовать обновления.