(function () { const root = document.getElementById("editor-root"); if (!root) return; const state = { broadcast: JSON.parse(root.dataset.broadcast), savedBroadcast: JSON.parse(root.dataset.broadcast), permissions: JSON.parse(root.dataset.permissions), translations: JSON.parse(root.dataset.translations), busy: false, busyMessage: "", operation: null, pollTimer: null, localScheduleValue: "", localUnpinScheduleValue: "", announcements: [], previewTimers: {}, imageImportScope: null, activeTab: "main", }; const INLINE_STYLE_ORDER = ["bold", "italic", "underline", "strikethrough"]; const INLINE_STYLE_MARKERS = { bold: "**", italic: "_", underline: "++", strikethrough: "~~", }; const titleInput = document.getElementById("title-input"); const headerTitle = document.querySelector(".editor-header h1"); const headerDirectory = document.querySelector(".editor-header p"); const telegramBotSelect = document.getElementById("telegram-bot-select"); const telegramChatSelect = document.getElementById("telegram-chat-select"); const orderSelect = document.getElementById("language-order-select"); const discussionPinningSelect = document.getElementById("discussion-pinning-select"); const deletePinServiceMessagesCheckbox = document.getElementById("delete-pin-service-messages"); const disableLinkPreviewsGlobal = document.getElementById("disable-link-previews-global"); const scheduleInput = document.getElementById("schedule-input"); const statusBoxContent = document.getElementById("status-box-content"); const clearStatusButton = document.getElementById("clear-status-button"); const telegramBindingPanel = document.getElementById("telegram-binding-panel"); const tabButtons = Array.from(document.querySelectorAll(".editor-tab-button")); const tabPanels = Array.from(document.querySelectorAll(".editor-tab-panel")); const pinningTabPanel = document.getElementById("editor-tab-panel-pinning"); const languagesContainer = document.getElementById("languages-container"); const contentContainer = document.querySelector(".content"); const sidebarContainer = document.querySelector(".sidebar"); const globalUpload = document.getElementById("upload-global"); const openGlobalImageImportButton = document.getElementById("open-global-image-import-button"); const imageImportModal = document.getElementById("image-import-modal"); const imageImportFormatSelect = document.getElementById("image-import-format-select"); const imageImportUrlInput = document.getElementById("image-import-url-input"); const imageImportSubmitUrlButton = document.getElementById("image-import-submit-url-button"); const imageImportCloseButton = document.getElementById("image-import-close-button"); const imageImportCancelButton = document.getElementById("image-import-cancel-button"); const saveButton = document.getElementById("save-button"); const publishButton = document.getElementById("publish-button"); const scheduleButton = document.getElementById("schedule-button"); const deleteTelegramButton = document.getElementById("delete-telegram-button"); const deleteStorageButton = document.getElementById("delete-storage-button"); const unpinScheduleInput = document.getElementById("unpin-schedule-input"); const unpinScheduleButton = document.getElementById("unpin-schedule-button"); const SIDEBAR_SCROLL_STORAGE_KEY = "linkki.sidebar.scrollTop"; function clone(obj) { return JSON.parse(JSON.stringify(obj)); } function persistSidebarScrollPosition() { if (!sidebarContainer) return; window.sessionStorage.setItem(SIDEBAR_SCROLL_STORAGE_KEY, String(sidebarContainer.scrollTop)); } function restoreSidebarScrollPosition() { if (!sidebarContainer) return; const savedValue = window.sessionStorage.getItem(SIDEBAR_SCROLL_STORAGE_KEY); if (!savedValue) return; const scrollTop = Number(savedValue); if (!Number.isFinite(scrollTop)) return; sidebarContainer.scrollTop = scrollTop; window.requestAnimationFrame(() => { sidebarContainer.scrollTop = scrollTop; }); } function renderPreservingContentScroll(callback) { const scroller = contentContainer || document.scrollingElement; if (!scroller) { callback(); return; } const scrollTop = scroller.scrollTop; const scrollLeft = scroller.scrollLeft; callback(); scroller.scrollTop = scrollTop; scroller.scrollLeft = scrollLeft; window.requestAnimationFrame(() => { scroller.scrollTop = scrollTop; scroller.scrollLeft = scrollLeft; }); } function t(key, fallback) { return Object.prototype.hasOwnProperty.call(state.translations, key) ? state.translations[key] : (fallback || key); } function setActiveTab(tabName) { state.activeTab = tabName; for (const button of tabButtons) { const active = button.dataset.tab === tabName; button.classList.toggle("active", active); button.setAttribute("aria-selected", active ? "true" : "false"); } for (const panel of tabPanels) { const active = panel.dataset.tabPanel === tabName; panel.classList.toggle("active", active); panel.hidden = !active; } } function renderTabLabels() { for (const button of tabButtons) { const tabName = button.dataset.tab || ""; if (tabName === "telegram") { button.textContent = t("editor.tab_telegram"); if (hasIncompleteTelegramMapping()) { button.textContent += ` (${t("editor.telegram_binding.incomplete")})`; } button.classList.toggle("tab-missing", hasIncompleteTelegramMapping()); } else { button.classList.remove("tab-missing"); } } } function can(permission) { return !!state.permissions[permission]; } function setPermissionDisabledState(element, permissionDenied) { if (!element) return; element.classList.toggle("permission-disabled", permissionDenied); } function canEditContent() { return can("edit_content"); } function canEditTargets() { return can("edit_targets"); } function canSaveAnnouncements() { return can("save_announcements"); } function canOpenTelegram() { return can("open_telegram"); } function updateSaveButtonState() { const canSave = canSaveAnnouncements(); saveButton.disabled = state.busy || !canSave || !hasUnsavedChanges(); saveButton.title = !canSave ? permissionReason("save_announcements") : ""; } function syncGlobalLinkPreviewToggle() { const previewFlags = state.broadcast.metadata.disable_link_previews || {}; const previewValues = state.broadcast.metadata.language_order.map((language) => !!previewFlags[language]); const allPreviewDisabled = previewValues.length > 0 && previewValues.every(Boolean); const noPreviewDisabled = previewValues.every((value) => !value); disableLinkPreviewsGlobal.checked = allPreviewDisabled; disableLinkPreviewsGlobal.indeterminate = !allPreviewDisabled && !noPreviewDisabled; } function normalizeComparableText(value) { return String(value || "") .replace(/\r\n/g, "\n") .replace(/\r/g, "\n") .split("\n") .map((line) => line.replace(/\s+$/u, "")) .join("\n") .replace(/\s+$/u, ""); } function buildEditableSnapshot(broadcast) { const metadata = broadcast.metadata; const includePersistedTarget = !metadata.telegram_binding; return { title: metadata.title, telegram_bot_id: includePersistedTarget ? (metadata.telegram_bot_id || "") : "", telegram_chat_id: includePersistedTarget ? (metadata.telegram_chat_id || "") : "", language_order: [...metadata.language_order], discussion_pinning_mode: metadata.discussion_pinning_mode || "first", delete_pin_service_messages: !!metadata.delete_pin_service_messages, schedule_draft_for: metadata.schedule_draft_for || "", enabled_languages: clone(metadata.enabled_languages), disable_link_previews: clone(metadata.disable_link_previews || {}), texts: { fi: normalizeComparableText(broadcast.texts.fi || ""), sv: normalizeComparableText(broadcast.texts.sv || ""), en: normalizeComparableText(broadcast.texts.en || ""), }, images: { global: (broadcast.images.global || []).map((item) => item.name), fi: (broadcast.images.fi || []).map((item) => item.name), sv: (broadcast.images.sv || []).map((item) => item.name), en: (broadcast.images.en || []).map((item) => item.name), }, }; } function scheduleInputValueForMetadata(metadata) { if (metadata.scheduled_for) { return toDatetimeLocal(new Date(metadata.scheduled_for)); } return metadata.schedule_draft_for || ""; } function unpinScheduleInputValueForMetadata(metadata) { if (!metadata.scheduled_unpin_for) { return ""; } return toDatetimeLocal(new Date(metadata.scheduled_unpin_for)); } function hasUnsavedChanges() { return JSON.stringify(buildEditableSnapshot(state.broadcast)) !== JSON.stringify(buildEditableSnapshot(state.savedBroadcast)); } function applyServerBroadcast(broadcast) { state.broadcast = broadcast; state.savedBroadcast = clone(broadcast); root.dataset.broadcast = JSON.stringify(broadcast); state.localScheduleValue = scheduleInputValueForMetadata(broadcast.metadata); state.localUnpinScheduleValue = unpinScheduleInputValueForMetadata(broadcast.metadata); } function applyServerBroadcastPreservingLocalEdits(broadcast) { const localBroadcast = clone(state.broadcast); state.savedBroadcast = clone(broadcast); state.broadcast = clone(broadcast); state.broadcast.metadata.title = localBroadcast.metadata.title; state.broadcast.metadata.telegram_bot_id = localBroadcast.metadata.telegram_bot_id || ""; state.broadcast.metadata.telegram_chat_id = localBroadcast.metadata.telegram_chat_id || ""; state.broadcast.metadata.language_order = [...localBroadcast.metadata.language_order]; state.broadcast.metadata.discussion_pinning_mode = localBroadcast.metadata.discussion_pinning_mode || "first"; state.broadcast.metadata.delete_pin_service_messages = !!localBroadcast.metadata.delete_pin_service_messages; state.broadcast.metadata.schedule_draft_for = localBroadcast.metadata.schedule_draft_for || null; state.broadcast.metadata.enabled_languages = clone(localBroadcast.metadata.enabled_languages); state.broadcast.metadata.disable_link_previews = clone(localBroadcast.metadata.disable_link_previews || {}); state.broadcast.texts = clone(localBroadcast.texts); state.broadcast.images = clone(localBroadcast.images); root.dataset.broadcast = JSON.stringify(state.broadcast); state.localScheduleValue = scheduleInputValueForMetadata(state.broadcast.metadata); state.localUnpinScheduleValue = unpinScheduleInputValueForMetadata(state.savedBroadcast.metadata); } function applyApiPayload(payload, preserveLocalEdits = false) { if (payload.announcements) { state.announcements = payload.announcements; } if (payload.broadcast) { if (preserveLocalEdits) { applyServerBroadcastPreservingLocalEdits(payload.broadcast); } else { applyServerBroadcast(payload.broadcast); } } } function confirmUnsavedChanges(messageKey) { if (!hasUnsavedChanges()) return true; return window.confirm(t(messageKey)); } function isLinked() { return !!state.broadcast.metadata.telegram_binding; } function isDeleteFromTelegramExpired() { if (!state.broadcast.telegram_delete_deadline) return false; return Date.now() >= new Date(state.broadcast.telegram_delete_deadline).getTime(); } function isTextOnlyEditable() { return isLinked(); } function areTargetsLocked() { return isLinked() || state.broadcast.metadata.status === "scheduled"; } function linkedLockReason() { return t("locked.text_only"); } function targetsLockReason() { if (isLinked()) { return linkedLockReason(); } if (state.broadcast.metadata.status === "scheduled") { return t("locked.targets_scheduled"); } return ""; } function permissionReason(permission) { return t(`permission.${permission}`, "You do not have permission for this action."); } function contentEditReasonFor(locked = isTextOnlyEditable()) { return locked ? linkedLockReason() : (!canEditContent() ? permissionReason("edit_content") : ""); } function telegramTargetCatalog() { return state.broadcast.telegram_targets || { bots: [], default_bot_id: "" }; } function selectedBot() { return telegramTargetCatalog().bots.find((bot) => bot.id === (state.broadcast.metadata.telegram_bot_id || "")) || null; } function selectedChatTarget() { const bot = selectedBot(); if (!bot) { return null; } return bot.chats.find((item) => item.chat_id === (state.broadcast.metadata.telegram_chat_id || "")) || null; } function defaultChatIdForBot(botId) { const bot = telegramTargetCatalog().bots.find((item) => item.id === botId); if (!bot) return ""; return bot.default_chat_id || (bot.chats[0]?.chat_id || ""); } function selectedChatHasDiscussionGroup() { return !!selectedChatTarget()?.has_discussion_group; } function hasCompleteDiscussionLinks(binding = telegramBinding()) { if (!binding) { return false; } const rows = bindingPrimaryRows(binding); return rows.length > 0 && rows.every((row) => !!row.discussionMessageId); } function supportsGroupLinks() { return !!selectedDiscussionLinkTarget().internalId; } function discussionPinningAvailability() { if (!selectedChatHasDiscussionGroup()) { return { available: false, reason: t("editor.discussion_pinning_requires_group"), }; } if (isLinked() && !hasCompleteDiscussionLinks()) { return { available: false, reason: t("editor.discussion_pinning_requires_links"), }; } return { available: true, reason: "", }; } function hasSelectedTarget() { const bot = selectedBot(); if (!bot) { return false; } return bot.chats.some((chat) => chat.chat_id === (state.broadcast.metadata.telegram_chat_id || "")); } function hasPersistedTarget() { return !!(state.savedBroadcast.metadata.telegram_bot_id && state.savedBroadcast.metadata.telegram_chat_id); } function linkLabelForLanguage(language) { if (language === "fi") return "\uD83C\uDDEB\uD83C\uDDEE t\u00e4st\u00e4"; if (language === "sv") return "\uD83C\uDDF8\uD83C\uDDEA h\u00e4r"; return "\uD83C\uDDEC\uD83C\uDDE7 here"; } function flagEmojiForLanguage(language) { if (language === "fi") return "\uD83C\uDDEB\uD83C\uDDEE"; if (language === "sv") return "\uD83C\uDDF8\uD83C\uDDEA"; return "\uD83C\uDDEC\uD83C\uDDE7"; } function buildCrossLanguageSnippet(language) { const enabledLanguages = state.broadcast.metadata.language_order.filter( (candidate) => candidate !== language && state.broadcast.metadata.enabled_languages[candidate], ); return enabledLanguages.map((candidate) => `[${linkLabelForLanguage(candidate)}](${candidate})`).join("\n"); } function buildLanguageFlagsSnippet() { return state.broadcast.metadata.language_order .map((language) => flagEmojiForLanguage(language)) .join(""); } function insertTextWithSpacing(textarea, language, textToInsert) { if (!textToInsert) return; const currentText = state.broadcast.texts[language] || ""; const start = typeof textarea.selectionStart === "number" ? textarea.selectionStart : currentText.length; const end = typeof textarea.selectionEnd === "number" ? textarea.selectionEnd : start; const before = currentText.slice(0, start); const after = currentText.slice(end); let insertion = textToInsert; if (before.length > 0 && !before.endsWith("\n")) { insertion = `\n${insertion}`; } if (after.length > 0 && !after.startsWith("\n")) { insertion = `${insertion}\n`; } const updatedText = `${before}${insertion}${after}`; const caretPosition = before.length + insertion.length; updateTextareaValue(textarea, language, updatedText, caretPosition, caretPosition); } function emptyInlineStyleState() { return { bold: false, italic: false, underline: false, strikethrough: false, }; } function cloneInlineStyleState(styles) { return { ...emptyInlineStyleState(), ...(styles || {}) }; } function updateTextareaValue(textarea, language, value, selectionStart, selectionEnd) { state.broadcast.texts[language] = value; textarea.value = value; textarea.focus(); textarea.selectionStart = selectionStart; textarea.selectionEnd = selectionEnd; syncTextareaHeight(textarea); updateSaveButtonState(); } function replaceTextRange(textarea, language, start, end, replacement, selectionStart, selectionEnd) { const currentText = state.broadcast.texts[language] || ""; const updatedText = `${currentText.slice(0, start)}${replacement}${currentText.slice(end)}`; updateTextareaValue(textarea, language, updatedText, selectionStart, selectionEnd); } function getSelectionRange(textarea, text) { const fallback = text.length; const start = typeof textarea.selectionStart === "number" ? textarea.selectionStart : fallback; const end = typeof textarea.selectionEnd === "number" ? textarea.selectionEnd : start; return start <= end ? { start, end } : { start: end, end: start }; } function isWhitespaceCharacter(character) { return /\s/u.test(character || ""); } function isWordCharacter(character) { return /[\p{L}\p{N}_]/u.test(character || ""); } function expandRangeToWord(text, range) { if (range.start !== range.end) { return range; } if (!text) { return range; } let anchor = range.start; if (anchor < text.length && isWordCharacter(text[anchor])) { // keep current anchor } else if (anchor > 0 && isWordCharacter(text[anchor - 1])) { anchor -= 1; } else { while (anchor < text.length && !isWordCharacter(text[anchor])) { anchor += 1; } if (anchor >= text.length) { anchor = range.start - 1; while (anchor >= 0 && !isWordCharacter(text[anchor])) { anchor -= 1; } } if (anchor < 0 || anchor >= text.length || !isWordCharacter(text[anchor])) { return range; } } let start = anchor; let end = anchor + 1; while (start > 0 && isWordCharacter(text[start - 1])) { start -= 1; } while (end < text.length && isWordCharacter(text[end])) { end += 1; } return { start, end }; } function expandRangeToWholeLines(text, range) { let start = range.start; let end = range.end; while (start > 0 && text[start - 1] !== "\n") { start -= 1; } while (end < text.length && text[end] !== "\n") { end += 1; } return { start, end }; } function trimRangeWhitespace(text, range) { let { start, end } = range; while (start < end && isWhitespaceCharacter(text[start])) { start += 1; } while (start < end && isWhitespaceCharacter(text[end - 1])) { end -= 1; } return { start, end }; } function collectOpaqueRanges(text) { const ranges = []; let index = 0; while (index < text.length) { if (text.startsWith("```", index)) { const closeIndex = text.indexOf("```", index + 3); const end = closeIndex === -1 ? text.length : closeIndex + 3; ranges.push({ start: index, end, kind: "code_block" }); index = end; continue; } if (text[index] === "`") { const closeIndex = text.indexOf("`", index + 1); const end = closeIndex === -1 ? text.length : closeIndex + 1; ranges.push({ start: index, end, kind: "inline_code" }); index = end; continue; } if (text.startsWith("||", index)) { const closeIndex = text.indexOf("||", index + 2); const end = closeIndex === -1 ? text.length : closeIndex + 2; ranges.push({ start: index, end, kind: "spoiler" }); index = end; continue; } const linkMatch = text.slice(index).match(/^\[[^\]\n]+\]\((?:\\.|[^)\n])+\)/u); if (linkMatch) { const end = index + linkMatch[0].length; ranges.push({ start: index, end, kind: "link" }); index = end; continue; } index += 1; } return ranges; } function findOpaqueRangeAt(ranges, index) { return ranges.find((range) => range.start <= index && index < range.end) || null; } function selectionIntersectsOpaqueRange(ranges, start, end) { return ranges.some((range) => range.start < end && start < range.end); } function selectionContainsBlockedLineMarkup(text) { return /(^|\n)\s*(?:>\s|[-+*]\s|\d+\.\s)/u.test(text); } function isQuotedLine(line) { return /^\s*>\s?/u.test(line); } function removeQuotedLinePrefix(line) { return line.replace(/^(\s*)>\s?/u, "$1"); } function parseLinkRange(text, range) { const raw = text.slice(range.start, range.end); const match = raw.match(/^\[([^\]\n]+)\]\(((?:\\.|[^)\n])+)\)$/u); if (!match) { return null; } return { label: match[1], url: match[2], }; } function unwrapLinkRange(text, range) { const parsed = parseLinkRange(text, range); if (!parsed) { return null; } return { prefixLength: 1, suffixLength: parsed.url.length + 3, innerText: parsed.label, }; } function linkRangeAt(ranges, start, end) { if (start === end) { return ranges.find((range) => range.kind === "link" && range.start <= start && start < range.end) || null; } return ranges.find((range) => range.kind === "link" && range.start < end && start < range.end) || null; } function touchedLinkRanges(ranges, start, end) { if (start === end) { const containing = linkRangeAt(ranges, start, end); return containing ? [containing] : []; } return ranges.filter((range) => range.kind === "link" && range.start < end && start < range.end); } function looksLikeLinkTarget(text) { const candidate = String(text || "").trim(); if (!candidate) { return false; } if (/^(fi|sv|en)$/iu.test(candidate)) { return true; } return /^(?:https?:\/\/|www\.)\S+$/iu.test(candidate); } function matchingOpaqueKinds(actionType) { if (actionType === "code") { return new Set(["inline_code", "code_block"]); } if (actionType === "spoiler") { return new Set(["spoiler"]); } return new Set(); } function isMatchingOpaqueRange(range, actionType) { return matchingOpaqueKinds(actionType).has(range.kind); } function opaqueContentBounds(text, range) { const unwrapped = unwrapOpaqueRange(text, range); return { start: range.start + unwrapped.prefixLength, end: range.end - unwrapped.suffixLength, }; } function findContainingOpaqueRange(text, ranges, start, end, actionType) { return ranges.find((range) => { if (!isMatchingOpaqueRange(range, actionType)) { return false; } const bounds = opaqueContentBounds(text, range); if (start === end) { return bounds.start <= start && start <= bounds.end; } return bounds.start <= start && end <= bounds.end; }) || null; } function findIntersectingOpaqueRanges(ranges, start, end) { return ranges.filter((range) => range.start < end && start < range.end); } function unwrapOpaqueRange(text, range) { const raw = text.slice(range.start, range.end); if (range.kind === "inline_code") { return { prefixLength: 1, suffixLength: 1, innerText: raw.slice(1, -1), }; } if (range.kind === "spoiler") { return { prefixLength: 2, suffixLength: 2, innerText: raw.slice(2, -2), }; } if (range.kind === "code_block") { if (raw.startsWith("```\n") && raw.endsWith("\n```")) { return { prefixLength: 4, suffixLength: 4, innerText: raw.slice(4, -4), }; } return { prefixLength: 3, suffixLength: 3, innerText: raw.slice(3, -3), }; } return { prefixLength: 0, suffixLength: 0, innerText: raw, }; } function removeContainingOpaqueRange(textarea, language, text, range, selectionStart, selectionEnd) { const unwrapped = unwrapOpaqueRange(text, range); const contentStart = range.start + unwrapped.prefixLength; const relativeStart = Math.max(0, Math.min(selectionStart - contentStart, unwrapped.innerText.length)); const relativeEnd = Math.max(0, Math.min(selectionEnd - contentStart, unwrapped.innerText.length)); replaceTextRange( textarea, language, range.start, range.end, unwrapped.innerText, range.start + relativeStart, range.start + relativeEnd, ); return true; } function stripContainedOpaqueRanges(text, start, end, ranges) { const parts = []; let cursor = start; for (const range of ranges) { parts.push(text.slice(cursor, range.start)); parts.push(unwrapOpaqueRange(text, range).innerText); cursor = range.end; } parts.push(text.slice(cursor, end)); return parts.join(""); } function buildOpaqueReplacement(selectedText, actionType) { if (actionType === "code") { if (selectedText.includes("\n")) { if (selectedText.includes("```")) { return null; } return { replacement: `\`\`\`\n${selectedText}\n\`\`\``, contentOffset: 4, }; } if (selectedText.includes("`")) { return null; } return { replacement: `\`${selectedText}\``, contentOffset: 1, }; } if (actionType === "spoiler") { return { replacement: `||${selectedText}||`, contentOffset: 2, }; } return null; } function applyOpaqueFormatting(textarea, language, actionType) { const text = state.broadcast.texts[language] || ""; const opaqueRanges = collectOpaqueRanges(text); const originalRange = getSelectionRange(textarea, text); if (originalRange.start === originalRange.end) { const containingRange = findContainingOpaqueRange(text, opaqueRanges, originalRange.start, originalRange.end, actionType); if (containingRange) { return removeContainingOpaqueRange(textarea, language, text, containingRange, originalRange.start, originalRange.end); } } let range = originalRange.start === originalRange.end ? expandRangeToWord(text, originalRange) : originalRange; if (range.start === range.end) { return false; } if (actionType === "code" && text.slice(range.start, range.end).includes("\n")) { range = expandRangeToWholeLines(text, range); } const containingRange = findContainingOpaqueRange(text, opaqueRanges, range.start, range.end, actionType); if (containingRange) { return removeContainingOpaqueRange(textarea, language, text, containingRange, range.start, range.end); } const intersectingRanges = findIntersectingOpaqueRanges(opaqueRanges, range.start, range.end); const partialIntersectingRanges = intersectingRanges.filter( (item) => !(range.start <= item.start && item.end <= range.end), ); if (partialIntersectingRanges.length > 0) { return false; } if (actionType !== "spoiler") { const otherIntersectingRanges = intersectingRanges.filter((item) => !isMatchingOpaqueRange(item, actionType)); if (otherIntersectingRanges.length > 0) { return false; } } const containedMatchingRanges = intersectingRanges.filter( (item) => isMatchingOpaqueRange(item, actionType) && range.start <= item.start && item.end <= range.end, ); const selectedText = containedMatchingRanges.length > 0 ? stripContainedOpaqueRanges(text, range.start, range.end, containedMatchingRanges) : text.slice(range.start, range.end); if (!selectedText) { return false; } const built = buildOpaqueReplacement(selectedText, actionType); if (!built) { return false; } replaceTextRange( textarea, language, range.start, range.end, built.replacement, range.start + built.contentOffset, range.start + built.contentOffset + selectedText.length, ); return true; } function inlineMarkerAt(text, index) { if (text.startsWith("**", index) || text.startsWith("__", index)) { return { style: "bold", marker: text.slice(index, index + 2) }; } if (text.startsWith("++", index)) { return { style: "underline", marker: "++" }; } if (text.startsWith("~~", index)) { return { style: "strikethrough", marker: "~~" }; } if (text[index] === "*" || text[index] === "_") { return { style: "italic", marker: text[index] }; } return null; } function inlineStyleStateAt(text, end, opaqueRanges) { const active = emptyInlineStyleState(); const stack = inlineStyleStackAt(text, end, opaqueRanges); for (const style of stack) { active[style] = true; } return active; } function inlineStyleStackAt(text, end, opaqueRanges) { const activeStack = []; let index = 0; while (index < end) { const opaqueRange = findOpaqueRangeAt(opaqueRanges, index); if (opaqueRange) { index = Math.min(opaqueRange.end, end); continue; } if (text[index] === "\\" && index + 1 < end) { index += 2; continue; } const marker = inlineMarkerAt(text, index); if (marker && index + marker.marker.length <= end) { const existingIndex = activeStack.lastIndexOf(marker.style); if (existingIndex === -1) { activeStack.push(marker.style); } else { activeStack.splice(existingIndex, 1); } index += marker.marker.length; continue; } index += 1; } return activeStack; } function parseInlineSelection(text, start, end, initialStyles, opaqueRanges) { const characters = []; const active = cloneInlineStyleState(initialStyles); let index = start; while (index < end) { const opaqueRange = findOpaqueRangeAt(opaqueRanges, index); if (opaqueRange) { return null; } if (text[index] === "\\" && index + 1 < end) { characters.push({ value: text[index], styles: cloneInlineStyleState(active) }); characters.push({ value: text[index + 1], styles: cloneInlineStyleState(active) }); index += 2; continue; } const marker = inlineMarkerAt(text, index); if (marker && index + marker.marker.length <= end) { active[marker.style] = !active[marker.style]; index += marker.marker.length; continue; } characters.push({ value: text[index], styles: cloneInlineStyleState(active) }); index += 1; } return { characters, finalStyles: active, }; } function collectInlineStyleSpans(text, opaqueRanges) { const spans = { bold: [], italic: [], underline: [], strikethrough: [], }; const activeStarts = { bold: null, italic: null, underline: null, strikethrough: null, }; let index = 0; while (index < text.length) { const opaqueRange = findOpaqueRangeAt(opaqueRanges, index); if (opaqueRange) { index = opaqueRange.end; continue; } if (text[index] === "\\" && index + 1 < text.length) { index += 2; continue; } const marker = inlineMarkerAt(text, index); if (marker) { if (activeStarts[marker.style] === null) { activeStarts[marker.style] = index; } else { spans[marker.style].push({ start: activeStarts[marker.style], end: index + marker.marker.length, }); activeStarts[marker.style] = null; } index += marker.marker.length; continue; } index += 1; } return spans; } function flattenInlineStyleSpans(spansByStyle) { const spans = []; for (const style of INLINE_STYLE_ORDER) { for (const span of spansByStyle[style] || []) { spans.push({ ...span, style }); } } return spans; } function inlineSpanContentBounds(text, span) { const marker = inlineMarkerAt(text, span.start); const markerLength = marker ? marker.marker.length : 0; return { start: span.start + markerLength, end: span.end - markerLength, }; } function expandRangeToInlineStyleSpans(range, spans) { let expanded = { ...range }; let changed = true; while (changed) { changed = false; for (const span of spans) { if (span.start < expanded.end && expanded.start < span.end) { const nextStart = Math.min(expanded.start, span.start); const nextEnd = Math.max(expanded.end, span.end); if (nextStart !== expanded.start || nextEnd !== expanded.end) { expanded = { start: nextStart, end: nextEnd }; changed = true; } } } } return expanded; } function expandRangeToCoveredInlineStyleSpans(text, range, spans) { let expanded = { ...range }; let changed = true; while (changed) { changed = false; for (const span of spans) { const bounds = inlineSpanContentBounds(text, span); if (expanded.start <= bounds.start && bounds.end <= expanded.end) { const nextStart = Math.min(expanded.start, span.start); const nextEnd = Math.max(expanded.end, span.end); if (nextStart !== expanded.start || nextEnd !== expanded.end) { expanded = { start: nextStart, end: nextEnd }; changed = true; } } } } return expanded; } function balanceInlineSelectionEnd(text, range, opaqueRanges) { let expanded = { ...range }; while (expanded.end < text.length) { const initialStyles = inlineStyleStateAt(text, expanded.start, opaqueRanges); const parsedSelection = parseInlineSelection(text, expanded.start, expanded.end, initialStyles, opaqueRanges); if (!parsedSelection || inlineStyleStatesMatch(parsedSelection.finalStyles, initialStyles)) { return expanded; } const trailingMarker = inlineMarkerAt(text, expanded.end); if (!trailingMarker) { return expanded; } if (parsedSelection.finalStyles[trailingMarker.style] === initialStyles[trailingMarker.style]) { return expanded; } expanded = { start: expanded.start, end: expanded.end + trailingMarker.marker.length, }; } return expanded; } function inlineStyleStatesMatch(first, second) { return INLINE_STYLE_ORDER.every((style) => !!first[style] === !!second[style]); } function buildTargetStyleStack(activeStack, desiredStyles) { const targetStack = activeStack.filter((style) => !!desiredStyles[style]); for (const style of INLINE_STYLE_ORDER) { if (!!desiredStyles[style] && !targetStack.includes(style)) { targetStack.push(style); } } return targetStack; } function transitionInlineStyles(parts, activeStack, desiredStyles) { const targetStack = buildTargetStyleStack(activeStack, desiredStyles); let sharedPrefixLength = 0; while ( sharedPrefixLength < activeStack.length && sharedPrefixLength < targetStack.length && activeStack[sharedPrefixLength] === targetStack[sharedPrefixLength] ) { sharedPrefixLength += 1; } while (activeStack.length > sharedPrefixLength) { const style = activeStack.pop(); parts.push(INLINE_STYLE_MARKERS[style]); } while (activeStack.length < targetStack.length) { const style = targetStack[activeStack.length]; activeStack.push(style); parts.push(INLINE_STYLE_MARKERS[style]); } } function serializeInlineSelection(characters, surroundingStyles, surroundingStack) { const parts = []; const activeStack = [...(surroundingStack || [])]; for (const character of characters) { transitionInlineStyles(parts, activeStack, character.styles); parts.push(character.value); } transitionInlineStyles(parts, activeStack, surroundingStyles); return parts.join(""); } function toggleInlineStyle(textarea, language, style) { const text = state.broadcast.texts[language] || ""; const opaqueRanges = collectOpaqueRanges(text); const inlineStyleSpans = collectInlineStyleSpans(text, opaqueRanges); const allInlineSpans = flattenInlineStyleSpans(inlineStyleSpans); let range = expandRangeToWord(text, getSelectionRange(textarea, text)); if (range.start === range.end) { return false; } range = expandRangeToInlineStyleSpans(range, inlineStyleSpans[style] || []); range = expandRangeToCoveredInlineStyleSpans(text, range, allInlineSpans); range = balanceInlineSelectionEnd(text, range, opaqueRanges); const selectedText = text.slice(range.start, range.end); if (selectedText.includes("\n")) { return false; } if (selectionIntersectsOpaqueRange(opaqueRanges, range.start, range.end)) { return false; } if (selectionContainsBlockedLineMarkup(selectedText)) { return false; } const surroundingStack = inlineStyleStackAt(text, range.start, opaqueRanges); const initialStyles = cloneInlineStyleState(); for (const activeStyle of surroundingStack) { initialStyles[activeStyle] = true; } const parsedSelection = parseInlineSelection(text, range.start, range.end, initialStyles, opaqueRanges); if (!parsedSelection || parsedSelection.characters.length === 0) { return false; } if (!inlineStyleStatesMatch(parsedSelection.finalStyles, initialStyles)) { return false; } const shouldApply = parsedSelection.characters.some((character) => !character.styles[style]); for (const character of parsedSelection.characters) { character.styles[style] = shouldApply; } const replacement = serializeInlineSelection(parsedSelection.characters, initialStyles, surroundingStack); replaceTextRange(textarea, language, range.start, range.end, replacement, range.start, range.start + replacement.length); return true; } function applyCodeFormatting(textarea, language) { return applyOpaqueFormatting(textarea, language, "code"); } function applySpoilerFormatting(textarea, language) { return applyOpaqueFormatting(textarea, language, "spoiler"); } function applyLinkFormatting(textarea, language) { const text = state.broadcast.texts[language] || ""; const opaqueRanges = collectOpaqueRanges(text); const originalRange = getSelectionRange(textarea, text); const touchedLinks = touchedLinkRanges(opaqueRanges, originalRange.start, originalRange.end); if (touchedLinks.length > 0) { const replaceStart = touchedLinks[0].start; const replaceEnd = touchedLinks[touchedLinks.length - 1].end; const parts = []; let cursor = replaceStart; for (const range of touchedLinks) { const unwrapped = unwrapLinkRange(text, range); if (!unwrapped) { return false; } parts.push(text.slice(cursor, range.start)); parts.push(unwrapped.innerText); cursor = range.end; } parts.push(text.slice(cursor, replaceEnd)); const replacement = parts.join(""); replaceTextRange(textarea, language, replaceStart, replaceEnd, replacement, replaceStart, replaceStart + replacement.length); return true; } let range = originalRange.start === originalRange.end ? expandRangeToWord(text, originalRange) : originalRange; range = trimRangeWhitespace(text, range); if (range.start === range.end || selectionIntersectsOpaqueRange(opaqueRanges, range.start, range.end)) { return false; } const selectedText = text.slice(range.start, range.end); if (!selectedText || selectedText.includes("\n")) { return false; } const selectionLooksLikeLink = looksLikeLinkTarget(selectedText); const replacement = `[${selectedText}](${selectedText})`; const labelStart = range.start + 1; const urlStart = range.start + selectedText.length + 3; if (selectionLooksLikeLink) { replaceTextRange(textarea, language, range.start, range.end, replacement, labelStart, labelStart + selectedText.length); } else { replaceTextRange(textarea, language, range.start, range.end, replacement, urlStart, urlStart + selectedText.length); } return true; } function applyBlockquoteFormatting(textarea, language) { const text = state.broadcast.texts[language] || ""; let range = expandRangeToWholeLines(text, getSelectionRange(textarea, text)); const selectedText = text.slice(range.start, range.end); if (!selectedText) { return false; } const lines = selectedText.split("\n"); const shouldApply = lines.some((line) => !isQuotedLine(line)); const replacement = lines.map((line) => { if (shouldApply) { return isQuotedLine(line) ? line : `> ${line}`; } return isQuotedLine(line) ? removeQuotedLinePrefix(line) : line; }).join("\n"); replaceTextRange(textarea, language, range.start, range.end, replacement, range.start, range.start + replacement.length); return true; } function applyLinePrefix(textarea, language, prefixBuilder) { const text = state.broadcast.texts[language] || ""; const range = expandRangeToWholeLines(text, getSelectionRange(textarea, text)); const selectedText = text.slice(range.start, range.end); if (!selectedText) { return false; } const lines = selectedText.split("\n"); const replacement = lines.map((line, index) => `${prefixBuilder(index)}${line}`).join("\n"); replaceTextRange(textarea, language, range.start, range.end, replacement, range.start, range.start + replacement.length); return true; } function applyLanguageLinks(textarea, language, snippet) { if (!snippet) { return false; } insertTextWithSpacing(textarea, language, snippet); return true; } function applyFormattingAction(textarea, language, action, previewContent) { let changed = false; if (action.type === "inline_style") { changed = toggleInlineStyle(textarea, language, action.style); } else if (action.type === "code") { changed = applyCodeFormatting(textarea, language); } else if (action.type === "spoiler") { changed = applySpoilerFormatting(textarea, language); } else if (action.type === "link") { changed = applyLinkFormatting(textarea, language); } else if (action.type === "blockquote") { changed = applyBlockquoteFormatting(textarea, language); } else if (action.type === "bullet_list") { changed = applyLinePrefix(textarea, language, () => "- "); } else if (action.type === "numbered_list") { changed = applyLinePrefix(textarea, language, (index) => `${index + 1}. `); } else if (action.type === "language_links") { changed = applyLanguageLinks(textarea, language, action.snippet || ""); } else if (action.type === "language_flags") { changed = applyLanguageLinks(textarea, language, action.snippet || ""); } if (changed) { schedulePreviewUpdate(language, previewContent); } return changed; } function syncTextareaHeight(textarea) { if (!textarea) { return; } textarea.style.height = "auto"; const previewPanel = textarea._previewPanel || null; const previewHeight = previewPanel && !previewPanel.hidden ? previewPanel.offsetHeight : 0; const targetHeight = Math.max(240, textarea.scrollHeight, previewHeight); textarea.style.height = `${targetHeight}px`; } function syncAllTextareaHeights() { for (const textarea of languagesContainer.querySelectorAll("textarea")) { syncTextareaHeight(textarea); } } function isModifierShortcut(event) { return (event.ctrlKey || event.metaKey) && !event.altKey; } function handleTextareaShortcut(event, textarea, language, previewContent) { if (!isModifierShortcut(event)) { return; } let action = null; const key = String(event.key || "").toLowerCase(); if (key === "b") { action = { type: "inline_style", style: "bold" }; } else if (key === "i") { action = { type: "inline_style", style: "italic" }; } else if (key === "u") { action = { type: "inline_style", style: "underline" }; } else { return; } event.preventDefault(); applyFormattingAction(textarea, language, action, previewContent); } function sourceBroadcastForOutboundAction() { return hasUnsavedChanges() ? state.savedBroadcast : state.broadcast; } function collectInactiveLinkWarnings(broadcast) { const issues = []; const seen = new Set(); const placeholderPattern = /\[([^\]]+)\]\((fi|sv|en)\)/gi; for (const sourceLanguage of broadcast.metadata.language_order) { if (!broadcast.metadata.enabled_languages[sourceLanguage]) continue; const text = String(broadcast.texts[sourceLanguage] || ""); let match; while ((match = placeholderPattern.exec(text)) !== null) { const targetLanguage = match[2].toLowerCase(); const targetEnabled = !!broadcast.metadata.enabled_languages[targetLanguage]; const targetHasText = String(broadcast.texts[targetLanguage] || "").trim().length > 0; if (targetEnabled && targetHasText) { continue; } const reason = !targetEnabled ? t("warning.target_disabled") : t("warning.target_empty"); const issueKey = `${sourceLanguage}:${targetLanguage}:${reason}`; if (seen.has(issueKey)) { continue; } seen.add(issueKey); issues.push( `${displayName(sourceLanguage)} -> ${displayName(targetLanguage)}: ${reason}` ); } } return issues; } function confirmOutboundAction(actionKey) { const messages = []; if (actionKey === "publish" && hasUnsavedChanges()) { messages.push(t("confirm.publish_unsaved")); } if (actionKey === "schedule" && hasUnsavedChanges()) { messages.push(t("confirm.schedule_unsaved")); } const inactiveLinkWarnings = collectInactiveLinkWarnings(sourceBroadcastForOutboundAction()); if (inactiveLinkWarnings.length > 0) { messages.push(`${t("confirm.inactive_links")}\n- ${inactiveLinkWarnings.join("\n- ")}`); } if (messages.length === 0) { return true; } return window.confirm(messages.join("\n\n")); } function announcementUrl(path) { return `/api/announcements/${state.broadcast.directory}${path}`; } function announcementPageUrl() { return `/announcements/${state.broadcast.directory}`; } async function fetchJson(url, options) { const response = await fetch(url, options); if (response.status === 401) { window.location.href = "/login"; throw new Error("Authentication required."); } let payload = {}; const contentType = response.headers.get("content-type") || ""; if (contentType.includes("application/json")) { payload = await response.json(); } else { const text = await response.text(); if (!response.ok) { throw new Error(text || `Request failed with status ${response.status}.`); } } if (!response.ok) { throw new Error(payload.detail || `Request failed with status ${response.status}.`); } return payload; } function renderTelegramTargetSelects(locked, canEdit, editReason) { const catalog = telegramTargetCatalog(); const currentBotId = state.broadcast.metadata.telegram_bot_id || ""; const currentChatId = state.broadcast.metadata.telegram_chat_id || ""; const botIds = new Set(catalog.bots.map((bot) => bot.id)); const temporaryDeleteSelectionAllowed = isLinked() && !hasPersistedTarget() && can("delete_telegram"); const targetControlsLocked = state.busy || (!temporaryDeleteSelectionAllowed && (locked || !canEdit)); const allowEmptySelection = !(state.broadcast.metadata.status === "scheduled" && !isLinked()); telegramBotSelect.innerHTML = ""; if (allowEmptySelection || !currentBotId) { telegramBotSelect.appendChild(new Option(t("editor.select_bot"), "")); } for (const bot of catalog.bots) { telegramBotSelect.appendChild(new Option(bot.name, bot.id)); } if (currentBotId && !botIds.has(currentBotId)) { telegramBotSelect.appendChild(new Option(currentBotId, currentBotId)); } telegramBotSelect.value = currentBotId; telegramBotSelect.disabled = targetControlsLocked; telegramBotSelect.title = temporaryDeleteSelectionAllowed ? t("editor.temporary_delete_target") : editReason; setPermissionDisabledState(telegramBotSelect, !canEdit); const activeBot = selectedBot(); const chatIds = new Set((activeBot?.chats || []).map((chat) => chat.chat_id)); telegramChatSelect.innerHTML = ""; if (allowEmptySelection || !currentChatId) { telegramChatSelect.appendChild(new Option(t("editor.select_chat"), "")); } for (const chat of activeBot?.chats || []) { telegramChatSelect.appendChild(new Option(chat.name, chat.chat_id)); } if (currentChatId && !chatIds.has(currentChatId)) { telegramChatSelect.appendChild(new Option(currentChatId, currentChatId)); } telegramChatSelect.value = currentChatId; telegramChatSelect.disabled = targetControlsLocked || !currentBotId; telegramChatSelect.title = !currentBotId ? t("editor.select_bot_first") : (temporaryDeleteSelectionAllowed ? t("editor.temporary_delete_target") : editReason); setPermissionDisabledState(telegramChatSelect, !canEdit); } function renderSidebarAnnouncements() { if (!state.announcements || state.announcements.length === 0) { return; } const container = document.querySelector(".announcement-list"); if (!container) { return; } container.innerHTML = ""; for (const item of state.announcements) { const entry = document.createElement("div"); entry.className = "announcement-entry"; const link = document.createElement("a"); link.className = "announcement-item"; if (item.directory === state.broadcast.directory) { link.classList.add("active"); } link.href = `/announcements/${item.directory}`; const titleRow = document.createElement("span"); titleRow.className = "announcement-title-row"; if (item.sidebar_has_pins) { const primaryMarker = document.createElement("span"); primaryMarker.className = `status-primary-marker status-${item.sidebar_pin_status || item.status} status-primary-marker-pinned`; primaryMarker.classList.add("status-primary-marker-pinned"); primaryMarker.setAttribute("aria-hidden", "true"); titleRow.appendChild(primaryMarker); } const titleSpan = document.createElement("span"); titleSpan.className = "announcement-title"; titleSpan.textContent = item.title; if (item.status_symbol) { const symbol = document.createElement("span"); symbol.className = `status-symbol status-${item.status}`; if (item.sidebar_links_incomplete) { symbol.classList.add("status-symbol-incomplete"); } symbol.textContent = item.status_symbol; titleRow.appendChild(symbol); } titleRow.appendChild(titleSpan); link.appendChild(titleRow); const targetLine = document.createElement("small"); targetLine.textContent = item.target_summary; link.appendChild(targetLine); for (const metaLine of item.sidebar_meta_lines || []) { const meta = document.createElement("small"); meta.className = "announcement-meta"; meta.textContent = metaLine; link.appendChild(meta); } entry.appendChild(link); const controls = document.createElement("div"); controls.className = "announcement-order-controls"; controls.appendChild(buildMoveForm(item, "up", item.can_move_up)); controls.appendChild(buildMoveForm(item, "down", item.can_move_down)); entry.appendChild(controls); container.appendChild(entry); } } function buildMoveForm(item, direction, enabled) { const form = document.createElement("form"); form.method = "post"; form.action = `/announcements/${item.directory}/move`; const directionInput = document.createElement("input"); directionInput.type = "hidden"; directionInput.name = "direction"; directionInput.value = direction; form.appendChild(directionInput); const returnToInput = document.createElement("input"); returnToInput.type = "hidden"; returnToInput.name = "return_to"; returnToInput.value = window.location.pathname; form.appendChild(returnToInput); const button = document.createElement("button"); button.type = "submit"; button.innerHTML = direction === "up" ? "↑" : "↓"; button.disabled = !enabled || !canSaveAnnouncements(); form.appendChild(button); return form; } async function updatePreview(language, previewContent) { const availableTargets = state.broadcast.metadata.language_order.filter((candidate) => { if (candidate === language) { return false; } if (!state.broadcast.metadata.enabled_languages[candidate]) { return false; } return String(state.broadcast.texts[candidate] || "").trim().length > 0; }); const payload = await fetchJson("/api/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: state.broadcast.texts[language] || "", available_targets: availableTargets, }), }); previewContent.innerHTML = payload.html || ""; if (previewContent._textarea) { syncTextareaHeight(previewContent._textarea); } } function schedulePreviewUpdate(language, previewContent) { if (state.previewTimers[language]) { window.clearTimeout(state.previewTimers[language]); } state.previewTimers[language] = window.setTimeout(() => { updatePreview(language, previewContent).catch((error) => { previewContent.textContent = error.message; if (previewContent._textarea) { syncTextareaHeight(previewContent._textarea); } }); }, 150); } function applyDisabledState(element, disabled, title, permissionDenied = false) { element.disabled = disabled; element.title = title || ""; setPermissionDisabledState(element, permissionDenied); } function createSecondaryButton({ label, disabled = false, title = "", permissionDenied = false, onClick = null }) { const button = document.createElement("button"); button.type = "button"; button.className = "secondary-button"; button.textContent = label; applyDisabledState(button, disabled, title, permissionDenied); if (onClick) { button.addEventListener("click", onClick); } return button; } function applyContentEditControlState(element, { locked, canContentEdit, contentEditReason }) { applyDisabledState( element, locked || state.busy || !canContentEdit, contentEditReason, !canContentEdit, ); } function renderContext() { const metadata = state.broadcast.metadata; const locked = isTextOnlyEditable(); const targetsLocked = areTargetsLocked(); const canContentEdit = canEditContent(); const canTargetEdit = canEditTargets(); const canSave = canSaveAnnouncements(); const hasSelectedTelegramTarget = hasSelectedTarget(); const hasSchedule = !!metadata.scheduled_for; const hasScheduledUnpin = !!metadata.scheduled_unpin_for; const pinningAvailability = discussionPinningAvailability(); const canUseSchedule = hasSchedule ? can("unschedule") : can("schedule"); const canUseUnpinSchedule = hasScheduledUnpin ? can("unschedule") : can("schedule"); return { metadata, locked, targetsLocked, canContentEdit, canTargetEdit, canSave, hasSelectedTelegramTarget, hasSchedule, hasScheduledUnpin, pinningAvailability, canUseSchedule, canUseUnpinSchedule, contentEditReason: locked ? linkedLockReason() : (!canContentEdit ? permissionReason("edit_content") : ""), targetEditReason: targetsLocked ? targetsLockReason() : (!canTargetEdit ? permissionReason("edit_targets") : ""), titleEditReason: !canContentEdit ? permissionReason("edit_content") : "", scheduleEditReason: !canSave ? permissionReason("save_announcements") : "", }; } function renderCoreFieldValues(context) { const { metadata } = context; if (headerTitle) { headerTitle.textContent = metadata.title; } if (headerDirectory) { headerDirectory.textContent = state.broadcast.directory; } titleInput.value = metadata.title; orderSelect.value = metadata.language_order.join(","); renderLanguageOrderOptions(); discussionPinningSelect.value = metadata.discussion_pinning_mode || "first"; deletePinServiceMessagesCheckbox.checked = !!metadata.delete_pin_service_messages; scheduleInput.value = scheduleInputValueForMetadata(metadata); unpinScheduleInput.value = state.localUnpinScheduleValue || unpinScheduleInputValueForMetadata(metadata); } function renderPrimaryControls(context) { const { metadata, locked, targetsLocked, canContentEdit, canTargetEdit, canSave, hasSelectedTelegramTarget, hasSchedule, hasScheduledUnpin, pinningAvailability, canUseSchedule, canUseUnpinSchedule, contentEditReason, targetEditReason, titleEditReason, scheduleEditReason, } = context; applyDisabledState(titleInput, state.busy || !canContentEdit, titleEditReason, !canContentEdit); applyDisabledState(orderSelect, locked || state.busy || !canContentEdit, contentEditReason, !canContentEdit); pinningTabPanel.classList.toggle("tab-panel-disabled", !pinningAvailability.available); pinningTabPanel.setAttribute("aria-disabled", pinningAvailability.available ? "false" : "true"); const pinningDisabled = state.busy || !canTargetEdit || !pinningAvailability.available; const pinningTitle = !canTargetEdit ? permissionReason("edit_targets") : pinningAvailability.reason; applyDisabledState(discussionPinningSelect, pinningDisabled, pinningTitle, !canTargetEdit); applyDisabledState(deletePinServiceMessagesCheckbox, pinningDisabled, pinningTitle, !canTargetEdit); const canEditScheduleDraft = !hasSchedule && canSave; applyDisabledState(scheduleInput, state.busy || !canEditScheduleDraft, scheduleEditReason, !canSave); applyContentEditControlState(globalUpload, context); applyContentEditControlState(openGlobalImageImportButton, context); syncImageImportModalState(); syncGlobalLinkPreviewToggle(); applyDisabledState( disableLinkPreviewsGlobal, state.busy || !canContentEdit, !canContentEdit ? permissionReason("edit_content") : "", !canContentEdit, ); renderTelegramTargetSelects(targetsLocked, canTargetEdit, targetEditReason); updateSaveButtonState(); setPermissionDisabledState(saveButton, !canSave); const deleteExpired = isDeleteFromTelegramExpired(); let deleteTelegramTitle = ""; if (!can("delete_telegram")) { deleteTelegramTitle = permissionReason("delete_telegram"); } else if (deleteExpired) { deleteTelegramTitle = t("locked.delete_telegram_expired"); } applyDisabledState( deleteTelegramButton, !isLinked() || state.busy || !can("delete_telegram") || deleteExpired, deleteTelegramTitle, !can("delete_telegram"), ); applyDisabledState( deleteStorageButton, state.busy || !can("delete_storage"), !can("delete_storage") ? permissionReason("delete_storage") : "", !can("delete_storage"), ); publishButton.disabled = state.busy || hasSchedule || metadata.status === "published" || !can("post_now") || !hasSelectedTelegramTarget; publishButton.title = !can("post_now") ? permissionReason("post_now") : (!hasSelectedTelegramTarget ? t("editor.target_required") : ""); publishButton.textContent = metadata.status === "modified" ? t("editor.post_modifications_now") : t("editor.post_now"); setPermissionDisabledState(publishButton, !can("post_now")); scheduleButton.disabled = state.busy || !canUseSchedule || (!hasSchedule && !hasSelectedTelegramTarget) || (isLinked() && metadata.status !== "modified" && metadata.status !== "scheduled"); scheduleButton.title = !canUseSchedule ? permissionReason(hasSchedule ? "unschedule" : "schedule") : ((!hasSchedule && !hasSelectedTelegramTarget) ? t("editor.target_required") : ""); scheduleButton.textContent = hasSchedule ? t("editor.unschedule") : (metadata.status === "modified" ? t("editor.schedule_modifications") : t("editor.schedule")); setPermissionDisabledState(scheduleButton, !canUseSchedule); const unpinAllowedForState = isLinked() || metadata.status === "scheduled"; const unpinTitle = !canTargetEdit ? permissionReason("edit_targets") : (!unpinAllowedForState ? t("editor.discussion_pinning_requires_links") : (!canUseUnpinSchedule ? permissionReason(hasScheduledUnpin ? "unschedule" : "schedule") : pinningAvailability.reason)); const unpinDisabled = state.busy || !unpinAllowedForState || !canTargetEdit || !canUseUnpinSchedule || !pinningAvailability.available; applyDisabledState(unpinScheduleInput, unpinDisabled, unpinTitle, !canTargetEdit || !canUseUnpinSchedule); applyDisabledState(unpinScheduleButton, unpinDisabled, unpinTitle, !canTargetEdit || !canUseUnpinSchedule); unpinScheduleButton.textContent = hasScheduledUnpin ? t("editor.cancel_unpin") : t("editor.schedule_unpin"); } function renderStaticSections(locked) { renderImageList("global", document.querySelector('.image-list[data-scope="global"]'), locked); renderLanguages(locked); renderStatus(); renderTelegramBindingPanel(); renderTabLabels(); setActiveTab(state.activeTab); renderSidebarAnnouncements(); } function renderSidebarBusyState() { for (const item of document.querySelectorAll(".announcement-item")) { item.classList.toggle("disabled-nav", state.busy); item.title = state.busy ? t("status.wait_finish") : ""; } } async function loadBroadcastIntoEditor(directoryName, { replaceHistory = false } = {}) { const payload = await fetchJson(`/api/announcements/${encodeURIComponent(directoryName)}`, { method: "GET" }); state.operation = null; closeImageImportModal(); stopOperationPolling(); setBusy(false, ""); applyApiPayload(payload, false); render(); const url = announcementPageUrl(); if (replaceHistory) { window.history.replaceState({ directory: directoryName }, "", url); } else { window.history.pushState({ directory: directoryName }, "", url); } const scroller = contentContainer || document.scrollingElement; if (scroller) { scroller.scrollTop = 0; scroller.scrollLeft = 0; } } function navigationDirectoryFromHref(href) { const url = new URL(href, window.location.origin); const match = url.pathname.match(/^\/announcements\/([^/]+)$/); return match ? decodeURIComponent(match[1]) : null; } function shouldInterceptSidebarAnnouncementLink(link) { if (!link.closest(".sidebar")) return false; if (link.target === "_blank" || link.hasAttribute("download")) return false; const href = link.getAttribute("href") || ""; return /^\/announcements\/[^/]+$/.test(href); } function render() { const context = renderContext(); renderCoreFieldValues(context); renderPrimaryControls(context); renderStaticSections(context.locked); renderSidebarBusyState(); } function renderStatus() { const metadata = state.broadcast.metadata; const lines = [`${t("status.prefix")}: ${statusLabel(metadata.status)}`]; const canClearStatus = !!(metadata.last_attempt_at || metadata.last_output || metadata.last_result); if (state.busy && state.busyMessage) { lines.push(state.busyMessage); } if (metadata.scheduled_for) { lines.push(`${t("status.scheduled_for")} (${state.broadcast.timezone}): ${formatTimestamp(metadata.scheduled_for)}`); } if (metadata.last_attempt_at) { lines.push(`${lastAttemptLabel(metadata)} (${state.broadcast.timezone}): ${formatTimestamp(metadata.last_attempt_at)}`); } const output = state.operation && (state.operation.running || state.operation.completed) ? state.operation.output : metadata.last_output; if (output) { lines.push(""); lines.push(output); } statusBoxContent.textContent = lines.join("\n"); clearStatusButton.hidden = !canClearStatus; clearStatusButton.disabled = state.busy || !canSaveAnnouncements(); clearStatusButton.title = !canSaveAnnouncements() ? permissionReason("save_announcements") : ""; setPermissionDisabledState(clearStatusButton, !canSaveAnnouncements()); } function telegramBinding() { const binding = state.broadcast.metadata.telegram_binding; return binding && typeof binding === "object" ? binding : null; } function bindingPrimaryRows(binding) { const rows = []; const globalPrimaryMessageId = Number(binding.global_primary_message_id || 0); const globalDiscussionMessageId = Number(binding.global_discussion_message_id || 0); if ((globalPrimaryMessageId || globalDiscussionMessageId) && !binding.global_attached_to_language) { rows.push({ kind: "global", label: t("editor.telegram_binding.global"), channelMessageId: globalPrimaryMessageId || "", discussionMessageId: globalDiscussionMessageId || "", }); } const languageBindings = binding.languages || {}; for (const language of state.broadcast.metadata.language_order) { const languageBinding = languageBindings[language]; if (!languageBinding || typeof languageBinding !== "object") continue; const primaryMessageId = Number(languageBinding.primary_message_id || 0) || ""; const discussionMessageId = Number(languageBinding.discussion_message_id || 0) || ""; if (!primaryMessageId && !discussionMessageId) continue; rows.push({ kind: "language", language, label: displayName(language), channelMessageId: primaryMessageId, discussionMessageId, }); } return rows; } function mappingRows() { const binding = telegramBinding(); const boundRows = new Map(); if (binding) { for (const row of bindingPrimaryRows(binding)) { const rowKey = row.kind === "global" ? "global" : row.language; boundRows.set(rowKey, row); } } const rows = []; if (boundRows.has("global")) { rows.push(boundRows.get("global")); } for (const language of state.broadcast.metadata.language_order) { if (!state.broadcast.metadata.enabled_languages[language] && !boundRows.has(language)) { continue; } rows.push(boundRows.get(language) || { kind: "language", language, label: displayName(language), channelMessageId: "", discussionMessageId: "", }); } return rows; } function hasIncompleteTelegramMapping(binding = telegramBinding()) { if (!binding) { return false; } return bindingPrimaryRows(binding) .filter((row) => row.kind === "language") .some((row) => !row.channelMessageId || !row.discussionMessageId); } function selectedChannelLinkTarget() { const chat = selectedChatTarget(); return { username: String(chat?.channel_username || "").trim().replace(/^@/, ""), internalId: String(chat?.channel_internal_id || "").trim(), }; } function selectedDiscussionLinkTarget() { const chat = selectedChatTarget(); return { username: "", internalId: String(chat?.discussion_group_internal_id || state.broadcast.discussion_group_link_chat_id || "").trim(), }; } function normalizeTelegramLinkValue(value, target) { const text = String(value || "").trim(); if (!text) { return ""; } if (/^\d+$/.test(text)) { return text; } const cLinkMatch = /^(?:https?:\/\/)?(?:www\.)?(?:t\.me|telegram\.me)\/c\/(\d+)\/(\d+)(?:[/?#].*)?$/i.exec(text); if (cLinkMatch) { const [, chatId, messageId] = cLinkMatch; if (target.internalId && chatId !== target.internalId) { return ""; } return messageId; } const publicLinkMatch = /^(?:https?:\/\/)?(?:www\.)?(?:t\.me|telegram\.me)\/([A-Za-z0-9_]+)\/(\d+)(?:[/?#].*)?$/i.exec(text); if (publicLinkMatch) { const [, username, messageId] = publicLinkMatch; if (target.username && username.toLowerCase() !== target.username.toLowerCase()) { return ""; } return messageId; } return ""; } function buildTelegramMessageUrl(messageId, target) { const resolvedId = String(messageId || "").trim(); if (!resolvedId) { return ""; } if (target.username) { return `https://t.me/${target.username}/${resolvedId}`; } if (target.internalId) { return `https://t.me/c/${target.internalId}/${resolvedId}`; } return ""; } function telegramMappingRowKey(row) { return row.kind === "global" ? "global" : row.language; } function telegramMappingEditReason(canEdit, hasTarget) { if (!canEdit) { return permissionReason("edit_targets"); } if (!hasTarget) { return t("editor.target_required"); } return ""; } function telegramMappingGroupBlockReason(groupLinksAvailable, row) { if (groupLinksAvailable && !row.channelMessageId) { return t("editor.group_link_requires_channel"); } if (!groupLinksAvailable) { return t("editor.discussion_pinning_requires_group"); } return ""; } function telegramMappingRowsPayload(targetRow, side, normalizedValue) { const targetRowKey = telegramMappingRowKey(targetRow); return mappingRows().map((currentRow) => { const isSameRow = telegramMappingRowKey(currentRow) === targetRowKey; const channelMessageId = isSameRow ? (side === "channel" ? normalizedValue : String(currentRow.channelMessageId || "").trim()) : String(currentRow.channelMessageId || "").trim(); const discussionMessageId = isSameRow ? (side === "group" ? normalizedValue : String(currentRow.discussionMessageId || "").trim()) : String(currentRow.discussionMessageId || "").trim(); return { kind: currentRow.kind, language: currentRow.language || null, channel_message_id: channelMessageId || null, discussion_message_id: discussionMessageId || null, }; }); } function saveTelegramMappingCell(row, side, rawValue, channelTarget, discussionTarget) { const target = side === "channel" ? channelTarget : discussionTarget; const normalized = normalizeTelegramLinkValue(rawValue, target); return actionPost("/set-telegram-mapping", { telegram_bot_id: state.broadcast.metadata.telegram_bot_id || "", telegram_chat_id: state.broadcast.metadata.telegram_chat_id || "", rows: telegramMappingRowsPayload(row, side, normalized), }, { busyMessage: t("status.saving_telegram_mapping"), preserveLocalEdits: true, }); } function buildTelegramMappingActionButton(label, href, disabled, reason) { const button = document.createElement("button"); button.type = "button"; button.textContent = label; button.disabled = disabled || !href; button.title = reason || ""; if (href && !disabled) { button.addEventListener("click", () => { window.open(href, "_blank", "noopener,noreferrer"); }); } return button; } function buildTelegramMappingLinkedCell({ row, side, messageId, target, canEdit, canOpen, groupBlocked, groupBlockedReason, channelUnlinkBlocked, channelTarget, discussionTarget, }) { const cell = document.createElement("td"); cell.className = "telegram-mapping-cell"; const actions = document.createElement("div"); actions.className = "telegram-cell-actions"; actions.appendChild(buildTelegramMappingActionButton( t("editor.open"), buildTelegramMessageUrl(messageId, target), state.busy || !canOpen || groupBlocked, !canOpen ? permissionReason("open_telegram") : (groupBlocked ? groupBlockedReason : ""), )); const unlinkCellButton = document.createElement("button"); unlinkCellButton.type = "button"; unlinkCellButton.textContent = t("editor.unlink_message_id").replace("{id}", String(messageId)); unlinkCellButton.disabled = state.busy || !canEdit || groupBlocked || channelUnlinkBlocked; unlinkCellButton.title = !canEdit ? permissionReason("edit_targets") : (groupBlocked ? groupBlockedReason : (channelUnlinkBlocked ? t("editor.unlink_channel_requires_group_clear") : "")); setPermissionDisabledState(unlinkCellButton, !canEdit); unlinkCellButton.addEventListener("click", () => { saveTelegramMappingCell(row, side, "", channelTarget, discussionTarget).catch((error) => alert(error.message)); }); actions.appendChild(unlinkCellButton); cell.appendChild(actions); return cell; } function buildTelegramMappingEditableCell({ row, side, canEdit, hasTarget, editReason, groupBlocked, groupBlockedReason, channelTarget, discussionTarget, }) { const cell = document.createElement("td"); cell.className = "telegram-mapping-cell"; const editor = document.createElement("div"); editor.className = "telegram-cell-editor"; const input = document.createElement("input"); input.type = "text"; input.value = ""; input.placeholder = t("editor.telegram_binding_manual_placeholder"); input.disabled = state.busy || !canEdit || !hasTarget || groupBlocked; input.title = groupBlocked ? groupBlockedReason : editReason; const saveButton = document.createElement("button"); saveButton.type = "button"; saveButton.textContent = t("editor.save"); saveButton.disabled = state.busy || !canEdit || !hasTarget || groupBlocked; saveButton.title = input.title; setPermissionDisabledState(saveButton, !canEdit); const submit = () => { saveTelegramMappingCell(row, side, input.value, channelTarget, discussionTarget) .catch((error) => alert(error.message)); }; saveButton.addEventListener("click", submit); input.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); submit(); } }); editor.appendChild(input); editor.appendChild(saveButton); cell.appendChild(editor); return cell; } function buildTelegramMappingCell({ row, side, channelTarget, discussionTarget, canEdit, canOpen, hasTarget, editReason, groupLinksAvailable, }) { const messageId = side === "channel" ? row.channelMessageId : row.discussionMessageId; const target = side === "channel" ? channelTarget : discussionTarget; const groupBlocked = side === "group" && (!groupLinksAvailable || !row.channelMessageId); const groupBlockedReason = telegramMappingGroupBlockReason(groupLinksAvailable, row); if (messageId) { return buildTelegramMappingLinkedCell({ row, side, messageId, target, canEdit, canOpen, groupBlocked, groupBlockedReason, channelUnlinkBlocked: side === "channel" && !!row.discussionMessageId, channelTarget, discussionTarget, }); } return buildTelegramMappingEditableCell({ row, side, canEdit, hasTarget, editReason, groupBlocked, groupBlockedReason, channelTarget, discussionTarget, }); } function renderTelegramBindingPanel() { telegramBindingPanel.innerHTML = ""; const canEdit = canEditTargets(); const hasTarget = hasSelectedTarget(); const editReason = telegramMappingEditReason(canEdit, hasTarget); const body = document.createElement("div"); body.className = "telegram-binding-body"; const help = document.createElement("p"); help.className = "telegram-binding-help"; help.textContent = t("editor.telegram_binding_help"); body.appendChild(help); const rows = mappingRows(); const table = document.createElement("table"); table.className = "telegram-mapping-table"; const head = document.createElement("thead"); head.innerHTML = `