/
deka
/
buffer
Обзор
Документация
Войти
/
deka
/
buffer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
index.html
10 809 строк
1018 KB
gitverse-ci
release:
698d06e
fix: в Excel уезжала одна таблица из десяти
10 авг 2026, 16:45
10 авг 2026, 16:45
cf24d2e
Код
Авторство
О чём код?
<!doctype html> <html lang="ru"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Wiki Clipboard Lab</title> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23157a4b'/%3E%3Ctext x='16' y='21' text-anchor='middle' font-family='Arial, sans-serif' font-size='13' font-weight='700' fill='white'%3EWL%3C/text%3E%3C/svg%3E"> <style> @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:700;font-display:swap;src:url(data:font/woff2;base64,d09GMgABAAAAACHkABEAAAAAW5AAACGBAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGl4bgSAcLg … [Строка слишком длинная. Вы можете скачать файл] @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:700;font-display:swap;src:url(data:font/woff2;base64,d09GMgABAAAAADo8ABEAAAAAorAAADnZAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGkwbhnYchl … [Строка слишком длинная. Вы можете скачать файл] @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(data:font/woff2;base64,d09GMgABAAAAACEMABEAAAAAWcQAACCsAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGl4bgSAcLg … [Строка слишком длинная. Вы можете скачать файл] @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(data:font/woff2;base64,d09GMgABAAAAADooABEAAAAAoqQAADnGAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGkwbhnYchl … [Строка слишком длинная. Вы можете скачать файл] @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(data:font/woff2;base64,d09GMgABAAAAACCkABEAAAAAWQQAACBCAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGl4bgSAcLg … [Строка слишком длинная. Вы можете скачать файл] @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(data:font/woff2;base64,d09GMgABAAAAADl0ABEAAAAAoSAAADkQAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGkwbhlIchl … [Строка слишком длинная. Вы можете скачать файл] @font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:100 700;font-display:swap;src:url(data:font/woff2;base64,d09GMgABAAAAAHNIABgAAAAA4XgAAHLIAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGoREG/ … [Строка слишком длинная. Вы можете скачать файл] @font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:100 700;font-display:swap;src:url(data:font/woff2;base64,d09GMgABAAAAALKQABgAAAABgPQAALIPAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGoZPG4 … [Строка слишком длинная. Вы можете скачать файл] :root { color-scheme: light; /* Colour */ --bg: #f4f6f8; --panel: #ffffff; --panel-sunken: #f8fafb; --text: #171a1f; --text-strong: #111419; --muted: #69717d; --line: #dfe4e9; --line-strong: #c9d0d8; --accent: #157a4b; --accent-strong: #0e5d39; --accent-soft: #e9f6ef; --on-accent: #ffffff; --ok: #117025; --warn: #ad5800; --warn-soft: #fdecc8; --bad: #b91c1c; --bad-soft: #fbe2e2; /* Focus + shadow */ --focus-ring: 0 0 0 3px rgba(21, 122, 75, 0.18); --focus-ring-inset: inset 0 0 0 3px rgba(21, 122, 75, 0.18); --shadow: 0 12px 30px rgba(23, 26, 31, 0.10); /* Typography */ --font-sans: 'IBM Plex Sans', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; --font-mono: 'IBM Plex Mono', "SFMono-Regular", "Cascadia Mono", Consolas, "Liberation Mono", monospace; --fs-xs: 11px; --fs-sm: 12px; --fs-base: 13px; --fs-md: 14px; --fs-lg: 16px; --lh-tight: 1.3; --lh-base: 1.5; --ls-label: 0; /* Spacing + shape */ --radius: 6px; --panel-radius: 6px; --btn-radius: 6px; --gap-tight: 6px; --gap: 12px; --gap-loose: 24px; --btn-h: 34px; --header-h: 68px; --statusbar-h: 38px; --sidebar-w: 232px; /* Wiki-block surfaces (set in Task 14) */ --wiki-info-bg: #e8eef8; --wiki-info-border: #b1c3df; --wiki-info-text: #14385f; --wiki-warning-bg: #fdecc8; --wiki-warning-border: #e3b46d; --wiki-warning-text: #7a4b00; --wiki-tip-bg: #e2f0e6; --wiki-tip-border: #95c2a4; --wiki-tip-text: #1d5732; --wiki-hint-bg: #fff4c2; --wiki-hint-text: #604700; --wiki-table-bg: #ffffff; --wiki-table-border: #c8d1dd; --wiki-table-head-bg: #e6ebf1; /* Legacy aliases — kept ONLY while specific surfaces still reference them. Each is removed in the task that migrates the consumer. */ --link: var(--accent-strong); --btn-bg-hover: var(--panel-sunken); --accent-soft-bg: var(--accent-soft); --accent-soft-bg-hover: #d6e8db; --accent-soft-border: var(--accent); --accent-soft-border-hover: var(--accent-strong); --warn-btn-bg: var(--warn-soft); --warn-btn-bg-hover: #fbdfa5; --warn-btn-border: var(--warn); --warn-btn-border-hover: #8a4500; --warn-btn-text: var(--warn); --warn-btn-text-hover: #6a3700; --ok-bg: rgba(17, 112, 37, 0.08); --warn-bg: rgba(173, 88, 0, 0.08); --bad-bg: rgba(185, 28, 28, 0.08); --ok-border: rgba(17, 112, 37, 0.35); --warn-border: rgba(173, 88, 0, 0.35); --bad-border: rgba(185, 28, 28, 0.35); --overlay: rgba(14, 22, 34, 0.45); --wiki-chrome-bg: var(--panel-sunken); --wiki-chrome-border: var(--line); --wiki-chrome-border-soft: var(--line); --wiki-chrome-text: var(--text); --wiki-chrome-active-border: var(--accent); --wiki-preview-tab-bg: var(--panel); --wiki-preview-tab-text: var(--muted); --wiki-preview-tab-hover-text: var(--text); --wiki-preview-tab-active-text: var(--accent-strong); --tab-font-size: var(--fs-base); --placeholder-font-size: var(--tab-font-size); --panel-gap: var(--gap); --panel-padding: var(--gap); --panel-title-height: 38px; font-family: var(--font-sans); } * { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--text); min-height: 100vh; } button, input, textarea { font: inherit; } button { border: 1px solid var(--line); background: var(--panel); color: var(--text); min-height: var(--btn-h); padding: 0 11px; border-radius: var(--btn-radius); cursor: pointer; font-family: var(--font-sans); font-size: var(--fs-sm); font-weight: 500; letter-spacing: 0; } button:hover { border-color: var(--line-strong); background: var(--btn-bg-hover); } button.btn-primary { border-color: var(--accent); background: var(--accent); color: var(--on-accent); } button.btn-primary:hover { border-color: var(--accent-strong); background: var(--accent-strong); } .btn-soft { border: 1px solid var(--accent); background: var(--accent-soft); color: var(--accent-strong); font-weight: 600; min-height: var(--btn-h); padding: 0 11px; border-radius: var(--btn-radius); font-family: var(--font-sans); font-size: var(--fs-sm); cursor: pointer; } .btn-soft:hover { border-color: var(--accent-strong); background: #d6e8db; } .btn-warning { border: 1px solid var(--warn); background: var(--warn-soft); color: var(--warn); font-weight: 600; min-height: var(--btn-h); padding: 0 11px; border-radius: var(--btn-radius); font-family: var(--font-sans); font-size: var(--fs-sm); cursor: pointer; } .btn-warning:hover { border-color: #8a4500; background: #fbdfa5; } .btn-external { display: inline-flex; align-items: center; gap: 6px; min-height: var(--btn-h); padding: 0 11px; border: 1px solid transparent; background: transparent; color: var(--muted); font-family: var(--font-sans); font-size: var(--fs-sm); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line-strong); cursor: pointer; } .btn-external:hover { color: var(--accent-strong); text-decoration-color: var(--accent); } .btn-external:has(.llm-ico) { text-decoration: none; } .llm-ico { width: 15px; height: 15px; flex: none; border-radius: 4px; } .llm-launchers { display: inline-flex; align-items: center; gap: 8px; flex-wrap: nowrap; } .toolbar .btn-clear { margin-left: auto; } button:disabled { cursor: not-allowed; border-color: var(--line); background: var(--panel-sunken); color: var(--muted); box-shadow: none; } .btn-external:disabled { cursor: not-allowed; border-color: transparent; background: transparent; color: var(--muted); text-decoration-color: var(--line); } .btn-flash-ok { background: var(--accent) !important; color: var(--on-accent) !important; border-color: var(--accent-strong) !important; transition: background 150ms; } .external-group-label { align-self: center; color: var(--muted); font-family: var(--font-sans); font-size: var(--fs-xs); letter-spacing: var(--ls-label); text-transform: lowercase; padding-right: 4px; } .contact-link { display: inline-flex; align-items: center; color: var(--muted); font-family: var(--font-sans); font-size: var(--fs-sm); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line-strong); } .contact-link:hover { color: var(--accent-strong); text-decoration-color: var(--accent); } .release-meta { display: flex; flex: none; flex-direction: column; align-items: flex-end; gap: 4px; margin-left: auto; } .version-meta { display: inline-flex; align-items: center; gap: 6px; color: var(--text); font-family: var(--font-mono); font-size: var(--fs-sm); line-height: var(--lh-tight); white-space: nowrap; } .version-number { padding: 2px 6px; border: 1px solid var(--accent); border-radius: var(--btn-radius); background: var(--accent-soft); color: var(--accent-strong); font-weight: 700; } .version-date { font-weight: 600; } .prompt-modal { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; } .prompt-modal[hidden] { display: none !important; } .prompt-modal-backdrop { position: absolute; inset: 0; background: var(--overlay); } .prompt-modal-panel { position: relative; max-width: 720px; width: calc(100vw - 32px); max-height: calc(100vh - 64px); background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; } .prompt-modal-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .prompt-modal-header h2 { margin: 0; font-family: var(--font-sans); font-size: var(--fs-md); font-weight: 600; color: var(--accent-strong); letter-spacing: 0; } .prompt-modal-close { width: 26px; height: 26px; min-height: 26px; padding: 0; border-radius: 999px; font-size: var(--fs-md); line-height: 1; } .prompt-modal-subtitle { margin: 0; color: var(--muted); font-size: var(--fs-sm); } .prompt-modal-label { margin: 2px 0 -4px; font-family: var(--font-sans); font-size: var(--fs-xs); font-weight: 600; color: var(--muted); } .prompt-modal-source { flex: 1 1 auto; min-height: 160px; resize: vertical; padding: 10px 12px; background: var(--panel-sunken); border: 1px solid var(--line); border-radius: var(--radius); font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--text-strong); } .prompt-modal-toolbar { min-height: 0; padding: 0 0 10px; border-top: 0; border-bottom: 1px solid var(--line); background: transparent; } .llm-open-group { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; } .prompt-modal-body { flex: 1; min-height: 0; margin: 0; padding: 10px 12px; background: var(--panel-sunken); border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; font-family: var(--font-mono); font-size: var(--fs-xs); white-space: pre-wrap; word-break: break-word; color: var(--text-strong); } .prompt-modal-footer { display: flex; justify-content: flex-end; align-items: center; flex-wrap: wrap; gap: 8px; } textarea::-webkit-scrollbar, .editor::-webkit-scrollbar, .layout-output::-webkit-scrollbar, .paste-zone::-webkit-scrollbar, .excel-paste-zone::-webkit-scrollbar, .prompt-modal-body::-webkit-scrollbar { width: 10px; height: 10px; } textarea::-webkit-scrollbar-track, .editor::-webkit-scrollbar-track, .layout-output::-webkit-scrollbar-track, .paste-zone::-webkit-scrollbar-track, .excel-paste-zone::-webkit-scrollbar-track, .prompt-modal-body::-webkit-scrollbar-track { background: var(--panel-sunken); } textarea::-webkit-scrollbar-thumb, .editor::-webkit-scrollbar-thumb, .layout-output::-webkit-scrollbar-thumb, .paste-zone::-webkit-scrollbar-thumb, .excel-paste-zone::-webkit-scrollbar-thumb, .prompt-modal-body::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 2px; } textarea::-webkit-scrollbar-thumb:hover, .editor::-webkit-scrollbar-thumb:hover, .layout-output::-webkit-scrollbar-thumb:hover, .paste-zone::-webkit-scrollbar-thumb:hover, .excel-paste-zone::-webkit-scrollbar-thumb:hover, .prompt-modal-body::-webkit-scrollbar-thumb:hover { background: var(--muted); } .app { display: grid; grid-template-rows: auto auto 1fr; min-height: 100vh; } header { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 14px; padding: 8px 18px; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 10; min-height: var(--header-h); } .brand { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; flex: none; border-radius: var(--btn-radius); background: var(--accent); color: var(--on-accent); font-family: var(--font-mono); font-weight: 700; font-size: 13px; letter-spacing: 0; user-select: none; } .tool-tabs-label { color: var(--accent-strong); font-family: var(--font-sans); font-size: var(--fs-base); font-weight: 600; line-height: var(--lh-tight); letter-spacing: 0; white-space: nowrap; } .status-bar { position: sticky; top: var(--header-h); z-index: 9; display: flex; align-items: center; gap: 14px; height: var(--statusbar-h); padding: 4px 18px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); border-left: 3px solid var(--line-strong); font-family: var(--font-mono); font-size: var(--fs-sm); font-weight: 500; background: var(--panel-sunken); color: var(--muted); white-space: nowrap; overflow-x: auto; } .status-bar .status-prefix { letter-spacing: var(--ls-label); flex: none; } .status-bar .status-message { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; } .status-bar[data-state="ok"] { background: var(--accent-soft); color: var(--accent-strong); border-left-color: var(--accent); } .status-bar[data-state="warn"] { background: var(--warn-soft); color: var(--warn); border-left-color: var(--warn); } .status-bar[data-state="bad"] { background: var(--bad-soft); color: var(--bad); border-left-color: var(--bad); } .toolbar { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 18px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: var(--panel-sunken); } .toolbar .spacer { flex: 1 1 auto; margin-left: auto; } .goal-diff-panel .goal-diff-actions { display: flex; gap: 8px; margin: 10px 0; } .goal-diff-report { max-height: 320px; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); } .goal-diff-report:empty { display: none; } .goal-diff-report table { width: 100%; border-collapse: collapse; font-size: var(--fs-xs); } .goal-diff-report th, .goal-diff-report td { padding: 5px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; } .goal-diff-report th { position: sticky; top: 0; background: var(--panel-sunken); } .goal-diff-report tr[data-kind="removed"] { background: #fff0f0; } .goal-diff-report tr[data-kind="added"] { background: #f2fbf5; } .goal-diff-summary { padding: 8px 10px; border-bottom: 1px solid var(--line); font-family: var(--font-mono); font-size: var(--fs-xs); } .panel-title-note { margin-left: auto; max-width: 60%; color: var(--muted); font-size: var(--fs-xs); text-align: right; } .toolbar-field { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-family: var(--font-mono); font-size: var(--fs-xs); } .toolbar-field select { padding: 5px 8px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); color: var(--text); font-family: var(--font-mono); font-size: var(--fs-xs); } .meet-prompt-modes { flex: none; } .meet-prompt-modes button { white-space: nowrap; } .tool-tabs { display: flex; flex-wrap: wrap; align-items: center; gap: var(--gap-tight); flex: 1 1 auto; border-radius: var(--btn-radius); padding: 6px; background: var(--panel-sunken); border: 1px solid var(--line); } .tab-group { display: flex; flex-wrap: wrap; align-items: center; gap: var(--gap-tight); } .tool-tabs button { min-height: 32px; padding: 0 11px; border: 1px solid var(--line); border-radius: var(--btn-radius); background: var(--panel); color: var(--muted); font-family: var(--font-sans); font-weight: 500; font-size: var(--tab-font-size); display: inline-flex; align-items: center; gap: 8px; cursor: pointer; } .tool-tabs button:hover { border-color: var(--line-strong); color: var(--text); } .tool-tabs button[aria-selected="true"] { background: var(--accent-soft); border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); color: var(--accent-strong); font-weight: 600; } .tool-tabs .tab-info { width: 18px; height: 18px; flex: none; border: 1px solid transparent; background: rgba(86, 101, 122, 0.12); color: var(--muted); font-weight: 600; font-style: italic; font-family: ui-serif, Georgia, "Times New Roman", serif; font-size: 12px; border-radius: 999px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; } .tool-tabs .tab-info:hover { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-strong); } .tool-tabs button[aria-expanded="true"] .tab-info { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-strong); } .popover { position: fixed; max-width: 320px; padding: 12px 14px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); color: var(--text); z-index: 50; font-family: var(--font-sans); } .popover-title { margin: 0 0 6px 0; font-size: var(--fs-base); font-family: var(--font-sans); font-weight: 600; letter-spacing: 0; color: var(--accent-strong); } .popover-body { margin: 0; font-size: var(--fs-sm); line-height: var(--lh-base); color: var(--text); } .tools { padding: 12px; min-height: 0; } .tool-view { min-height: 0; } .tool-view[hidden] { display: none !important; } .clipboard-layout { display: grid; grid-template-columns: minmax(280px, 380px) minmax(0, 1fr); min-height: 0; } .tool-workspace, .clipboard-layout { gap: var(--panel-gap); padding-top: var(--panel-gap); } .clipboard-layout > aside, .clipboard-layout > section, .tool-workspace > section { min-height: 0; } .panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--panel-radius); box-shadow: var(--shadow); min-height: 0; overflow: hidden; } .panel + .panel { margin-top: var(--panel-gap); } .tool-workspace > .panel + .panel, .raw-grid > .panel + .panel { margin-top: 0; } .panel-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--panel-sunken); min-height: var(--panel-title-height); } .panel-title-link { flex: none; min-height: 26px; padding: 0 2px; border: 0; background: transparent; color: var(--muted); font-size: var(--fs-sm); font-weight: 500; text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line-strong); cursor: pointer; } .panel-title-link:hover { border: 0; background: transparent; color: var(--accent-strong); text-decoration-color: var(--accent); } h2 { font-size: var(--fs-base); line-height: var(--lh-tight); margin: 0; font-weight: 500; font-family: var(--font-sans); letter-spacing: 0; color: var(--text-strong); } .raw-body { height: calc(100% - 45px); min-height: 220px; } .full-body, .layout-output, .panel-body, .raw-body { padding: var(--panel-padding); } .paste-zone, .excel-paste-zone { border: 1px dashed #8aa8c6; border-radius: var(--panel-radius); padding: var(--panel-padding); background: #f8fbff; color: var(--muted); font-size: var(--placeholder-font-size); line-height: 1.45; outline: none; overflow: auto; } .paste-zone { min-height: 138px; } .paste-target { position: relative; display: grid; min-height: 0; height: 100%; } .paste-target > textarea, .paste-target > .paste-zone, .paste-target > .excel-paste-zone { grid-area: 1 / 1; } .paste-target-overlay { grid-area: 1 / 1; z-index: 1; align-self: start; padding: 18px; pointer-events: none; color: var(--muted); font-family: var(--font-sans); line-height: var(--lh-base); } .paste-target-prompt { margin-bottom: 8px; color: var(--accent); font-size: 24px; font-weight: 400; font-style: italic; line-height: var(--lh-tight); letter-spacing: 0; white-space: nowrap; } .paste-target-helper { max-width: 62ch; color: var(--muted); font-size: var(--placeholder-font-size); line-height: 1.45; } .paste-target:focus-within .paste-target-overlay { display: none; } .paste-target > textarea:not(:placeholder-shown) ~ .paste-target-overlay, .paste-target > .paste-zone:not(:empty) ~ .paste-target-overlay, .paste-target > .excel-paste-zone:not(:empty) ~ .paste-target-overlay { display: none; } .paste-zone:empty::before, .excel-paste-zone:empty::before { content: attr(data-placeholder); color: var(--muted); white-space: pre-wrap; pointer-events: none; } .paste-zone:focus:empty::before, .excel-paste-zone:focus:empty::before { content: ""; } .excel-paste-zone { min-height: 96px; } .paste-zone:focus, .excel-paste-zone:focus, textarea:focus { border-color: var(--accent); box-shadow: var(--focus-ring); } textarea { font-family: var(--font-mono); font-size: var(--fs-sm); line-height: var(--lh-base); } textarea::placeholder { color: var(--muted); font-size: var(--placeholder-font-size); font-family: var(--font-sans); line-height: 1.45; opacity: 1; } textarea:focus::placeholder { color: transparent; } .types-list { display: flex; flex-wrap: wrap; gap: 6px; min-height: 31px; } .type-pill { display: inline-flex; align-items: center; min-height: 22px; padding: 2px 8px; border-radius: 3px; background: var(--panel-sunken); border: 1px solid var(--line); color: var(--muted); font-family: var(--font-mono); font-size: var(--fs-xs); line-height: 1; white-space: nowrap; } .type-pill.wiki { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-strong); } .mini-label { margin: 12px 0 6px; color: var(--muted); font-family: var(--font-mono); font-size: var(--fs-xs); font-weight: 600; letter-spacing: var(--ls-label); text-transform: uppercase; } .meta-grid { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 8px; margin-top: 10px; color: var(--muted); font-family: var(--font-mono); font-size: var(--fs-sm); } .meta-grid code { color: var(--text); overflow-wrap: anywhere; font-family: var(--font-mono); } .workspace { display: grid; grid-template-rows: minmax(320px, 1fr) minmax(320px, 42vh); gap: 12px; min-height: 0; } .editor-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 0; } .editor { min-height: 0; height: 100%; padding: 16px; overflow: auto; outline: none; background: #fff; line-height: 1.55; } .preview-empty { display: flex; flex-direction: column; align-items: stretch; gap: 16px; padding: 18px; pointer-events: none; user-select: none; } .preview-empty[hidden] { display: none !important; } .preview-skeleton { display: grid; gap: 10px; } .preview-skeleton > div { background: var(--line); opacity: 0.30; border-radius: 2px; } .preview-skeleton .sk-heading { height: 22px; width: 50%; } .preview-skeleton .sk-paragraph { height: 10px; } .preview-skeleton .sk-paragraph-1 { width: 92%; } .preview-skeleton .sk-paragraph-2 { width: 78%; } .preview-skeleton .sk-paragraph-3 { width: 85%; } .preview-skeleton .sk-table { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--line); opacity: 0.30; margin-top: 6px; padding: 1px; border-radius: 2px; } .preview-skeleton .sk-table > div { height: 24px; background: var(--panel); opacity: 1; } .preview-skeleton .sk-buttons { display: flex; gap: 8px; margin-top: 6px; } .preview-skeleton .sk-buttons > div { width: 80px; height: 28px; border-radius: var(--btn-radius); } .preview-empty-hint { margin: 6px 0 0; font-family: var(--font-mono); font-size: var(--fs-sm); color: var(--muted); text-align: center; } .prompt-output-shell { position: relative; display: grid; min-height: 0; } .prompt-output-shell > textarea, .prompt-output-empty { grid-area: 1 / 1; } .prompt-output-empty { z-index: 1; align-self: start; margin: 0; background: var(--panel-sunken); border-radius: var(--radius); } .prompt-output-shell > textarea:disabled { cursor: default; opacity: 1; } #htmlEditor { min-height: 0; resize: none; border: 0; border-radius: 0; } .editor:focus { box-shadow: var(--focus-ring-inset); } .editor h1, .editor h2, .editor h3, .editor h4, .editor h5, .editor h6 { margin: 18px 0 10px; line-height: 1.25; color: #162336; } .editor h1 { font-size: 26px; } .editor h2 { font-size: 21px; padding-bottom: 5px; border-bottom: 1px solid #d9e3ee; } .editor h3 { font-size: 17px; } .editor p { margin: 7px 0; } .editor .tiptap.ProseMirror { padding: 4px; } .editor [data-type="tableOfContent"], .editor .node-tableOfContent .toctainer_1lfut, .editor .toctainer_geoc4 { display: block; margin: 12px 0; padding: 12px; border: 1px solid #c8d8e8; border-radius: 8px; background: #f4f9ff; color: #35506c; } .editor [data-type="tableOfContent"]::before, .editor .node-tableOfContent .toctainer_1lfut::before, .editor .toctainer_geoc4::before { content: "Содержание"; display: block; margin-bottom: 4px; color: #173f68; font-weight: 700; } .editor [data-type="info"], .editor .node-info .container_15vtd { display: block; margin: 12px 0; padding: 12px 14px; border: 1px solid var(--wiki-info-border); border-left: 4px solid var(--wiki-info-border); border-radius: var(--radius); background: var(--wiki-info-bg); color: var(--wiki-info-text); } .editor [data-type="info"][title]::before { content: attr(title); display: block; margin: -2px 0 8px; color: #173f68; font-weight: 700; line-height: 1.25; } .editor [data-type="info"][showtitle="false"]::before, .editor [data-type="info"][title=""]::before { display: none; } /* A wiki panel starts with a text descriptor div followed by the content div. The descriptor is part of the paste contract, not something to show here. */ .editor [data-type="info"] > div:first-child:not(:last-child) { display: none; } .editor .node-info .header_15vtd, .editor .node-info .title_15vtd { display: block; margin-bottom: 8px; color: #173f68; font-weight: 700; line-height: 1.25; } .editor [data-type="info"][type="warning"], .editor .node-info .warning-0-2-331 { border-left-color: var(--wiki-warning-border); background: var(--wiki-warning-bg); color: var(--wiki-warning-text); } .editor [data-type="info"][type="tip"] { border-left-color: var(--wiki-tip-border); background: var(--wiki-tip-bg); color: var(--wiki-tip-text); } .editor [data-type="status"], .editor .node-status [class*="status-"] { display: inline-flex; align-items: center; max-width: 100%; min-height: 22px; margin: 0 3px 2px 0; padding: 2px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--panel-sunken); color: var(--muted); font-family: var(--font-mono); font-size: var(--fs-xs); line-height: var(--lh-tight); white-space: nowrap; vertical-align: middle; } .editor [data-type="status"][color="green"] { border-color: #9bd0b2; background: #e8f7ee; color: #146c43; } .editor [data-type="status"][color="yellow"], .editor [data-type="status"][color="orange"] { border-color: #e3c078; background: #fff7df; color: #7a4b00; } .editor [data-type="status"][color="red"] { border-color: #e2a5a5; background: #fff0f0; color: #9b2c2c; } .editor [data-type="status"][color="blue"], .editor [data-type="status"][color="light-blue"] { border-color: #a8c8ea; background: #eef7ff; color: #165a9f; } .editor [data-type="status"][color="purple"] { border-color: #c8b4e8; background: #f6f0ff; color: #5d3b8c; } .editor [data-type="status"][color="black"], .editor [data-type="status"][color="gray"] { border-color: #c8ced6; background: #f0f2f4; color: #3e4854; } .editor [data-type="uiButton"], .editor .node-uiButton { display: inline-flex; max-width: 100%; margin: 5px 8px 8px 0; vertical-align: middle; } .editor [data-type="uiButton"] button, .editor .node-uiButton button { min-height: 30px; border-radius: var(--btn-radius); border-color: var(--line-strong); background: var(--panel); color: var(--text); box-shadow: 0 1px 2px rgba(14, 22, 34, 0.05); font-family: var(--font-sans); font-size: var(--fs-sm); } .editor [data-type="uiButton"][display="block"] { display: flex; } .editor [data-type="uiButton"][display="block"] button { width: 100%; justify-content: center; } .editor [data-type="uiButton"][color="Foreground.forePositive"] button { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-strong); } .editor [data-type="uiButton"][color="Foreground.foreAccent"] button, .editor [data-type="uiButton"][color="sky.sky500"] button { border-color: var(--wiki-info-border); background: var(--wiki-info-bg); color: var(--wiki-info-text); } .editor [data-type="uiButton"][color="Foreground.foreWarning"] button { border-color: var(--wiki-warning-border); background: var(--wiki-warning-bg); color: var(--wiki-warning-text); } .editor [data-type="uiButton"][color="Foreground.foreNegative"] button { border-color: #e6a5a5; background: var(--bad-soft); color: var(--bad); } .editor [data-type="table"], .editor .node-table { display: block; max-width: 100%; margin: 12px 0; overflow: auto; border: 1px solid var(--wiki-table-border); border-radius: var(--radius); background: var(--wiki-table-bg); } .editor [data-type="table"] table, .editor .node-table table { width: max-content; min-width: 100%; border-collapse: collapse; } .editor [data-type="table"] th, .editor [data-type="table"] td, .editor .node-table th, .editor .node-table td { min-width: 120px; padding: 7px 9px; border: 1px solid var(--wiki-table-border); vertical-align: top; } .editor [data-type="table"] th, .editor .node-table th { background: var(--wiki-table-head-bg); color: var(--text-strong); font-weight: 600; } .editor [data-type="blocksGrid"], .editor .grid_1orlh { display: grid; grid-template-columns: var(--blocksgrid-template, repeat(2, minmax(0, 1fr))); gap: 12px; max-width: 100%; margin: 12px 0; padding: 0; } .editor [data-type="column"], .editor .column_yu7m7, .editor .column_kzy3y { min-width: 0; padding: 10px; border: 1px dashed var(--wiki-chrome-border-soft); border-radius: 8px; background: #fbfdff; } .editor [data-type="taskList"] { margin: 10px 0; padding-left: 0; list-style: none; } .editor [data-type="taskItem"], .editor .node-taskItem { margin: 6px 0; } .editor .orderedList_14jqc { margin: 10px 0; padding-left: 28px; } .editor .listItem_14jqc { margin: 5px 0; } .editor [data-type="hint"], .editor .hint_jjms4 { display: inline; padding: 1px 5px; border-radius: 4px; background: var(--wiki-hint-bg); color: var(--wiki-hint-text); box-decoration-break: clone; } .editor [data-type="include"], .editor .node-include [data-type="include"] { display: block; margin: 12px 0; padding: 12px; border: 1px dashed #9fbbd8; border-radius: 8px; background: #f4f9ff; color: #35506c; } .editor [data-type="include"]::before, .editor .node-include [data-type="include"]::before { content: "Include"; display: inline-flex; margin-right: 8px; padding: 2px 7px; border-radius: 999px; background: #deecf9; color: #173f68; font-size: 12px; font-weight: 700; } .editor [data-type="plantuml"], .editor .plantContainer_1hv20 { display: block; margin: 12px 0; padding: 12px; border: 1px solid #cbd9e7; border-radius: 8px; background: #fbfcfd; } .editor .codeToolbar_rfjdv, .editor .imageContainer_1hv20, .editor .imageToolbar_w55d3 { display: none; } .editor [data-type="plantuml"] pre, .editor .plantContainer_1hv20 pre, .editor pre[data-set="highlighted-code"] { margin: 0; overflow: auto; padding: 10px; border-radius: 6px; background: #1f2937; color: #edf2f7; white-space: pre; } .editor [data-type="tabs"], .editor .tabsWrapper_d6kqf { display: block; margin: 12px 0; border: 1px solid var(--wiki-chrome-border); border-radius: 8px; background: #fff; overflow: hidden; } .editor .tabHeader_d6kqf { display: flex; align-items: center; gap: 6px; padding: 8px 10px; background: var(--wiki-chrome-bg); border-bottom: 1px solid var(--wiki-chrome-border); } .editor .leftBtnContainer_d6kqf, .editor .rightBtnContainer_d6kqf, .editor .tab-handler, .editor .removeNodeBtn_d6kqf, .editor .tabLabel_1f49m > button { display: none !important; } .editor .tabLabelContainer_d6kqf { display: flex; flex-wrap: wrap; gap: 6px; } .editor .tabLabel_1f49m { display: inline-flex; align-items: center; min-height: 28px; padding: 0 8px; border: 1px solid var(--wiki-chrome-border-soft); border-radius: 6px; background: #fff; } .editor .tabLabel_1f49m.active_1f49m { border-color: var(--wiki-chrome-active-border); background: #e7f2ff; } .editor .tabLabelInput_1f49m { width: auto; min-width: 72px; border: 0; outline: 0; background: transparent; color: var(--wiki-chrome-text); font: inherit; font-weight: 600; } .editor [data-type="tab"], .editor .tabWrapper_z4u1j, .editor .tabContainer_z4u1j { display: block; border-top: 1px solid #e2e8f0; } .editor .tabTitle_z4u1j { display: block; padding: 8px 10px; background: var(--wiki-chrome-bg); color: var(--wiki-chrome-text); font-weight: 700; } .layout-preview .preview-tab-bar { display: flex; flex-wrap: wrap; gap: 2px; padding: 6px 8px 0; background: var(--wiki-chrome-bg); border-bottom: 1px solid var(--wiki-chrome-border); } .layout-preview .preview-tab-bar button { appearance: none; border: 1px solid var(--wiki-chrome-border-soft); border-bottom: 0; background: var(--wiki-preview-tab-bg); color: var(--wiki-preview-tab-text); padding: 6px 12px; border-top-left-radius: 6px; border-top-right-radius: 6px; font: inherit; font-weight: 600; cursor: pointer; margin-bottom: -1px; } .layout-preview .preview-tab-bar button:hover { background: #fff; color: var(--wiki-preview-tab-hover-text); } .layout-preview .preview-tab-bar button[aria-selected="true"] { background: #fff; color: var(--wiki-preview-tab-active-text); border-color: var(--wiki-chrome-active-border); box-shadow: 0 -1px 0 #fff inset; } .layout-preview .tabWrapper_z4u1j > .tabTitle_z4u1j { display: none; } .layout-preview .tabWrapper_z4u1j[data-preview-active="false"] > div:not(.tabTitle_z4u1j) { display: none; } .editor .expand, .editor [data-type="expand"] { display: block; margin: 12px 0; border: 1px solid var(--wiki-chrome-border); border-radius: 8px; background: #fff; overflow: hidden; } .editor .expand > button { display: none; } .editor .expand-summary { display: block; padding: 10px 12px; background: var(--wiki-chrome-bg); color: var(--wiki-chrome-text); font-weight: 700; } .editor .expand-content { display: block !important; padding: 10px 12px; } .editor .expand-content[hidden] { opacity: 0.75; } .editor [data-analytics], .editor [data-testid*="DeleteNode"], .editor [data-testid*="CopyCode"], .editor [data-testid*="CollapseCode"], .editor [data-testid*="OpenNode"], .editor [data-testid*="Button.Arrow"], .editor [data-testid*="Button.Close"], .editor [data-testid*="Button.DragIndicator"] { display: none !important; } .raw-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; height: 100%; min-height: 0; } textarea { width: 100%; height: 100%; min-width: 260px; min-height: 260px; resize: both; border: 1px solid var(--line); border-radius: 8px; padding: 10px; color: var(--text); background: var(--panel-sunken); line-height: 1.45; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; outline: none; tab-size: 2; } .tabs { display: inline-flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; background: var(--panel); } .tabs button { border: 0; border-radius: 0; min-height: 30px; padding: 0 10px; } .tabs button[aria-pressed="true"] { background: #deecf9; color: #063f78; } .tool-workspace { display: grid; grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr); min-height: calc(100vh - 140px); } .kb-workspace { display: grid; grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr); gap: var(--panel-gap); padding-top: var(--panel-gap); min-height: calc(100vh - 140px); } .kb-left { display: grid; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); gap: var(--panel-gap); min-height: 0; } .kb-workspace > .panel, .kb-left > .panel { min-height: 0; } @media (max-width: 1100px) { .kb-workspace { grid-template-columns: 1fr; min-height: calc(100vh - 200px); } .kb-left { grid-template-rows: minmax(180px, 1fr) minmax(180px, 1fr); } } .work-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 0; } .full-body { display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 10px; min-height: 0; } .action-row { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } .large-textarea { min-height: 0; resize: none; } .result-meta { min-height: 24px; color: var(--muted); font-size: 13px; overflow-wrap: anywhere; } .layout-output { display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 10px; min-height: 0; } .layout-preview { min-height: 0; border: 1px solid var(--line); border-radius: 8px; } .hidden { display: none !important; } @media (max-width: 980px) { .status { max-width: 100%; } .clipboard-layout, .tool-workspace { grid-template-columns: 1fr; } .workspace { grid-template-rows: minmax(260px, 48vh) minmax(320px, auto); } .raw-grid { grid-template-columns: 1fr; } } /* Modern application shell */ body { overflow: hidden; font-size: var(--fs-md); } .app { display: grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); grid-template-rows: var(--header-h) minmax(0, 1fr) var(--statusbar-h); width: 100%; height: 100vh; min-height: 0; overflow: hidden; } header { position: static; z-index: 10; grid-column: 1; grid-row: 1 / -1; display: flex; min-width: 0; min-height: 0; padding: 0; flex-direction: column; align-items: stretch; flex-wrap: nowrap; gap: 0; border-right: 1px solid var(--line); border-bottom: 0; background: #fbfcfd; } .brand-row { display: flex; min-height: var(--header-h); align-items: center; gap: 10px; padding: 0 18px; border-bottom: 1px solid var(--line); } .brand { width: 32px; height: 32px; border-radius: 5px; font-size: 13px; letter-spacing: 0; } .brand-copy { display: flex; min-width: 0; flex-direction: column; line-height: var(--lh-tight); } .brand-copy strong { font-size: 15px; font-weight: 700; } .brand-copy span { margin-top: 2px; color: var(--muted); font-size: var(--fs-xs); } .tool-tabs { display: block; min-height: 0; padding: 18px 12px; flex: 1 1 auto; overflow-y: auto; border: 0; border-radius: 0; background: transparent; } .tab-group { display: flex; align-items: stretch; flex-direction: column; gap: 2px; } .tab-group + .tab-group { margin-top: 24px; } .nav-group-label { margin: 0 8px 7px; color: #8b929c; font-size: var(--fs-xs); font-weight: 700; line-height: var(--lh-tight); text-transform: uppercase; } .tool-tabs button { position: relative; display: flex; width: 100%; min-height: 38px; justify-content: space-between; padding: 0 10px 0 13px; border-color: transparent; background: transparent; color: #4e5661; text-align: left; transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease; } .tool-tabs button:hover { border-color: transparent; background: #f0f3f5; } .tool-tabs button[aria-selected="true"] { border-color: transparent; background: var(--accent-soft); box-shadow: none; color: var(--accent-strong); font-weight: 700; } .tool-tabs button[aria-selected="true"]::before { position: absolute; top: 8px; bottom: 8px; left: 0; width: 3px; border-radius: 2px; background: var(--accent); content: ""; } .tool-tabs .tab-info { width: 17px; height: 17px; background: #e9edf1; font-size: 11px; } .release-meta { align-items: flex-start; gap: 8px; margin: 0; padding: 14px 18px 18px; border-top: 1px solid var(--line); } .version-meta { font-size: var(--fs-xs); } .version-number { border-color: #79b894; border-radius: 4px; } .version-date { color: var(--muted); } .app-topbar { grid-column: 2; grid-row: 1; display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 20px; padding: 0 24px; border-bottom: 1px solid var(--line); background: var(--panel); } .active-tool-heading { min-width: 0; } .active-tool-heading > span { display: block; color: var(--muted); font-size: var(--fs-xs); font-weight: 600; line-height: var(--lh-tight); } .active-tool-heading h1 { margin: 2px 0 0; overflow: hidden; color: var(--text-strong); font-size: 18px; font-weight: 700; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; } .mobile-topbar-brand { display: none; } .topbar-icon { width: 34px; min-width: 34px; padding: 0; color: #4f5863; font-weight: 700; } button, summary, select { transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease, color 140ms ease, transform 100ms ease; } button:active { transform: translateY(1px); } .mobile-tool-switcher { display: none; } .status-bar { position: static; z-index: 9; grid-column: 2; grid-row: 3; height: var(--statusbar-h); padding: 0 24px; gap: 8px; border-top: 1px solid var(--line); border-right: 0; border-bottom: 0; border-left: 0; background: var(--panel); font-family: var(--font-sans); font-size: var(--fs-sm); } .status-bar .status-prefix { position: relative; padding-left: 15px; font-weight: 700; } .status-bar .status-prefix::before { position: absolute; top: 50%; left: 0; width: 7px; height: 7px; border-radius: 50%; background: var(--line-strong); content: ""; transform: translateY(-50%); transition: background-color 180ms ease, box-shadow 180ms ease; } .status-bar[data-state="ok"], .status-bar[data-state="warn"], .status-bar[data-state="bad"] { background: var(--panel); } .status-bar[data-state="ok"] .status-prefix::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } .status-bar[data-state="warn"] .status-prefix::before { background: var(--warn); box-shadow: 0 0 0 3px var(--warn-soft); } .status-bar[data-state="bad"] .status-prefix::before { background: var(--bad); box-shadow: 0 0 0 3px var(--bad-soft); } .tools { grid-column: 2; grid-row: 2; min-width: 0; min-height: 0; padding: 0; overflow: hidden; } .tool-view { display: grid; width: 100%; height: 100%; min-width: 0; min-height: 0; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; } .tool-view:not([hidden]) { animation: tool-view-enter 160ms ease-out both; } .toolbar { align-items: center; gap: 8px; min-height: 58px; padding: 10px 24px; border-top: 0; border-bottom: 1px solid var(--line); background: var(--panel-sunken); } button, .btn-soft, .btn-warning, .btn-external { min-height: var(--btn-h); border-radius: var(--btn-radius); font-size: var(--fs-base); } .btn-soft { border-color: var(--line-strong); background: var(--panel); color: var(--text); } .btn-soft:hover { border-color: #aab4bf; background: var(--panel-sunken); } .btn-warning { border-color: #e4bf82; background: #fff6e8; color: #96590e; } .btn-external { border: 1px solid transparent; text-decoration: none; } .meet-prompt-modes, .tabs { display: inline-grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); padding: 3px; overflow: visible; border-color: var(--line); border-radius: var(--radius); background: #edf1f4; } .meet-prompt-modes { position: relative; isolation: isolate; width: 350px; max-width: 100%; min-height: 34px; border: 1px solid var(--line-strong); border-radius: 999px; background: #e7ebef; overflow: hidden; } .meet-prompt-modes::before { position: absolute; z-index: 0; top: 3px; bottom: 3px; left: 3px; width: calc((100% - 6px) / 2); border-radius: 999px; background: var(--panel); box-shadow: 0 1px 3px rgba(23, 26, 31, 0.16); content: ""; transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1); } .meet-prompt-modes:has([data-meet-prompt-mode="brief"][aria-pressed="true"])::before { transform: translateX(100%); } .meet-prompt-selector { display: inline-flex; flex: none; align-items: center; gap: 8px; } .meet-prompt-modes-label { color: var(--muted); font-size: var(--fs-xs); font-weight: 700; letter-spacing: 0; text-transform: uppercase; } .meet-prompt-modes button, .tabs button { min-width: 0; min-height: 30px; border: 1px solid transparent; border-radius: 4px; background: transparent; color: var(--muted); } .meet-prompt-modes button { position: relative; z-index: 1; width: 100%; min-height: 28px; padding: 0 8px; border: 0; border-radius: 999px; font-size: var(--fs-sm); font-weight: 600; } .meet-prompt-modes button:hover:not([aria-pressed="true"]) { background: transparent; color: var(--accent-strong); } .meet-prompt-modes button[aria-pressed="true"], .tabs button[aria-pressed="true"] { border-color: var(--line-strong); background: var(--panel); box-shadow: 0 1px 2px rgba(23, 26, 31, 0.08); color: var(--text); font-weight: 700; } .meet-prompt-modes button[aria-pressed="true"] { border-color: transparent; background: transparent; box-shadow: none; color: var(--accent-strong); } .tool-workspace, .kb-workspace, .clipboard-layout { height: 100%; min-height: 0; gap: 1px; padding: 8px; background: var(--line); } .tool-workspace, .kb-workspace { grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr); } /* Layout tab: the rendered preview is the star — give it the wider column. */ @media (min-width: 981px) { :where(#layoutTool) .tool-workspace { grid-template-columns: minmax(320px, 460px) minmax(0, 1fr); } } .kb-workspace { min-height: 0; } .kb-left { gap: 1px; background: var(--line); } .clipboard-layout { grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); } .panel { border-color: var(--line); border-radius: 0; background: var(--panel); box-shadow: 0 1px 2px rgba(17, 20, 25, 0.04); transition: border-color 160ms ease, box-shadow 160ms ease; } .tool-workspace > .panel:first-child, .kb-workspace > :first-child, .clipboard-layout > :first-child { border-radius: var(--radius) 0 0 var(--radius); } .tool-workspace > .panel:last-child, .kb-workspace > .panel:last-child, .clipboard-layout > :last-child { border-radius: 0 var(--radius) var(--radius) 0; } .panel-title { min-height: 48px; padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--panel); } h2 { font-size: var(--fs-md); font-weight: 700; } .full-body, .layout-output, .panel-body, .raw-body { padding: 0; } textarea { min-width: 0; padding: 18px 20px; resize: none; border: 0; border-radius: 0; background: var(--panel); color: #2d333a; font-size: var(--fs-base); line-height: 1.62; } textarea:focus, .paste-zone:focus, .excel-paste-zone:focus { border-color: transparent; box-shadow: var(--focus-ring-inset); } .paste-zone, .excel-paste-zone { min-height: 100%; border: 0; border-radius: 0; background: var(--panel); } .paste-target-overlay { align-self: center; justify-self: center; width: min(520px, calc(100% - 36px)); padding: 18px; text-align: left; } .paste-target-prompt { margin-bottom: 8px; color: var(--text-strong); font-size: var(--fs-lg); font-style: normal; font-weight: 700; } .paste-target-helper { max-width: 64ch; font-size: var(--fs-base); line-height: 1.55; } .result-meta { min-height: 34px; padding: 8px 14px; border-top: 1px solid var(--line); font-size: var(--fs-sm); } .layout-output { gap: 0; } .layout-preview { border: 0; border-radius: 0; } .editor { padding: 20px 24px 36px; } .preview-empty { justify-content: center; min-height: 100%; padding: 24px; } .prompt-output-shell { min-height: 0; } .prompt-modal-panel, .popover { background: var(--panel); border-color: var(--line); } .popover:not([hidden]), .prompt-modal:not([hidden]) .prompt-modal-panel { animation: floating-surface-enter 160ms ease-out both; } @keyframes tool-view-enter { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } @keyframes floating-surface-enter { from { opacity: 0; transform: translateY(-4px) scale(0.99); } to { opacity: 1; transform: translateY(0) scale(1); } } .mobile-pane-tabs { display: none; } @media (max-width: 900px) { :root { --header-h: 58px; } body { overflow: auto; } .app { grid-template-columns: minmax(0, 1fr); grid-template-rows: var(--header-h) auto minmax(0, 1fr) var(--statusbar-h); height: 100svh; } header { display: none; } .app-topbar { grid-column: 1; grid-row: 1; justify-content: flex-start; gap: 10px; padding: 0 14px; } .mobile-topbar-brand { display: grid; width: 32px; height: 32px; flex: none; place-items: center; border-radius: 5px; background: var(--accent); color: var(--on-accent); font-family: var(--font-mono); font-size: 12px; font-weight: 700; } .active-tool-heading { flex: 1; } .active-tool-heading > span { display: none; } .active-tool-heading h1 { margin: 0; font-size: var(--fs-lg); } .topbar-icon { margin-left: auto; } .mobile-tool-switcher { grid-column: 1; grid-row: 2; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 7px 10px; padding: 8px 14px; border-bottom: 1px solid var(--line); background: var(--panel); } .mobile-tool-switcher > label { align-self: center; color: var(--muted); font-size: var(--fs-xs); font-weight: 700; } .mobile-tool-switcher select { width: 100%; min-width: 0; min-height: 34px; padding: 0 34px 0 10px; border: 1px solid var(--line-strong); border-radius: var(--radius); background: var(--panel); color: var(--text); font: inherit; font-weight: 700; } .mobile-release-meta { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 10px; } .mobile-release-meta .contact-link, .mobile-release-meta .version-meta { font-size: 10px; } .tools { grid-column: 1; grid-row: 3; overflow: auto; } .status-bar { grid-column: 1; grid-row: 4; padding: 0 14px; } .toolbar { align-content: flex-start; min-height: 0; padding: 10px 14px; } .toolbar .spacer { display: none; } .meet-prompt-selector { display: grid; grid-template-columns: 1fr; gap: 4px; width: 100%; order: -2; } .meet-prompt-modes { width: 100%; } .meet-prompt-selector + button { order: -1; } .external-group-label { display: none; } .mobile-pane-tabs { display: grid; grid-template-columns: 1fr 1fr; padding: 8px 14px 0; background: var(--bg); } .mobile-pane-tabs button { border: 0; border-bottom: 1px solid var(--line-strong); border-radius: 0; background: transparent; color: var(--muted); } .mobile-pane-tabs button[aria-selected="true"] { border-bottom: 2px solid var(--accent); color: var(--accent-strong); font-weight: 700; } .tool-view.has-mobile-pane-tabs { grid-template-rows: auto auto minmax(0, 1fr); } .tool-workspace, .kb-workspace, .clipboard-layout { display: block; height: auto; min-height: 0; padding: 8px; background: var(--bg); } .tool-view.has-mobile-pane-tabs[data-mobile-pane="input"] .tool-workspace > .panel:last-child, .tool-view.has-mobile-pane-tabs[data-mobile-pane="output"] .tool-workspace > .panel:first-child, .tool-view.has-mobile-pane-tabs[data-mobile-pane="input"] .kb-workspace > .panel:last-child, .tool-view.has-mobile-pane-tabs[data-mobile-pane="output"] .kb-workspace > .kb-left:first-child { display: none; } /* Pane tabs already carry the panel titles — hide the duplicated text, and collapse the title row entirely when it holds nothing else. */ .tool-view.has-mobile-pane-tabs .tool-workspace > .panel > .panel-title > h2, .tool-view.has-mobile-pane-tabs .kb-workspace > .panel > .panel-title > h2 { display: none; } .tool-view.has-mobile-pane-tabs .tool-workspace > .panel > .panel-title:not(:has(> :not(h2))), .tool-view.has-mobile-pane-tabs .kb-workspace > .panel > .panel-title:not(:has(> :not(h2))) { display: none; } .tool-view.has-mobile-pane-tabs .tool-workspace > .panel, .tool-view.has-mobile-pane-tabs .kb-workspace > .panel, .tool-view.has-mobile-pane-tabs .kb-workspace > .kb-left { animation: tool-view-enter 150ms ease-out both; } .tool-workspace > .panel, .kb-workspace > .panel, .clipboard-layout > aside, .clipboard-layout > section { border: 1px solid var(--line); border-radius: var(--radius) !important; } .tool-workspace > .panel + .panel, .kb-workspace > .panel + .panel, .clipboard-layout > section { margin-top: 10px; } .panel-title { min-height: 46px; } .large-textarea, .layout-preview, .prompt-output-shell { min-height: 430px; } .editor { padding: 17px 17px 30px; } .paste-target-overlay { align-self: start; margin-top: 24px; } .raw-grid { grid-template-columns: 1fr; } .popover { max-width: calc(100vw - 24px); } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } } </style> </head> <body> <svg class="llm-sprite" width="0" height="0" aria-hidden="true" focusable="false" style="position:absolute"> <symbol id="ico-zai" viewBox="0 0 24 24"><rect width="24" height="24" rx="6" fill="#3859e8"></rect><path d="M7.5 8.5h9l-9 7h9" fill="none" stroke="#fff" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"></path></symbol> <symbol id="ico-deepseek" viewBox="0 0 24 24"><rect width="24" height="24" rx="6" fill="#4d6bfe"></rect><path d="M4.5 12.8c0-2.3 2.2-3.9 5-3.9 3.2 0 5.4 1.8 5.9 4.3.9-.1 1.7-.7 2.1-1.5.3 1.7-.3 3-1.5 3.8.5.2 1.1.2 1.7.1-.9 1.3-2.4 1.7-3.8 1.2-1.6.6-4.2.9-6.3.4-2.9-.7-5.1-2.4-5.1-4.4z" fill="#fff"></path><circle cx="8" cy="12.3" r="1" fill="#4d6bfe"></circle></symbol> <symbol id="ico-gigachat" viewBox="0 0 24 24"><rect width="24" height="24" rx="6" fill="#159a57"></rect><path d="M12 5.4c.7 3.4 2.3 4.9 5.6 5.6-3.3.7-4.9 2.2-5.6 5.6-.7-3.4-2.3-4.9-5.6-5.6 3.3-.7 4.9-2.2 5.6-5.6z" fill="#fff"></path></symbol> <symbol id="ico-kimi" viewBox="0 0 24 24"><rect width="24" height="24" rx="6" fill="#16182b"></rect><path d="M15 8a5 5 0 1 0 0 8 4 4 0 1 1 0-8z" fill="#fff"></path></symbol> <symbol id="ico-qwen" viewBox="0 0 24 24"><rect width="24" height="24" rx="6" fill="#7a5cf0"></rect><path d="M7.6 8h8.8a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4l-3 2.6V14H7.6a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z" fill="#fff"></path></symbol> </svg> <div class="app"> <header> <div class="brand-row"> <div class="brand" role="img" aria-label="Wiki Lab">WL</div> <div class="brand-copy"> <strong>Wiki Lab</strong> <span>Пространство знаний</span> </div> </div> <nav class="tool-tabs" aria-label="Lab tools"> <div class="tab-group" data-tab-group="generate"> <div class="nav-group-label">Создание</div> <button data-tool-tab="meet-protocol" aria-controls="meetProtocolTool" aria-selected="true" aria-expanded="false"><span>Протокол встречи</span><span class="tab-info" aria-label="Что делает вкладка Протокол встречи?">i</span></button> <button data-tool-tab="knowledge-base" aria-controls="knowledgeBaseTool" aria-selected="false" aria-expanded="false"><span>База знаний</span><span class="tab-info" aria-label="Что делает вкладка База знаний?">i</span></button> <button data-tool-tab="goal-extraction" aria-controls="goalExtractionTool" aria-selected="false" aria-expanded="false"><span>Цель: сбор и обновление</span><span class="tab-info" aria-label="Что делает вкладка Цель: сбор и обновление?">i</span></button> </div> <div class="tab-group" data-tab-group="visualize"> <div class="nav-group-label">Визуализация</div> <button data-tool-tab="layout" aria-controls="layoutTool" aria-selected="false" aria-expanded="false"><span>JSON to SberTrack</span><span class="tab-info" aria-label="Что делает вкладка JSON to SberTrack?">i</span></button> <button data-tool-tab="board" aria-controls="boardTool" aria-selected="false" aria-expanded="false"><span>JSON to Board</span><span class="tab-info" aria-label="Что делает вкладка JSON to Board?">i</span></button> </div> <div class="tab-group" data-tab-group="convert"> <div class="nav-group-label">Конвертация</div> <button data-tool-tab="md-to-wiki" aria-controls="mdToWikiTool" aria-selected="false" aria-expanded="false"><span>Markdown to HTML</span><span class="tab-info" aria-label="Что делает вкладка Markdown to HTML?">i</span></button> <button data-tool-tab="markdown" aria-controls="markdownTool" aria-selected="false" aria-expanded="false"><span>HTML to Markdown</span><span class="tab-info" aria-label="Что делает вкладка HTML to Markdown?">i</span></button> <button data-tool-tab="excel" aria-controls="excelTool" aria-selected="false" aria-expanded="false"><span>WIKI to Excel</span><span class="tab-info" aria-label="Что делает вкладка WIKI to Excel?">i</span></button> <button data-tool-tab="clipboard" aria-controls="clipboardTool" aria-selected="false" aria-expanded="false"><span>Clipboard</span><span class="tab-info" aria-label="Что делает вкладка Clipboard?">i</span></button> </div> </nav> <div class="release-meta"> <a class="contact-link" href="https://sberchat.sberbank.ru/@MMRatnikov" target="_blank" rel="noopener noreferrer">По любым вопросам обращайтесь</a> <span class="version-meta" aria-label="Версия 1.2.11 от 10 августа 2026 года"> <span class="version-number">v1.2.11</span> <time class="version-date" datetime="2026-08-10">10.08.2026</time> </span> </div> </header> <div class="app-topbar"> <div class="mobile-topbar-brand" role="img" aria-label="Wiki Lab">WL</div> <div class="active-tool-heading"> <span id="activeToolGroup">Создание</span> <h1 id="activeToolTitle">Протокол встречи</h1> </div> <button id="activeToolInfo" class="topbar-icon" type="button" data-active-tool-info aria-label="О текущем инструменте" aria-expanded="false">?</button> </div> <div class="mobile-tool-switcher"> <label for="mobileToolSelect">Инструмент</label> <select id="mobileToolSelect" aria-label="Выберите инструмент"> <optgroup label="Создание"> <option value="meet-protocol">Протокол встречи</option> <option value="knowledge-base">База знаний</option> <option value="goal-extraction">Цель: сбор и обновление</option> </optgroup> <optgroup label="Визуализация"> <option value="layout">JSON to SberTrack</option> <option value="board">JSON to Board</option> </optgroup> <optgroup label="Конвертация"> <option value="md-to-wiki">Markdown to HTML</option> <option value="markdown">HTML to Markdown</option> <option value="excel">WIKI to Excel</option> <option value="clipboard">Clipboard</option> </optgroup> </select> <div class="mobile-release-meta"> <a class="contact-link" href="https://sberchat.sberbank.ru/@MMRatnikov" target="_blank" rel="noopener noreferrer">По любым вопросам обращайтесь</a> <span class="version-meta" aria-label="Версия 1.2.11 от 10 августа 2026 года"> <span class="version-number">v1.2.11</span> <time class="version-date" datetime="2026-08-10">10.08.2026</time> </span> </div> </div> <div id="statusBar" class="status-bar" data-state="idle" role="status" aria-live="polite"> <span class="status-prefix">— ОЖИДАНИЕ —</span> <span class="status-message">Ожидание вставки</span> </div> <div id="tabInfoPopover" class="popover" role="group" aria-labelledby="tabInfoPopoverTitle" hidden> <h3 id="tabInfoPopoverTitle" class="popover-title"></h3> <p id="tabInfoPopoverBody" class="popover-body"></p> </div> <main class="tools"> <section id="clipboardTool" class="tool-view" hidden> <div class="toolbar"> <button id="applyRaw">Apply HTML</button> <div class="tabs" aria-label="Editor mode"> <button id="visualTab" aria-pressed="true">Visual</button> <button id="htmlTab" aria-pressed="false">HTML</button> </div> <button id="formatHtmlEditor">Format HTML</button> <button id="syncRaw">Sync From Editor</button> <span class="spacer"></span> <button id="copyPayload">Copy Payload</button> <button id="copyRich" class="btn-primary">Copy Back</button> <button id="clearAll">Очистить</button> </div> <div class="clipboard-layout"> <aside> <div class="panel"> <div class="panel-title"> <h2>Вставка</h2> </div> <div class="panel-body"> <div class="paste-target"> <div id="pasteZone" class="paste-zone" contenteditable="true" role="textbox" aria-label="Paste wiki content here" data-placeholder=""></div> <div class="paste-target-overlay" aria-hidden="true"> <div class="paste-target-prompt">Вставить сюда...</div> <div class="paste-target-helper">Вставьте фрагмент wiki прямо из браузера — вкладка покажет MIME-типы и payload, даст отредактировать содержимое визуально или как HTML и вернёт результат в wiki через Copy Back.</div> </div> </div> </div> </div> <div class="panel"> <div class="panel-title"> <h2>Форматы</h2> </div> <div class="panel-body"> <div id="typesList" class="types-list"></div> <div class="mini-label">Wiki-элементы</div> <div id="wikiTypesList" class="types-list"></div> <div class="meta-grid"> <div>HTML</div> <code id="htmlSize">0 bytes</code> <div>Текст</div> <code id="textSize">0 bytes</code> <div>Источник</div> <code id="sourceUrl">none</code> </div> </div> </div> </aside> <section class="workspace"> <div class="panel editor-panel"> <div class="panel-title"> <h2>Редактор</h2> </div> <div id="editor" class="editor" contenteditable="true" spellcheck="false" aria-label="Editable wiki content"></div> <textarea id="htmlEditor" class="hidden" spellcheck="false" aria-label="Raw HTML editor"></textarea> </div> <div class="raw-grid"> <div class="panel"> <div class="panel-title"> <h2>Payload</h2> </div> <div class="panel-body raw-body"> <textarea id="payloadDump" readonly spellcheck="false"></textarea> </div> </div> <div class="panel"> <div class="panel-title"> <h2>Исходный HTML</h2> </div> <div class="panel-body raw-body"> <textarea id="originalHtml" readonly spellcheck="false"></textarea> </div> </div> <div class="panel"> <div class="panel-title"> <h2>Обычный текст</h2> </div> <div class="panel-body raw-body"> <textarea id="plainText" spellcheck="false"></textarea> </div> </div> </div> </section> </div> </section> <section id="excelTool" class="tool-view" hidden> <div class="toolbar"> <button id="normalizeExcel">Convert</button> <button id="copyExcelOutput" class="btn-primary">Copy Excel</button> <button id="clearExcelInput" class="btn-clear">Очистить</button> </div> <div class="tool-workspace"> <section class="panel work-panel"> <div class="panel-title"> <h2>Wiki Table HTML</h2> </div> <div class="full-body"> <div class="paste-target"> <div id="excelPasteZone" class="excel-paste-zone" contenteditable="true" role="textbox" aria-label="Paste wiki table here" data-placeholder=""></div> <div class="paste-target-overlay" aria-hidden="true"> <div class="paste-target-prompt">Вставить сюда...</div> <div class="paste-target-helper">Вставьте wiki-таблицу из браузера — она сразу нормализуется (rowspan/colspan) и будет готова для вставки в Excel, Р7 Офис или Google Sheets.</div> </div> </div> </div> </section> <section class="panel work-panel"> <div class="panel-title"> <h2>Excel Output</h2> </div> <div class="full-body"> <div class="prompt-output-shell" data-output-shell="excel"> <textarea id="excelOutput" class="large-textarea" readonly spellcheck="false" aria-label="Excel TSV output"></textarea> <div id="excelOutputEmpty" class="preview-empty prompt-output-empty" data-empty-for="excel-output"> <div class="preview-skeleton" aria-hidden="true"> <div class="sk-heading"></div> <div class="sk-paragraph sk-paragraph-1"></div> <div class="sk-paragraph sk-paragraph-2"></div> <div class="sk-paragraph sk-paragraph-3"></div> <div class="sk-table"> <div></div><div></div><div></div> <div></div><div></div><div></div> <div></div><div></div><div></div> </div> <div class="sk-buttons"> <div></div><div></div> </div> </div> <p class="preview-empty-hint">Сконвертируйте таблицу — здесь появится результат для Excel</p> </div> </div> <div id="excelMeta" class="result-meta">Таблица ещё не сконвертирована</div> </div> </section> </div> </section> <section id="layoutTool" class="tool-view" hidden> <div class="toolbar"> <button id="buildLayoutPromptFromText" class="btn-soft">Нет JSON? Собрать промт</button> <button id="renderLayoutJson">Render</button> <button id="copyLayoutRich" class="btn-primary">Copy Back</button> <button id="loadLayoutExample" type="button">Пример</button> <button id="loadKnowledgeBaseExample" type="button">Пример База Знаний</button> <button id="loadProtocolExample" type="button">Пример Протокола</button> <button id="formatLayoutJson">Format JSON</button> <button id="copyLayoutHtml">Save HTML</button> <label class="toolbar-field" for="layoutGoalProfile"> <span>Профиль страницы цели:</span> <select id="layoutGoalProfile"> <option value="public" selected>публичная</option> <option value="audit">аудит</option> </select> </label> <button id="clearLayout" class="btn-clear">Очистить</button> </div> <div class="tool-workspace"> <section class="panel work-panel"> <div class="panel-title"> <h2>Layout JSON</h2> </div> <div class="full-body"> <div class="paste-target"> <textarea id="layoutJsonInput" class="large-textarea" spellcheck="false" aria-label="Layout JSON input" placeholder=" "></textarea> <div class="paste-target-overlay" aria-hidden="true"> <div class="paste-target-prompt">Вставить сюда...</div> <div class="paste-target-helper">Принимаются два формата: layout JSON с массивом blocks и JSON цели (goal-template.v1) — формат определяется автоматически, из цели страница собирается сама, профиль переключается в тулбаре. Нет готового JSON? Нажмите «Нет JSON? Собрать промт», вставьте исходный текст — получите промт для LLM. Вставьте JSON сюда: Render запустится автоматически, Copy Back перенесёт результат в wiki. Кнопка «Пример Протокола» подставляет Markdown протокола и сразу рендерит preview.</div> </div> </div> </div> </section> <section class="panel work-panel"> <div class="panel-title"> <h2>Wiki Preview</h2> </div> <div class="layout-output"> <div id="layoutPreview" class="editor layout-preview" aria-label="Rendered wiki preview"> <div class="preview-empty" data-empty-for="layout"> <div class="preview-skeleton" aria-hidden="true"> <div class="sk-heading"></div> <div class="sk-paragraph sk-paragraph-1"></div> <div class="sk-paragraph sk-paragraph-2"></div> <div class="sk-paragraph sk-paragraph-3"></div> <div class="sk-table"> <div></div><div></div><div></div> <div></div><div></div><div></div> <div></div><div></div><div></div> </div> <div class="sk-buttons"> <div></div><div></div> </div> </div> <p class="preview-empty-hint">Вставьте JSON слева — здесь появится preview</p> </div> </div> <input id="layoutHtmlOutput" type="hidden"> <div id="layoutMeta" class="result-meta">Layout ещё не отрендерен</div> </div> </section> </div> </section> <section id="boardTool" class="tool-view" hidden> <div class="toolbar"> <button id="buildBoardPromptFromText" class="btn-soft">Нет JSON? Собрать промт</button> <button id="parseBoardPayloadBtn">Разобрать</button> <button id="copyBoardPayload" class="btn-primary">Копировать для доски</button> <button id="boardToMarkdown">В Markdown</button> <button id="formatBoardScene">Format JSON</button> <button id="clearBoard" class="btn-clear">Очистить</button> </div> <div class="tool-workspace"> <section class="panel work-panel"> <div class="panel-title"> <h2>Board JSON</h2> </div> <div class="full-body"> <div class="paste-target"> <textarea id="boardSceneInput" class="large-textarea" spellcheck="false" aria-label="Board scene JSON" placeholder=" "></textarea> <div class="paste-target-overlay" aria-hidden="true"> <div class="paste-target-prompt">Вставить сюда...</div> <div class="paste-target-helper">Принимаются три формата, вид определяется сам: фрагмент, скопированный с доски; board-layout-v1 от LLM; разобранная сцена. «Разобрать» приводит поле к board-layout-v1 — этот JSON и отдавайте модели вместе с промтом («Нет JSON? Собрать промт»), чтобы она доработала доску. «Копировать для доски» кладёт нативный payload в буфер одним text/plain. «В Markdown» делает обратное: собирает с доски таблицу объектов — бланки «поле/значение» становятся строками, таблицы одинаковой формы сливаются в одну.</div> </div> </div> </div> </section> <section class="panel work-panel"> <div class="panel-title"> <h2>Предупреждения</h2> </div> <div class="full-body"> <div id="boardWarnings" class="result-meta">Ничего не разобрано</div> </div> </section> </div> </section> <section id="meetProtocolTool" class="tool-view"> <div class="toolbar"> <div class="meet-prompt-selector"> <span class="meet-prompt-modes-label">Промт</span> <div class="tabs meet-prompt-modes" role="group" aria-label="Выбор промта встречи"> <button type="button" data-meet-prompt-mode="knowledge-base" aria-pressed="false">Для базы знаний</button> <button type="button" data-meet-prompt-mode="brief" aria-pressed="true">Краткая информация</button> </div> </div> <button id="buildMeetProtocol">Создать промт</button> <button id="prepareMeetProtocol" class="btn-primary">Copy Result</button> <span class="llm-launchers"> <span class="external-group-label">открыть в:</span> <button class="btn-external meet-llm-link" type="button" data-meet-llm-url="https://chat.z.ai/" aria-label="Открыть в Z.ai"><svg class="llm-ico" aria-hidden="true"><use href="#ico-zai"></use></svg><span>Z.ai</span></button> <button class="btn-external meet-llm-link" type="button" data-meet-llm-url="https://chat.deepseek.com/" aria-label="Открыть в DeepSeek"><svg class="llm-ico" aria-hidden="true"><use href="#ico-deepseek"></use></svg><span>DeepSeek</span></button> <button class="btn-external meet-llm-link" type="button" data-meet-llm-url="https://giga.chat/" aria-label="Открыть в GigaChat"><svg class="llm-ico" aria-hidden="true"><use href="#ico-gigachat"></use></svg><span>GigaChat</span></button> <button class="btn-external meet-llm-link" type="button" data-meet-llm-url="https://www.kimi.com/" aria-label="Открыть в Kimi"><svg class="llm-ico" aria-hidden="true"><use href="#ico-kimi"></use></svg><span>Kimi</span></button> <button class="btn-external meet-llm-link" type="button" data-meet-llm-url="https://chat.qwen.ai/" aria-label="Открыть в Qwen"><svg class="llm-ico" aria-hidden="true"><use href="#ico-qwen"></use></svg><span>Qwen</span></button> </span> <button id="clearMeetProtocol" class="btn-clear">Очистить</button> </div> <div class="tool-workspace"> <section class="panel work-panel"> <div class="panel-title"> <h2>Транскрибация</h2> <button id="showMeetProtocolPrompt" class="panel-title-link" type="button">Промт для LLM</button> </div> <div class="full-body"> <div class="paste-target"> <textarea id="meetTranscriptInput" class="large-textarea" spellcheck="false" aria-label="Meeting transcript input" placeholder=" "></textarea> <div class="paste-target-overlay" aria-hidden="true"> <div class="paste-target-prompt">Вставить сюда...</div> <div class="paste-target-helper">Вставьте сырую транскрибацию встречи — вкладка уберёт штампы времени и маркер (распознано), затем соберёт полный промт для LLM. Copy Result копирует его, кнопки внешних LLM копируют и сразу открывают чат.</div> </div> </div> </div> </section> <section class="panel work-panel"> <div class="panel-title"> <h2>Результат</h2> </div> <div class="full-body"> <div class="prompt-output-shell" data-output-shell="meet-protocol"> <textarea id="meetProtocolOutput" class="large-textarea" readonly spellcheck="false" aria-label="Meeting protocol prompt output"></textarea> <div id="meetProtocolEmpty" class="preview-empty prompt-output-empty" data-empty-for="meet-protocol-output"> <div class="preview-skeleton" aria-hidden="true"> <div class="sk-heading"></div> <div class="sk-paragraph sk-paragraph-1"></div> <div class="sk-paragraph sk-paragraph-2"></div> <div class="sk-paragraph sk-paragraph-3"></div> <div class="sk-table"> <div></div><div></div><div></div> <div></div><div></div><div></div> <div></div><div></div><div></div> </div> <div class="sk-buttons"> <div></div><div></div> </div> </div> <p class="preview-empty-hint">Создайте промт — здесь появится итоговый промт</p> </div> </div> <div id="meetProtocolMeta" class="result-meta">Промт ещё не собран</div> </div> </section> </div> </section> <section id="knowledgeBaseTool" class="tool-view" hidden> <div class="toolbar"> <button id="buildKbPrompt">Создать промт</button> <button id="prepareKbPrompt" class="btn-primary">Copy Result</button> <span class="llm-launchers"> <span class="external-group-label">открыть в:</span> <button class="btn-external kb-llm-link" type="button" data-kb-llm-url="https://chat.z.ai/" aria-label="Открыть в Z.ai"><svg class="llm-ico" aria-hidden="true"><use href="#ico-zai"></use></svg><span>Z.ai</span></button> <button class="btn-external kb-llm-link" type="button" data-kb-llm-url="https://chat.deepseek.com/" aria-label="Открыть в DeepSeek"><svg class="llm-ico" aria-hidden="true"><use href="#ico-deepseek"></use></svg><span>DeepSeek</span></button> <button class="btn-external kb-llm-link" type="button" data-kb-llm-url="https://giga.chat/" aria-label="Открыть в GigaChat"><svg class="llm-ico" aria-hidden="true"><use href="#ico-gigachat"></use></svg><span>GigaChat</span></button> <button class="btn-external kb-llm-link" type="button" data-kb-llm-url="https://www.kimi.com/" aria-label="Открыть в Kimi"><svg class="llm-ico" aria-hidden="true"><use href="#ico-kimi"></use></svg><span>Kimi</span></button> <button class="btn-external kb-llm-link" type="button" data-kb-llm-url="https://chat.qwen.ai/" aria-label="Открыть в Qwen"><svg class="llm-ico" aria-hidden="true"><use href="#ico-qwen"></use></svg><span>Qwen</span></button> </span> <button id="clearKb" class="btn-clear">Очистить</button> </div> <div class="kb-workspace"> <div class="kb-left"> <section class="panel work-panel"> <div class="panel-title"> <h2>Текущая база знаний</h2> <button id="showKbUpdatePrompt" class="panel-title-link" type="button">Промт для LLM</button> </div> <div class="full-body"> <div class="paste-target"> <textarea id="kbCurrentInput" class="large-textarea" spellcheck="false" aria-label="Current knowledge base input" placeholder=" "></textarea> <div class="paste-target-overlay" aria-hidden="true"> <div class="paste-target-prompt">Вставить сюда...</div> <div class="paste-target-helper">Вставьте текущую Markdown-базу знаний (можно прямо из wiki — HTML сконвертируется автоматически). Пустое поле — нормальный кейс первичного заполнения.</div> </div> </div> </div> </section> <section class="panel work-panel"> <div class="panel-title"> <h2>Новый протокол встречи</h2> </div> <div class="full-body"> <div class="paste-target"> <textarea id="kbProtocolInput" class="large-textarea" spellcheck="false" aria-label="New meeting protocol input" placeholder=" "></textarea> <div class="paste-target-overlay" aria-hidden="true"> <div class="paste-target-prompt">Вставить сюда...</div> <div class="paste-target-helper">Вставьте Markdown протокола встречи — можно прямо из wiki, HTML сконвертируется автоматически.</div> </div> </div> </div> </section> </div> <section class="panel work-panel"> <div class="panel-title"> <h2>Итоговый промт</h2> </div> <div class="full-body"> <div class="prompt-output-shell" data-output-shell="knowledge-base"> <textarea id="kbPromptOutput" class="large-textarea" readonly spellcheck="false" aria-label="Knowledge base update prompt output"></textarea> <div id="kbPromptEmpty" class="preview-empty prompt-output-empty" data-empty-for="knowledge-base-output"> <div class="preview-skeleton" aria-hidden="true"> <div class="sk-heading"></div> <div class="sk-paragraph sk-paragraph-1"></div> <div class="sk-paragraph sk-paragraph-2"></div> <div class="sk-paragraph sk-paragraph-3"></div> <div class="sk-table"> <div></div><div></div><div></div> <div></div><div></div><div></div> <div></div><div></div><div></div> </div> <div class="sk-buttons"> <div></div><div></div> </div> </div> <p class="preview-empty-hint">Создайте промт — здесь появится итоговый промт для базы знаний</p> </div> </div> <div id="kbMeta" class="result-meta">Промт ещё не собран</div> </div> </section> </div> </section> <section id="goalExtractionTool" class="tool-view" hidden> <div class="toolbar"> <button id="buildGoalExtractionPrompt">Создать промт</button> <button id="prepareGoalExtractionPrompt" class="btn-primary">Copy Result</button> <span class="llm-launchers"> <span class="external-group-label">открыть в:</span> <button class="btn-external goal-extraction-llm-link" type="button" data-goal-extraction-llm-url="https://chat.z.ai/" aria-label="Открыть в Z.ai"><svg class="llm-ico" aria-hidden="true"><use href="#ico-zai"></use></svg><span>Z.ai</span></button> <button class="btn-external goal-extraction-llm-link" type="button" data-goal-extraction-llm-url="https://chat.deepseek.com/" aria-label="Открыть в DeepSeek"><svg class="llm-ico" aria-hidden="true"><use href="#ico-deepseek"></use></svg><span>DeepSeek</span></button> <button class="btn-external goal-extraction-llm-link" type="button" data-goal-extraction-llm-url="https://giga.chat/" aria-label="Открыть в GigaChat"><svg class="llm-ico" aria-hidden="true"><use href="#ico-gigachat"></use></svg><span>GigaChat</span></button> <button class="btn-external goal-extraction-llm-link" type="button" data-goal-extraction-llm-url="https://www.kimi.com/" aria-label="Открыть в Kimi"><svg class="llm-ico" aria-hidden="true"><use href="#ico-kimi"></use></svg><span>Kimi</span></button> <button class="btn-external goal-extraction-llm-link" type="button" data-goal-extraction-llm-url="https://chat.qwen.ai/" aria-label="Открыть в Qwen"><svg class="llm-ico" aria-hidden="true"><use href="#ico-qwen"></use></svg><span>Qwen</span></button> </span> <button id="clearGoalExtraction" class="btn-clear">Очистить</button> </div> <div class="kb-workspace"> <div class="kb-left"> <section class="panel work-panel"> <div class="panel-title"> <h2>Данные по вопросу</h2> <button id="showGoalExtractionPrompt" class="panel-title-link" type="button">Промт для LLM</button> </div> <div class="full-body"> <div class="paste-target"> <textarea id="goalExtractionDataInput" class="large-textarea" spellcheck="false" aria-label="Goal extraction data input" placeholder=" "></textarea> <div class="paste-target-overlay" aria-hidden="true"> <div class="paste-target-prompt">Вставить сюда...</div> <div class="paste-target-helper">Вставьте исходные материалы по одной цели: документы, заметки, письма, протоколы. Единственное обязательное поле вкладки.</div> </div> </div> </div> </section> <section class="panel work-panel"> <div class="panel-title"> <h2>Текущий JSON</h2> </div> <div class="full-body"> <div class="paste-target"> <textarea id="goalExtractionJsonInput" class="large-textarea" spellcheck="false" aria-label="Optional current goal JSON input" placeholder=" "></textarea> <div class="paste-target-overlay" aria-hidden="true"> <div class="paste-target-prompt">Вставить сюда...</div> <div class="paste-target-helper">Опционально: уже собранный JSON по цели. Если он есть, LLM обновит текущую версию, а не начнёт с нуля.</div> </div> </div> </div> </section> </div> <section class="panel work-panel"> <div class="panel-title"> <h2>Итоговый промт</h2> </div> <div class="full-body"> <div class="prompt-output-shell" data-output-shell="goal-extraction"> <textarea id="goalExtractionPromptOutput" class="large-textarea" readonly spellcheck="false" aria-label="Goal extraction prompt output"></textarea> <div id="goalExtractionPromptEmpty" class="preview-empty prompt-output-empty" data-empty-for="goal-extraction-output"> <div class="preview-skeleton" aria-hidden="true"> <div class="sk-heading"></div> <div class="sk-paragraph sk-paragraph-1"></div> <div class="sk-paragraph sk-paragraph-2"></div> <div class="sk-paragraph sk-paragraph-3"></div> <div class="sk-table"> <div></div><div></div><div></div> <div></div><div></div><div></div> <div></div><div></div><div></div> </div> <div class="sk-buttons"> <div></div><div></div> </div> </div> <p class="preview-empty-hint">Создайте промт — здесь появится итоговый промт для извлечения данных по цели</p> </div> </div> <div id="goalExtractionMeta" class="result-meta">Промт ещё не собран</div> </div> </section> </div> <section class="panel work-panel goal-diff-panel"> <div class="panel-title"> <h2>Сверка версий</h2> <span class="panel-title-note">Нейросеть возвращает документ целиком, поэтому потеря куска данных выглядит как обычный ответ. Сверка ловит её механически.</span> </div> <div class="full-body"> <div class="paste-target"> <textarea id="goalDiffNewJson" class="large-textarea" spellcheck="false" aria-label="Updated goal JSON" placeholder=" "></textarea> <div class="paste-target-overlay" aria-hidden="true"> <div class="paste-target-prompt">Вставить сюда...</div> <div class="paste-target-helper">Вставьте обновлённый JSON, который вернула нейросеть. Он сравнивается с «Текущим JSON» слева: что добавилось, что изменилось и что исчезло.</div> </div> </div> <div class="goal-diff-actions"> <button id="compareGoalVersions" class="btn-soft" type="button">Сравнить с текущим</button> <button id="copyGoalDiffJson" type="button">Копировать обновлённый JSON</button> </div> <div id="goalDiffReport" class="goal-diff-report" aria-live="polite"></div> <div id="goalDiffMeta" class="result-meta">Сверка не запускалась</div> </div> </section> </section> <section id="markdownTool" class="tool-view" hidden> <div class="toolbar"> <button id="convertMarkdown">Convert</button> <button id="copyMarkdown" class="btn-primary">Copy Markdown</button> <button id="clearMarkdown" class="btn-clear">Очистить</button> </div> <div class="tool-workspace"> <section class="panel work-panel"> <div class="panel-title"> <h2>HTML Input</h2> </div> <div class="full-body"> <div class="paste-target"> <textarea id="markdownHtmlInput" class="large-textarea" spellcheck="false" aria-label="HTML to Markdown input" placeholder=" "></textarea> <div class="paste-target-overlay" aria-hidden="true"> <div class="paste-target-prompt">Вставить сюда...</div> <div class="paste-target-helper">Вставьте фрагмент страницы или text/html — конвертация в Markdown запустится автоматически; результат заберите кнопкой Copy Markdown.</div> </div> </div> </div> </section> <section class="panel work-panel"> <div class="panel-title"> <h2>Markdown Output</h2> </div> <div class="full-body"> <div class="prompt-output-shell" data-output-shell="markdown"> <textarea id="markdownOutput" class="large-textarea" readonly spellcheck="false" aria-label="Markdown output"></textarea> <div id="markdownOutputEmpty" class="preview-empty prompt-output-empty" data-empty-for="markdown-output"> <div class="preview-skeleton" aria-hidden="true"> <div class="sk-heading"></div> <div class="sk-paragraph sk-paragraph-1"></div> <div class="sk-paragraph sk-paragraph-2"></div> <div class="sk-paragraph sk-paragraph-3"></div> <div class="sk-table"> <div></div><div></div><div></div> <div></div><div></div><div></div> <div></div><div></div><div></div> </div> <div class="sk-buttons"> <div></div><div></div> </div> </div> <p class="preview-empty-hint">Сконвертируйте HTML — здесь появится Markdown</p> </div> </div> <div id="markdownMeta" class="result-meta">HTML ещё не сконвертирован</div> </div> </section> </div> </section> <section id="mdToWikiTool" class="tool-view" hidden> <div class="toolbar"> <button id="convertMdToWiki">Convert</button> <button id="copyMdToWikiRich" class="btn-primary">Copy Back</button> <button id="loadMdToWikiProtocolExample" type="button">Пример Протокола</button> <button id="saveMdToWikiHtml">Save HTML</button> <button id="clearMdToWiki" class="btn-clear">Очистить</button> </div> <div class="tool-workspace"> <section class="panel work-panel"> <div class="panel-title"> <h2>Markdown</h2> </div> <div class="full-body"> <div class="paste-target"> <textarea id="mdToWikiInput" class="large-textarea" spellcheck="false" aria-label="Markdown input" placeholder=" "></textarea> <div class="paste-target-overlay" aria-hidden="true"> <div class="paste-target-prompt">Вставить сюда...</div> <div class="paste-target-helper">Вставьте Markdown-черновик — заголовки, списки и таблицы сразу превратятся в wiki-HTML с preview; Copy Back перенесёт результат в wiki.</div> </div> </div> </div> </section> <section class="panel work-panel"> <div class="panel-title"> <h2>Wiki Preview</h2> </div> <div class="layout-output"> <div id="mdToWikiPreview" class="editor layout-preview" aria-label="Rendered wiki preview"> <div class="preview-empty" data-empty-for="md-to-wiki"> <div class="preview-skeleton" aria-hidden="true"> <div class="sk-heading"></div> <div class="sk-paragraph sk-paragraph-1"></div> <div class="sk-paragraph sk-paragraph-2"></div> <div class="sk-paragraph sk-paragraph-3"></div> <div class="sk-table"> <div></div><div></div><div></div> <div></div><div></div><div></div> <div></div><div></div><div></div> </div> <div class="sk-buttons"> <div></div><div></div> </div> </div> <p class="preview-empty-hint">Вставьте Markdown — здесь появится preview</p> </div> </div> <input id="mdToWikiHtmlOutput" type="hidden"> <div id="mdToWikiMeta" class="result-meta">Markdown ещё не сконвертирован</div> </div> </section> </div> </section> </main> </div> <div id="layoutPromptModal" class="prompt-modal" hidden> <div class="prompt-modal-backdrop" data-modal-close="true"></div> <div class="prompt-modal-panel" role="dialog" aria-labelledby="layoutPromptModalTitle" aria-modal="true"> <div class="prompt-modal-header"> <h2 id="layoutPromptModalTitle">Собрать промт для layout JSON</h2> <button id="closeLayoutPrompt" class="prompt-modal-close" type="button" aria-label="Закрыть">×</button> </div> <div class="toolbar prompt-modal-toolbar"> <button id="copyLayoutPrompt" type="button" disabled>Копировать</button> <span class="external-group-label" id="layoutLlmGroupLabel">открыть в:</span> <span class="llm-open-group" role="group" aria-labelledby="layoutLlmGroupLabel"> <button class="btn-external layout-llm-link" type="button" data-layout-llm-url="https://chat.z.ai/" aria-label="Открыть собранный промт в Z.ai" disabled><svg class="llm-ico" aria-hidden="true"><use href="#ico-zai"></use></svg><span>Z.ai</span></button> <button class="btn-external layout-llm-link" type="button" data-layout-llm-url="https://chat.deepseek.com/" aria-label="Открыть собранный промт в DeepSeek" disabled><svg class="llm-ico" aria-hidden="true"><use href="#ico-deepseek"></use></svg><span>DeepSeek</span></button> <button class="btn-external layout-llm-link" type="button" data-layout-llm-url="https://giga.chat/" aria-label="Открыть собранный промт в GigaChat" disabled><svg class="llm-ico" aria-hidden="true"><use href="#ico-gigachat"></use></svg><span>GigaChat</span></button> <button class="btn-external layout-llm-link" type="button" data-layout-llm-url="https://www.kimi.com/" aria-label="Открыть собранный промт в Kimi" disabled><svg class="llm-ico" aria-hidden="true"><use href="#ico-kimi"></use></svg><span>Kimi</span></button> <button class="btn-external layout-llm-link" type="button" data-layout-llm-url="https://chat.qwen.ai/" aria-label="Открыть собранный промт в Qwen" disabled><svg class="llm-ico" aria-hidden="true"><use href="#ico-qwen"></use></svg><span>Qwen</span></button> </span> </div> <p class="prompt-modal-subtitle">Вставьте исходный текст — кнопки соберут промт (правила + ваш текст) и скопируют или откроют его во внешней LLM. Полученный JSON вставьте в поле слева.</p> <label class="prompt-modal-label" for="layoutPromptSource">Исходный текст</label> <textarea id="layoutPromptSource" class="prompt-modal-source" spellcheck="false" placeholder="Вставьте начальный текст, из которого нужно собрать wiki-страницу…"></textarea> </div> </div> <div id="boardPromptModal" class="prompt-modal" hidden> <div class="prompt-modal-backdrop" data-modal-close="true"></div> <div class="prompt-modal-panel" role="dialog" aria-labelledby="boardPromptModalTitle" aria-modal="true"> <div class="prompt-modal-header"> <h2 id="boardPromptModalTitle">Собрать промт для доски</h2> <button id="closeBoardPrompt" class="prompt-modal-close" type="button" aria-label="Закрыть">×</button> </div> <div class="toolbar prompt-modal-toolbar"> <button id="copyBoardPrompt" type="button" disabled>Копировать</button> <span class="external-group-label" id="boardLlmGroupLabel">открыть в:</span> <span class="llm-open-group" role="group" aria-labelledby="boardLlmGroupLabel"> <button class="btn-external board-llm-link" type="button" data-board-llm-url="https://chat.z.ai/" aria-label="Открыть собранный промт в Z.ai" disabled><svg class="llm-ico" aria-hidden="true"><use href="#ico-zai"></use></svg><span>Z.ai</span></button> <button class="btn-external board-llm-link" type="button" data-board-llm-url="https://chat.deepseek.com/" aria-label="Открыть собранный промт в DeepSeek" disabled><svg class="llm-ico" aria-hidden="true"><use href="#ico-deepseek"></use></svg><span>DeepSeek</span></button> <button class="btn-external board-llm-link" type="button" data-board-llm-url="https://giga.chat/" aria-label="Открыть собранный промт в GigaChat" disabled><svg class="llm-ico" aria-hidden="true"><use href="#ico-gigachat"></use></svg><span>GigaChat</span></button> <button class="btn-external board-llm-link" type="button" data-board-llm-url="https://www.kimi.com/" aria-label="Открыть собранный промт в Kimi" disabled><svg class="llm-ico" aria-hidden="true"><use href="#ico-kimi"></use></svg><span>Kimi</span></button> <button class="btn-external board-llm-link" type="button" data-board-llm-url="https://chat.qwen.ai/" aria-label="Открыть собранный промт в Qwen" disabled><svg class="llm-ico" aria-hidden="true"><use href="#ico-qwen"></use></svg><span>Qwen</span></button> </span> </div> <p class="prompt-modal-subtitle">Вставьте исходный текст — кнопки соберут промт (контракт board-layout-v1 + правила сборки + ваш текст) и скопируют или откроют его во внешней LLM. Полученный JSON вставьте в поле слева. Чтобы обновить существующую доску, добавьте в текст её JSON и опишите правку.</p> <label class="prompt-modal-label" for="boardPromptSource">Исходный текст</label> <textarea id="boardPromptSource" class="prompt-modal-source" spellcheck="false" placeholder="Вставьте текст, из которого нужно собрать доску…"></textarea> </div> </div> <div id="meetProtocolPromptModal" class="prompt-modal" hidden> <div class="prompt-modal-backdrop" data-modal-close="true"></div> <div class="prompt-modal-panel" role="dialog" aria-labelledby="meetProtocolPromptModalTitle" aria-modal="true"> <div class="prompt-modal-header"> <h2 id="meetProtocolPromptModalTitle">Промт для протокола встречи</h2> <button id="closeMeetProtocolPrompt" class="prompt-modal-close" type="button" aria-label="Закрыть">×</button> </div> <p class="prompt-modal-subtitle">Скопируйте базовый промт или подготовьте полный промт с очищенной транскрибацией во вкладке.</p> <pre id="meetProtocolPromptText" class="prompt-modal-body"></pre> <div class="prompt-modal-footer"> <button id="copyMeetProtocolPrompt" class="btn-soft" type="button">Копировать</button> <button id="closeMeetProtocolPromptFooter" type="button">Закрыть</button> </div> </div> </div> <div id="kbUpdatePromptModal" class="prompt-modal" hidden> <div class="prompt-modal-backdrop" data-modal-close="true"></div> <div class="prompt-modal-panel" role="dialog" aria-labelledby="kbUpdatePromptModalTitle" aria-modal="true"> <div class="prompt-modal-header"> <h2 id="kbUpdatePromptModalTitle">Промт для обновления базы знаний</h2> <button id="closeKbUpdatePrompt" class="prompt-modal-close" type="button" aria-label="Закрыть">×</button> </div> <p class="prompt-modal-subtitle">Скопируйте и вставьте в любую LLM, чтобы обновить Markdown-базу знаний по новому протоколу встречи.</p> <pre id="kbUpdatePromptText" class="prompt-modal-body"></pre> <div class="prompt-modal-footer"> <button id="copyKbUpdatePrompt" class="btn-soft" type="button">Копировать</button> <button id="closeKbUpdatePromptFooter" type="button">Закрыть</button> </div> </div> </div> <div id="goalExtractionPromptModal" class="prompt-modal" hidden> <div class="prompt-modal-backdrop" data-modal-close="true"></div> <div class="prompt-modal-panel" role="dialog" aria-labelledby="goalExtractionPromptModalTitle" aria-modal="true"> <div class="prompt-modal-header"> <h2 id="goalExtractionPromptModalTitle">Промт для извлечения цели</h2> <button id="closeGoalExtractionPrompt" class="prompt-modal-close" type="button" aria-label="Закрыть">×</button> </div> <p class="prompt-modal-subtitle">Скопируйте базовый промт или подготовьте полный промт с данными по вопросу во вкладке.</p> <pre id="goalExtractionPromptText" class="prompt-modal-body"></pre> <div class="prompt-modal-footer"> <button id="copyGoalExtractionPrompt" class="btn-soft" type="button">Копировать</button> <button id="closeGoalExtractionPromptFooter" type="button">Закрыть</button> </div> </div> </div> <script> const DEFAULT_TOOL_TAB = "meet-protocol"; const ACTIVE_TAB_STORAGE_KEY = "wcl.activeTab"; const GROUP_TITLES = { generate: "Создание", visualize: "Визуализация", convert: "Конвертация" }; const TAB_DESCRIPTIONS = { "clipboard": { title: "Clipboard", body: "Вставьте содержимое wiki из браузера, отредактируйте его визуально или как HTML и скопируйте обратно. Боковые панели показывают исходный payload, plain-текст и какие типы wiki-элементов обнаружены." }, "excel": { title: "WIKI to Excel", body: "Вставьте wiki-таблицу и нормализуйте её в TSV/HTML, чтобы она корректно вставлялась в Excel, Р7 Офис или Google Sheets без потери структуры. Сохраняются заголовочные строки, объединённые ячейки и порядок столбцов." }, "layout": { title: "JSON to SberTrack", body: "Как сделать layout: возьмите промт по кнопке «Промт для LLM» (контракт wiki-layout и правила сборки JSON-страницы из исходного текста), отдайте его LLM со своим текстом — промт можно дополнять уточняющими инструкциями, чтобы получить нужный вариант данных. Готовый JSON вставьте в textarea — Render запустится автоматически, Copy Back перенесёт wiki-HTML обратно в wiki." }, "board": { title: "JSON to Board", body: "Полный круг работы с доской. Вставьте скопированный с доски фрагмент и нажмите «Разобрать» — получите board-layout-v1: дорожки, таблицы, карточки и связи без пикселей, с осмысленными id. Отдайте этот JSON модели вместе с промтом («Нет JSON? Собрать промт»), попросите доработать, верните ответ в поле и нажмите «Копировать для доски» — в буфер уйдёт нативный payload одним text/plain. Координаты и размеры считает лаб, модель отвечает только за смысл." }, "meet-protocol": { title: "Протокол встречи", body: "Собирает полный промт для LLM в одном из двух режимов: подробный протокол для базы знаний или краткий связный рассказ. Очищает транскрибацию от ведущих штампов времени и маркера «(распознано)» после ФИО, затем добавляет текст встречи к выбранному промту. Кнопки Z.ai, DeepSeek, GigaChat и Kimi копируют готовый промт и сразу открывают выбранный чат." }, "knowledge-base": { title: "База знаний", body: "Собирает промт для обновления Markdown-базы знаний по новому протоколу встречи. Слева — два поля (текущая база и новый протокол); каждое принимает чистый Markdown или wiki HTML — HTML конвертируется в Markdown на лету при вставке. Справа — итоговый промт, готовый к копированию в LLM (Z.ai, DeepSeek, GigaChat, Kimi)." }, "goal-extraction": { title: "Цель: сбор и обновление", body: "Помогает превратить исходные материалы по одной цели в полную структурированную картину: собирает факты о том, что это за цель, зачем она нужна, где применяется, какие есть сроки, метрики, требования, ответственные, действия, риски и открытые вопросы. Если уже есть собранный JSON, вкладка добавит его как текущую версию — нейросеть обновит данные, а не начнёт с нуля. Обновлённый ответ вставьте в «Сверку версий»: она покажет, что добавилось, что изменилось и что исчезло, чтобы обновление не потеряло уже собранное. Готовый JSON вставляется во вкладку «Layout JSON to Wiki» — она сама соберёт из него страницу." }, "markdown": { title: "HTML to Markdown", body: "Конвертирует wiki-HTML в Markdown для использования вне wiki — issues, документация, черновики. Таблицы становятся Markdown-таблицами; ссылки и inline-форматирование сохраняются." }, "md-to-wiki": { title: "Markdown to HTML", body: "Конвертирует Markdown обратно в wiki-HTML, чтобы можно было вставить Markdown-черновики в wiki без потери форматирования. Заголовки, списки, таблицы и ссылки переносятся чисто." } }; const state = { originalHtml: "", originalPlain: "", currentHtml: "", currentPlain: "", types: [], sourceUrl: "", layoutHtml: "", mdToWikiHtml: "", excelSourceHtml: "", meetPromptMode: "brief", lastEditedSurface: null }; const el = { status: document.getElementById("statusBar"), applyRaw: document.getElementById("applyRaw"), syncRaw: document.getElementById("syncRaw"), copyRich: document.getElementById("copyRich"), clearAll: document.getElementById("clearAll"), pasteZone: document.getElementById("pasteZone"), typesList: document.getElementById("typesList"), wikiTypesList: document.getElementById("wikiTypesList"), htmlSize: document.getElementById("htmlSize"), textSize: document.getElementById("textSize"), sourceUrl: document.getElementById("sourceUrl"), editor: document.getElementById("editor"), htmlEditor: document.getElementById("htmlEditor"), payloadDump: document.getElementById("payloadDump"), originalHtml: document.getElementById("originalHtml"), plainText: document.getElementById("plainText"), copyPayload: document.getElementById("copyPayload"), visualTab: document.getElementById("visualTab"), htmlTab: document.getElementById("htmlTab"), formatHtmlEditor: document.getElementById("formatHtmlEditor"), activeToolGroup: document.getElementById("activeToolGroup"), activeToolTitle: document.getElementById("activeToolTitle"), activeToolInfo: document.getElementById("activeToolInfo"), mobileToolSelect: document.getElementById("mobileToolSelect"), toolTabs: Array.from(document.querySelectorAll("[data-tool-tab]")), toolViews: { clipboard: document.getElementById("clipboardTool"), excel: document.getElementById("excelTool"), layout: document.getElementById("layoutTool"), board: document.getElementById("boardTool"), "meet-protocol": document.getElementById("meetProtocolTool"), "knowledge-base": document.getElementById("knowledgeBaseTool"), "goal-extraction": document.getElementById("goalExtractionTool"), markdown: document.getElementById("markdownTool"), "md-to-wiki": document.getElementById("mdToWikiTool") }, excelPasteZone: document.getElementById("excelPasteZone"), excelOutput: document.getElementById("excelOutput"), excelOutputEmpty: document.getElementById("excelOutputEmpty"), excelMeta: document.getElementById("excelMeta"), clearExcelInput: document.getElementById("clearExcelInput"), normalizeExcel: document.getElementById("normalizeExcel"), copyExcelOutput: document.getElementById("copyExcelOutput"), layoutJsonInput: document.getElementById("layoutJsonInput"), boardSceneInput: document.getElementById("boardSceneInput"), boardPromptModal: document.getElementById("boardPromptModal"), boardPromptSource: document.getElementById("boardPromptSource"), buildBoardPromptFromText: document.getElementById("buildBoardPromptFromText"), copyBoardPrompt: document.getElementById("copyBoardPrompt"), closeBoardPrompt: document.getElementById("closeBoardPrompt"), boardLlmLinks: Array.from(document.querySelectorAll("[data-board-llm-url]")), boardWarnings: document.getElementById("boardWarnings"), parseBoardPayloadBtn: document.getElementById("parseBoardPayloadBtn"), copyBoardPayload: document.getElementById("copyBoardPayload"), formatBoardScene: document.getElementById("formatBoardScene"), boardToMarkdown: document.getElementById("boardToMarkdown"), clearBoard: document.getElementById("clearBoard"), layoutHtmlOutput: document.getElementById("layoutHtmlOutput"), layoutPreview: document.getElementById("layoutPreview"), layoutMeta: document.getElementById("layoutMeta"), clearLayout: document.getElementById("clearLayout"), formatLayoutJson: document.getElementById("formatLayoutJson"), renderLayoutJson: document.getElementById("renderLayoutJson"), layoutGoalProfile: document.getElementById("layoutGoalProfile"), loadLayoutExample: document.getElementById("loadLayoutExample"), loadKnowledgeBaseExample: document.getElementById("loadKnowledgeBaseExample"), loadProtocolExample: document.getElementById("loadProtocolExample"), copyLayoutHtml: document.getElementById("copyLayoutHtml"), copyLayoutRich: document.getElementById("copyLayoutRich"), meetTranscriptInput: document.getElementById("meetTranscriptInput"), meetProtocolOutput: document.getElementById("meetProtocolOutput"), meetProtocolEmpty: document.getElementById("meetProtocolEmpty"), meetProtocolMeta: document.getElementById("meetProtocolMeta"), meetPromptModeButtons: Array.from(document.querySelectorAll("[data-meet-prompt-mode]")), buildMeetProtocol: document.getElementById("buildMeetProtocol"), prepareMeetProtocol: document.getElementById("prepareMeetProtocol"), clearMeetProtocol: document.getElementById("clearMeetProtocol"), kbCurrentInput: document.getElementById("kbCurrentInput"), kbProtocolInput: document.getElementById("kbProtocolInput"), kbPromptOutput: document.getElementById("kbPromptOutput"), kbPromptEmpty: document.getElementById("kbPromptEmpty"), kbMeta: document.getElementById("kbMeta"), showKbUpdatePrompt: document.getElementById("showKbUpdatePrompt"), buildKbPrompt: document.getElementById("buildKbPrompt"), prepareKbPrompt: document.getElementById("prepareKbPrompt"), clearKb: document.getElementById("clearKb"), goalExtractionDataInput: document.getElementById("goalExtractionDataInput"), goalExtractionJsonInput: document.getElementById("goalExtractionJsonInput"), goalExtractionPromptOutput: document.getElementById("goalExtractionPromptOutput"), goalExtractionPromptEmpty: document.getElementById("goalExtractionPromptEmpty"), goalExtractionMeta: document.getElementById("goalExtractionMeta"), goalDiffNewJson: document.getElementById("goalDiffNewJson"), compareGoalVersions: document.getElementById("compareGoalVersions"), copyGoalDiffJson: document.getElementById("copyGoalDiffJson"), goalDiffReport: document.getElementById("goalDiffReport"), goalDiffMeta: document.getElementById("goalDiffMeta"), showGoalExtractionPrompt: document.getElementById("showGoalExtractionPrompt"), buildGoalExtractionPrompt: document.getElementById("buildGoalExtractionPrompt"), prepareGoalExtractionPrompt: document.getElementById("prepareGoalExtractionPrompt"), clearGoalExtraction: document.getElementById("clearGoalExtraction"), markdownHtmlInput: document.getElementById("markdownHtmlInput"), markdownOutput: document.getElementById("markdownOutput"), markdownOutputEmpty: document.getElementById("markdownOutputEmpty"), markdownMeta: document.getElementById("markdownMeta"), clearMarkdown: document.getElementById("clearMarkdown"), convertMarkdown: document.getElementById("convertMarkdown"), copyMarkdown: document.getElementById("copyMarkdown"), mdToWikiInput: document.getElementById("mdToWikiInput"), mdToWikiPreview: document.getElementById("mdToWikiPreview"), mdToWikiHtmlOutput: document.getElementById("mdToWikiHtmlOutput"), mdToWikiMeta: document.getElementById("mdToWikiMeta"), clearMdToWiki: document.getElementById("clearMdToWiki"), convertMdToWiki: document.getElementById("convertMdToWiki"), loadMdToWikiProtocolExample: document.getElementById("loadMdToWikiProtocolExample"), saveMdToWikiHtml: document.getElementById("saveMdToWikiHtml"), copyMdToWikiRich: document.getElementById("copyMdToWikiRich"), tabInfoButtons: Array.from(document.querySelectorAll("[data-tool-tab], [data-active-tool-info]")), tabInfoPopover: document.getElementById("tabInfoPopover"), tabInfoPopoverTitle: document.getElementById("tabInfoPopoverTitle"), tabInfoPopoverBody: document.getElementById("tabInfoPopoverBody"), buildLayoutPromptFromText: document.getElementById("buildLayoutPromptFromText"), layoutPromptModal: document.getElementById("layoutPromptModal"), layoutPromptSource: document.getElementById("layoutPromptSource"), layoutLlmLinks: Array.from(document.querySelectorAll(".layout-llm-link")), copyLayoutPrompt: document.getElementById("copyLayoutPrompt"), closeLayoutPrompt: document.getElementById("closeLayoutPrompt"), showMeetProtocolPrompt: document.getElementById("showMeetProtocolPrompt"), meetProtocolPromptModal: document.getElementById("meetProtocolPromptModal"), meetProtocolPromptText: document.getElementById("meetProtocolPromptText"), copyMeetProtocolPrompt: document.getElementById("copyMeetProtocolPrompt"), closeMeetProtocolPrompt: document.getElementById("closeMeetProtocolPrompt"), closeMeetProtocolPromptFooter: document.getElementById("closeMeetProtocolPromptFooter"), kbUpdatePromptModal: document.getElementById("kbUpdatePromptModal"), kbUpdatePromptText: document.getElementById("kbUpdatePromptText"), closeKbUpdatePrompt: document.getElementById("closeKbUpdatePrompt"), closeKbUpdatePromptFooter: document.getElementById("closeKbUpdatePromptFooter"), copyKbUpdatePrompt: document.getElementById("copyKbUpdatePrompt"), goalExtractionPromptModal: document.getElementById("goalExtractionPromptModal"), goalExtractionPromptText: document.getElementById("goalExtractionPromptText"), copyGoalExtractionPrompt: document.getElementById("copyGoalExtractionPrompt"), closeGoalExtractionPrompt: document.getElementById("closeGoalExtractionPrompt"), closeGoalExtractionPromptFooter: document.getElementById("closeGoalExtractionPromptFooter"), closeGoalReviewPromptFooter: document.getElementById("closeGoalReviewPromptFooter") }; function byteSize(value) { return `${new Blob([value || ""]).size} bytes`; } const FLASH_PREV_LABEL = new WeakMap(); function flashCopySuccess(btn, opts = {}) { if (!btn || FLASH_PREV_LABEL.has(btn)) return; FLASH_PREV_LABEL.set(btn, btn.textContent); btn.classList.add("btn-flash-ok"); btn.disabled = true; btn.textContent = opts.label || "Скопировано ✓"; const duration = typeof opts.duration === "number" ? opts.duration : 900; setTimeout(() => { const original = FLASH_PREV_LABEL.get(btn); if (original !== undefined) btn.textContent = original; btn.classList.remove("btn-flash-ok"); btn.disabled = false; FLASH_PREV_LABEL.delete(btn); }, duration); } function setPreviewEmpty(container, isEmpty) { if (!container) return; const emptyEl = container.querySelector(".preview-empty"); if (emptyEl) emptyEl.hidden = !isEmpty; } function setPromptOutputEmpty(output, emptyEl) { if (!output || !emptyEl) return; const hasValue = Boolean(output.value.trim()); emptyEl.hidden = hasValue; output.disabled = !hasValue; } function renderIntoPreview(container, html) { if (!container) return; const emptyEl = container.querySelector(".preview-empty"); container.innerHTML = ""; if (html) container.insertAdjacentHTML("beforeend", html); if (emptyEl) { emptyEl.hidden = Boolean(html); container.appendChild(emptyEl); } else { container.innerHTML = html; } } const STATUS_PREFIX_BY_STATE = { idle: "— ОЖИДАНИЕ —", ok: "— ГОТОВО —", warn: "— ВНИМАНИЕ —", bad: "— ОШИБКА —", "": "— ОЖИДАНИЕ —" }; function setStatus(message, kind = "") { const state = kind || "idle"; el.status.dataset.state = state; const prefixEl = el.status.querySelector(".status-prefix"); const messageEl = el.status.querySelector(".status-message"); if (prefixEl) prefixEl.textContent = STATUS_PREFIX_BY_STATE[state] || "— ОЖИДАНИЕ —"; if (messageEl) messageEl.textContent = message; } function activateToolTab(name, { persist = true } = {}) { for (const tab of el.toolTabs) { const active = tab.dataset.toolTab === name; tab.setAttribute("aria-selected", String(active)); } for (const [key, view] of Object.entries(el.toolViews)) { view.hidden = key !== name; } const selectedTab = el.toolTabs.find((tab) => tab.dataset.toolTab === name); const group = selectedTab?.closest("[data-tab-group]")?.dataset.tabGroup; const description = TAB_DESCRIPTIONS[name]; el.activeToolGroup.textContent = GROUP_TITLES[group] ?? "Создание"; el.activeToolTitle.textContent = description?.title || selectedTab?.innerText.trim() || name; el.activeToolInfo.dataset.toolTab = name; el.activeToolInfo.setAttribute("aria-label", `О разделе «${el.activeToolTitle.textContent}»`); el.mobileToolSelect.value = name; hideTabInfoPopover(); if (persist) { try { localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, name); } catch { // private mode / disabled storage — silently skip } } } function initMobilePaneTabs() { document.querySelectorAll(".tool-workspace, .kb-workspace").forEach((workspace) => { const panels = Array.from(workspace.children); if (panels.length !== 2) return; const view = workspace.closest(".tool-view"); if (!view || view.querySelector(":scope > .mobile-pane-tabs")) return; const firstTitle = panels[0].querySelector("h2")?.textContent.trim() || "Вход"; const secondTitle = panels[1].querySelector("h2")?.textContent.trim() || "Результат"; const tabs = document.createElement("div"); tabs.className = "mobile-pane-tabs"; tabs.setAttribute("role", "tablist"); tabs.setAttribute("aria-label", "Область инструмента"); for (const [pane, title] of [["input", firstTitle], ["output", secondTitle]]) { const button = document.createElement("button"); button.type = "button"; button.textContent = title; button.dataset.mobilePaneTarget = pane; button.setAttribute("role", "tab"); button.setAttribute("aria-selected", String(pane === "input")); button.addEventListener("click", () => { view.dataset.mobilePane = pane; for (const other of tabs.querySelectorAll("button")) { other.setAttribute("aria-selected", String(other === button)); } }); tabs.appendChild(button); } view.classList.add("has-mobile-pane-tabs"); view.dataset.mobilePane = "input"; view.insertBefore(tabs, workspace); }); } function scheduleAfterPaste(action) { window.setTimeout(() => action(), 0); } function setMode(mode) { const htmlMode = mode === "html"; el.editor.classList.toggle("hidden", htmlMode); el.htmlEditor.classList.toggle("hidden", !htmlMode); el.visualTab.setAttribute("aria-pressed", String(!htmlMode)); el.htmlTab.setAttribute("aria-pressed", String(htmlMode)); if (htmlMode) { if (state.lastEditedSurface === "html") return; syncFromEditor(); } else { if (state.lastEditedSurface === "visual") return; applyHtmlToEditor(el.htmlEditor.value); } } function renderTypes() { el.typesList.replaceChildren(); if (!state.types.length) { const item = document.createElement("span"); item.className = "type-pill"; item.textContent = "none"; el.typesList.append(item); return; } for (const type of state.types) { const item = document.createElement("span"); item.className = "type-pill"; item.textContent = type; el.typesList.append(item); } } function collectWikiTypes(html) { const template = document.createElement("template"); template.innerHTML = html || ""; const counts = new Map(); for (const node of template.content.querySelectorAll("[data-type]")) { const type = node.getAttribute("data-type"); counts.set(type, (counts.get(type) || 0) + 1); } return Array.from(counts.entries()) .sort(([left], [right]) => left.localeCompare(right)) .map(([type, count]) => ({ type, count })); } function renderWikiTypes() { el.wikiTypesList.replaceChildren(); const wikiTypes = collectWikiTypes(getCurrentHtml()); if (!wikiTypes.length) { const item = document.createElement("span"); item.className = "type-pill"; item.textContent = "none"; el.wikiTypesList.append(item); return; } for (const { type, count } of wikiTypes) { const item = document.createElement("span"); item.className = "type-pill wiki"; item.textContent = `${type} × ${count}`; el.wikiTypesList.append(item); } } function updateMeta() { const html = getCurrentHtml(); const text = getCurrentPlainText(); el.htmlSize.textContent = byteSize(html); el.textSize.textContent = byteSize(text); el.sourceUrl.textContent = state.sourceUrl || "none"; renderTypes(); renderWikiTypes(); } function buildPayloadDump() { const copyBackHtml = state.currentHtml || getCurrentHtml(); const copyBackPlain = state.currentPlain || getCurrentPlainText(); return JSON.stringify({ mimeTypes: state.types, sourceUrl: state.sourceUrl || null, original: { "text/html": state.originalHtml, "text/html:formatted": formatHtml(state.originalHtml), "text/plain": state.originalPlain }, copyBack: { "text/html": copyBackHtml, "text/html:formatted": formatHtml(copyBackHtml), "text/plain": copyBackPlain } }, null, 2); } function refreshPayloadDump() { el.payloadDump.value = buildPayloadDump(); } function getCurrentHtml() { if (!el.htmlEditor.classList.contains("hidden")) { return el.htmlEditor.value; } return el.editor.innerHTML; } function getCurrentPlainText() { return el.plainText.value; } const LLM_PROMPT_TEXT = "# Wiki layout — full prompt\n\nYou generate wiki layout JSON. The schema and rules below are the complete\ncontract — produce JSON that obeys them exactly. Do not emit Mar … [Строка слишком длинная. Вы можете скачать файл] const BOARD_PROMPT_TEXT = "# Сборка доски из текста\n\nВы собираете доску для корпоративного whiteboard. На вход — рабочий материал:\nпротокол встречи, заметки, выгрузка таблицы, письмо. На выход … [Строка слишком длинная. Вы можете скачать файл] const MEET_PROTOCOL_PROMPT_TEXT = "Ты — операционный ассистент по извлечению операционных знаний из встреч.\r\n\r\nТвоя задача — превращать сырой транскрипт встречи в компактную,\nструктурированну … [Строка слишком длинная. Вы можете скачать файл] const MEETING_NARRATIVE_PROMPT_TEXT = "# Промт: содержательный рассказ по итогам встречи\n\nТы — редактор внутренних знаний. Преврати сырую транскрибацию встречи в\nсамостоятельный Markdown-докуме … [Строка слишком длинная. Вы можете скачать файл] const KB_UPDATE_PROMPT_TEXT = "# Промт: обновление базы знаний по протоколу встречи\n\nТы — редактор внутренней базы знаний.\n\nТвоя задача — обновить Markdown-базу знаний на основе нового протоко … [Строка слишком длинная. Вы можете скачать файл] const GOAL_EXTRACTION_PROMPT_TEXT = "# System Prompt: Goal Template Extraction\n\n## Роль и задача\n\nТы — ассистент структурированного извлечения данных. На входе — текст одного или нескольких ис … [Строка слишком длинная. Вы можете скачать файл] const KNOWLEDGE_BASE_LAYOUT_TEXT = "{\n \"$schema\": \"wiki-layout-v1\",\n \"blocks\": [\n {\n \"type\": \"heading\",\n \"level\": 1,\n \"text\": \"База знаний: Концептуальное моделиро … [Строка слишком длинная. Вы можете скачать файл] const PROTOCOL_EXAMPLE_MARKDOWN_TEXT = "## 1. Краткий итог встречи\r\n\r\nВстреча рабочей группы стрима «Единый аудит» (129) была посвящена ответам на вопросы команд по заполнению планов, интеграц … [Строка слишком длинная. Вы можете скачать файл] // ===== FILE: src/util/format.js ===== function escapeHtml(value) { return String(value ?? "") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function cleanTableText(value) { return String(value ?? "") .replace(/\u00a0/g, " ") .replace(/[ \t\f\v\r\n]+/g, " ") .trim(); } function layoutId() { if (window.crypto?.randomUUID) { return window.crypto.randomUUID(); } return `lab-${Math.random().toString(16).slice(2)}-${Date.now().toString(16)}`; } function layoutWidths(widths, count) { if (Array.isArray(widths)) { return widths.join(","); } if (typeof widths === "string") { return widths; } const base = Math.floor(1500 / count); const values = Array.from({ length: count }, () => base); values[values.length - 1] += 1500 - values.reduce((sum, value) => sum + value, 0); return values.join(","); } function formatHtml(html) { if (!html.trim()) { return ""; } const template = document.createElement("template"); template.innerHTML = html; const voidElements = new Set([ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr" ]); function serializeAttributes(node) { return Array.from(node.attributes) .map((attribute) => ` ${attribute.name}="${escapeHtml(attribute.value)}"`) .join(""); } function serializeNode(node, depth, preserveWhitespace = false) { const indent = " ".repeat(depth); if (node.nodeType === Node.TEXT_NODE) { const value = preserveWhitespace ? node.textContent : node.textContent.replace(/\s+/g, " ").trim(); return value ? [`${indent}${escapeHtml(value)}`] : []; } if (node.nodeType === Node.COMMENT_NODE) { return [`${indent}<!--${node.textContent}-->`]; } if (node.nodeType !== Node.ELEMENT_NODE) { return []; } const tag = node.tagName.toLowerCase(); const open = `<${tag}${serializeAttributes(node)}>`; const preserveChildWhitespace = preserveWhitespace || tag === "pre" || tag === "code" || tag === "textarea"; if (voidElements.has(tag)) { return [`${indent}${open}`]; } const children = Array.from(node.childNodes).flatMap((child) => serializeNode(child, depth + 1, preserveChildWhitespace)); if (!children.length) { return [`${indent}${open}</${tag}>`]; } return [`${indent}${open}`, ...children, `${indent}</${tag}>`]; } return Array.from(template.content.childNodes) .flatMap((node) => serializeNode(node, 0)) .join("\n"); } // ===== FILE: src/converters/wiki-table.js ===== function expandTableCells(cells) { const values = []; const rowspans = []; for (const cell of cells) { const colspan = Number.parseInt(cell.getAttribute("colspan") || "1", 10); const rowspan = Number.parseInt(cell.getAttribute("rowspan") || "1", 10); for (let offset = 0; offset < colspan; offset += 1) { values.push(offset === 0 ? cleanTableText(cell.innerText || cell.textContent || "") : ""); rowspans.push(rowspan); } } return { values, rowspans }; } function nextOpenSlots(rowspans, headerWidth) { return Array.from({ length: headerWidth }, (_, index) => index) .filter((index) => (rowspans[index] || 1) <= 1); } function appendTableCell(row, index, value) { const clean = cleanTableText(value); if (!clean) return; row[index] = row[index] ? `${row[index]}\n${clean}` : clean; } function appendTablePayloadCell(row, index, text) { const clean = cleanTableText(text); if (!clean) return; if (!row[index]) { row[index] = { text: "", style: "" }; } row[index].text = row[index].text ? `${row[index].text}\n${clean}` : clean; } function mergeContinuationRow(logicalRow, cells, openSlots, headerWidth) { let slotIndex = 0; let lastTarget = openSlots.at(-1) ?? headerWidth - 1; for (const cell of cells) { const colspan = Number.parseInt(cell.getAttribute("colspan") || "1", 10); for (let offset = 0; offset < colspan; offset += 1) { const text = offset === 0 ? cell.innerText || cell.textContent || "" : ""; const target = slotIndex < openSlots.length ? openSlots[slotIndex] : lastTarget; slotIndex += 1; lastTarget = target; appendTableCell(logicalRow, target, text); } } } // Таблица, вложенная в ячейку другой, не должна отдавать свои строки наружу: // querySelectorAll("tr") ищет по всему поддереву, поэтому строки отбираются по // ближайшей таблице-владельцу. function ownRowCells(table) { return Array.from(table.querySelectorAll("tr")) .filter((row) => row.closest("table") === table) .map((row) => Array.from(row.children).filter((cell) => /^(TD|TH)$/.test(cell.tagName))); } // Ширина таблицы берётся из её первой строки, поэтому пустые строки перед // шапкой надо отбросить: иначе ширина окажется нулевой и все данные таблицы // молча исчезнут. function rowsFromHeader(table) { const rows = ownRowCells(table); const headerIndex = rows.findIndex((cells) => cells.length); return headerIndex === -1 ? [] : rows.slice(headerIndex); } function topLevelTables(root) { return Array.from(root.querySelectorAll("table")) .filter((table) => !table.parentElement?.closest("table")); } // Ссылка на CSS-переменную вне исходной страницы не разрешается ни во что: // wiki вешает backgroundcolor="var(--editor-highlight-bgc)" на шапки таблиц, а // табличному редактору такое значение отдавать нельзя. function usableStyleValue(value) { return value && !/var\s*\(/i.test(value) ? value : ""; } function cloneCellStyle(cell) { const allowed = new Set([ "background-color", "color", "font-weight", "font-style", "text-align", "vertical-align", "border", "border-top", "border-right", "border-bottom", "border-left", "white-space" ]); const style = []; for (const property of allowed) { const value = usableStyleValue(cell.style?.getPropertyValue(property)); if (value) { style.push(`${property}: ${value}`); } } for (const attribute of ["backgroundcolor", "data-bgc", "bgcolor"]) { const value = usableStyleValue(cell.getAttribute(attribute)); if (value && !style.some((item) => item.startsWith("background-color:"))) { style.push(`background-color: ${value}`); } } if (cell.tagName === "TH" && !style.some((item) => item.startsWith("font-weight:"))) { style.push("font-weight: 700"); } return style.join("; "); } function expandTablePayloadCells(cells) { const values = []; const rowspans = []; for (const cell of cells) { const colspan = Number.parseInt(cell.getAttribute("colspan") || "1", 10); const rowspan = Number.parseInt(cell.getAttribute("rowspan") || "1", 10); for (let offset = 0; offset < colspan; offset += 1) { values.push({ text: offset === 0 ? cleanTableText(cell.innerText || cell.textContent || "") : "", style: offset === 0 ? cloneCellStyle(cell) : "" }); rowspans.push(rowspan); } } return { values, rowspans }; } function mergeContinuationPayloadRow(logicalRow, cells, openSlots, headerWidth) { let slotIndex = 0; let lastTarget = openSlots.at(-1) ?? headerWidth - 1; for (const cell of cells) { const colspan = Number.parseInt(cell.getAttribute("colspan") || "1", 10); for (let offset = 0; offset < colspan; offset += 1) { const text = offset === 0 ? cell.innerText || cell.textContent || "" : ""; const target = slotIndex < openSlots.length ? openSlots[slotIndex] : lastTarget; slotIndex += 1; lastTarget = target; appendTablePayloadCell(logicalRow, target, text); } } } function detectNumberedTableMode(domRows) { let numericFirstCells = 0; let hasRowspan = false; for (const cells of domRows.slice(1)) { const firstText = cleanTableText(cells[0]?.innerText || cells[0]?.textContent || ""); if (/^\d+$/.test(firstText)) { numericFirstCells += 1; } for (const cell of cells) { if (Number.parseInt(cell.getAttribute("rowspan") || "1", 10) > 1) { hasRowspan = true; } } } return numericFirstCells >= 2 && hasRowspan; } function normalizeTableToRows(table) { const domRows = rowsFromHeader(table); if (!domRows.length) return []; const header = expandTableCells(domRows[0]).values; const headerWidth = header.length; const output = [header]; if (!detectNumberedTableMode(domRows)) { for (const cells of domRows.slice(1)) { const expanded = expandTableCells(cells); const row = Array.from({ length: headerWidth }, (_, index) => expanded.values[index] || ""); output.push(row); } return output; } let currentLogicalRow = null; let openSlots = []; for (const cells of domRows.slice(1)) { const firstText = cleanTableText(cells[0]?.innerText || cells[0]?.textContent || ""); if (/^\d+$/.test(firstText)) { const expanded = expandTableCells(cells); currentLogicalRow = Array.from({ length: headerWidth }, (_, index) => expanded.values[index] || ""); openSlots = nextOpenSlots(expanded.rowspans, headerWidth); output.push(currentLogicalRow); } else if (currentLogicalRow) { mergeContinuationRow(currentLogicalRow, cells, openSlots, headerWidth); } } return output; } // Шапку метит нормализация, а не сериализатор: при склейке нескольких таблиц // заголовочная строка второй и последующих уже не имеет индекса 0. function markHeaderRow(row) { return row.map((cell) => { const style = cell.style || ""; if (/font-weight/i.test(style)) return cell; return { ...cell, style: style ? `${style}; font-weight: 700` : "font-weight: 700" }; }); } function excelRowsFromTable(table) { const domRows = rowsFromHeader(table); if (!domRows.length) return []; const header = expandTablePayloadCells(domRows[0]).values; const headerWidth = header.length; const output = [header]; if (!detectNumberedTableMode(domRows)) { for (const cells of domRows.slice(1)) { const expanded = expandTablePayloadCells(cells); const row = Array.from({ length: headerWidth }, (_, index) => { return expanded.values[index] || { text: "", style: "" }; }); output.push(row); } return [markHeaderRow(output[0]), ...output.slice(1)]; } let currentLogicalRow = null; let openSlots = []; for (const cells of domRows.slice(1)) { const firstText = cleanTableText(cells[0]?.innerText || cells[0]?.textContent || ""); if (/^\d+$/.test(firstText)) { const expanded = expandTablePayloadCells(cells); currentLogicalRow = Array.from({ length: headerWidth }, (_, index) => { return expanded.values[index] || { text: "", style: "" }; }); openSlots = nextOpenSlots(expanded.rowspans, headerWidth); output.push(currentLogicalRow); } else if (currentLogicalRow) { mergeContinuationPayloadRow(currentLogicalRow, cells, openSlots, headerWidth); } } return [markHeaderRow(output[0]), ...output.slice(1)]; } function normalizeTableToExcelPayload(table) { const rows = excelRowsFromTable(table); return { rows, html: excelHtmlFromRows(rows) }; } // Из Markdown-протокола приезжает документ с десятком таблиц. Все они // складываются в один лист: таблица за таблицей, между ними пустая строка. // Ширина выравнивается по самой широкой таблице, чтобы строгий парсер // (Р7/OnlyOffice) получил прямоугольную сетку без неоднозначных строк. function normalizeTablesToExcelPayload(tables) { const blocks = []; for (const table of tables) { // Сериализация здесь не нужна: HTML собирается один раз, из склейки. const rows = excelRowsFromTable(table); if (rows.some((row) => row.length)) { blocks.push(rows); } } if (!blocks.length) return { rows: [], html: "", tables: 0, contentRows: 0, width: 0 }; const stacked = []; for (const rows of blocks) { if (stacked.length) stacked.push([]); stacked.push(...rows); } const width = Math.max(...stacked.map((row) => row.length)); const rows = stacked.map((row) => { return Array.from({ length: width }, (_, index) => row[index] || { text: "", style: "" }); }); return { rows, html: excelHtmlFromRows(rows), tables: blocks.length, contentRows: blocks.reduce((sum, block) => sum + block.length, 0), width }; } function excelTsvFromRows(rows) { return rows.map((row) => row.map((cell) => { const value = typeof cell === "object" && cell !== null ? cell.text : cell; return String(value ?? "") .replace(/\r\n?/g, "\n") .replace(/\n+/g, "; ") .replace(/\t/g, " ") .trim(); }).join("\t")).join("\n"); } function excelHtmlFromRows(rows) { // Spreadsheet editors (Excel, Google Sheets, Р7/OnlyOffice) are picky about // clipboard HTML: they expect a complete document, td-only cells and the // mso-data-placement hint so <br> stays a line break inside one cell. // Жирность шапки проставляет нормализация — здесь только сериализация. const body = rows.map((row) => { return `<tr>${row.map((cell) => { const value = typeof cell === "object" && cell !== null ? cell.text : cell; const style = typeof cell === "object" && cell !== null && cell.style ? cell.style : ""; const styleAttribute = style ? ` style="${escapeHtml(style)}"` : ""; return `<td${styleAttribute}>${escapeHtml(value).replace(/\n/g, "<br>")}</td>`; }).join("")}</tr>`; }).join(""); return `<html><head><meta charset="utf-8"><style>br { mso-data-placement: same-cell; }</style></head>` + `<body><table border="0" cellpadding="0" cellspacing="0" style="border-collapse: collapse;"><tbody>${body}</tbody></table></body></html>`; } // ===== FILE: src/converters/layout-renderer.js ===== function renderLayoutInlines(content) { if (Array.isArray(content)) { return content.map(renderLayoutInline).join(""); } return escapeHtml(content ?? ""); } function renderLayoutInline(part) { if (typeof part === "string") { return escapeHtml(part); } switch (part.type) { case "text": return escapeHtml(part.text ?? ""); case "strong": return `<strong>${renderLayoutInlines(part.content ?? part.text ?? "")}</strong>`; case "em": return `<em>${renderLayoutInlines(part.content ?? part.text ?? "")}</em>`; case "strike": return `<s>${renderLayoutInlines(part.content ?? part.text ?? "")}</s>`; case "code": return `<code data-set="inline">${escapeHtml(part.text ?? "")}</code>`; case "br": return "<br>"; case "status": return renderLayoutStatus(part); case "span": return `<span${part.style ? ` style="${escapeHtml(part.style)}"` : ""}>${renderLayoutInlines(part.content ?? part.text ?? "")}</span>`; case "hint": return `<span class="hint_jjms4" data-type="hint" data-testid="wikiUnitFormEditor.Hint" contenteditable="true">${escapeHtml(part.text ?? "")}</span>`; default: throw new Error(`Unsupported inline type: ${part.type}`); } } function renderLayoutParagraph(content, { slice = false } = {}) { const sliceAttr = slice ? ' data-pm-slice="0 0 []"' : ""; return `<p data-id="${layoutId()}" data-indent="0"${sliceAttr} style="text-align: justify; margin-left: 0px !important;">${content}</p>`; } function renderLayoutParagraphText(text, options) { return renderLayoutParagraph(escapeHtml(text), options); } function renderLayoutStatus(status) { const title = status.title ?? ""; const color = status.color ?? "yellow"; return `<span data-id="${layoutId()}" title="${escapeHtml(title)}" color="${escapeHtml(color)}" data-type="status"><span>[Статус: ${escapeHtml(title)} (${escapeHtml(color)})]</span></span>`; } function renderLayoutTableOfContent(block = {}) { const title = block.title ?? "Содержание"; return `<nav data-id="${layoutId()}" title="${escapeHtml(title)}" minlevel="${escapeHtml(block.minlevel ?? 1)}" maxlevel="${escapeHtml(block.maxlevel ?? 6)}" class="toctainer_geoc4" data-type="tableOfContent"> <span class="toctitle_geoc4"> ${escapeHtml(title)} </span> <span class="tocmessage_geoc4"> Узел документа "Содержание" не может быть сгенерирован при рендере контента в HTML </span> </nav>`; } function layoutInlineContent(item) { if (item?.inlines) { return renderLayoutInlines(item.inlines); } return escapeHtml(item?.text ?? item ?? ""); } function renderLayoutCellContent(cell) { if (cell === null || cell === undefined) { return ""; } if (typeof cell === "string") { return escapeHtml(cell); } if (Array.isArray(cell)) { return renderLayoutInlines(cell); } if (cell.inlines) { return renderLayoutInlines(cell.inlines); } if (cell.text !== undefined) { return escapeHtml(cell.text); } if (cell.status) { return renderLayoutStatus(cell.status); } throw new Error("Unsupported table cell format"); } function layoutCellStyle(hex) { const colors = { "#ffffff": "background-color: rgb(255, 255, 255);", "#FFBDAD": "background-color: rgb(255, 189, 173);", "#ABF5D1": "background-color: rgb(171, 245, 209);", "#F4F5F7": "background-color: rgb(244, 245, 247);" }; return colors[hex] ?? colors["#ffffff"]; } function renderLayoutTable(block) { const headers = block.headers ?? []; const rows = block.rows ?? []; const widths = layoutWidths(block.columnwidths, headers.length); const headerRow = `<tr data-id="${layoutId()}">${headers.map((header) => { return `<th data-id="${layoutId()}" colspan="1" rowspan="1" backgroundcolor="var(--editor-highlight-bgc)"><span>${renderLayoutParagraphText(header)}</span></th>`; }).join("")}</tr>`; const bodyRows = rows.map((row, rowIndex) => { const cells = row.map((cell) => { const bg = typeof cell === "object" && !Array.isArray(cell) && cell.bg ? cell.bg : "#ffffff"; return `<td data-id="${layoutId()}" colspan="1" rowspan="1" data-bgc="${escapeHtml(bg)}" class="numbered-column-td" style="${escapeHtml(layoutCellStyle(bg))}">${renderLayoutParagraph(renderLayoutCellContent(cell))}</td>`; }).join(""); return `<tr data-id="${layoutId()}" data-original-row-index="${rowIndex}">${cells}</tr>`; }).join(""); return `<section data-id="${layoutId()}" data-table-id="${layoutId()}" columnwidths="${escapeHtml(widths)}" numberedactive="${block.numberedactive ? "true" : "false"}" data-type="table"><table><tbody>${headerRow}${bodyRows}</tbody></table></section>`; } function renderLayoutBlocks(blocks) { return blocks.map(renderLayoutBlock).join(""); } function renderLayoutBlock(block) { switch (block.type) { case "heading": { const level = block.level ?? 1; if (level < 1 || level > 6) { throw new Error(`Unsupported heading level: ${level}`); } const sliceAttr = block.slice ? ' data-pm-slice="0 0 []"' : ""; // The wiki anchors headings by `id`, and it always equals `data-id`. const headingId = layoutId(); return `<h${level} data-id="${headingId}" data-indent="0" id="${headingId}"${sliceAttr} style="text-align: justify; margin-left: 0px !important;">${escapeHtml(block.text ?? "")}</h${level}>`; } case "toc": case "tableOfContent": return renderLayoutTableOfContent(block); case "paragraph": return renderLayoutParagraph(block.inlines ? renderLayoutInlines(block.inlines) : escapeHtml(block.text ?? ""), { slice: block.slice }); case "status": return renderLayoutParagraph(renderLayoutStatus(block), { slice: block.slice }); case "hint": return renderLayoutParagraph(renderLayoutInline({ type: "hint", text: block.text ?? "" }), { slice: block.slice }); case "panel": case "info": { const panelType = block.panelType ?? "info"; const title = block.title ?? ""; const showtitle = block.showtitle === false ? "false" : "true"; const showicon = block.showicon === false ? "false" : "true"; const background = block.custombackground ?? "gray"; const borderStyle = block.customborderstyle ?? "none"; const borderWidth = block.customborderwidth ?? "1"; const borderColor = block.custombordercolor ?? "gray"; // The wiki serializes a panel as two divs: a text descriptor of the node, // then the content. Mirror it — its own copy round-trips through paste. const descriptor = `[Информационная панель: тип - ${escapeHtml(panelType)}, заголовок - ${escapeHtml(title)}, показывать заголовок - ${showtitle}, показывать иконку - ${showicon}. Атрибуты для произвольного типа: цвет фона - ${escapeHtml(background)}, стиль границы - ${escapeHtml(borderStyle)}, толщина границы - ${escapeHtml(borderWidth)}, цвет границы - ${escapeHtml(borderColor)}]`; return `<div data-id="${layoutId()}" type="${escapeHtml(panelType)}" title="${escapeHtml(title)}" showtitle="${showtitle}" showicon="${showicon}" custombackground="${escapeHtml(background)}" customborderstyle="${escapeHtml(borderStyle)}" customborderwidth="${escapeHtml(borderWidth)}" custombordercolor="${escapeHtml(borderColor)}" data-type="info"><div>${descriptor}</div><div>${renderLayoutBlocks(block.blocks ?? [])}</div></div>`; } case "plantuml": { const language = block.language ?? "uml"; const codeLanguage = block.codeLanguage ?? "java"; return `<div class="plantContainer_1hv20" data-type="plantuml" data-id="${layoutId()}" plantumlid="${layoutId()}" plantumltitle="${escapeHtml(block.title ?? "")}" plantumllanguage="${escapeHtml(language)}" language="${escapeHtml(codeLanguage)}"><pre><code>${escapeHtml(block.code ?? "")}</code></pre></div>`; } case "include": { const classes = ["wrapper_1cxbv"]; if (block.bordered !== false) { classes.push("bordered_1cxbv"); } return `<div data-id="${layoutId()}" fragmentsearchstrategy="${escapeHtml(block.fragmentsearchstrategy ?? "by-fragment-id")}" class="${classes.join(" ")}" data-type="include">${escapeHtml(block.text ?? block.title ?? "Включение()")}</div>`; } case "tabs": return `<div data-id="${layoutId()}" class="tabsWrapper_d6kqf" data-type="tabs">${(block.tabs ?? []).map((tab) => { const label = tab.label ?? tab.title ?? ""; return `<div data-id="${layoutId()}" label="${escapeHtml(label)}" class="tabWrapper_z4u1j" data-type="tab"><div class="tabTitle_z4u1j">${escapeHtml(label)}</div><div>${renderLayoutBlocks(tab.blocks ?? [])}</div></div>`; }).join("")}</div>`; case "expand": { const openClass = block.open === false ? "" : " is-open"; return `<div class="expand${openClass}" data-id="${layoutId()}"><button type="button"></button><div><summary class="expand-summary">${escapeHtml(block.title ?? "")}</summary><div class="expand-content" data-type="expandContent">${renderLayoutBlocks(block.blocks ?? [])}</div></div></div>`; } case "table": return renderLayoutTable(block); case "taskList": return `<ul data-id="${layoutId()}" data-type="taskList" style="text-align: justify; margin-left: 0px !important;">${(block.items ?? []).map((item) => { const checked = typeof item === "object" ? Boolean(item.checked) : false; const text = typeof item === "object" ? item.text ?? "" : item; const checkedAttr = checked ? ' checked=""' : ""; return `<li data-id="${layoutId()}" data-checked="${checked ? "true" : "false"}" data-type="taskItem" style="display: flex; align-items: flex-start; gap: 0.5rem;"><label style="display: flex; align-items: center; margin-top: 0.25rem; pointer-events: none;"><input type="checkbox" disabled=""${checkedAttr}><span></span></label><div>${renderLayoutParagraphText(text)}</div></li>`; }).join("")}</ul>`; case "orderedList": { const sliceAttr = block.slice ? ' data-pm-slice="0 0 []"' : ""; return `<ol class="orderedList_14jqc" data-id="${layoutId()}"${sliceAttr}>${(block.items ?? []).map((item) => { const itemObj = typeof item === "object" && item !== null ? item : { text: item }; const children = itemObj.children ? renderLayoutBlock({ type: "orderedList", items: itemObj.children }) : ""; return `<li class="listItem_14jqc" data-id="${layoutId()}">${renderLayoutParagraph(layoutInlineContent(itemObj))}${children}</li>`; }).join("")}</ol>`; } case "uiButton": { const title = block.title ?? block.label ?? ""; const url = block.url ?? ""; const attrs = [ `data-id="${layoutId()}"`, `title="${escapeHtml(title)}"`, `tooltip="${escapeHtml(block.tooltip ?? url)}"`, `url="${escapeHtml(url)}"`, `color="${escapeHtml(block.color ?? "Background.surfaceSecondary")}"`, `size="${escapeHtml(block.size ?? "md")}"`, block.icon ? `icon="${escapeHtml(block.icon)}"` : "", `display="${escapeHtml(block.display ?? "")}"`, `newwindow="${block.newwindow === false ? "false" : "true"}"`, 'data-type="uiButton"' ].filter(Boolean).join(" "); return `<div ${attrs}><button>${escapeHtml(title)}</button></div>`; } case "blocksGrid": { const columns = block.columns ?? []; if (columns.length > 3) { throw new Error(`blocksGrid supports at most 3 columns, got ${columns.length}`); } const gridId = layoutId(); const template = block.template ?? columns.map(() => "minmax(0, 1fr)").join(" "); return `<section data-id="${layoutId()}" blockscount="${columns.length}" blocksgridid="${gridId}" data-type="blocksGrid" class="grid_1orlh overlap_1orlh" style="--blocksgrid-template: ${escapeHtml(template)};">${columns.map((blocks) => { return `<div data-id="${layoutId()}" blocksgridid="${gridId}" data-type="column" class="column_yu7m7"><div class="content_yu7m7">${renderLayoutBlocks(blocks)}</div></div>`; }).join("")}</section>`; } case "code": return `<pre data-set="highlighted-code" data-id="${layoutId()}"><code>${escapeHtml(block.code ?? "")}</code></pre>`; case "hr": return "<hr>"; default: throw new Error(`Unsupported block type: ${block.type}`); } } // ===== FILE: src/converters/goal-to-layout.js ===== // goal-template.v1 JSON -> wiki-layout-v1 JSON. // // Модуль только ПОРОЖДАЕТ layout JSON: рендерер (layout-renderer.js) остаётся универсальным, // в его switch не добавляется ни одного goal-специфичного case, а обычный путь // «текст -> промт -> layout JSON» не затрагивается. // // Профили страницы: // public — читателю: провенанс показывается только у неподтверждённых полей; // audit — владельцу и аудитору: статусы у всего, плюс покрытие источников, // упоминания сущностей и цитаты. /* ------------------------------------------------------------------ labels */ const STATUS_META = { confirmed: { label: "подтверждено", color: "green" }, inferred: { label: "выведено", color: "blue" }, requires_review: { label: "требует проверки", color: "yellow" }, conflicting: { label: "конфликт", color: "red" }, missing: { label: "не найдено", color: "gray" } }; const CONFIDENCE_LABEL = { high: "высокая", medium: "средняя", low: "низкая", none: "не определена" }; const ENTITY_TYPE_LABEL = { as: "АС", fp: "ФП", module: "модуль", agent: "агент", team: "команда", cluster: "кластер", artificial_block: "искусственный блок", api: "API", service: "сервис", integration: "интеграция", other: "другое" }; const CONTACT_TYPE_LABEL = { goal_owner: "Владелец цели", process_owner: "Владелец процесса", metric_owner: "Владелец метрики", registry_owner: "Владелец реестра", support_unit: "Поддержка (подразделение)", support_person: "Поддержка (человек)", escalation: "Эскалация", communication_channel: "Канал связи", other: "Другое" }; const ASSIGNEE_KIND_LABEL = { person: "человек", unit: "подразделение", channel: "канал", role: "роль" }; const RELATION_LABEL = { aggregates: "агрегирует", realizes: "реализует", depends_on: "зависит от", blocks: "блокирует", influences: "влияет на", derived_from: "производная от", related_to: "связана с" }; const OBSTACLE_KIND_LABEL = { source_quality: "качество источника", data_conflict: "конфликт данных", missing_information: "нехватка информации", technical_constraint: "техническое ограничение", process_blocker: "процессный блокер", external_dependency: "внешняя зависимость", other: "другое" }; const SEVERITY_LABEL = { blocker: "блокер", major: "существенное", minor: "незначительное", info: "информация" }; const SCOPE_LABEL = { extraction: "извлечение", goal_execution: "исполнение цели", downstream_resolution: "смежные решения" }; const COVERAGE_LABEL = { used: "использован", reviewed_no_direct_goal_facts: "просмотрен, прямых фактов нет", partially_used: "использован частично", conflicting: "конфликтует", requires_review: "требует проверки" }; const MATCH_METHOD_LABEL = { exact_ci: "по CI-коду", exact_alias: "по псевдониму", exact_name: "по точному названию", fuzzy_name: "по похожему названию", manual: "вручную", unresolved: "не разрешено" }; const SET_KIND_LABEL = { wave: "волна", quarter_scope: "объём квартала", registry: "реестр", manual_list: "ручной список", cluster_scope: "объём кластера", team_scope: "объём команды", artificial_block: "искусственный блок", source_table: "таблица источника", other: "другое" }; const EXTRACTION_STATUS_LABEL = { complete: "извлечено полностью", partial: "извлечено частично", extraction_blocked: "извлечение заблокировано", requires_source_refresh: "нужна свежая выгрузка" }; const SOURCE_TYPE_LABEL = { pdf: "PDF", html: "HTML", xlsx: "XLSX", wiki: "wiki", dashboard: "дашборд", system: "система", manual_note: "заметка", other: "другое" }; const COMPARATOR_LABEL = { eq: "=", gte: "≥", lte: "≤", gt: ">", lt: "<", between: "в диапазоне", approx: "≈" }; const AGGREGATION_LABEL = { sum: "сумма", avg: "среднее", median: "медиана", min: "минимум", max: "максимум", ratio: "доля", count_distinct: "уникальные значения", weighted_avg: "взвешенное среднее", other: "другое" }; const OP_LABEL = { eq: "=", in: "входит в", contains: "содержит", exists: "задан", gte: "≥", lte: "≤" }; const SOURCE_STATUS_LABEL = { draft: "черновик", confirmed: "подтверждено", mixed: "смешанный", requires_review: "требует проверки" }; /* ----------------------------------------------------------------- helpers */ const isFilled = (value) => Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value !== ""; // Схема требует строку в title, name, label и подобных полях, но LLM регулярно оборачивает их // в textField `{value, state}` — вокруг ведь все текстовые поля такие. Принимаем оба вида: // иначе одна такая ошибка модели рушит всю страницу. function plainText(value) { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); if (typeof value === "object" && typeof value.value === "string") return value.value; return ""; } // Поля, которые по схеме обязаны быть строкой. Если LLM прислала объект, страница всё равно // собирается, но пользователь должен об этом узнать. function goalShapeProblems(goal) { const problems = []; const check = (value, path) => { if (value === undefined || value === null || typeof value === "string") return; problems.push(`${path}: ожидается строка, пришёл ${Array.isArray(value) ? "массив" : typeof value}`); }; (goal.metrics?.primary_metrics ?? []).forEach((metric, index) => check(metric.title, `/metrics/primary_metrics/${index}/title`)); (goal.obstacles ?? []).forEach((obstacle, index) => check(obstacle.title, `/obstacles/${index}/title`)); (goal.source_refs ?? []).forEach((source, index) => check(source.title, `/source_refs/${index}/title`)); (goal.applicability?.explicit_target_sets ?? []).forEach((set, index) => check(set.title, `/applicability/explicit_target_sets/${index}/title`)); (goal.responsibility?.contacts ?? []).forEach((contact, index) => check(contact.name, `/responsibility/contacts/${index}/name`)); for (const [key, actions] of [["required_actions", goal.execution?.required_actions], ["recommended_actions", goal.execution?.recommended_actions]]) { (actions ?? []).forEach((action, index) => check(action.title, `/execution/${key}/${index}/title`)); } return problems; } function statusChip(state) { const meta = STATUS_META[state?.status] ?? { label: state?.status ?? "—", color: "gray" }; return { type: "status", title: meta.label, color: meta.color }; } // Provenance as inlines: a status chip plus (in audit profile) confidence and source ids. // Rendering ids inside `code` inlines is what kills the Markdown underscore problem — // `src_01` never passes through an emphasis parser. function provenance(state, { profile, always = false } = {}) { if (!state) return []; if (profile === "public" && !always && state.status === "confirmed") return []; const parts = [statusChip(state)]; if (profile === "audit") { if (state.confidence && state.confidence !== "none") { parts.push({ type: "text", text: " уверенность: " }, { type: "em", text: CONFIDENCE_LABEL[state.confidence] }); } if (isFilled(state.source_ref_ids)) { parts.push({ type: "text", text: "; источники: " }); state.source_ref_ids.forEach((id, index) => { if (index > 0) parts.push({ type: "text", text: ", " }); parts.push({ type: "code", text: id }); }); } } if (state.notes) parts.push({ type: "text", text: ` — ${state.notes}` }); return parts; } // In the public profile a table full of green "подтверждено" chips is noise: // only non-confirmed rows get marked. The audit profile marks every row. function statusCell(state, options) { if (options?.profile === "public" && state?.status === "confirmed") return ""; return { inlines: provenance(state, { ...options, always: true }) }; } // A textField becomes a value paragraph plus, when there is anything to say, a provenance line. // With showEmpty, a field that has no value but a non-confirmed state is still rendered: for a // partial JSON "что именно не найдено и почему" is the most valuable thing on the page. function textFieldBlocks(field, { profile, label, showEmpty = false } = {}) { if (!field) return []; if (!isFilled(field.value)) { if (!showEmpty || !field.state || field.state.status === "confirmed") return []; const head = label ? [{ type: "strong", text: `${label}: ` }] : []; return [{ type: "paragraph", inlines: [...head, ...provenance(field.state, { profile, always: true })] }]; } const blocks = []; const lead = label ? [{ type: "strong", text: `${label}: ` }, { type: "text", text: field.value }] : [{ type: "text", text: field.value }]; blocks.push({ type: "paragraph", inlines: lead }); const prov = provenance(field.state, { profile }); if (prov.length) blocks.push({ type: "paragraph", inlines: prov }); if (isFilled(field.conflicting_values)) { blocks.push({ type: "panel", panelType: "warning", title: "Конфликтующие варианты", blocks: [{ type: "table", headers: ["Источник", "Вариант", "Уверенность", "Примечание"], columnwidths: [200, 700, 200, 400], rows: field.conflicting_values.map((cv) => [ { inlines: [{ type: "code", text: cv.source_ref_id }] }, cv.value ?? "", CONFIDENCE_LABEL[cv.confidence] ?? "", cv.notes ?? "" ]) }] }); } return blocks; } function heading(level, text) { return { type: "heading", level, text }; } // Drops any column that is empty in every row — the first column always stays. Partial goal // JSONs leave whole columns blank (no CI codes, everything confirmed, no notes), and an empty // column is pure noise on the page. function table(headers, inputRows, columnwidths) { // Ячейка, в которую по ошибке модели попал textField, превращается в его текст, // а не роняет рендер сообщением «Unsupported table cell format». // Пустая таблица (все строки отфильтрованы) не должна появляться на странице шапкой без данных. if (!(inputRows ?? []).length) return null; const rows = (inputRows ?? []).map((row) => row.map((cell) => ( cell && typeof cell === "object" && !Array.isArray(cell) && cell.text === undefined && !cell.inlines && !cell.status && typeof cell.value === "string" ? cell.value : cell ))); const isEmpty = (cell) => cell === "" || cell === undefined || cell === null || (typeof cell === "object" && !Array.isArray(cell) && Array.isArray(cell.inlines) && cell.inlines.length === 0); const keep = headers.map((_, index) => index === 0 || !rows.length || !rows.every((row) => isEmpty(row[index]))); const trimmedHeaders = headers.filter((_, index) => keep[index]); const trimmedRows = rows.map((row) => row.filter((_, index) => keep[index])); // Таблица из одной строки — это один факт с подписями, а не таблица: шапка ради // единственной строки создаёт ощущение перегруженной страницы на пустом месте. if (trimmedRows.length === 1 && trimmedHeaders.length > 1) { const inlines = []; trimmedHeaders.forEach((header, index) => { const cell = trimmedRows[0][index]; const empty = cell === "" || cell === undefined || cell === null || (typeof cell === "object" && !Array.isArray(cell) && Array.isArray(cell.inlines) && cell.inlines.length === 0); if (empty) return; if (inlines.length) inlines.push({ type: "text", text: " · " }); inlines.push({ type: "strong", text: `${header}: ` }); if (typeof cell === "string") inlines.push({ type: "text", text: cell }); else if (Array.isArray(cell)) inlines.push(...cell); else if (cell.inlines) inlines.push(...cell.inlines); else if (cell.text !== undefined) inlines.push({ type: "text", text: cell.text }); }); return inlines.length ? { type: "paragraph", inlines } : null; } // Таблица, от которой осталась одна колонка, — это список: рисуем его списком. if (trimmedHeaders.length === 1) { return { type: "orderedList", items: trimmedRows.map((row) => (typeof row[0] === "string" ? { text: row[0] } : { inlines: row[0]?.inlines ?? [{ type: "text", text: String(row[0] ?? "") }] })) }; } const widths = Array.isArray(columnwidths) ? columnwidths.filter((_, index) => keep[index]) : columnwidths; if (Array.isArray(widths) && widths.length) { const sum = widths.reduce((acc, width) => acc + width, 0); const scaled = widths.map((width) => Math.max(60, Math.round((width / sum) * 1500))); scaled[scaled.length - 1] += 1500 - scaled.reduce((acc, width) => acc + width, 0); return { type: "table", headers: trimmedHeaders, rows: trimmedRows, columnwidths: scaled }; } return { type: "table", headers: trimmedHeaders, rows: trimmedRows }; } function taskListFromActions(actions, { profile }) { return { type: "taskList", items: actions.map((action) => ({ checked: false, text: [action.title, action.role ? `(${action.role})` : "", action.details ? `— ${action.details}` : "", profile === "audit" && action.state?.status && action.state.status !== "confirmed" ? `[${STATUS_META[action.state.status]?.label ?? action.state.status}]` : ""] .filter(Boolean).join(" ") })) }; } function linkButton(link, { icon = "Link", color = "Foreground.foreAccent" } = {}) { return { type: "uiButton", title: link.label ?? link.title ?? link.url, url: link.url, tooltip: link.url, color, size: "md", icon, display: "", newwindow: true }; } function selectorText(selector) { if (!selector) return ""; switch (selector.kind) { case "all_of_type": return "все сущности указанных типов"; case "manual_set": return `ручной набор: ${(selector.entity_ids ?? []).join(", ") || "—"}`; case "explicit_entities": return `перечисление: ${(selector.entity_ids ?? []).join(", ")}`; case "attribute_filter": return Object.entries(selector.attributes ?? {}) .map(([key, cond]) => `${key} ${OP_LABEL[cond?.op] ?? cond?.op ?? ""} ${formatScalar(cond?.value)}`.trim()) .join("; "); case "relation_filter": { const relation = selector.relation ?? {}; const target = relation.target_entity_type ? ` → ${ENTITY_TYPE_LABEL[relation.target_entity_type] ?? relation.target_entity_type}` : ""; const id = relation.target_entity_id ? ` (${relation.target_entity_id})` : ""; const scope = relation.scope ? `, ${relation.scope}` : ""; return `связь «${relation.type}»${target}${id}${scope}`; } case "source_list": return `список из источника: ${selector.source_list_ref}`; case "textual_rule": return selector.text ?? ""; case "compound": return (selector.sub_selectors ?? []).map(selectorText).join(selector.combinator === "and" ? " И " : " ИЛИ "); default: return selector.kind; } } function formatScalar(value) { if (value === undefined) return ""; if (Array.isArray(value)) return value.join(", "); if (value === null) return "null"; return String(value); } function typedValueText(typed) { if (!typed) return ""; const comparator = typed.comparator ? `${COMPARATOR_LABEL[typed.comparator] ?? typed.comparator} ` : ""; const range = typed.comparator === "between" ? `${typed.comparator_lower}…${typed.comparator_upper}` : formatScalar(typed.value); const unit = typed.unit ? ` ${typed.unit}` : ""; const aggregation = typed.aggregation ? `, агрегация: ${AGGREGATION_LABEL[typed.aggregation] ?? typed.aggregation}` : ""; const rounding = typed.rounding ? `, округление: ${typed.rounding}` : ""; return `${comparator}${range}${unit}${aggregation}${rounding}`.trim(); } // Ключи в attributes/dimensions приходят из источника как есть. Латинские snake_case-ключи // (representatives, window_days) — машинные поля: читателю показываем только значения. function keyValueText(map, { profile } = {}) { return Object.entries(map ?? {}) .map(([key, value]) => (profile !== "audit" && /^[a-z][a-z0-9_]*$/.test(key) ? formatScalar(value) : `${key}: ${formatScalar(value)}`)) .join("; "); } function entityTypeList(types) { return (types ?? []).map((type) => ENTITY_TYPE_LABEL[type] ?? type).join(", "); } /* ------------------------------------------------------------- links */ const LINK_GROUPS = { dashboard: { title: "Дашборды и витрины", icon: "Search", color: "Foreground.foreAccent" }, // Расчёт — это документ, а не согласование: позитивный «Approved» по контракту // закреплён за доступом, заявкой и готовностью. calculation: { title: "Детальные расчёты", icon: "File", color: "Foreground.foreAccent" }, access: { title: "Доступы и заявки", icon: "Approved", color: "Foreground.forePositive" }, instruction: { title: "Методики, регламенты, стандарты", icon: "File", color: "Background.surfaceSecondary" }, artifact: { title: "Артефакты и подтверждения", icon: "Link", color: "Foreground.foreAccent" }, contact: { title: "Контакты и каналы", icon: "Mail", color: "Foreground.forePositive" }, source: { title: "Прочее", icon: "Link", color: "Background.surfaceSecondary" } }; const LINK_GROUP_ORDER = ["dashboard", "calculation", "access", "instruction", "artifact", "contact", "source"]; // Ссылки — навигационная основа страницы, поэтому они собираются со всего документа в один // раздел и дедуплицируются по URL: один адрес = одна строка, даже если он упомянут в // нескольких местах (у метрики, в инструкциях и в источниках). // Одна и та же страница часто приходит и прямой ссылкой, и ссылкой-редиректом с адресом // внутри параметра. Для читателя это одна кнопка, поэтому дедуп идёт по конечному адресу, // а показывается прямая ссылка как более короткая и понятная. function canonicalUrl(url) { if (!url) return url; const match = /[?&]sourceUrl=([^&]+)/i.exec(url); if (!match) return url; try { return decodeURIComponent(match[1]); } catch { return match[1]; } } function collectLinks(goal) { const byUrl = new Map(); const add = (url, label, group, context, state) => { if (!url) return; const key = canonicalUrl(url); const existing = byUrl.get(key); if (existing) { if (context && !existing.contexts.includes(context)) existing.contexts.push(context); if (!existing.state && state) existing.state = state; // прямая ссылка вытесняет редирект if (existing.url !== key && url === key) { existing.url = url; existing.label = label || existing.label; } return; } byUrl.set(key, { url, label: label || url, group, contexts: context ? [context] : [], state }); }; for (const metric of goal.metrics?.primary_metrics ?? []) { const playbook = metric.calculation_playbook ?? {}; // Название метрики нередко уже начинается со слова «Метрика» — не удваиваем его. const metricName = plainText(metric.title); const where = /^метрик/i.test(metricName) ? metricName : `Метрика «${metricName}»`; for (const link of playbook.dashboards ?? []) add(link.url, link.label, "dashboard", where, link.state); for (const link of playbook.access_instructions ?? []) { // В access_instructions попадают и страницы расчёта, и заявки на доступ — различаем по смыслу подписи. const isAccess = /доступ|заявк|запрос|учётн|access|request/i.test(link.label ?? ""); add(link.url, link.label, isAccess ? "access" : "calculation", where, link.state); } for (const contact of playbook.contacts ?? []) add(contact.url, contact.name, "contact", where, contact.state); } for (const link of goal.knowledge?.instructions ?? []) add(link.url, link.label, "instruction", "Общие материалы по цели", link.state); const standards = goal.standards_and_requirements ?? {}; for (const [group, items] of [["Стандарт", standards.standards], ["Требование", standards.requirements], ["Процесс", standards.processes]]) { for (const item of items ?? []) add(item.url, item.title, "instruction", `${group}: ${item.title}`, item.state); } const completion = goal.completion ?? {}; for (const [kind, items] of [["Закрывающий артефакт", completion.closing_artifacts], ["Пример подтверждения", completion.evidence_examples]]) { for (const item of items ?? []) add(item.url, item.title, "artifact", kind, item.state); } for (const contact of goal.responsibility?.contacts ?? []) { add(contact.url, contact.name, "contact", CONTACT_TYPE_LABEL[contact.contact_type] ?? "Контакт", contact.state); } for (const source of goal.source_refs ?? []) add(source.url, source.title, "source", "Источник", null); return Array.from(byUrl.values()); } // Ссылку, содержимое которой не проверялось, нельзя красить в позитивный цвет: // зелёная кнопка «Approved» читается как «проверено и годно». function linkButtonStyle(link, meta) { if (/\.xlsx?($|\?)/i.test(link.url)) return { icon: "Download", color: "sky.sky500" }; if (link.state && link.state.status !== "confirmed") return { icon: "Question", color: "Foreground.foreWarning" }; return { icon: meta.icon, color: meta.color }; } function linksBlocks(goal, options) { const links = collectLinks(goal); if (!links.length) return []; const blocks = [heading(2, "Куда идти")]; blocks.push({ type: "paragraph", inlines: [{ type: "text", text: `Все ссылки, встреченные в источниках цели: ${links.length}. Содержимое страниц по ссылкам при извлечении не проверялось.` }] }); for (const groupKey of LINK_GROUP_ORDER) { const groupLinks = links.filter((link) => link.group === groupKey); if (!groupLinks.length) continue; const meta = LINK_GROUPS[groupKey]; blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: meta.title }] }); // Читателю ссылка нужна как кнопка, аудитору — как адрес с провенансом. // Печатать и таблицу, и кнопку в одном профиле — дублирование. if (options.profile === "audit") { blocks.push(table( ["Ссылка", "Зачем нужна", "Адрес", "Статус"], groupLinks.map((link) => [ link.label, truncate(link.contexts.join("; "), 70), { inlines: [{ type: "code", text: link.url }] }, statusCell(link.state, options) ]), [420, 380, 520, 180] )); continue; } // Контракт: кнопки группируем по смыслу и не ставим длинным сплошным столбцом. const buttons = groupLinks.map((link) => linkButton(link, linkButtonStyle(link, meta))); for (let index = 0; index < buttons.length; index += 3) { const chunk = buttons.slice(index, index + 3); blocks.push(chunk.length === 1 ? chunk[0] : { type: "blocksGrid", columns: chunk.map((button) => [button]) }); } } return blocks; } /* --------------------------------------------------- cross-cutting issues */ // Walks the whole document and collects every evidenceState that is not "confirmed". // This is the part a prompt cannot do reliably: it is an exhaustive traversal, not a judgement. function collectIssues(goal) { const issues = []; const SECTION_LABEL = { goal_identity: "Паспорт", goal_meaning: "Смысл цели", timeline: "Сроки", metrics: "Метрики", standards_and_requirements: "Стандарты и требования", applicability: "Применимость", entity_mentions: "Упоминания сущностей", responsibility: "Ответственные", execution: "Действия", implementation_details: "Детали реализации", completion: "Закрытие", knowledge: "Знания", obstacles: "Блокеры", goal_relations: "Связи целей", outcomes: "Результаты", source_coverage: "Покрытие источников", source_refs: "Источники" }; const FIELD_LABEL = { description: "Описание", business_reason: "Зачем нужна", expected_effect: "Что даёт", target_state: "Целевое состояние", dod: "Definition of Done", metric_formula: "Формула закрытия", acceptance_notes: "Примечания приёмки", refresh_cadence: "Периодичность расчёта", calculation_period_cutoff: "Отсечка расчётного периода", requirement_text: "Текст требования", impact: "Влияние", annual_target: "Годовая цель", numerator: "Числитель", denominator: "Знаменатель" }; function walk(node, section, trail, fromKey) { if (Array.isArray(node)) { node.forEach((item) => walk(item, section, trail, fromKey)); return; } if (!node || typeof node !== "object") return; const label = node.title ?? node.label ?? node.question ?? node.raw_text ?? node.raw_name ?? node.name ?? node.source_ref_id ?? node.metric_id ?? node.period_id ?? node.rule_id ?? node.criterion_id ?? node.requirement_id ?? node.obstacle_id ?? node.outcome_id ?? node.relation_id ?? null; const nextTrail = typeof label === "string" ? label : (typeof node.value === "string" && node.value.trim() ? node.value : (FIELD_LABEL[fromKey] ?? trail)); // Detail fields of an object that already reported an issue would duplicate it // (e.g. an obstacle's own state plus its description.state saying the same thing). const DETAIL_KEYS = new Set(["description", "impact", "requirement_text", "title"]); let reported = false; if (node.state && typeof node.state === "object" && "status" in node.state && node.state.status !== "confirmed") { issues.push({ section: SECTION_LABEL[section] ?? section, status: node.state.status, subject: (typeof nextTrail === "string" ? nextTrail : "") || "(без названия)", notes: node.state.notes ?? "" }); reported = true; } for (const [key, child] of Object.entries(node)) { if (key === "state") continue; if (reported && DETAIL_KEYS.has(key)) continue; walk(child, section, nextTrail, key); } } for (const [section, node] of Object.entries(goal)) { if (typeof node === "object" && node !== null) walk(node, section, null, section); } return issues; } // Сколько всего evidenceState в документе и как они распределены по статусам — // для карточки «наполнение» в шапке страницы. function countStates(goal) { const counts = { confirmed: 0, inferred: 0, requires_review: 0, conflicting: 0, missing: 0 }; const walk = (node) => { if (Array.isArray(node)) return node.forEach(walk); if (!node || typeof node !== "object") return; for (const [key, child] of Object.entries(node)) { if (key === "state" && child && typeof child === "object" && "status" in child) { if (child.status in counts) counts[child.status] += 1; continue; } walk(child); } }; walk(goal); counts.total = Object.values(counts).reduce((sum, value) => sum + value, 0); return counts; } function truncate(text, max = 90) { const value = String(text ?? ""); return value.length > max ? `${value.slice(0, max - 1)}…` : value; } /* -------------------------------------------------------------- sections */ // Шапка страницы: три карточки в ряд вместо таблицы «поле — значение». // Первое, что видит читатель: о чём цель, чем меряется, насколько данные полны. function headerCardBlocks(goal, options, { withToc = true } = {}) { const identity = goal.goal_identity ?? {}; const annual = goal.timeline?.annual_target; const owner = (goal.responsibility?.contacts ?? []).find((contact) => contact.contact_type === "goal_owner"); const metrics = goal.metrics?.primary_metrics ?? []; const counts = countStates(goal); // Контракт для верхней сетки: в широкой колонке — информационная таблица внутри panel. const row = (label, value) => (isFilled(value) ? [[label, value]] : []); const aboutRows = [ ...row("Период", identity.period), ...row("Годовая цель", annual ? `${annual.target_value ?? ""} ${annual.target_text ?? ""}`.trim() : ""), ...row("Владелец", owner ? `${owner.name}${owner.unit ? `, ${owner.unit}` : ""}` : ""), ...row("Область применения", entityTypeList(goal.applicability?.target_entity_types)), ...row("Другие названия", isFilled(identity.aliases) ? (identity.aliases ?? []).join(", ") : "") ]; const about = aboutRows.length ? [table(["Поле", "Значение"], aboutRows, [420, 1080])] : [{ type: "paragraph", text: "Паспортные поля цели в источниках не описаны." }]; // В карточке шапки метрике хватает названия и цели: владелец и периодичность — // в сводной таблице раздела «Метрики и расчёт», дублировать их здесь незачем. const metricRows = metrics.map((metric) => [ plainText(metric.title), metric.typed_value ? typedValueText(metric.typed_value) : "целевое значение не задано" ]); const metricLines = metricRows.length ? [table(["Метрика", "Целевое значение"], metricRows, [960, 540])] : [{ type: "paragraph", text: "Метрики в источниках не описаны." }]; // Третья карточка отвечает на вопрос «что сейчас», а не «насколько полно извлечены данные»: // счётчики провенанса ничего не говорят читателю и уходят в профиль «аудит». const stateLines = options.profile === "audit" ? provenanceSummaryLines(counts, goal) : currentStateLines(goal); // Контракт для больших страниц: после h1 идёт верхний blocksGrid — узкая колонка с toc, // широкая с информационной панелью. Остальные карточки — вторым рядом. return [ { type: "blocksGrid", template: "minmax(240px, 300px) minmax(0, 1fr)", // В оглавление берём только разделы верхнего уровня: подзаголовки и подписи внутри // разделов там только мешают. columns: withToc ? [ [{ type: "toc", minlevel: 2, maxlevel: 2 }], [{ type: "panel", panelType: "info", title: "О цели", blocks: about.length ? about : [{ type: "paragraph", text: "—" }] }] ] : [[{ type: "panel", panelType: "info", title: "О цели", blocks: about.length ? about : [{ type: "paragraph", text: "—" }] }]] }, { type: "blocksGrid", columns: [ [{ type: "panel", panelType: "tip", title: metrics.length > 1 ? `Метрики (${metrics.length})` : "Метрика", blocks: metricLines }], [{ type: "panel", panelType: "note", title: options.profile === "audit" ? "Наполнение данных" : "Текущее состояние", blocks: stateLines }] ] } ]; } // «Что сейчас с целью»: последний результат, последнее наблюдение или индикатор. // Если ничего этого нет — прямо говорим, что оценить состояние не по чему. function currentStateLines(goal) { const lines = []; const byDate = (items, key) => [...(items ?? [])].sort((a, b) => String(b[key] ?? "").localeCompare(String(a[key] ?? ""))); const outcome = byDate(goal.outcomes, "achieved_at")[0]; if (outcome) { lines.push({ type: "paragraph", inlines: [ { type: "strong", text: "Последний результат: " }, { type: "text", text: `${outcome.title}${outcome.achieved_at ? ` (${outcome.achieved_at})` : ""}` } ] }); } const observation = byDate(goal.metrics?.baseline_observations, "observed_at")[0]; if (observation) { lines.push({ type: "paragraph", inlines: [ { type: "strong", text: "Последнее измерение: " }, { type: "text", text: `${observation.label}${observation.observed_at ? ` (${observation.observed_at})` : ""} — ${observationText(observation.values)}` } ] }); } for (const indicator of (goal.metrics?.indicator_states ?? []).slice(0, 2)) { if (!isFilled(indicator.value)) continue; lines.push({ type: "paragraph", inlines: [{ type: "text", text: truncate(indicator.value, 160) }] }); } const blockers = (goal.obstacles ?? []).filter((obstacle) => obstacle.severity === "blocker" || obstacle.severity === "major"); if (blockers.length) { lines.push({ type: "paragraph", inlines: [ { type: "strong", text: "Мешает: " }, { type: "text", text: blockers.map((obstacle) => obstacle.title).slice(0, 2).join("; ") } ] }); } if (!lines.length) { lines.push({ type: "paragraph", inlines: [ { type: "text", text: "Фактических значений, результатов и индикаторов в источниках нет — оценить текущее состояние не по чему." } ] }); } return lines; } // Ключи в `values` приходят из источника как есть (иногда латиницей: status, cause). // Если все значения текстовые, ключи ничего не добавляют — печатаем только значения. function observationText(values) { const entries = Object.entries(values ?? {}); if (!entries.length) return ""; const allText = entries.every(([, value]) => typeof value === "string"); return allText ? entries.map(([, value]) => value).join("; ") : keyValueText(values); } function provenanceSummaryLines(counts, goal) { const fillLine = []; for (const status of ["confirmed", "inferred", "requires_review", "conflicting", "missing"]) { if (!counts[status]) continue; if (fillLine.length) fillLine.push({ type: "text", text: " " }); fillLine.push(statusChip({ status }), { type: "text", text: ` ${counts[status]}` }); } return [ { type: "paragraph", inlines: fillLine.length ? fillLine : [{ type: "text", text: "нет данных" }] }, { type: "paragraph", inlines: [{ type: "text", text: `Всего полей с провенансом: ${counts.total}. Источников: ${(goal.source_refs ?? []).length}.` }] } ]; } // Паспорт остаётся только в профиле «аудит»: в публичном всё нужное уже в карточках шапки, // а вторая таблица с теми же значениями — чистый дубль. function passportBlocks(goal, { profile }) { if (profile !== "audit") return []; const identity = goal.goal_identity ?? {}; const annual = goal.timeline?.annual_target; const owner = (goal.responsibility?.contacts ?? []).find((contact) => contact.contact_type === "goal_owner"); const rows = [ ["Идентификатор", { inlines: [{ type: "code", text: identity.id ?? "" }] }], ["Период", identity.period ?? ""], ["Статус источников", SOURCE_STATUS_LABEL[identity.source_status] ?? identity.source_status ?? ""] ]; if (isFilled(identity.aliases)) rows.push(["Другие названия", (identity.aliases ?? []).join(", ")]); if (annual) rows.push(["Годовая цель", `${annual.target_text}${annual.target_value ? ` (${annual.target_value})` : ""}`]); if (owner) rows.push(["Владелец цели", `${owner.name}${owner.unit ? `, ${owner.unit}` : ""}`]); if (isFilled(goal.applicability?.target_entity_types)) { rows.push(["Область применения", entityTypeList(goal.applicability.target_entity_types)]); } rows.push(["Версия шаблона", { inlines: [{ type: "code", text: `${goal.schema_version} / ${goal.schema_minor_version}` }] }]); return [heading(2, "Паспорт"), table(["Поле", "Значение"], rows, [400, 1100])]; } // Пробелы, которые меняют решения. Голый чип «не найдено» ничего не объясняет, поэтому // каждый пробел выводится тройкой «чего нет — почему важно — что делать». const CRITICAL_GAPS = [ { id: "goal_owner", what: "Владелец цели не назначен", why: "Некому принимать решения по спорным значениям, подтверждать корректировки и отвечать за итог.", action: "Уточнить владельца цели и зафиксировать его в источнике.", missing: (goal) => !(goal.responsibility?.contacts ?? []).some((contact) => contact.contact_type === "goal_owner") }, { id: "annual_target", what: "Годовое целевое значение не задано", why: "Без него нельзя сказать, достигнута цель или нет.", action: "Взять значение из методики или запросить у владельца цели.", missing: (goal) => !goal.timeline?.annual_target && !(goal.timeline?.quarterly_targets ?? []).some((target) => /год|year|^y\d/i.test(`${target.label ?? ""} ${target.period_id ?? ""}`)) }, { id: "metric_formula", what: "Формула закрытия цели не определена", why: "Есть формулы отдельных метрик, но не описано, как из них складывается результат цели.", action: "Согласовать правило агрегации метрик с владельцем цели.", missing: (goal) => !isFilled(goal.completion?.metric_formula?.value) }, { id: "dod", what: "Definition of Done не описан", why: "Непонятно, какие свидетельства принимаются при подведении итогов.", action: "Зафиксировать условия приёмки до периода фиксации.", missing: (goal) => !isFilled(goal.completion?.dod?.value) }, { id: "applicability", what: "Правила применимости не описаны", why: "Команда не может проверить, попадает ли она под цель.", action: "Уточнить, кому цель устанавливается и какие есть исключения.", missing: (goal) => !isFilled(goal.applicability?.inclusion_rules) && !isFilled(goal.applicability?.explicit_target_sets) }, { id: "actuals", what: "Фактические значения метрик не собраны", why: "Без измерений нельзя оценить, где цель находится сейчас.", action: "Выгрузить значения с дашборда и внести их в цель.", missing: (goal) => !isFilled(goal.metrics?.baseline_observations) && !isFilled(goal.outcomes) } ]; function collectGaps(goal) { return CRITICAL_GAPS.filter((gap) => gap.missing(goal)); } function attentionBlocks(goal, { profile }) { const issues = collectIssues(goal); if (!issues.length && !collectGaps(goal).length) return []; const order = { conflicting: 0, missing: 1, requires_review: 2, inferred: 3 }; // Пробелы, уже описанные человеческим языком в блоке «Чего не хватает», здесь не повторяем. const gapWords = collectGaps(goal).map((gap) => gap.id === "goal_owner" ? "владел" : gap.id); const shown = issues .filter((issue) => profile === "audit" || issue.status !== "inferred") .filter((issue) => profile === "audit" || !gapWords.some((word) => issue.subject.toLowerCase().includes(word))) .sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9)); const gaps = collectGaps(goal); if (!shown.length && !gaps.length) return []; const counts = shown.reduce((acc, issue) => ({ ...acc, [issue.status]: (acc[issue.status] ?? 0) + 1 }), {}); const summary = Object.entries(counts) .map(([status, count]) => `${STATUS_META[status]?.label ?? status}: ${count}`).join(", "); // Строки с конфликтом подсвечиваются фоном ячейки — рендерер поддерживает // фиксированный набор цветов (#FFBDAD, #ABF5D1, #F4F5F7, #ffffff). const CELL_BG = { conflicting: "#FFBDAD", missing: "#F4F5F7", requires_review: "#ffffff", inferred: "#ffffff" }; const rows = shown.map((issue) => [ { inlines: provenance({ status: issue.status }, { profile, always: true }), bg: CELL_BG[issue.status] ?? "#ffffff" }, issue.section, truncate(issue.subject), truncate(issue.notes, 120) ]); const HEAD = profile === "audit" ? 12 : 3; const inferredRows = profile === "audit" ? rows.filter((row, index) => shown[index].status === "inferred") : []; const head = rows.slice(0, HEAD); const rest = rows.slice(HEAD); const columns = ["Статус", "Раздел", "Что именно", "Комментарий"]; const widths = [220, 280, 600, 400]; const blocks = []; // Пробелы и «что проверить» живут в одной панели: два предупреждающих блока подряд // соревнуются за внимание и дробят одну и ту же мысль. if (gaps.length) { blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: "Чего не хватает" }] }); blocks.push(table( ["Чего не хватает", "Почему это важно", "Что делать"], gaps.map((gap) => [gap.what, gap.why, gap.action]), [420, 620, 460] )); } if (shown.length) { if (gaps.length) blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: "Что проверить" }] }); blocks.push({ type: "paragraph", inlines: [{ type: "text", text: `Полей, требующих внимания: ${shown.length} (${summary}). Ниже — самое важное; остальное раскрывается по клику.` }] }); blocks.push(table(columns, head, widths)); } if (shown.length && rest.length) { blocks.push({ type: "expand", title: `Ещё ${rest.length}`, open: false, blocks: [table(columns, rest, widths)] }); } return [{ type: "panel", panelType: "warning", title: "Требует решения", showtitle: true, showicon: true, blocks }]; } function meaningBlocks(goal, options) { const meaning = goal.goal_meaning ?? {}; const blocks = [heading(2, "Смысл цели")]; const sections = [ ["Описание", meaning.description], ["Зачем нужна", meaning.business_reason], ["Что даёт", meaning.expected_effect], ["Целевое состояние", meaning.target_state] ]; // Каждый пункт смысла — один-два абзаца, отдельный заголовок для них избыточен: // подпись в самом абзаце читается так же и не засоряет оглавление. for (const [title, field] of sections) { const body = textFieldBlocks(field, { ...options, showEmpty: true, label: title }); if (!body.length) continue; blocks.push(...body); } return blocks.length > 1 ? blocks : []; } function timelineBlocks(goal, options) { const timeline = goal.timeline ?? {}; const blocks = []; // Читателю имя метрики говорит больше, чем её идентификатор. const metricTitles = new Map((goal.metrics?.primary_metrics ?? []).map((metric) => [metric.metric_id, plainText(metric.title)])); const metricCell = (id) => { if (!id) return ""; if (options.profile === "audit") return { inlines: [{ type: "code", text: id }] }; return truncate(metricTitles.get(id) ?? id, 60); }; // Целевые значения метрик по периодам живут в той же таблице сроков: отдельный раздел // для них повторял бы и период, и цель. const targets = [timeline.annual_target, ...(timeline.quarterly_targets ?? []), ...(goal.metrics?.target_values ?? [])].filter(Boolean); if (targets.length) { blocks.push(heading(2, "Сроки и закрытие")); blocks.push(table( ["Период", "Цель периода", "Значение", "Метрика", "Статус"], targets.map((target) => [ target.label ?? target.period_id, `${target.target_text}${target.scope ? ` (${target.scope})` : ""}${target.notes ? ` — ${target.notes}` : ""}`, target.target_value ?? "", metricCell(target.metric_id), statusCell(target.state, options) ]), [220, 620, 180, 260, 220] )); } if (isFilled(timeline.milestones)) { blocks.push(heading(3, "Вехи")); const milestones = [...timeline.milestones ?? []].sort((a, b) => String(a.due_date ?? "9999").localeCompare(String(b.due_date ?? "9999"))); blocks.push(table( ["Срок", "Веха", "Период", "Детали", "Статус"], milestones.map((milestone) => [ milestone.due_date ?? "", milestone.title, options.profile === "audit" ? (milestone.period_id ?? "") : "", milestone.details ?? "", statusCell(milestone.state, options) ]), [180, 420, 180, 500, 220] )); } return blocks; } function metricOwnerName(metric) { const owner = (metric.calculation_playbook?.contacts ?? []).find((contact) => contact.contact_type === "metric_owner"); return owner ? [owner.name, owner.email].filter(Boolean).join(", ") : ""; } // Одна цель может нести десятки метрик, поэтому раздел строится в два уровня: // сводная таблица по всем метрикам + детали каждой. При трёх и более метриках детали // уходят в сворачиваемый expand, иначе страница превращается в простыню. function metricsSummaryTable(metrics, options) { const withId = options.profile === "audit"; return table( withId ? ["Метрика", "ID", "Целевое значение", "Владелец метрики", "Периодичность", "Статус"] : ["Метрика", "Целевое значение", "Владелец метрики", "Периодичность", "Статус"], metrics.map((metric) => { const row = [metric.title]; if (withId) row.push({ inlines: [{ type: "code", text: metric.metric_id }] }); row.push( metric.typed_value ? typedValueText(metric.typed_value) : "", metricOwnerName(metric), metric.calculation_playbook?.refresh_cadence?.value ?? "", statusCell(metric.state, options) ); return row; }), withId ? [420, 200, 260, 300, 260, 160] : [480, 280, 340, 300, 100] ); } function metricsBlocks(goal, options) { const metrics = goal.metrics ?? {}; if (!isFilled(metrics.primary_metrics)) return []; const blocks = [heading(2, "Метрики и расчёт")]; // Сворачиваем не только по количеству метрик: одна метрика с десятком исключений // и полным playbook занимает столько же места, сколько три простые. const playbookWeight = (metric) => { const playbook = metric.calculation_playbook ?? {}; return ["data_sources", "dashboards", "access_instructions", "manual_steps", "filters", "dimension_mappings", "exclusions", "contacts"] .reduce((sum, key) => sum + (playbook[key]?.length ?? 0), 0) + (metric.calculation_notes?.length ?? 0); }; const collapse = (metrics.primary_metrics ?? []).length >= 3 || ((metrics.primary_metrics ?? []).length > 1 && (metrics.primary_metrics ?? []).some((metric) => playbookWeight(metric) > 8)); if ((metrics.primary_metrics ?? []).length > 1) { blocks.push(metricsSummaryTable(metrics.primary_metrics, options)); } for (const metric of metrics.primary_metrics) { const detail = []; const push = (...items) => detail.push(...items); if (!collapse) blocks.push(heading(3, plainText(metric.title))); if (options.profile === "audit") push({ type: "paragraph", inlines: [{ type: "text", text: "Идентификатор метрики: " }, { type: "code", text: metric.metric_id }, ...(provenance(metric.state, options).length ? [{ type: "text", text: " " }, ...provenance(metric.state, options)] : [])] }); push(...textFieldBlocks(metric.description, options)); const formulaRows = []; if (metric.numerator) formulaRows.push(["Числитель", metric.numerator.value, statusCell(metric.numerator.state, options)]); if (metric.denominator) formulaRows.push(["Знаменатель", metric.denominator.value, statusCell(metric.denominator.state, options)]); if (metric.typed_value) { formulaRows.push([ "Целевое значение", `${typedValueText(metric.typed_value)}${metric.typed_value.notes ? ` — ${metric.typed_value.notes}` : ""}`, "" ]); } if (formulaRows.length) push(table(["Элемент", "Значение", "Статус"], formulaRows, [300, 900, 300])); if (isFilled(metric.calculation_notes)) { push({ type: "paragraph", inlines: [{ type: "strong", text: "Примечания к расчёту" }] }); push({ type: "orderedList", items: metric.calculation_notes.map((note) => ({ inlines: [{ type: "text", text: note.value }, ...(provenance(note.state, options).length ? [{ type: "text", text: " " }, ...provenance(note.state, options)] : [])] })) }); } const playbook = metric.calculation_playbook; if (playbook) { const tabs = []; const dataBlocks = []; for (const source of playbook.data_sources ?? []) dataBlocks.push(...textFieldBlocks(source, options)); // Кнопка дашборда не дублируется здесь: все ссылки собраны в разделе «Куда идти». for (const dashboard of playbook.dashboards ?? []) { dataBlocks.push({ type: "paragraph", inlines: [ { type: "strong", text: "Дашборд: " }, { type: "text", text: dashboard.label ?? dashboard.url } ] }); } // access_instructions не дублируются кнопкой здесь — все ссылки собраны в разделе «Куда идти». if (dataBlocks.length) tabs.push({ label: "Источники данных", blocks: dataBlocks }); if (isFilled(playbook.manual_steps)) { tabs.push({ label: "Шаги расчёта", blocks: [{ type: "orderedList", items: playbook.manual_steps.map((step) => ({ text: [step.title, step.details ? `— ${step.details}` : "", step.role ? `(${step.role})` : ""].filter(Boolean).join(" ") })) }] }); } const filterRows = [ ...(playbook.filters ?? []).map((filter) => ["Фильтр", filter.title, filter.details ?? "", statusCell(filter.state, options)]), ...(playbook.exclusions ?? []).map((exclusion) => ["Исключение", exclusion.title, exclusion.details ?? "", statusCell(exclusion.state, options)]) ]; if (filterRows.length) { tabs.push({ label: "Фильтры и исключения", blocks: [table(["Тип", "Название", "Детали", "Статус"], filterRows, [240, 420, 620, 220])] }); } if (isFilled(playbook.dimension_mappings)) { tabs.push({ label: "Сопоставление размерностей", blocks: [table( ["Сопоставление", "Размерность", "Значение источника", "Соответствует", "Область", "Статус"], playbook.dimension_mappings.map((mapping) => [ `${plainText(mapping.label)}${mapping.notes ? ` — ${mapping.notes}` : ""}`, plainText(mapping.dimension), plainText(mapping.source_value), (mapping.mapped_to_values ?? []).join(", "), mapping.scope ?? "", statusCell(mapping.state, options) ]), [320, 220, 260, 300, 240, 160] )] }); } const regimeBlocks = []; for (const contact of playbook.contacts ?? []) { regimeBlocks.push({ type: "paragraph", inlines: [ { type: "strong", text: `${CONTACT_TYPE_LABEL[contact.contact_type] ?? contact.contact_type}: ` }, { type: "text", text: [contact.name, contact.role_details, contact.unit, contact.email, contact.phone].filter(Boolean).join(", ") } ] }); } regimeBlocks.push(...textFieldBlocks(playbook.refresh_cadence, { ...options, label: "Периодичность" })); regimeBlocks.push(...textFieldBlocks(playbook.calculation_period_cutoff, { ...options, label: "Отсечка периода" })); if (regimeBlocks.length) tabs.push({ label: "Регламент и контакты", blocks: regimeBlocks }); if (tabs.length > 1) { push({ type: "paragraph", inlines: [{ type: "strong", text: "Как считать" }] }); push({ type: "tabs", tabs }); } else if (tabs.length === 1) { // Одна вкладка — это не альтернативные представления, а просто раздел. push({ type: "paragraph", inlines: [{ type: "strong", text: `Как считать · ${tabs[0].label}` }] }); push(...tabs[0].blocks); } } if (collapse) { blocks.push({ type: "expand", title: plainText(metric.title), open: false, blocks: compact(detail) }); } else { blocks.push(...detail); } } if (false && isFilled(metrics.target_values)) { blocks.push(heading(3, "Дополнительные целевые значения")); blocks.push(table( ["Период", "Цель", "Значение", "Метрика", "Статус"], (metrics.target_values ?? []).map((target) => [ target.label ?? target.period_id, target.target_text, target.target_value ?? "", options.profile === "audit" ? { inlines: [{ type: "code", text: target.metric_id ?? "" }] } : truncate((metrics.primary_metrics ?? []).find((m) => m.metric_id === target.metric_id)?.title ?? target.metric_id ?? "", 60), statusCell(target.state, options) ]), [240, 620, 180, 240, 220] )); } if (isFilled(metrics.baseline_observations)) { blocks.push(heading(3, "Наблюдения и базовая линия")); blocks.push(table( ["Наблюдение", "Дата", "Значения", "Разрезы", "Статус"], (metrics.baseline_observations ?? []).map((observation) => [ observation.label, observation.observed_at ?? "", observationText(observation.values), observationText(observation.dimensions), statusCell(observation.state, options) ]), [400, 200, 400, 280, 220] )); } if (isFilled(metrics.indicator_states)) { const indicatorBlocks = metrics.indicator_states.flatMap((indicator) => textFieldBlocks(indicator, { ...options, showEmpty: true })); if (indicatorBlocks.length) blocks.push(heading(3, "Индикаторы"), ...indicatorBlocks); } return blocks; } // Реестр целевых сущностей рисуется одинаково и внутри вкладки «Реестры», и в линейной вёрстке. function targetSetBlocks(set, options) { const out = []; const meta = [ `Вид набора: ${SET_KIND_LABEL[set.set_kind] ?? set.set_kind}`, set.dimensions ? `разрезы: ${keyValueText(set.dimensions, options)}` : "", set.source_list_ref ? `источник: ${set.source_list_ref}` : "" ].filter(Boolean).join("; "); out.push({ type: "paragraph", inlines: [{ type: "strong", text: `${set.title}. ` }, { type: "text", text: meta }] }); out.push(...textFieldBlocks(set.description, options)); const declared = set.declared_count ?? null; const extracted = set.extracted_count ?? (set.items?.length ?? 0); if (set.extraction_status && set.extraction_status !== "complete") { out.push({ type: "panel", panelType: "warning", title: EXTRACTION_STATUS_LABEL[set.extraction_status] ?? set.extraction_status, blocks: [{ type: "paragraph", inlines: [{ type: "text", text: `${declared !== null ? `Заявлено строк: ${declared}, извлечено: ${extracted}. ` : ""}${set.extraction_notes ?? ""}` }] }] }); } if (isFilled(set.items)) { const itemRows = (set.items ?? []).map((item) => [ item.raw_name, item.ci_code ? { inlines: [{ type: "code", text: item.ci_code }] } : "", item.normalized_name ?? "", MATCH_METHOD_LABEL[item.match_method] ?? item.match_method, keyValueText(item.attributes, options), statusCell(item.state, options) ]); const itemsTable = table(["Объект", "CI", "Нормализовано", "Сопоставление", "Атрибуты", "Статус"], itemRows, [340, 200, 300, 240, 260, 160]); // Реестр на два десятка строк не должен занимать половину страницы. out.push(itemRows.length > 8 ? { type: "expand", title: `Состав набора (${itemRows.length})`, open: false, blocks: compact([itemsTable]) } : itemsTable); } return compact(out); } function applicabilityBlocks(goal, options) { const applicability = goal.applicability ?? {}; const blocks = [heading(2, "Применимость")]; blocks.push({ type: "paragraph", inlines: [ { type: "strong", text: "Типы объектов: " }, { type: "text", text: entityTypeList(applicability.target_entity_types) } ] }); // Включение, исключения и реестры — это ответы на один вопрос «попадаю ли я под цель», // но с разных сторон. От трёх наполненных групп они читаются вкладками, а не одной // таблицей с колонкой «Тип», которая повторяется в каждой строке. const ruleTable = (rules) => table( ["Правило", "Условие", "Объекты", "Статус"], (rules ?? []).map((rule) => [ rule.title, [rule.description?.value, `Условие: ${selectorText(rule.selector)}`, rule.description?.state?.notes ? `(${rule.description.state.notes})` : ""] .filter(Boolean).join(" "), entityTypeList(rule.target_entity_types), statusCell(rule.state, options) ]), [340, 700, 260, 200] ); const criteriaBlocks = isFilled(applicability.selection_criteria) ? compact([table( ["Критерий", "Детали", "Статус"], (applicability.selection_criteria ?? []).map((criterion) => [criterion.title, criterion.details ?? "", statusCell(criterion.state, options)]), [420, 860, 220] )]) : []; const setBlocks = compact((applicability.explicit_target_sets ?? []).flatMap((set) => targetSetBlocks(set, options))); const uncertainBlocks = isFilled(applicability.uncertain_cases) ? compact([table( ["Формулировка", "Почему неоднозначно", "Кандидаты", "Статус"], (applicability.uncertain_cases ?? []).map((item) => [ item.raw_text, item.reason, (item.candidate_entity_ids ?? []).join(", "), statusCell(item.state, options) ]), [400, 520, 380, 200] )]) : []; const groups = [ ["Кому устанавливается", compact([ruleTable(applicability.inclusion_rules)])], ["Исключения", compact([ruleTable(applicability.exclusion_rules)])], ["Критерии отбора", criteriaBlocks], ["Реестры", setBlocks], ["Спорные случаи", uncertainBlocks] ].filter(([, body]) => body.length); const filledGroups = groups.filter(([, body]) => compact(body).length); if (filledGroups.length >= 3) { blocks.push({ type: "tabs", tabs: filledGroups.map(([label, body]) => ({ label, blocks: compact(body) })) }); return compact(blocks); } const ruleRows = (rules, kind) => (rules ?? []).map((rule) => [ kind, rule.title, [rule.description?.value, `Условие: ${selectorText(rule.selector)}`, rule.description?.state?.notes ? `(${rule.description.state.notes})` : ""] .filter(Boolean).join(" "), entityTypeList(rule.target_entity_types), statusCell(rule.state, options) ]); const rules = [...ruleRows(applicability.inclusion_rules, "Включение"), ...ruleRows(applicability.exclusion_rules, "Исключение")]; if (rules.length) blocks.push(table(["Тип", "Правило", "Условие", "Объекты", "Статус"], rules, [220, 320, 620, 180, 160])); if (isFilled(applicability.selection_criteria)) { blocks.push(heading(3, "Критерии отбора")); blocks.push(table( ["Критерий", "Детали", "Статус"], (applicability.selection_criteria ?? []).map((criterion) => [criterion.title, criterion.details ?? "", statusCell(criterion.state, options)]), [420, 860, 220] )); } for (const set of applicability.explicit_target_sets ?? []) { blocks.push(heading(3, `Реестр: ${set.title}`), ...targetSetBlocks(set, options)); } if (isFilled(applicability.temporary_exclusions)) { blocks.push(heading(3, "Временные исключения")); blocks.push(table( ["Объект", "CI", "Тип", "Статус"], (applicability.temporary_exclusions ?? []).map((mention) => [ mention.normalized_name && mention.normalized_name !== mention.raw_text ? `${mention.normalized_name} (в источнике: ${mention.raw_text})` : mention.raw_text, mention.ci_code ? { inlines: [{ type: "code", text: mention.ci_code }] } : "", ENTITY_TYPE_LABEL[mention.entity_type] ?? mention.entity_type ?? "", statusCell(mention.state, options) ]), [500, 250, 250, 500] )); } if (isFilled(applicability.uncertain_cases)) { blocks.push({ type: "panel", panelType: "warning", title: "Неоднозначные случаи применимости", blocks: [table( ["Формулировка", "Почему неоднозначно", "Кандидаты", "Статус"], (applicability.uncertain_cases ?? []).map((item) => [ item.raw_text, item.reason, (item.candidate_entity_ids ?? []).join(", "), statusCell(item.state, options) ]), [400, 520, 380, 200] )] }); } return blocks; } function standardsBlocks(goal, options) { const standards = goal.standards_and_requirements ?? {}; const groups = [ ["Стандарты", standards.standards], ["Требования", standards.requirements], ["Процессы", standards.processes] ].filter(([, items]) => isFilled(items)); if (!groups.length) return []; const blocks = [heading(2, "Стандарты, требования и процессы")]; // Вкладки ради двух таблиц по одной строке только прячут содержимое: до трёх групп // выводим их подряд обычными подзаголовками. if (groups.length < 3) { for (const [label, items] of groups) { blocks.push(heading(3, label)); blocks.push(table( ["Название", "Стандарт", "Содержание", "Статус"], items.map((item) => [ plainText(item.title), item.standard_name ?? "", `${item.requirement_text?.value ?? ""}${item.requirement_text?.state?.notes ? ` (${item.requirement_text.state.notes})` : ""}`, statusCell(item.requirement_text?.state?.status && item.requirement_text.state.status !== "confirmed" ? item.requirement_text.state : item.state, options) ]), [340, 240, 700, 220] )); } return blocks; } blocks.push({ type: "tabs", tabs: groups.map(([label, items]) => ({ label, blocks: [ table( ["Название", "Стандарт", "Содержание", "Статус"], items.map((item) => [ plainText(item.title), item.standard_name ?? "", `${item.requirement_text?.value ?? ""}${item.requirement_text?.state?.notes ? ` (${item.requirement_text.state.notes})` : ""}`, statusCell(item.requirement_text?.state?.status && item.requirement_text.state.status !== "confirmed" ? item.requirement_text.state : item.state, options) ]), [340, 240, 700, 220] ), // ссылки стандартов и требований собраны в разделе «Куда идти» ] })) }); return blocks; } // Действия — таблица, а не taskList: JSON не хранит выполнение, а чек-лист с вечно снятыми // галочками выглядит как заброшенный трекер. function actionsTable(actions, options) { return table( ["Действие", "Кто делает", "Детали", "Статус"], actions.map((action) => [ action.title, action.role ?? "", action.details ?? "", statusCell(action.state, options) ]), [420, 280, 620, 180] ); } function executionBlocks(goal, options) { const execution = goal.execution ?? {}; const groups = [ ["Обязательные", execution.required_actions], ["Рекомендуемые", execution.recommended_actions], ...(execution.role_actions ?? []).map((roleActions) => [`Роль: ${roleActions.role}`, roleActions.actions]) ].filter(([, actions]) => isFilled(actions)); if (!groups.length) return []; // Обязательные, рекомендуемые и ролевые действия — параллельные взгляды на один список. // От трёх групп вкладки читаются лучше, чем три подряд идущие таблицы; меньше — заголовками. if (groups.length >= 3) { return [ heading(2, "Что нужно сделать"), { type: "tabs", tabs: groups.map(([label, actions]) => ({ label, blocks: compact([actionsTable(actions, options)]) })) } ]; } const blocks = [heading(2, "Что нужно сделать")]; for (const [label, actions] of groups) { blocks.push(heading(3, `${label} действия`), actionsTable(actions, options)); } return compact(blocks); } // Заголовки действий, уже перечисленных в «Что нужно сделать»: чтобы не повторять их // третий раз внутри блокеров. function plannedActionTitles(goal) { const execution = goal.execution ?? {}; const all = [ ...(execution.required_actions ?? []), ...(execution.recommended_actions ?? []), ...(execution.role_actions ?? []).flatMap((role) => role.actions ?? []) ]; return new Set(all.map((action) => String(action.title ?? "").trim().toLowerCase())); } function implementationBlocks(goal, options) { const details = goal.implementation_details ?? {}; if (!isFilled(details.areas) && !isFilled(details.notes)) return []; // По контракту expand — для справочной и второстепенной информации. Детали реализации // нужны исполнителю в момент работы, а не при чтении страницы сверху вниз. const inner = []; for (const area of details.areas ?? []) { inner.push({ type: "paragraph", inlines: [{ type: "strong", text: area.area }] }); inner.push(actionsTable(area.items ?? [], options)); } for (const note of details.notes ?? []) inner.push(...textFieldBlocks(note, options)); const body = compact(inner); if (!body.length) return []; const count = (details.areas ?? []).reduce((sum, area) => sum + (area.items?.length ?? 0), 0); return [ heading(2, "Детали реализации"), { type: "expand", title: count ? `Как это делается (${count})` : "Как это делается", open: false, blocks: body } ]; } function completionBlocks(goal, options) { const completion = goal.completion ?? {}; const has = ["dod", "metric_formula", "acceptance_notes"].some((key) => completion[key]) || isFilled(completion.closing_artifacts) || isFilled(completion.evidence_examples); if (!has) return []; // Закрытие — это продолжение разговора о сроках: когда фиксируем, по каким признакам // считаем достигнутым. Отдельный раздел верхнего уровня для двух абзацев избыточен. const blocks = [heading(3, "Закрытие цели")]; if (completion.dod) blocks.push(...textFieldBlocks(completion.dod, { ...options, label: "Definition of Done" })); // Пустой code-блок выглядит как ошибка рендера, поэтому формула выводится только когда она есть; // её отсутствие уже объяснено в блоке «Чего не хватает». if (isFilled(completion.metric_formula?.value)) { blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: "Формула закрытия" }] }); blocks.push({ type: "code", code: completion.metric_formula.value }); const prov = provenance(completion.metric_formula.state, options); if (prov.length) blocks.push({ type: "paragraph", inlines: prov }); } if (completion.acceptance_notes) blocks.push(...textFieldBlocks(completion.acceptance_notes, { ...options, label: "Приёмка" })); const artifacts = [ ...(completion.closing_artifacts ?? []).map((artifact) => ["Закрывающий артефакт", artifact]), ...(completion.evidence_examples ?? []).map((artifact) => ["Пример подтверждения", artifact]) ]; if (artifacts.length) { blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: "Артефакты и подтверждения" }] }); blocks.push(table( ["Вид", "Название", "Детали", "Статус"], artifacts.map(([kind, artifact]) => [kind, artifact.title, artifact.details ?? "", statusCell(artifact.state, options)]), [320, 500, 480, 200] )); } return blocks; } function responsibilityBlocks(goal, options) { const responsibility = goal.responsibility ?? {}; if (!isFilled(responsibility.contacts) && !isFilled(responsibility.open_questions)) return []; const blocks = [heading(2, "Кто отвечает и куда обращаться")]; if (isFilled(responsibility.contacts)) { blocks.push(table( options.profile === "audit" ? ["Роль", "Кто", "Тип", "Подразделение", "Контакт", "Статус"] : ["Роль", "Кто", "Подразделение", "Контакт", "Статус"], (responsibility.contacts ?? []).map((contact) => [ CONTACT_TYPE_LABEL[contact.contact_type] ?? contact.contact_type, `${contact.name}${contact.role_details ? ` — ${contact.role_details}` : ""}`, ...(options.profile === "audit" ? [ASSIGNEE_KIND_LABEL[contact.assignee_kind] ?? contact.assignee_kind] : []), contact.unit ?? "", [contact.email, contact.phone].filter(Boolean).join(", "), statusCell(contact.state, options) ]), options.profile === "audit" ? [240, 420, 180, 280, 260, 120] : [260, 480, 320, 320, 120] )); } const questions = [...(responsibility.open_questions ?? []), ...(goal.knowledge?.open_questions ?? [])]; if (questions.length) { blocks.push({ type: "panel", panelType: "note", title: "Открытые вопросы", blocks: [table( ["Вопрос", "Почему открыт", "Статус"], questions.map((question) => [question.question, question.reason, statusCell(question.state, options)]), [600, 680, 220] )] }); } return blocks; } function obstaclesBlocks(goal, options) { if (!isFilled(goal.obstacles)) return []; const blocks = [heading(2, "Блокеры и риски")]; const planned = plannedActionTitles(goal); const isMajor = (obstacle) => obstacle.severity === "blocker" || obstacle.severity === "major" || !obstacle.severity; const minor = []; const panelType = (severity) => (severity === "blocker" || severity === "major" ? "warning" : severity === "info" ? "info" : "note"); for (const obstacle of goal.obstacles) { const inner = []; // В шапке блокера показываем значимость самого риска. Провенанс («подтверждено») // здесь читается как «всё хорошо», поэтому он остаётся только в аудиторском профиле. inner.push({ type: "paragraph", inlines: [ { type: "text", text: `Вид: ${OBSTACLE_KIND_LABEL[obstacle.kind] ?? obstacle.kind}; область: ${SCOPE_LABEL[obstacle.scope] ?? obstacle.scope}${obstacle.severity ? `; значимость: ${SEVERITY_LABEL[obstacle.severity] ?? obstacle.severity}` : ""}` }, ...(options.profile === "audit" ? [{ type: "text", text: " " }, ...provenance(obstacle.state, { ...options, always: true })] : []) ] }); inner.push(...textFieldBlocks(obstacle.description, obstacle.description?.state?.status === obstacle.state?.status ? { ...options, profile: "public" } : options)); if (obstacle.impact) { const impactRows = []; if (obstacle.impact.risk) impactRows.push(["Риск", obstacle.impact.risk]); if (obstacle.impact.potential_reward) impactRows.push(["Выигрыш от решения", obstacle.impact.potential_reward]); if (impactRows.length) inner.push(table(["Аспект", "Описание"], impactRows, [400, 1100])); } if (isFilled(obstacle.mitigation)) { const fresh = obstacle.mitigation.filter((action) => !planned.has(String(action.title ?? "").trim().toLowerCase())); inner.push({ type: "paragraph", inlines: [{ type: "strong", text: "Что делать" }] }); if (fresh.length) { inner.push(actionsTable(fresh, options)); } if (fresh.length !== obstacle.mitigation.length) { inner.push({ type: "paragraph", inlines: [{ type: "em", text: "Остальные шаги перечислены в разделе «Что нужно сделать»." }] }); } } if (options.profile === "audit" && isFilled(obstacle.affected_field_paths)) { inner.push({ type: "paragraph", inlines: [ { type: "text", text: "Затронутые поля: " }, ...obstacle.affected_field_paths.flatMap((path, index) => (index ? [{ type: "text", text: ", " }] : []).concat([{ type: "code", text: path }])) ] }); } const panel = { type: "panel", panelType: panelType(obstacle.severity), title: obstacle.title, showtitle: true, showicon: true, blocks: compact(inner) }; if (isMajor(obstacle)) blocks.push(panel); else minor.push(panel); } // Стена из панелей утомляет: незначительное прячем, но не выбрасываем. if (minor.length) { blocks.push({ type: "expand", title: `Незначительные риски (${minor.length})`, open: false, blocks: minor }); } return blocks; } function relationsBlocks(goal, options) { if (!isFilled(goal.goal_relations)) return []; return [ heading(2, "Связи с другими целями"), table( ["Связь", "Цель", "Описание", "Статус"], (goal.goal_relations ?? []).map((relation) => [ RELATION_LABEL[relation.relation_type] ?? relation.relation_type, `${relation.object_goal_title ?? ""} (${relation.object_goal_id})`, relation.description?.value ?? "", statusCell(relation.state, options) ]), [240, 460, 580, 220] ) ]; } function outcomesBlocks(goal, options) { if (!isFilled(goal.outcomes)) return []; return [ heading(2, "Результаты"), table( ["Результат", "Период", "Дата", "Значение", "Метрика", "Статус"], (goal.outcomes ?? []).map((outcome) => [ `${outcome.title}${outcome.description?.value ? ` — ${outcome.description.value}` : ""}`, outcome.period_id ?? "", outcome.achieved_at ?? "", outcome.typed_value ? typedValueText(outcome.typed_value) : formatScalar(outcome.achieved_value), options.profile === "audit" ? (outcome.linked_metric_id ? { inlines: [{ type: "code", text: outcome.linked_metric_id }] } : "") : truncate((goal.metrics?.primary_metrics ?? []).find((m) => m.metric_id === outcome.linked_metric_id)?.title ?? "", 50), statusCell(outcome.state, options) ]), [520, 180, 180, 240, 240, 140] ) ]; } // Внутренние идентификаторы источников нужны аудитору; читателю достаточно раздела «Куда идти». function sourcesBlocks(goal, options) { if (options.profile !== "audit") return []; const blocks = [heading(2, "Источники")]; blocks.push({ type: "paragraph", inlines: [{ type: "text", text: "Сегменты исходного текста, из которых собрана страница. Ссылки на внешние документы — в разделе «Куда идти»." }] }); blocks.push(table( ["ID", "Тип", "Название", "Где лежит", "Собрано"], (goal.source_refs ?? []).map((source) => [ { inlines: [{ type: "code", text: source.id }] }, SOURCE_TYPE_LABEL[source.type] ?? source.type, source.title, [source.path, source.url, source.locator].filter(Boolean).join(" · "), source.collected_at ?? "" ]), [180, 180, 480, 460, 200] )); return blocks; } function mentionsBlocks(goal, options) { const mentions = goal.entity_mentions ?? {}; if (!isFilled(mentions.raw_mentions) && !isFilled(mentions.unresolved_mentions) && !isFilled(mentions.normalization_notes)) return []; const blocks = []; if (isFilled(mentions.raw_mentions)) { blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: "Распознанные" }] }); blocks.push(table( ["Упоминание", "Нормализовано", "Тип", "CI", "Метод", "Статус"], (mentions.raw_mentions ?? []).map((mention) => [ mention.raw_text, mention.normalized_name ?? "", ENTITY_TYPE_LABEL[mention.entity_type] ?? "", mention.ci_code ? { inlines: [{ type: "code", text: mention.ci_code }] } : "", MATCH_METHOD_LABEL[mention.match_method] ?? mention.match_method, statusCell(mention.state, options) ]), [300, 300, 200, 200, 300, 200] )); } if (isFilled(mentions.unresolved_mentions)) { blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: "Неразрешённые" }] }); blocks.push(table( ["Упоминание", "Причина", "Кандидаты", "Статус"], (mentions.unresolved_mentions ?? []).map((item) => [ item.raw_text, item.reason, (item.candidate_entity_ids ?? []).join(", "), statusCell(item.state, options) ]), [340, 460, 500, 200] )); } if (isFilled(mentions.normalization_notes)) { blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: "Заметки нормализации" }] }); blocks.push({ type: "orderedList", items: (mentions.normalization_notes ?? []).map((note) => ({ text: note })) }); } return compact(blocks); } function auditBlocksLegacy(goal, options) { if (options.profile !== "audit") return []; const blocks = []; if (isFilled(goal.source_coverage)) { blocks.push(heading(2, "Аудит: покрытие источников")); blocks.push(table( ["Источник", "Покрытие", "Использован для", "Примечание", "Статус"], (goal.source_coverage ?? []).map((coverage) => [ { inlines: [{ type: "code", text: coverage.source_ref_id }] }, COVERAGE_LABEL[coverage.coverage_status] ?? coverage.coverage_status, (coverage.used_for ?? []).join(", "), coverage.notes ?? "", statusCell(coverage.state, options) ]), [200, 300, 400, 400, 200] )); } const mentions = goal.entity_mentions ?? {}; if (isFilled(mentions.raw_mentions) || isFilled(mentions.unresolved_mentions) || isFilled(mentions.normalization_notes)) { blocks.push(heading(2, "Аудит: упоминания сущностей")); const tabs = []; if (isFilled(mentions.raw_mentions)) { tabs.push({ label: "Распознанные", blocks: [table( ["Упоминание", "Нормализовано", "Тип", "CI", "Метод", "Статус"], (mentions.raw_mentions ?? []).map((mention) => [ mention.raw_text, mention.normalized_name ?? "", ENTITY_TYPE_LABEL[mention.entity_type] ?? "", mention.ci_code ? { inlines: [{ type: "code", text: mention.ci_code }] } : "", MATCH_METHOD_LABEL[mention.match_method] ?? mention.match_method, statusCell(mention.state, options) ]), [300, 300, 200, 200, 300, 200] )] }); } if (isFilled(mentions.unresolved_mentions)) { tabs.push({ label: "Неразрешённые", blocks: [table( ["Упоминание", "Причина", "Кандидаты", "Статус"], (mentions.unresolved_mentions ?? []).map((item) => [ item.raw_text, item.reason, (item.candidate_entity_ids ?? []).join(", "), statusCell(item.state, options) ]), [340, 460, 500, 200] )] }); } if (isFilled(mentions.normalization_notes)) { tabs.push({ label: "Заметки нормализации", blocks: [{ type: "orderedList", items: (mentions.normalization_notes ?? []).map((note) => ({ text: note })) }] }); } blocks.push({ type: "tabs", tabs }); } if (isFilled(goal.knowledge?.source_quotes)) { blocks.push(heading(2, "Аудит: цитаты из источников")); blocks.push({ type: "expand", title: `Цитаты (${(goal.knowledge?.source_quotes ?? []).length})`, open: false, blocks: [table( ["ID", "Источник", "Цитата", "Трактовка", "Поле"], goal.knowledge.source_quotes.map((quote) => [ { inlines: [{ type: "code", text: quote.quote_id }] }, { inlines: [{ type: "code", text: quote.source_ref_id }] }, quote.quote, quote.interpretation, quote.field_path ? { inlines: [{ type: "code", text: quote.field_path }] } : "" ]), [160, 160, 520, 420, 240] )] }); } return blocks; } /* ------------------------------------------------------------------- main */ // table() может свернуться в абзац или вовсе исчезнуть (если строк нет), // поэтому финальная сборка отбрасывает пустые места. function compact(blocks) { return blocks.filter(Boolean); } // Аудиторский хвост — одна секция с вкладками, а не четыре отдельных раздела: // паспорт, источники с покрытием, упоминания и цитаты читают по одному, а не подряд. function auditTailBlocks(goal, options) { if (options.profile !== "audit") return []; const tabs = []; const passport = passportBlocks(goal, options).filter((block) => block.type !== "heading"); if (passport.length) tabs.push({ label: "Паспорт", blocks: compact(passport) }); const sources = sourcesBlocks(goal, options).filter((block) => block.type !== "heading"); const coverage = coverageTable(goal, options); if (sources.length || coverage) tabs.push({ label: "Источники и покрытие", blocks: compact([...sources, coverage]) }); const mentions = mentionsBlocks(goal, options); if (mentions.length) tabs.push({ label: "Упоминания сущностей", blocks: compact(mentions) }); const quotes = quoteBlocks(goal, options); if (quotes.length) tabs.push({ label: "Цитаты", blocks: compact(quotes) }); if (!tabs.length) return []; return [heading(2, "Аудиторские данные"), { type: "tabs", tabs }]; } function coverageTable(goal, options) { if (!isFilled(goal.source_coverage)) return null; return table( ["Источник", "Покрытие", "Использован для", "Примечание", "Статус"], (goal.source_coverage ?? []).map((coverage) => [ { inlines: [{ type: "code", text: coverage.source_ref_id }] }, COVERAGE_LABEL[coverage.coverage_status] ?? coverage.coverage_status, (coverage.used_for ?? []).join(", "), coverage.notes ?? "", statusCell(coverage.state, options) ]), [200, 300, 400, 400, 200] ); } // Цитата — это текст, а не строка таблицы: в пятиколоночной ячейке она превращается // в столбик из двух слов высотой в пол-экрана. function quoteBlocks(goal, options) { const quotes = goal.knowledge?.source_quotes ?? []; if (!quotes.length) return []; const blocks = []; for (const quote of quotes) { blocks.push({ type: "paragraph", inlines: [ { type: "strong", text: `${quote.quote_id}` }, { type: "text", text: " · источник " }, { type: "code", text: quote.source_ref_id }, ...(quote.field_path ? [{ type: "text", text: " · поле " }, { type: "code", text: quote.field_path }] : []) ] }); blocks.push({ type: "paragraph", inlines: [{ type: "em", text: `«${quote.quote}»` }] }); blocks.push({ type: "paragraph", inlines: [{ type: "text", text: quote.interpretation }] }); } return [{ type: "expand", title: `Цитаты из источников (${quotes.length})`, open: false, blocks }]; } function goalToLayout(goal, { profile = "public" } = {}) { const options = { profile }; const title = goal.goal_identity?.title?.value ?? "Цель"; // Порядок разделов идёт по вопросам читателя: что это → действует ли на меня → // что делать → как считают → когда оценивают → кому писать → чем регламентировано → // что мешает → куда идти. Справочное и аудиторское уходит за черту в конец. const body = compact([ ...attentionBlocks(goal, options), ...meaningBlocks(goal, options), ...applicabilityBlocks(goal, options), ...executionBlocks(goal, options), ...metricsBlocks(goal, options), ...timelineBlocks(goal, options), ...completionBlocks(goal, options), ...responsibilityBlocks(goal, options), ...standardsBlocks(goal, options), ...implementationBlocks(goal, options), ...obstaclesBlocks(goal, options), ...outcomesBlocks(goal, options), ...relationsBlocks(goal, options), ...linksBlocks(goal, options) ]); // Оглавление нужно, когда по странице действительно есть куда прыгать. const sectionCount = body.filter((block) => block.type === "heading" && block.level === 2).length; const operational = [...headerCardBlocks(goal, options, { withToc: sectionCount >= 5 }), ...body]; const reference = auditTailBlocks(goal, options); const blocks = compact([ { type: "heading", level: 1, text: title, slice: true }, ...operational, ...(reference.length ? [{ type: "hr" }, ...compact(reference)] : []) ]); return { $schema: "wiki-layout-v1", blocks }; } // ===== FILE: src/converters/goal-diff.js ===== // Сверка двух версий goal-template.v1: что добавилось, что изменилось и — главное — что // исчезло. LLM в режиме обновления возвращает документ целиком, поэтому потеря куска данных // выглядит как обычный ответ; поймать её может только детерминированное сравнение. // // diffGoals(previous, next) -> { changes, summary } const ID_KEYS = [ "id", "source_ref_id", "metric_id", "period_id", "quote_id", "rule_id", "set_id", "item_id", "mention_id", "criterion_id", "requirement_id", "obstacle_id", "relation_id", "outcome_id" ]; const LABEL_KEYS = ["title", "label", "name", "question", "raw_text", "raw_name", "area", "role", "url", "value"]; const SECTION_LABEL = { goal_identity: "Паспорт", source_refs: "Источники", source_coverage: "Покрытие источников", goal_meaning: "Смысл цели", timeline: "Сроки", metrics: "Метрики", standards_and_requirements: "Стандарты и требования", applicability: "Применимость", entity_mentions: "Упоминания сущностей", responsibility: "Ответственные", execution: "Действия", implementation_details: "Детали реализации", completion: "Закрытие", knowledge: "Знания", obstacles: "Блокеры", goal_relations: "Связи целей", outcomes: "Результаты" }; // Элементы массивов сопоставляются по стабильному идентификатору из схемы, а когда его нет — // по человекочитаемому полю. Иначе перестановка элементов читалась бы как «всё удалено и заново создано». function itemKey(item, index) { if (item === null || typeof item !== "object") return `#${index}:${String(item)}`; for (const key of ID_KEYS) { if (typeof item[key] === "string" && item[key]) return `${key}=${item[key]}`; } for (const key of LABEL_KEYS) { if (typeof item[key] === "string" && item[key].trim()) return `${key}=${item[key].trim().slice(0, 80)}`; } return `#${index}`; } function isPlainObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function subjectOf(value, fallback) { if (isPlainObject(value)) { for (const key of LABEL_KEYS) { if (typeof value[key] === "string" && value[key].trim()) return value[key].trim(); } } if (typeof value === "string" && value.trim()) return value.trim(); return fallback; } function shortValue(value) { if (value === undefined) return ""; if (value === null) return "null"; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); if (Array.isArray(value)) return `[${value.length} элементов]`; if (isPlainObject(value)) { const subject = subjectOf(value, ""); return subject || `{${Object.keys(value).length} полей}`; } return String(value); } // Статус провенанса меняется чаще всего и меняется осмысленно, поэтому он выносится отдельно. function statusOf(value) { return isPlainObject(value) && isPlainObject(value.state) ? value.state.status : undefined; } function diffGoals(previous, next) { const changes = []; function record(kind, path, section, subject, before, after, note) { changes.push({ kind, path, section: SECTION_LABEL[section] ?? section, subject, before, after, note }); } function walk(before, after, path, section) { if (Array.isArray(before) || Array.isArray(after)) { const beforeList = Array.isArray(before) ? before : []; const afterList = Array.isArray(after) ? after : []; const beforeMap = new Map(beforeList.map((item, index) => [itemKey(item, index), item])); const afterMap = new Map(afterList.map((item, index) => [itemKey(item, index), item])); for (const [key, item] of beforeMap) { if (!afterMap.has(key)) { record("removed", `${path}/${key}`, section, subjectOf(item, key), shortValue(item), "", "элемент исчез из документа"); } } for (const [key, item] of afterMap) { if (!beforeMap.has(key)) { record("added", `${path}/${key}`, section, subjectOf(item, key), "", shortValue(item)); } else { walk(beforeMap.get(key), item, `${path}/${key}`, section); } } return; } if (isPlainObject(before) || isPlainObject(after)) { const beforeObject = isPlainObject(before) ? before : {}; const afterObject = isPlainObject(after) ? after : {}; const beforeStatus = statusOf(before); const afterStatus = statusOf(after); if (beforeStatus && afterStatus && beforeStatus !== afterStatus) { record("status", `${path}/state/status`, section, subjectOf(after, path), beforeStatus, afterStatus); } for (const key of new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)])) { if (key === "state" && (beforeStatus || afterStatus)) { // статус уже учтён выше; остальные поля state (источники, заметки) не шумим continue; } walk(beforeObject[key], afterObject[key], `${path}/${key}`, section); } return; } if (before === undefined && after !== undefined) { record("added", path, section, path.split("/").filter(Boolean).slice(-2).join(" · "), "", shortValue(after)); return; } if (before !== undefined && after === undefined) { record("removed", path, section, path.split("/").filter(Boolean).slice(-2).join(" · "), shortValue(before), "", "значение исчезло"); return; } if (before !== after) { record("changed", path, section, path.split("/").filter(Boolean).slice(-2).join(" · "), shortValue(before), shortValue(after)); } } const sections = new Set([...Object.keys(previous ?? {}), ...Object.keys(next ?? {})]); for (const section of sections) { if (section.startsWith("$") || section === "schema_version" || section === "schema_minor_version") continue; walk(previous?.[section], next?.[section], `/${section}`, section); } const summary = { added: changes.filter((change) => change.kind === "added").length, changed: changes.filter((change) => change.kind === "changed").length, removed: changes.filter((change) => change.kind === "removed").length, status: changes.filter((change) => change.kind === "status").length, sourcesBefore: (previous?.source_refs ?? []).length, sourcesAfter: (next?.source_refs ?? []).length }; summary.newSources = (next?.source_refs ?? []) .filter((source) => !(previous?.source_refs ?? []).some((old) => old.id === source.id)) .map((source) => `${source.id}: ${source.title}`); // Идентификаторы источников обязаны быть стабильными: если старый id исчез или сменил // название, провенанс всех полей, которые на него ссылались, становится ложным. summary.brokenSources = (previous?.source_refs ?? []) .filter((old) => { const same = (next?.source_refs ?? []).find((source) => source.id === old.id); return !same || same.title !== old.title; }) .map((old) => `${old.id}: ${old.title}`); return { changes, summary }; } // Потери и переписанные источники — то, ради чего сверка и делается. function riskyChanges(diff) { return diff.changes.filter((change) => change.kind === "removed"); } // ===== FILE: src/converters/markdown-to-wiki.js ===== function mdInlineToWiki(text, depth = 0) { if (text === "" || text == null) return ""; if (depth > 64) return escapeHtml(String(text)); const out = []; let i = 0; const src = String(text); function emitText(value) { out.push(escapeHtml(value)); } function readDelim(delim) { let close = src.indexOf(delim, i + delim.length); if (close < 0) return null; const ch = delim[0]; while (close + delim.length < src.length && src[close + delim.length] === ch) { close += 1; } return { inner: src.slice(i + delim.length, close), end: close + delim.length }; } while (i < src.length) { const ch = src[i]; if (ch === "\\" && i + 1 < src.length) { const next = src[i + 1]; if ("\\*_`[]~".includes(next)) { emitText(next); i += 2; continue; } } if (ch === "`") { const close = src.indexOf("`", i + 1); if (close > i) { out.push(`<code data-set="inline">${escapeHtml(src.slice(i + 1, close))}</code>`); i = close + 1; continue; } } if (ch === "[") { const closeBracket = src.indexOf("]", i + 1); if (closeBracket > i && src[closeBracket + 1] === "(") { const closeParen = src.indexOf(")", closeBracket + 2); if (closeParen > closeBracket + 1) { const label = src.slice(i + 1, closeBracket); const rawUrl = src.slice(closeBracket + 2, closeParen); const safeUrl = /^\s*(?:javascript|data|vbscript):/i.test(rawUrl) ? "#" : rawUrl; out.push(`<a href="${escapeHtml(safeUrl)}">${mdInlineToWiki(label, depth + 1)}</a>`); i = closeParen + 1; continue; } } } if (ch === "*" || ch === "_") { const doubled = src.startsWith(ch + ch, i); if (doubled) { const result = readDelim(ch + ch); if (result) { out.push(`<strong>${mdInlineToWiki(result.inner, depth + 1)}</strong>`); i = result.end; continue; } } else { const result = readDelim(ch); if (result) { out.push(`<em>${mdInlineToWiki(result.inner, depth + 1)}</em>`); i = result.end; continue; } } } if (ch === "~" && src.startsWith("~~", i)) { const result = readDelim("~~"); if (result) { out.push(`<s>${mdInlineToWiki(result.inner, depth + 1)}</s>`); i = result.end; continue; } } emitText(ch); i += 1; } return out.join(""); } function mdParaTag(content) { return `<p data-id="${layoutId()}" style="text-align: justify; margin-left: 0px !important;">${content}</p>`; } function mdListItemMatch(line) { const bullet = line.match(/^(\s*)([-+*])\s+(.*)$/); if (bullet) { const indent = bullet[1].length; const taskMatch = bullet[3].match(/^\[( |x|X)\]\s+(.*)$/); return { kind: "ul", indent, marker: bullet[2], isTask: Boolean(taskMatch), checked: taskMatch ? taskMatch[1].toLowerCase() === "x" : false, text: taskMatch ? taskMatch[2] : bullet[3] }; } const ordered = line.match(/^(\s*)(\d+)\.\s+(.*)$/); if (ordered) { return { kind: "ol", indent: ordered[1].length, marker: ordered[2], isTask: false, checked: false, text: ordered[3] }; } return null; } function mdGroupListItems(items) { const root = []; const stack = [{ indent: -1, items: root }]; for (const item of items) { while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) { stack.pop(); } const parent = stack[stack.length - 1]; const node = { ...item, children: [] }; parent.items.push(node); stack.push({ indent: item.indent, items: node.children }); } return root; } function mdEmitTaskList(items) { const inner = items.map((item) => { const checkedAttr = item.checked ? ' checked=""' : ""; const dataChecked = item.checked ? "true" : "false"; const childrenHtml = item.children.length ? mdEmitListNodes(item.children) : ""; return `<li data-id="${layoutId()}" data-checked="${dataChecked}" data-type="taskItem" style="display: flex; align-items: flex-start; gap: 0.5rem;"><label style="display: flex; align-items: center; margin-top: 0.25rem; pointer-events: none;"><input type="checkbox" disabled=""${checkedAttr}><span></span></label><div>${mdParaTag(mdInlineToWiki(item.text))}${childrenHtml}</div></li>`; }).join(""); return `<ul data-id="${layoutId()}" data-type="taskList" style="text-align: justify; margin-left: 0px !important;">${inner}</ul>`; } function mdEmitListNodes(items) { if (!items.length) return ""; const kind = items[0].kind; const allTask = kind === "ul" && items.every((item) => item.isTask); if (allTask) { return mdEmitTaskList(items); } const inner = items.map((item) => { const visibleText = item.isTask ? `[${item.checked ? "x" : " "}] ${item.text}` : item.text; const childrenHtml = item.children.length ? mdEmitListNodes(item.children) : ""; if (kind === "ol") { return `<li class="listItem_14jqc" data-id="${layoutId()}">${mdParaTag(mdInlineToWiki(visibleText))}${childrenHtml}</li>`; } return `<li data-id="${layoutId()}">${mdParaTag(mdInlineToWiki(visibleText))}${childrenHtml}</li>`; }).join(""); if (kind === "ol") { return `<ol class="orderedList_14jqc" data-id="${layoutId()}">${inner}</ol>`; } return `<ul data-id="${layoutId()}">${inner}</ul>`; } function mdEmitTable(headerCells, bodyRows) { const colCount = Math.max(headerCells.length, ...bodyRows.map((row) => row.length)); const widths = layoutWidths(undefined, colCount); const tableId = layoutId(); const headerCellHtml = Array.from({ length: colCount }, (_, idx) => { const text = headerCells[idx] ?? ""; return `<th data-id="${layoutId()}" colspan="1" rowspan="1" backgroundcolor="var(--editor-highlight-bgc)"><span>${mdParaTag(mdInlineToWiki(text))}</span></th>`; }).join(""); const bodyHtml = bodyRows.map((row, rowIdx) => { const cellsHtml = Array.from({ length: colCount }, (_, idx) => { const text = row[idx] ?? ""; return `<td data-id="${layoutId()}" colspan="1" rowspan="1" data-bgc="#ffffff" style="background-color: rgb(255, 255, 255);">${mdParaTag(mdInlineToWiki(text))}</td>`; }).join(""); return `<tr data-id="${layoutId()}" data-original-row-index="${rowIdx}">${cellsHtml}</tr>`; }).join(""); return `<section data-id="${layoutId()}" data-table-id="${tableId}" columnwidths="${widths}" numberedactive="false" data-type="table"><table><tbody><tr data-id="${layoutId()}">${headerCellHtml}</tr>${bodyHtml}</tbody></table></section>`; } function mdSplitTableRow(line) { const trimmed = line.replace(/^\s*\|/, "").replace(/\|\s*$/, ""); const cells = []; let current = ""; let escaped = false; for (const ch of trimmed) { if (escaped) { current += ch; escaped = false; continue; } if (ch === "\\") { escaped = true; continue; } if (ch === "|") { cells.push(current.trim()); current = ""; continue; } current += ch; } cells.push(current.trim()); return cells; } function mdParseBlocks(lines) { const out = []; let i = 0; while (i < lines.length) { const raw = lines[i]; if (!raw.trim()) { i += 1; continue; } const fence = raw.match(/^(\s*)(`{3,}|~{3,})(.*)$/); if (fence) { const closer = fence[2]; const buf = []; let j = i + 1; while (j < lines.length && !lines[j].trim().startsWith(closer)) { buf.push(lines[j]); j += 1; } out.push(`<pre data-set="highlighted-code" data-id="${layoutId()}"><code>${escapeHtml(buf.join("\n"))}</code></pre>`); i = j < lines.length ? j + 1 : j; continue; } const heading = raw.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/); if (heading) { const level = heading[1].length; out.push(`<h${level} data-id="${layoutId()}">${mdInlineToWiki(heading[2])}</h${level}>`); i += 1; continue; } if (/^\s{0,3}(-{3,}|\*{3,}|_{3,})\s*$/.test(raw)) { out.push("<hr>"); i += 1; continue; } if (/^\s*>/.test(raw)) { const buf = []; while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, "")); i += 1; } out.push(`<blockquote data-id="${layoutId()}">${mdParseBlocks(buf)}</blockquote>`); continue; } if (raw.includes("|") && i + 1 < lines.length && /^\s*\|?\s*:?-{1,}/.test(lines[i + 1])) { const headerCells = mdSplitTableRow(raw); const delim = mdSplitTableRow(lines[i + 1]); if (delim.every((cell) => /^:?-{1,}:?$/.test(cell.trim()))) { const bodyRows = []; let j = i + 2; while (j < lines.length && lines[j].trim() && lines[j].includes("|")) { bodyRows.push(mdSplitTableRow(lines[j])); j += 1; } out.push(mdEmitTable(headerCells, bodyRows)); i = j; continue; } } const firstListItem = mdListItemMatch(raw); if (firstListItem) { const items = []; while (i < lines.length) { const item = mdListItemMatch(lines[i]); if (!item) break; items.push(item); i += 1; } const grouped = mdGroupListItems(items); out.push(mdEmitListNodes(grouped)); continue; } const paraBuf = []; while (i < lines.length && lines[i].trim() && !/^(`{3,}|~{3,}|#{1,6}\s|\s{0,3}(-{3,}|\*{3,}|_{3,})\s*$|\s*>|\s*[-+*]\s+|\s*\d+\.\s+)/.test(lines[i])) { paraBuf.push(lines[i].trim()); i += 1; } if (paraBuf.length) { out.push(mdParaTag(mdInlineToWiki(paraBuf.join(" ")))); } } return out.join("\n"); } function markdownToWikiHtml(source) { if (source == null) return ""; const text = String(source); if (!text.trim()) return ""; const lines = text.replace(/\r\n?/g, "\n").replace(/\t/g, " ").split("\n"); return mdParseBlocks(lines); } // ===== FILE: src/converters/html-to-markdown.js ===== function normalizeMarkdownText(value) { return String(value ?? "") .replace(/\u00a0/g, " ") .replace(/[ \t\f\v\r\n]+/g, " ") .trim(); } function escapeMarkdownText(value) { return normalizeMarkdownText(value) .replace(/\\/g, "\\\\") .replace(/\*/g, "\\*") .replace(/_/g, "\\_") .replace(/`/g, "\\`") .replace(/\[/g, "\\[") .replace(/\]/g, "\\]"); } function escapeMarkdownInlineText(value) { return String(value ?? "") .replace(/\u00a0/g, " ") .replace(/[ \t\f\v\r\n]+/g, " ") .replace(/\\/g, "\\\\") .replace(/\*/g, "\\*") .replace(/_/g, "\\_") .replace(/`/g, "\\`") .replace(/\[/g, "\\[") .replace(/\]/g, "\\]"); } // Input already went through escapeMarkdownInlineText, so backslashes must not be escaped again. function escapeMarkdownTableCell(value) { return String(value ?? "") .replace(/\u00a0/g, " ") .replace(/\r\n?/g, "\n") .replace(/[ \t\f\v]+/g, " ") .replace(/ ?\n ?/g, "\n") .replace(/\|/g, "\\|") .trim() .replace(/\n+/g, "<br>"); } function markdownInlineFromNode(node) { if (node.nodeType === Node.TEXT_NODE) { return escapeMarkdownInlineText(node.textContent); } if (node.nodeType !== Node.ELEMENT_NODE) { return ""; } if (isIgnoredMarkdownElement(node)) { return ""; } const tag = node.tagName.toLowerCase(); switch (tag) { case "br": return " \n"; case "strong": case "b": return `**${markdownInlineChildren(node)}**`; case "em": case "i": return `_${markdownInlineChildren(node)}_`; case "del": case "s": case "strike": { const content = markdownInlineChildren(node); return content ? `~~${content}~~` : ""; } case "cite": case "dfn": case "var": { const content = markdownInlineChildren(node); return content ? `_${content}_` : ""; } case "code": return markdownInlineCode(node.textContent || ""); case "kbd": case "samp": return markdownInlineCode(node.textContent || ""); case "sub": case "sup": case "u": case "ins": { const content = markdownInlineChildren(node); return content ? `<${tag}>${content}</${tag}>` : ""; } case "q": { const content = markdownInlineChildren(node); return content ? `"${content}"` : ""; } case "abbr": { const content = markdownInlineChildren(node); const title = escapeMarkdownInlineText(node.getAttribute("title") || "").trim(); return content && title && title !== content ? `${content} (${title})` : content; } case "time": return markdownInlineChildren(node) || escapeMarkdownInlineText(node.getAttribute("datetime") || "").trim(); case "a": return markdownLinkFromElement(node); case "img": return markdownImageFromElement(node); case "audio": case "video": case "iframe": return markdownMediaFromElement(node); case "input": return node.getAttribute("type")?.toLowerCase() === "checkbox" ? `[${node.checked || node.hasAttribute("checked") ? "x" : " "}]` : ""; case "wbr": return ""; default: return markdownInlineChildren(node); } } function joinMarkdownInlineParts(parts) { return parts.filter(Boolean).reduce((output, part) => { if (!output) return part; if (/\s$/.test(output) || /^\s/.test(part)) return `${output}${part}`; if (/^[,.;:!?%)\]}»]/.test(part)) return `${output}${part}`; if (/[({\[«]$/.test(output)) return `${output}${part}`; return `${output} ${part}`; }, ""); } function markdownInlineChildren(node) { return joinMarkdownInlineParts(Array.from(node.childNodes).map(markdownInlineFromNode)) .replace(/[ \t]+/g, " ") .replace(/ ?\n ?/g, "\n") .replace(/\s+(<(?:sub|sup)>)/gi, "$1") .trim(); } function markdownInlineCode(value) { const text = String(value ?? "").replace(/\s+/g, " ").trim(); if (!text) return ""; const fence = text.includes("`") ? "``" : "`"; return `${fence}${text}${fence}`; } function markdownLinkFromElement(node) { const text = markdownInlineChildren(node) || normalizeMarkdownText(node.getAttribute("href") || ""); const href = normalizeMarkdownText(node.getAttribute("href") || ""); return href ? `[${text}](${href.replace(/\)/g, "%29")})` : text; } function markdownImageFromElement(node) { const alt = escapeMarkdownText(node.getAttribute("alt") || ""); const src = normalizeMarkdownText(node.getAttribute("src") || ""); return src ? `/g, "%29")})` : ""; } function markdownMediaFromElement(node) { const tag = node.tagName.toLowerCase(); const source = node.getAttribute("src") || node.querySelector("source[src]")?.getAttribute("src") || ""; const src = normalizeMarkdownText(source); const fallback = markdownInlineChildren(node); const label = fallback || escapeMarkdownText(node.getAttribute("title") || "") || ({ audio: "Audio", video: "Video", iframe: "Embedded content" }[tag] || "Media"); return src ? `[${label}](${src.replace(/\)/g, "%29")})` : fallback; } function markdownBlocksFromChildren(node) { return Array.from(node.childNodes) .map(markdownBlockFromNode) .filter(Boolean) .join("\n\n") .replace(/\n{3,}/g, "\n\n") .trim(); } function isIgnoredMarkdownElement(node) { if (node.nodeType !== Node.ELEMENT_NODE) return false; const tag = node.tagName.toLowerCase(); const style = node.getAttribute("style") || ""; return tag === "head" || tag === "style" || tag === "script" || tag === "template" || tag === "meta" || tag === "link" || tag === "title" || node.hidden || node.getAttribute("aria-hidden") === "true" || /display\s*:\s*none/i.test(style); } function hasBlockMarkdownChildren(node) { const blockTags = new Set([ "address", "article", "aside", "blockquote", "body", "details", "dialog", "div", "dl", "fieldset", "figure", "figcaption", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr", "html", "main", "menu", "nav", "ol", "p", "pre", "search", "section", "summary", "table", "ul" ]); return Array.from(node.children || []).some((child) => { return blockTags.has(child.tagName.toLowerCase()); }); } function markdownParagraphFromElement(node) { const inline = markdownInlineChildren(node); return inline || markdownBlocksFromChildren(node); } function markdownParagraphOrHeadingFromElement(node) { const inline = markdownInlineChildren(node); if (!inline) { return markdownBlocksFromChildren(node); } const style = node.getAttribute("style") || ""; const size = Number.parseFloat(style.match(/font-size\s*:\s*([\d.]+)px/i)?.[1] || ""); const bold = /font-weight\s*:\s*(bold|[6-9]00)/i.test(style); if (bold && size >= 24) { return `# ${inline}`; } if (bold && size >= 18) { return `## ${inline}`; } if (bold && size >= 16) { return `### ${inline}`; } return inline; } function markdownPreFromElement(node) { const code = node.textContent.replace(/\n+$/g, ""); const fence = code.includes("```") ? "````" : "```"; return `${fence}\n${code}\n${fence}`; } function markdownListFromElement(node, depth = 0) { const ordered = node.tagName.toLowerCase() === "ol"; const items = Array.from(node.children).filter((child) => child.tagName?.toLowerCase() === "li"); const reversed = ordered && node.hasAttribute("reversed"); const parsedStart = Number.parseInt(node.getAttribute("start") || "", 10); const start = Number.isFinite(parsedStart) ? parsedStart : (reversed ? items.length : 1); return items.map((item, index) => { const marker = ordered ? `${start + index * (reversed ? -1 : 1)}. ` : "- "; const nestedLists = Array.from(item.children).filter((child) => { const tag = child.tagName?.toLowerCase(); return tag === "ul" || tag === "ol" || tag === "menu"; }); const checkbox = Array.from(item.children).find((child) => { return child.tagName?.toLowerCase() === "input" && child.getAttribute("type")?.toLowerCase() === "checkbox"; }); const contentNodes = Array.from(item.childNodes).filter((child) => { return !nestedLists.includes(child) && child !== checkbox; }); const content = joinMarkdownInlineParts(contentNodes.map(markdownInlineFromNode)) .replace(/\s+/g, " ") .trim(); const task = checkbox ? `[${checkbox.checked || checkbox.hasAttribute("checked") ? "x" : " "}] ` : ""; const nested = nestedLists.map((list) => markdownListFromElement(list, depth + 1)).filter(Boolean).join("\n"); const indent = " ".repeat(depth); return `${indent}${marker}${task}${content}${nested ? `\n${nested}` : ""}`; }).join("\n"); } function markdownDefinitionListFromElement(node) { const output = []; let terms = []; let termGroupUsed = false; for (const child of node.children) { const tag = child.tagName.toLowerCase(); if (tag === "dt") { if (termGroupUsed) { terms = []; termGroupUsed = false; } const term = markdownInlineChildren(child); if (term) terms.push(term); continue; } if (tag !== "dd") continue; const definition = markdownBlocksFromChildren(child) || markdownInlineChildren(child); if (!definition) continue; const indented = definition.replace(/\n/g, "\n "); const label = terms.length ? `**${terms.join(", ")}:** ` : ""; output.push(`- ${label}${indented}`); termGroupUsed = true; } if (terms.length && !termGroupUsed) { output.push(`- **${terms.join(", ")}**`); } return output.join("\n"); } function markdownDetailsFromElement(node) { const summary = Array.from(node.children).find((child) => { return child.tagName?.toLowerCase() === "summary"; }); const summaryText = summary ? markdownInlineChildren(summary) : ""; const body = Array.from(node.childNodes) .filter((child) => child !== summary) .map(markdownBlockFromNode) .filter(Boolean) .join("\n\n") .replace(/\n{3,}/g, "\n\n") .trim(); return [summaryText ? `**${summaryText}**` : "", body].filter(Boolean).join("\n\n"); } function markdownFigureFromElement(node) { const caption = Array.from(node.children).find((child) => { return child.tagName?.toLowerCase() === "figcaption"; }); const body = Array.from(node.childNodes) .filter((child) => child !== caption) .map(markdownBlockFromNode) .filter(Boolean) .join("\n\n") .trim(); const captionText = caption ? markdownInlineChildren(caption) : ""; return [body, captionText ? `_${captionText}_` : ""].filter(Boolean).join("\n\n"); } function markdownBlockquoteFromElement(node) { const content = markdownBlocksFromChildren(node) || markdownInlineChildren(node); return content.split("\n").map((line) => `> ${line}`).join("\n"); } function directTableRows(table) { const rows = []; for (const child of table.children) { const tag = child.tagName.toLowerCase(); if (tag === "tr") { rows.push(child); } else if (tag === "thead" || tag === "tbody" || tag === "tfoot") { rows.push(...Array.from(child.children).filter((row) => row.tagName?.toLowerCase() === "tr")); } } return rows; } function directRowCells(row) { return Array.from(row.children).filter((cell) => /^(TD|TH)$/.test(cell.tagName)); } function isMarkdownDataTable(table, rows) { if (table.classList.contains("page-content") || table.getAttribute("role") === "presentation") { return false; } const cellRows = rows.map(directRowCells).filter((row) => row.length); if (cellRows.length < 2) { return false; } if (cellRows.some((row) => row.some((cell) => cell.querySelector("table")))) { return false; } const maxColumns = Math.max(...cellRows.map((row) => row.length)); if (maxColumns < 2) { return false; } return cellRows.some((row) => row.some((cell) => cell.tagName === "TH")) || cellRows[0].length === maxColumns || /border-collapse\s*:\s*collapse|table-layout\s*:\s*fixed/i.test(table.getAttribute("style") || ""); } function isRawHtmlFallbackTable(table) { const className = table.className || ""; if (/(infobox|navbox|metadata|ambox|mbox|vertical-navbox)/i.test(String(className))) { return false; } return true; } function markdownLayoutTableFromElement(table, rows) { return rows.map((row) => { return directRowCells(row) .map((cell) => markdownBlocksFromChildren(cell) || markdownInlineChildren(cell)) .filter(Boolean) .join("\n\n"); }).filter(Boolean).join("\n\n"); } function tableCellSpans(cell) { return Number.parseInt(cell.getAttribute("colspan") || "1", 10) !== 1 || Number.parseInt(cell.getAttribute("rowspan") || "1", 10) !== 1; } function isPropertySheetTable(table, rows) { if (table.classList.contains("page-content") || table.getAttribute("role") === "presentation") { return false; } const cellRows = rows.map(directRowCells).filter((row) => row.length); if (cellRows.length < 2) return false; if (!cellRows.every((row) => row.length === 2 && row[0].tagName === "TH")) return false; if (cellRows.some((row) => row.some(tableCellSpans))) return false; return cellRows.some((row) => markdownBlocksFromChildren(row[1]).includes("\n")); } function propertySheetDataRows(cellRows) { const headerRow = cellRows[0].every((cell) => cell.tagName === "TH") && cellRows.slice(1).some((row) => row.some((cell) => cell.tagName === "TD")); return headerRow ? cellRows.slice(1) : cellRows; } function markdownPropertySheetFromElement(table, rows) { const cellRows = rows.map(directRowCells).filter((row) => row.length); return propertySheetDataRows(cellRows).map(([labelCell, valueCell]) => { const label = markdownInlineChildren(labelCell); const value = markdownBlocksFromChildren(valueCell) || markdownInlineChildren(valueCell); return [label ? `## ${label}` : "", value].filter(Boolean).join("\n\n"); }).filter(Boolean).join("\n\n"); } function markdownTableWithCaption(table, markdown) { const caption = Array.from(table.children).find((child) => { return child.tagName?.toLowerCase() === "caption"; }); const captionText = caption ? markdownInlineChildren(caption) : ""; return [captionText ? `_${captionText}_` : "", markdown].filter(Boolean).join("\n\n"); } function markdownTableFromElement(table) { const directRows = directTableRows(table); if (isPropertySheetTable(table, directRows)) { return markdownTableWithCaption(table, markdownPropertySheetFromElement(table, directRows)); } if (!isMarkdownDataTable(table, directRows)) { return markdownTableWithCaption(table, markdownLayoutTableFromElement(table, directRows)); } const rows = directRows.map((row) => { return directRowCells(row) .map((cell) => ({ text: markdownInlineChildren(cell), header: cell.tagName === "TH", colspan: Number.parseInt(cell.getAttribute("colspan") || "1", 10), rowspan: Number.parseInt(cell.getAttribute("rowspan") || "1", 10) })); }).filter((row) => row.length); if (!rows.length) return markdownTableWithCaption(table, ""); const simple = rows.every((row) => row.every((cell) => cell.colspan === 1 && cell.rowspan === 1)); if (!simple) { return isRawHtmlFallbackTable(table) ? formatHtml(table.outerHTML) : markdownTableWithCaption(table, markdownLayoutTableFromElement(table, directRows)); } const width = Math.max(...rows.map((row) => row.length)); const normalizedRows = rows.map((row) => Array.from({ length: width }, (_, index) => row[index]?.text ?? "")); const firstRowIsHeader = rows[0].some((cell) => cell.header) || rows.length > 1 && rows[0].length === width && normalizedRows[0].every(Boolean); const header = firstRowIsHeader ? normalizedRows[0] : normalizedRows[0].map((_, index) => `Column ${index + 1}`); const body = firstRowIsHeader ? normalizedRows.slice(1) : normalizedRows; const line = (cells) => `| ${cells.map(escapeMarkdownTableCell).join(" | ")} |`; const markdown = [ line(header), line(header.map(() => "---")), ...body.map(line) ].join("\n"); return markdownTableWithCaption(table, markdown); } function markdownContentRoot(fragment) { const selectors = [ { selector: "#mw-content-text .mw-parser-output", minRatio: 0 }, { selector: ".body[role='main']", minRatio: 0 }, { selector: ".document", minRatio: 0 }, { selector: "#content .document", minRatio: 0 }, { selector: "#content article", minRatio: 0 }, { selector: "#content main", minRatio: 0 }, { selector: "article", minRatio: 0.2 }, { selector: "[role='main']", minRatio: 0.2 }, { selector: "main", minRatio: 0.35 }, { selector: "#content", minRatio: 0.35 }, { selector: ".content", minRatio: 0.35 } ]; const totalLength = normalizeMarkdownText(fragment.textContent || "").length; for (const { selector, minRatio } of selectors) { let best = null; let bestLength = 0; for (const candidate of fragment.querySelectorAll(selector)) { if (isIgnoredMarkdownElement(candidate)) continue; const length = normalizeMarkdownText(candidate.textContent || "").length; if (totalLength && length < totalLength * minRatio) continue; if (length > bestLength) { best = candidate; bestLength = length; } } if (best) return best; } return fragment; } function markdownBlockFromNode(node) { if (node.nodeType === Node.TEXT_NODE) { return escapeMarkdownText(node.textContent); } if (node.nodeType !== Node.ELEMENT_NODE) { return ""; } if (isIgnoredMarkdownElement(node)) { return ""; } const tag = node.tagName.toLowerCase(); switch (tag) { case "h1": case "h2": case "h3": case "h4": case "h5": case "h6": { const level = Number.parseInt(tag.slice(1), 10); const text = markdownInlineChildren(node); return text ? `${"#".repeat(level)} ${text}` : ""; } case "p": return markdownParagraphOrHeadingFromElement(node); case "figcaption": case "caption": { const content = markdownInlineChildren(node); return content ? `_${content}_` : ""; } case "summary": case "legend": { const content = markdownInlineChildren(node); return content ? `**${content}**` : ""; } case "br": return ""; case "hr": return "---"; case "pre": return markdownPreFromElement(node); case "blockquote": return markdownBlockquoteFromElement(node); case "ul": case "ol": case "menu": return markdownListFromElement(node); case "dl": return markdownDefinitionListFromElement(node); case "details": return markdownDetailsFromElement(node); case "figure": return markdownFigureFromElement(node); case "table": return markdownTableFromElement(node); case "audio": case "video": case "iframe": return markdownMediaFromElement(node); case "html": case "body": case "main": case "article": case "section": case "div": case "header": case "footer": case "address": case "aside": case "nav": case "form": case "fieldset": case "dialog": case "search": case "hgroup": case "center": return markdownBlocksFromChildren(node); case "thead": case "tbody": case "tfoot": case "tr": return markdownBlocksFromChildren(node); case "td": case "th": return markdownBlocksFromChildren(node) || markdownParagraphFromElement(node); case "dt": case "dd": case "li": return markdownBlocksFromChildren(node) || markdownParagraphFromElement(node); case "svg": case "source": case "track": case "col": case "colgroup": return ""; case "script": case "style": case "head": case "meta": case "link": case "title": case "template": return ""; default: return hasBlockMarkdownChildren(node) ? markdownBlocksFromChildren(node) : markdownInlineFromNode(node); } } function htmlToMarkdown(html, plainFallback = "") { const source = html.trim(); if (!source) { return normalizeMarkdownText(plainFallback); } const template = document.createElement("template"); template.innerHTML = source; const markdown = markdownBlocksFromChildren(markdownContentRoot(template.content)); return markdown || normalizeMarkdownText(plainFallback); } // ===== FILE: src/converters/board-text.js ===== const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: "\"", "#39": "'", nbsp: " " }; const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g; function decodeEntities(value) { return value.replace(/&(#?\w+);/g, (whole, name) => (name in ENTITIES ? ENTITIES[name] : whole)); } function escapeText(value) { return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); } function parseNativeText(raw) { if (typeof raw !== "string") return { blocks: [] }; const source = raw.replace(//g, ""); const blocks = []; const marks = { bold: 0, italic: 0, strike: 0 }; let listType = null; let current = null; const flush = () => { if (current && current.spans.length) { const first = current.spans[0]; const last = current.spans[current.spans.length - 1]; first.text = first.text.replace(/^ +/, ""); last.text = last.text.replace(/ +$/, ""); const merged = []; for (const span of current.spans) { if (!span.text) continue; const previous = merged[merged.length - 1]; const sameMarks = previous && !!previous.bold === !!span.bold && !!previous.italic === !!span.italic && !!previous.strike === !!span.strike; if (sameMarks) previous.text += span.text; else merged.push(span); } if (merged.length) blocks.push({ ...current, spans: merged }); } current = null; }; const addText = (chunk) => { const text = decodeEntities(chunk).replace(/\s+/g, " "); if (!text) return; if (!current && !text.trim()) return; const span = { text }; if (marks.bold > 0) span.bold = true; if (marks.italic > 0) span.italic = true; if (marks.strike > 0) span.strike = true; if (!current) current = { type: listType || "p", spans: [] }; current.spans.push(span); }; const mark = (name, closing) => { marks[name] = Math.max(0, marks[name] + (closing ? -1 : 1)); }; TAG_RE.lastIndex = 0; let cursor = 0; let match; while ((match = TAG_RE.exec(source))) { addText(source.slice(cursor, match.index)); cursor = TAG_RE.lastIndex; const closing = match[0][1] === "/"; const tag = match[1].toLowerCase(); if (tag === "ul" || tag === "ol") { flush(); listType = closing ? null : tag; } else if (tag === "li" || tag === "p" || tag === "div" || tag === "br") { flush(); } else if (tag === "b" || tag === "strong") { mark("bold", closing); } else if (tag === "i" || tag === "em") { mark("italic", closing); } else if (tag === "s" || tag === "strike" || tag === "del") { mark("strike", closing); } } addText(source.slice(cursor)); flush(); return { blocks }; } function renderNativeText(text) { const blocks = Array.isArray(text?.blocks) ? text.blocks : []; if (!blocks.length) return "<p></p>"; const out = []; let openList = null; for (const block of blocks) { const inner = (block.spans || []).map(spanToHtml).join(""); if (block.type === "ul" || block.type === "ol") { if (openList !== block.type) { if (openList) out.push(`</${openList}>`); out.push(`<${block.type}>`); openList = block.type; } out.push(`<li>${inner}</li>`); } else { if (openList) { out.push(`</${openList}>`); openList = null; } out.push(`<p>${inner}</p>`); } } if (openList) out.push(`</${openList}>`); return out.join(""); } function spanToHtml(span) { let html = escapeText(span.text ?? ""); if (span.strike) html = `<s>${html}</s>`; if (span.italic) html = `<i>${html}</i>`; if (span.bold) html = `<b>${html}</b>`; return html; } // Markdown-подмножество из board-layout-v1 в те же блоки, что даёт parseNativeText: // **жирный**, *курсив*, ~~зачёркнутый~~, списки "- " и "1. ", перенос строки. function markdownToBlocks(text) { if (typeof text !== "string" || !text.trim()) return { blocks: [] }; const blocks = []; for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim(); if (!line) continue; let type = "p"; let content = line; const bullet = line.match(/^[-*•]\s+(.*)$/); const ordered = line.match(/^\d+[.)]\s+(.*)$/); if (bullet) { type = "ul"; content = bullet[1]; } else if (ordered) { type = "ol"; content = ordered[1]; } const spans = markdownSpans(content); if (spans.length) blocks.push({ type, spans }); } return { blocks }; } const MD_TOKEN_RE = /(\*\*|__|~~|\*|_)/; function markdownSpans(text) { const marks = { bold: false, italic: false, strike: false }; const spans = []; let rest = text; const push = (chunk) => { if (!chunk) return; const span = { text: chunk }; if (marks.bold) span.bold = true; if (marks.italic) span.italic = true; if (marks.strike) span.strike = true; const previous = spans[spans.length - 1]; const sameMarks = previous && !!previous.bold === !!span.bold && !!previous.italic === !!span.italic && !!previous.strike === !!span.strike; if (sameMarks) previous.text += span.text; else spans.push(span); }; while (rest) { const match = rest.match(MD_TOKEN_RE); if (!match) { push(rest); break; } push(rest.slice(0, match.index)); const token = match[1]; if (token === "**" || token === "__") marks.bold = !marks.bold; else if (token === "~~") marks.strike = !marks.strike; else marks.italic = !marks.italic; rest = rest.slice(match.index + token.length); } if (spans.length) { spans[0].text = spans[0].text.replace(/^ +/, ""); spans[spans.length - 1].text = spans[spans.length - 1].text.replace(/ +$/, ""); } return spans.filter((span) => span.text); } // Сколько символов текста в блоке — нужно движку раскладки для оценки переносов. function blocksTextLength(blocks) { let total = 0; for (const block of blocks?.blocks ?? blocks ?? []) { for (const span of block.spans ?? []) total += span.text.length; } return total; } // Обратно в markdown — нужно обратному мапперу, чтобы отдать LLM текст, // а не блоки. Пара к markdownToBlocks. function blocksToMarkdown(text) { const blocks = text?.blocks ?? []; const lines = []; let ordinal = 0; for (const block of blocks) { const body = (block.spans ?? []).map(spanToMarkdown).join(""); if (block.type === "ul") { lines.push(`- ${body}`); ordinal = 0; } else if (block.type === "ol") { lines.push(`${++ordinal}. ${body}`); } else { lines.push(body); ordinal = 0; } } return lines.join("\n"); } function spanToMarkdown(span) { let out = span.text ?? ""; if (!out) return ""; if (span.strike) out = `~~${out}~~`; if (span.italic) out = `*${out}*`; if (span.bold) out = `**${out}**`; return out; } // ===== FILE: src/converters/board-payload.js ===== const DEFAULT_BACKGROUND = "#FFFFFF"; const DEFAULT_FONT_SIZE = 24; // Подтверждено двумя выгрузками: связи 1→3 идут слева направо (right→left), // связь 2→0 — сверху вниз (bottom→top). const PORT_SIDES = { 0: "top", 1: "right", 2: "bottom", 3: "left" }; const SIDE_PORTS = { top: 0, right: 1, bottom: 2, left: 3 }; function tableCellRects(attrs) { const rects = new Map(); let y = 0; for (const row of attrs?.rows ?? []) { let x = 0; let rowHeight = 0; for (const cell of row.cells ?? []) { rects.set(cell.id, { x, y, w: cell.w, h: cell.h }); x += cell.w ?? 0; rowHeight = Math.max(rowHeight, cell.h ?? 0); } y += rowHeight; } return rects; } function localToWorld(point, origin) { return { x: (origin?.x ?? 0) + (point?.x ?? 0), y: (origin?.y ?? 0) + (point?.y ?? 0) }; } function worldToLocal(point, origin) { return { x: (point?.x ?? 0) - (origin?.x ?? 0), y: (point?.y ?? 0) - (origin?.y ?? 0) }; } function unwrapPayload(text) { const parsed = typeof text === "string" ? JSON.parse(text) : text; const inner = parsed?.original?.["text/plain"]; const doc = typeof inner === "string" ? JSON.parse(inner) : parsed; if (!doc || typeof doc !== "object" || !doc.tree || !Array.isArray(doc.tree.children)) { throw new Error("board payload: ожидался объект { tree: { children: [] } }"); } return { tree: doc.tree, lines: Array.isArray(doc.lines) ? doc.lines : [] }; } function parseBoardPayload(text) { const doc = unwrapPayload(text); const warnings = []; const nodes = []; const byUuid = new Map(); const cellOwner = new Map(); const rawLines = [...doc.lines]; let nodeCounter = 0; let cellCounter = 0; const readTable = (attrs, nodeKey, worldRect) => { const rects = tableCellRects(attrs); const columns = (attrs.rows?.[0]?.cells ?? []).map((cell, index) => ({ key: `${nodeKey}col${index + 1}`, w: cell.w ?? 0 })); const rows = (attrs.rows ?? []).map((row, rowIndex) => ({ key: `${nodeKey}row${rowIndex + 1}`, h: row.cells?.[0]?.h ?? 0, cells: (row.cells ?? []).map((cell, index) => { const key = `c${++cellCounter}`; const rect = rects.get(cell.id) ?? { x: 0, y: 0, w: cell.w ?? 0, h: cell.h ?? 0 }; cellOwner.set(cell.id, { nodeKey, cellKey: key, origin: { x: worldRect.x + rect.x, y: worldRect.y + rect.y } }); if (columns[index] && columns[index].w !== cell.w) { warnings.push({ code: "ragged-column-width", path: `${nodeKey}/${key}`, severity: "warn" }); } return { key, columnKey: columns[index]?.key ?? `${nodeKey}col${index + 1}`, text: parseNativeText(cell.data?.text), color: cell.backgroundColor ?? DEFAULT_BACKGROUND, opacity: cell.opacity ?? 1, fontSize: cell.data?.textStyle?.fontSize ?? DEFAULT_FONT_SIZE }; }) })); return { columns, rows }; }; let admit; const visit = (entry, origin, parent) => { const element = entry?.element; if (!element) return; if (element.type === "line") { rawLines.push(element); return; } const attrs = element.attrs ?? {}; const key = `n${++nodeCounter}`; const world = localToWorld({ x: attrs.x, y: attrs.y }, origin); const rect = { x: world.x, y: world.y, w: attrs.w ?? 0, h: attrs.h ?? 0 }; const node = { key, kind: element.type, parent, rect, paintOrder: attrs.z ?? 0, style: { color: attrs.backgroundColor ?? DEFAULT_BACKGROUND, fontSize: attrs.textStyle?.fontSize ?? DEFAULT_FONT_SIZE, ...(attrs.opacity === undefined ? {} : { opacity: attrs.opacity }), ...(attrs.figureType ? { figureType: attrs.figureType } : {}), ...(attrs.strokeColor && element.type === "figure" ? { strokeColor: attrs.strokeColor } : {}) }, connectable: element.type !== "table" }; if (element.type === "table") { if (attrs.title !== undefined) node.title = parseNativeText(attrs.title); node.table = readTable(attrs, key, rect); const sumH = node.table.rows.reduce((sum, row) => sum + row.h, 0); const sumW = node.table.columns.reduce((sum, column) => sum + column.w, 0); if (Math.abs(rect.h - sumH) > 1e-6 || Math.abs(rect.w - sumW) > 1e-6) { warnings.push({ code: "table-geometry-mismatch", path: key, severity: "warn" }); } rect.h = sumH; rect.w = sumW; } else node.text = parseNativeText(attrs.text); nodes.push(node); byUuid.set(element.id, node); for (const child of entry.children ?? []) admit(child); }; // Разбор не зависит от порядка: элемент, чья ячейка ещё не встречена, // откладывается и разбирается, когда владелец появится. const deferred = []; admit = (entry) => { const space = entry?.element?.attrs?.space; if (space?.type !== "cell") { visit(entry, { x: 0, y: 0 }, null); return; } const owner = cellOwner.get(space.attrs?.cellId); if (!owner) { deferred.push(entry); return; } visit(entry, owner.origin, { nodeKey: owner.nodeKey, cellKey: owner.cellKey }); }; for (const entry of doc.tree.children) admit(entry); let pending = deferred.splice(0); while (pending.length) { const before = pending.length; for (const entry of pending.splice(0)) admit(entry); const next = deferred.splice(0); if (next.length >= before) { for (const entry of next) { warnings.push({ code: "unknown-cell", path: entry.element?.id ?? "", severity: "warn" }); } break; } pending = next; } nodes.slice().sort((a, b) => a.paintOrder - b.paintOrder).forEach((node, index) => { node.paintOrder = index; }); const edges = []; let edgeCounter = 0; for (const line of rawLines) { const attrs = line.attrs ?? {}; const from = resolveEnd(attrs.start, byUuid, warnings, line.id); const to = resolveEnd(attrs.end, byUuid, warnings, line.id); if (!from || !to) { warnings.push({ code: "absolute-line-dropped", path: line.id ?? "", severity: "warn" }); continue; } const startArrow = attrs.start?.form === "arrow"; const endArrow = attrs.end?.form === "arrow"; edges.push({ key: `e${++edgeCounter}`, from, to, arrow: startArrow && endArrow ? "both" : endArrow ? "end" : startArrow ? "start" : "none", ...(attrs.lineType && attrs.lineType !== "bezierCurve" ? { lineType: attrs.lineType } : {}) }); } return { scene: { nodes, edges }, warnings }; } function resolveEnd(end, byUuid, warnings, lineId) { const position = end?.position; if (position?.type !== "bind") return null; const node = byUuid.get(position.elementId); if (!node) return null; const side = PORT_SIDES[position.portId]; if (!side) warnings.push({ code: "unknown-port", path: lineId ?? "", severity: "info" }); return { nodeKey: node.key, side: side ?? "top" }; } const DEFAULT_TABLE_STYLE = { strokeColor: "#000000", strokeWidth: 1, strokeOpacity: 1, strokeStyle: "solid" }; function defaultIdFactory() { if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); const hex = (n) => Array.from({ length: n }, () => Math.floor(Math.random() * 16).toString(16)).join(""); return `${hex(8)}-${hex(4)}-4${hex(3)}-8${hex(3)}-${hex(12)}`; } function sceneToBoardPayload(scene, opts = {}) { const idFactory = opts.idFactory ?? defaultIdFactory; const nodes = scene?.nodes ?? []; const childrenOf = new Map(); for (const node of nodes) { const parentKey = node.parent?.nodeKey ?? null; if (!childrenOf.has(parentKey)) childrenOf.set(parentKey, []); childrenOf.get(parentKey).push(node); } const uuidOf = new Map(); const cellUuidOf = new Map(); const cellOriginOf = new Map(); for (const node of nodes) uuidOf.set(node.key, idFactory()); const lines = []; const buildEntry = (node, origin) => { const local = worldToLocal(node.rect, origin); const attrs = { x: local.x, y: local.y, z: node.paintOrder ?? 0, w: node.rect.w, h: node.rect.h }; if (node.kind === "table") { Object.assign(attrs, DEFAULT_TABLE_STYLE); attrs.rows = buildRows(node); if (node.title) attrs.title = renderNativeText(node.title); attrs.h = attrs.rows.reduce((sum, row) => sum + row.cells[0].h, 0); attrs.w = attrs.rows[0]?.cells.reduce((sum, cell) => sum + cell.w, 0) ?? attrs.w; } else { attrs.backgroundColor = node.style?.color ?? DEFAULT_BACKGROUND; attrs.text = renderNativeText(node.text); // textArea на доске подгоняет кегль под рамку — иначе подпись обрежется. attrs.textStyle = { fontSizeAuto: node.kind !== "sticker", fontSize: node.style?.fontSize ?? DEFAULT_FONT_SIZE, fontColor: "#000000", strike: false }; attrs.angle = 0; attrs.showOwnerName = false; if (node.kind === "figure") { Object.assign(attrs, DEFAULT_TABLE_STYLE, { strokeWidth: 2 }); attrs.strokeColor = node.style?.strokeColor ?? "#3F81FD"; attrs.figureType = node.style?.figureType ?? "rectangle"; } if (node.style?.opacity !== undefined) attrs.opacity = node.style.opacity; } attrs.space = node.parent ? { type: "cell", attrs: { id: uuidOf.get(node.parent.nodeKey), cellId: cellUuidOf.get(node.parent.cellKey) } } : { type: "root", attrs: {} }; return { element: { type: node.kind, id: uuidOf.get(node.key), attrs, deleted: false, userId: "", ver: 0 }, space: node.parent ? uuidOf.get(node.parent.nodeKey) : "root", children: (childrenOf.get(node.key) ?? []).map((child) => buildEntry(child, cellOriginOf.get(child.parent.cellKey) ?? { x: 0, y: 0 })) }; }; const buildRows = (node) => { const columns = node.table?.columns ?? []; let y = 0; return (node.table?.rows ?? []).map((row) => { let x = 0; const cells = (row.cells ?? []).map((cell, index) => { const uuid = idFactory(); cellUuidOf.set(cell.key, uuid); cellOriginOf.set(cell.key, { x: node.rect.x + x, y: node.rect.y + y }); const width = columns[index]?.w ?? 0; x += width; return { id: uuid, w: width, h: row.h, opacity: cell.opacity ?? 1, backgroundColor: cell.color ?? DEFAULT_BACKGROUND, dataType: "text", data: { text: renderNativeText(cell.text), textStyle: { fontSize: cell.fontSize ?? node.style?.fontSize ?? DEFAULT_FONT_SIZE, fontColor: "#000000", strike: false } } }; }); y += row.h; return { cells }; }); }; const children = (childrenOf.get(null) ?? []).map((node) => buildEntry(node, { x: 0, y: 0 })); const nodeByKey = new Map(nodes.map((node) => [node.key, node])); const maxPaintOrder = nodes.reduce((max, node) => Math.max(max, node.paintOrder ?? 0), -1); let edgeIndex = 0; for (const edge of scene?.edges ?? []) { const fromUuid = uuidOf.get(edge.from?.nodeKey); const toUuid = uuidOf.get(edge.to?.nodeKey); if (!fromUuid || !toUuid) continue; const fromParent = nodeByKey.get(edge.from.nodeKey)?.parent; const toParent = nodeByKey.get(edge.to.nodeKey)?.parent; const sameCell = fromParent && toParent && fromParent.cellKey === toParent.cellKey; const lineSpace = sameCell ? { type: "cell", attrs: { id: uuidOf.get(fromParent.nodeKey), cellId: cellUuidOf.get(fromParent.cellKey) } } : { type: "root", attrs: {} }; const entry = { element: { type: "line", id: idFactory(), attrs: { start: { form: edge.arrow === "both" || edge.arrow === "start" ? "arrow" : null, position: { type: "bind", elementId: fromUuid, portId: SIDE_PORTS[edge.from.side] ?? 0 } }, end: { form: edge.arrow === "both" || edge.arrow === "end" ? "arrow" : null, position: { type: "bind", elementId: toUuid, portId: SIDE_PORTS[edge.to.side] ?? 0 } }, controlPoints: [], strokeWidth: 2, strokeStyle: "solid", strokeColor: "#3F81FD", strokeOpacity: 1, lineType: edge.lineType ?? "bezierCurve", z: maxPaintOrder + 1 + edgeIndex, space: lineSpace }, deleted: false, userId: "", ver: 0 }, space: sameCell ? uuidOf.get(fromParent.nodeKey) : "root", children: [] }; edgeIndex += 1; // Привязанная связь всегда живёт в lines[], в каком бы пространстве ни была. // В дереве доска держит только свободные линии absolute → absolute, а // привязанные оттуда попросту не рисует — проверено на двух выгрузках. lines.push(entry.element); } return JSON.stringify({ tree: { space: "root", children }, lines }); } // ===== FILE: src/converters/board-layout.js ===== // Палитра доски: имена, которыми оперирует board-layout-v1, и их hex. // Значения взяты из реальных выгрузок. Новый цвет — одна строка здесь. // Палитры доски разные у разных объектов — это проверено по выгрузкам образцов, // а не выведено из общих соображений. Имя цвета разрешается в палитре своего // объекта, поэтому одно и то же имя может означать разный hex: violet у стикера, // у заливки фигуры и у её границы — три разных цвета, и так на доске и есть. // Одна палитра на элементы доски: ею красятся и заливка фигуры, и её граница, // и ячейки таблиц. Проверено по выгрузкам: все девять цветов, встреченных в // ячейках, входят в объединение заливок и границ фигур. const ELEMENT_COLORS = { white: "#FFFFFF", black: "#000000", "grey-light": "#DCDCDC", grey: "#939393", green: "#9DE97A", teal: "#21CA9A", yellow: "#FCEE73", orange: "#FFB45C", red: "#EE6880", pink: "#EE5EC6", violet: "#8275F3", lilac: "#AC75F3", blue: "#3F81FD", navy: "#235AC2", none: "#FFFFFF00" }; // У стикера своя палитра и нет границы вовсе — в его нативных атрибутах // strokeColor отсутствует. 29 образцов, выложенных на доску 2026-08-05. const STICKER_COLORS = { red: "#F66C6C", rose: "#F1A0AE", "dusty-rose": "#D8B0B0", clay: "#C09595", magenta: "#F17BED", "pink-light": "#F7B6E3", mauve: "#E4BFDA", orange: "#FC9251", peach: "#FFC786", cream: "#FBE1CB", gold: "#E3C528", yellow: "#FBEA8F", sand: "#E1E4BA", green: "#77DE73", lime: "#C0E55F", "lime-light": "#C8EE92", sage: "#BBDCB1", mint: "#84E4C9", sky: "#70C5E6", "sky-light": "#BCECF6", "blue-bright": "#8A91F5", blue: "#BDD4FF", periwinkle: "#B1B2DC", violet: "#B29AFF", "violet-light": "#DFCBFB", lilac: "#C9B1DC", white: "#FFFFFF", "grey-light": "#D7D7D7", grey: "#A2A1A1" }; const CELL_COLORS = ELEMENT_COLORS; const FIGURE_FILL_COLORS = ELEMENT_COLORS; const FIGURE_STROKE_COLORS = ELEMENT_COLORS; const TEXT_COLORS = { transparent: "#FFFFFF00", white: "#FFFFFF" }; const PALETTES = { cell: ELEMENT_COLORS, sticker: STICKER_COLORS, figureFill: ELEMENT_COLORS, figureStroke: ELEMENT_COLORS, text: TEXT_COLORS }; // Девять базовых фигур — отдельный набор доски, они пронумерованы в выгрузке. const FIGURE_TYPES = [ "rectangle", "roundedRect", "ellipse", "diamond", "triangle", "star", "dialog", "rightArrow", "parallelogram" ]; // Блок-схемы — второй набор, 30 символов. На доске это отдельная панель, // хотя нативно оба набора приходят как type: "figure". const FLOWCHART_TYPES = [ "terminator", "preparation", "manualInput", "display", "delay", "document", "multiDoc", "card", "sort", "sort2", "cycle", "loopLimit", "circleOr", "circleJunction", "database", "cylinder", "cylinderHalf", "tableRectangle", "tableSquare", "bracket", "bracketLeft", "bracketRight", "houseUp", "houseDown", "trapezeUp", "trapezeDown", "triangleDown", "threeRhombs", "flag", "sandGlass" ]; const ALL_FIGURE_TYPES = [...FIGURE_TYPES, ...FLOWCHART_TYPES]; // Типы линий, наблюдаемые на доске. const LINE_TYPES = { bezier: "bezierCurve", curve: "curve", straight: "straight" }; const CARD_SIZES = { sticker: { s: { w: 240, h: 240 }, m: { w: 360, h: 360 }, l: { w: 480, h: 480 } }, // textArea — текст без плашки: подпись, заголовок раздела, пояснение на полотне. text: { s: { w: 360, h: 80 }, m: { w: 640, h: 110 }, l: { w: 960, h: 150 } } }; // Естественные размеры фигур, снятые с доски. Фигура не растягивается: её // пропорции — часть смысла (ромб решения, цилиндр базы), а размеры друг // относительно друга задают масштаб набора. size только умножает обе стороны. const FIGURE_NATURAL = { bracket: { w: 60, h: 80 }, bracketLeft: { w: 62, h: 80 }, bracketRight: { w: 62, h: 80 }, card: { w: 120, h: 80 }, circleJunction: { w: 120, h: 120 }, circleOr: { w: 120, h: 120 }, cycle: { w: 202, h: 202 }, cylinder: { w: 102, h: 80 }, cylinderHalf: { w: 257, h: 202 }, database: { w: 95, h: 122 }, delay: { w: 102, h: 68 }, dialog: { w: 173, h: 173 }, diamond: { w: 150, h: 150 }, display: { w: 126, h: 80 }, document: { w: 126, h: 86 }, ellipse: { w: 150, h: 150 }, flag: { w: 202, h: 152 }, houseDown: { w: 120, h: 114 }, houseUp: { w: 120, h: 114 }, loopLimit: { w: 126, h: 84 }, manualInput: { w: 120, h: 80 }, multiDoc: { w: 120, h: 73 }, parallelogram: { w: 221, h: 175 }, preparation: { w: 202, h: 140 }, rectangle: { w: 150, h: 150 }, rightArrow: { w: 221, h: 173 }, roundedRect: { w: 173, h: 173 }, sandGlass: { w: 120, h: 122 }, sort: { w: 202, h: 202 }, sort2: { w: 202, h: 202 }, star: { w: 173, h: 173 }, tableRectangle: { w: 202, h: 121 }, tableSquare: { w: 102, h: 102 }, terminator: { w: 257, h: 158 }, threeRhombs: { w: 202, h: 120 }, trapezeDown: { w: 120, h: 80 }, trapezeUp: { w: 120, h: 80 }, triangle: { w: 173, h: 150 }, triangleDown: { w: 120, h: 80 } }; const FIGURE_SCALE = { s: 0.75, m: 1, l: 1.5 }; const TABLE_HEADER_COLOR = ELEMENT_COLORS["grey-light"]; const COLUMN_WIDTHS = { s: 360, m: 460, l: 700 }; // Пол, который держит сама доска. Замерено на реальной выгрузке: 89 строк из 97 // имеют высоту ровно 90–92, минимальная ширина ячейки — 347. Если отдать меньше, // доска поднимет строки до своего минимума, а table.h останется нашим — рамка // окажется короче суммы строк, и таблица отрисуется разъехавшейся. const TABLE_ROW_MIN_HEIGHT = 90; const TABLE_COLUMN_MIN_WIDTH = 347; const GAPS = { s: 24, m: 40, l: 80 }; const FONT_SIZES = { lane: 32, table: 24, card: 24 }; const LINE_HEIGHT = 1.35; const TEXT_PAD_X = 24; const TEXT_PAD_Y = 16; const CELL_PAD_X = 96; const CELL_PAD_Y = 48; const MAX_DEPTH = 6; const MAX_NODES = 500; const MAX_TEXT_LENGTH = 4000; const MAX_GRID_COLUMNS = 6; const CONTAINER_TYPES = new Set(["column", "row", "grid", "table", "swimlanes"]); const CONNECTABLE_TYPES = new Set(["card"]); // Средняя ширина глифа как доля кегля. Заниженная оценка режет текст, завышенная // раздувает полотно; 0.55 подобрано по кириллице основного текста досок. const GLYPH_RATIO = 0.55; function heuristicMeasure(text, width, fontSize) { const glyph = fontSize * GLYPH_RATIO; const usable = Math.max(glyph * 4, width - 2 * TEXT_PAD_X); const perLine = Math.max(1, Math.floor(usable / glyph)); let lines = 0; for (const block of text?.blocks ?? []) { const length = (block.spans ?? []).reduce((sum, span) => sum + span.text.length, 0); lines += Math.max(1, Math.ceil(length / perLine)); } return Math.ceil(Math.max(1, lines) * fontSize * LINE_HEIGHT + 2 * TEXT_PAD_Y); } function validateBoardLayout(layout) { const errors = []; const warnings = []; const ids = new Map(); const connectable = new Set(); const fail = (code, path, detail) => errors.push({ code, path, detail, severity: "error" }); if (!layout || typeof layout !== "object") { fail("not-an-object", "", "ожидался объект board-layout-v1"); return { errors, warnings }; } if (layout.$schema && layout.$schema !== "board-layout-v1") { warnings.push({ code: "unexpected-schema", path: "$schema", detail: String(layout.$schema), severity: "warn" }); } if (!layout.content) fail("no-content", "content", "нужен ровно один корневой контейнер"); let nodeCount = 0; const walk = (node, path, depth) => { if (!node || typeof node !== "object") { fail("not-a-node", path, "ожидался узел"); return; } nodeCount += 1; if (nodeCount > MAX_NODES) { fail("too-many-nodes", path, `больше ${MAX_NODES} узлов`); return; } if (depth > MAX_DEPTH) { fail("too-deep", path, `вложенность больше ${MAX_DEPTH}`); return; } const type = node.type; if (!CONTAINER_TYPES.has(type) && type !== "card") { fail("unknown-type", path, String(type)); return; } if (node.id !== undefined) { if (typeof node.id !== "string" || !node.id.trim()) fail("bad-id", path, String(node.id)); else if (ids.has(node.id)) fail("duplicate-id", path, `id "${node.id}" уже занят (${ids.get(node.id)})`); else ids.set(node.id, path); if (CONNECTABLE_TYPES.has(type)) connectable.add(node.id); } if (node.size !== undefined && !(node.size in CARD_SIZES.sticker)) { fail("bad-size", path, `${node.size}: допустимы s, m, l`); } if (node.gap !== undefined && !(node.gap in GAPS)) { fail("bad-gap", path, `${node.gap}: допустимы s, m, l`); } if (node.color !== undefined) { const scope = type !== "card" ? "cell" : node.variant === "figure" ? "figureFill" : node.variant === "text" ? "text" : "sticker"; if (!(node.color in PALETTES[scope])) { fail("unknown-color", path, `${node.color}: у этого объекта своя палитра — ${Object.keys(PALETTES[scope]).join(", ")}`); } } for (const field of ["title", "text"]) { const value = node[field]; if (value === undefined) continue; if (typeof value !== "string") { fail("bad-text", `${path}.${field}`, "текст должен быть строкой"); continue; } if (value.length > MAX_TEXT_LENGTH) fail("text-too-long", `${path}.${field}`, `${value.length} символов`); if (/<[a-zA-Z/]/.test(value)) fail("html-in-text", `${path}.${field}`, "HTML запрещён, используйте markdown"); } if (type === "card") { if (node.variant !== undefined && !["sticker", "figure", "text"].includes(node.variant)) { fail("bad-variant", path, `${node.variant}: допустимы sticker, figure, text`); } if (node.variant === "figure") { if (node.figure === undefined) { fail("figure-without-type", path, "у фигуры нужно указать figure — иначе непонятно, что рисовать"); } else if (!ALL_FIGURE_TYPES.includes(node.figure)) { fail("unknown-figure", path, `${node.figure}: нет такой фигуры на доске`); } } if (node.stroke !== undefined) { if (node.variant !== "figure") fail("stroke-on-non-figure", path, "граница есть только у фигур"); else if (!(node.stroke in ELEMENT_COLORS)) { fail("unknown-stroke", path, `${node.stroke}: допустимы ${Object.keys(ELEMENT_COLORS).join(", ")}`); } } if (!node.id) fail("card-without-id", path, "карточке нужен id, иначе на неё нельзя сослаться"); return; } for (const [field, expected] of [["items", ["row", "column", "grid"]], ["rows", ["table"]], ["columns", ["table"]], ["lanes", ["swimlanes"]]]) { if (expected.includes(type) && node[field] !== undefined && !Array.isArray(node[field])) { fail("not-an-array", `${path}.${field}`, "ожидался массив"); return; } } if (type === "grid") { const columns = node.columns; if (!Number.isInteger(columns) || columns < 1 || columns > MAX_GRID_COLUMNS) { fail("bad-grid-columns", path, `columns должен быть целым 1..${MAX_GRID_COLUMNS}`); } } if (type === "table") { const columns = Array.isArray(node.columns) ? node.columns : []; if (!columns.length) fail("table-without-columns", path, "нужна хотя бы одна колонка"); const columnIds = new Set(); columns.forEach((column, index) => { if (column?.width !== undefined && !(column.width in COLUMN_WIDTHS)) { fail("bad-width", `${path}.columns[${index}]`, `${column.width}: допустимы s, m, l`); } if (!column?.id) fail("column-without-id", `${path}.columns[${index}]`, "у колонки должен быть id"); else if (columnIds.has(column.id)) fail("duplicate-column-id", `${path}.columns[${index}]`, column.id); else columnIds.add(column.id); }); const rows = Array.isArray(node.rows) ? node.rows : []; const rowIds = new Set(); rows.forEach((row, index) => { const rowPath = `${path}.rows[${index}]`; if (row?.id) { if (rowIds.has(row.id)) fail("duplicate-row-id", rowPath, row.id); else rowIds.add(row.id); } if (row?.color !== undefined && !(row.color in ELEMENT_COLORS)) { fail("unknown-color", rowPath, `${row.color}: у ячеек своя палитра — ${Object.keys(ELEMENT_COLORS).join(", ")}`); } for (const key of Object.keys(row?.cells ?? {})) { if (!columnIds.has(key)) fail("unknown-column", `${rowPath}.cells.${key}`, "нет такой колонки"); const value = row.cells[key]; if (typeof value !== "string") { fail("cell-not-a-string", `${rowPath}.cells.${key}`, "значение ячейки — строка markdown; цвет задаётся у строки через row.color"); } else { if (/<[a-zA-Z/]/.test(value)) fail("html-in-text", `${rowPath}.cells.${key}`, "HTML запрещён, используйте markdown"); if (value.length > MAX_TEXT_LENGTH) fail("text-too-long", `${rowPath}.cells.${key}`, `${value.length} символов`); } } for (const [key, child] of Object.entries(row?.content ?? {})) { if (!columnIds.has(key)) fail("unknown-column", `${rowPath}.content.${key}`, "нет такой колонки"); // Текст ячейки рисуется поверх вложенного контейнера — получается каша. // Ячейка либо подписана, либо содержит вложение. if (String(row?.cells?.[key] ?? "").trim()) { fail("cell-text-and-content", `${rowPath}.${key}`, "в ячейке одновременно текст и вложенный контейнер — они наложатся друг на друга"); } walk(child, `${rowPath}.content.${key}`, depth + 1); } }); return; } if (type === "swimlanes") { const lanes = Array.isArray(node.lanes) ? node.lanes : []; if (!lanes.length) fail("swimlanes-without-lanes", path, "нужна хотя бы одна дорожка"); lanes.forEach((lane, index) => { const lanePath = `${path}.lanes[${index}]`; if (!lane?.id) fail("lane-without-id", lanePath, "у дорожки должен быть id"); else if (ids.has(lane.id)) fail("duplicate-id", lanePath, lane.id); else ids.set(lane.id, lanePath); if (lane?.color !== undefined && !(lane.color in ELEMENT_COLORS)) { fail("unknown-color", lanePath, `${lane.color}: у дорожек палитра ячеек — ${Object.keys(ELEMENT_COLORS).join(", ")}`); } if (lane?.content) walk(lane.content, `${lanePath}.content`, depth + 1); }); return; } const items = Array.isArray(node.items) ? node.items : []; if (!items.length) warnings.push({ code: "empty-container", path, detail: type, severity: "warn" }); items.forEach((item, index) => walk(item, `${path}.items[${index}]`, depth + 1)); }; if (layout.content) walk(layout.content, "content", 1); if (layout.edges !== undefined && !Array.isArray(layout.edges)) { fail("not-an-array", "edges", "ожидался массив"); return { errors, warnings }; } const edges = layout.edges ?? []; const edgeIds = new Set(); edges.forEach((edge, index) => { const path = `edges[${index}]`; if (edge?.id) { if (edgeIds.has(edge.id) || ids.has(edge.id)) fail("duplicate-id", path, `id "${edge.id}" уже занят`); else edgeIds.add(edge.id); } for (const end of ["from", "to"]) { const ref = edge?.[end]; if (!ref?.id) { fail("edge-without-target", `${path}.${end}`, "нужен id элемента"); continue; } if (!ids.has(ref.id)) { fail("dangling-edge", `${path}.${end}`, `нет элемента с id "${ref.id}"`); continue; } if (!connectable.has(ref.id)) { fail("edge-to-container", `${path}.${end}`, `связь можно тянуть только к card, а "${ref.id}" — контейнер`); } if (ref.side !== undefined && !["top", "right", "bottom", "left", "auto"].includes(ref.side)) { fail("bad-side", `${path}.${end}.side`, String(ref.side)); } } if (edge?.arrow !== undefined && !["none", "end", "both"].includes(edge.arrow)) { fail("bad-arrow", path, String(edge.arrow)); } if (edge?.line !== undefined && !(edge.line in LINE_TYPES)) { fail("bad-line-type", path, `${edge.line}: допустимы ${Object.keys(LINE_TYPES).join(", ")}`); } }); return { errors, warnings }; } function boardLayoutToScene(layout, opts = {}) { const measure = opts.measure ?? heuristicMeasure; const { errors, warnings } = validateBoardLayout(layout); if (errors.length) { const first = errors[0]; const error = new Error(`board-layout-v1: ${first.code} в ${first.path || "корне"} — ${first.detail}`); error.errors = errors; throw error; } const nodes = []; const idToKey = new Map(); let nodeCounter = 0; let cellCounter = 0; const emit = (node) => { const key = `n${++nodeCounter}`; const scene = { key, paintOrder: nodes.length, ...node }; nodes.push(scene); return scene; }; const register = (id, key) => { if (id) idToKey.set(id, key); }; const color = (scope, name, fallback) => PALETTES[scope][name] ?? PALETTES[scope][fallback]; const ctx = { emit, register, measure, warnings }; const root = buildNode(layout.content, ctx); root.place(0, 0, null); // Стороны считаются после раскладки: "auto" и пропущенное значение выбираются // по взаимному положению карточек, иначе стрелка втыкается в бок фигуры. const rectOf = new Map(nodes.map((node) => [node.key, node.rect])); const autoSides = (fromRect, toRect) => { const dx = (toRect.x + toRect.w / 2) - (fromRect.x + fromRect.w / 2); const dy = (toRect.y + toRect.h / 2) - (fromRect.y + fromRect.h / 2); if (Math.abs(dx) >= Math.abs(dy)) { return dx >= 0 ? ["right", "left"] : ["left", "right"]; } return dy >= 0 ? ["bottom", "top"] : ["top", "bottom"]; }; const edges = []; let edgeCounter = 0; for (const edge of layout.edges ?? []) { const fromKey = idToKey.get(edge.from.id); const toKey = idToKey.get(edge.to.id); if (!fromKey || !toKey) continue; const [autoFrom, autoTo] = autoSides(rectOf.get(fromKey), rectOf.get(toKey)); const pick = (side, fallback) => (side && side !== "auto" ? side : fallback); edges.push({ key: `e${++edgeCounter}`, from: { nodeKey: fromKey, side: pick(edge.from.side, autoFrom) }, to: { nodeKey: toKey, side: pick(edge.to.side, autoTo) }, arrow: edge.arrow ?? "end", ...(edge.line && LINE_TYPES[edge.line] ? { lineType: LINE_TYPES[edge.line] } : {}) }); } return { scene: { nodes, edges }, warnings }; // --- построение --------------------------------------------------------- // Каждый билдер возвращает собственный размер и функцию размещения. Размер // считается снизу вверх (сначала дети), координаты назначаются сверху вниз — // иначе вложенная таблица вылезает за свою дорожку. function buildNode(node, context) { switch (node.type) { case "card": return buildCard(node, context); case "row": return buildFlow(node, context, "row"); case "column": return buildFlow(node, context, "column"); case "grid": return buildGrid(node, context); case "table": return buildTable(node, context); case "swimlanes": return buildSwimlanes(node, context); default: return { w: 0, h: 0, place() {} }; } } function cardText(node) { const parts = []; if (node.title) parts.push(`**${node.title}**`); if (node.text) parts.push(node.text); return markdownToBlocks(parts.join("\n")); } function buildCard(node, context) { const variant = ["figure", "text"].includes(node.variant) ? node.variant : "sticker"; const text = cardText(node); let base; if (variant === "figure") { // Размер фигуры фиксирован: и пропорции, и масштаб относительно других // фигур — часть смысла схемы. Под длинную подпись доска сама уменьшает // кегль (fontSizeAuto), поэтому раздувать фигуру не нужно и вредно: // ряд из фигур разного размера перестаёт читаться как схема. const natural = FIGURE_NATURAL[node.figure] ?? FIGURE_NATURAL.rectangle; const scale = FIGURE_SCALE[node.size] ?? FIGURE_SCALE.m; base = { w: Math.round(natural.w * scale), h: Math.round(natural.h * scale) }; } else { const preset = CARD_SIZES[variant][node.size] ?? CARD_SIZES[variant].m; base = { w: preset.w, h: Math.max(preset.h, context.measure(text, preset.w, FONT_SIZES.card)) }; } return { w: base.w, h: base.h, place(x, y, parent) { const scene = context.emit({ kind: variant === "text" ? "textArea" : variant, parent, rect: { x, y, w: base.w, h: base.h }, style: { color: variant === "text" ? color("text", node.color, "transparent") : variant === "figure" ? color("figureFill", node.color, "white") : color("sticker", node.color, "blue"), fontSize: FONT_SIZES.card, ...(variant === "figure" ? { figureType: node.figure ?? "rectangle", opacity: 1, strokeColor: ELEMENT_COLORS[node.stroke] ?? ELEMENT_COLORS.blue } : {}), ...(variant === "text" ? { opacity: 1 } : {}) }, text, connectable: true }); context.register(node.id, scene.key); } }; } function buildFlow(node, context, direction) { const gap = GAPS[node.gap] ?? GAPS.m; const children = (node.items ?? []).map((item) => buildNode(item, context)); const w = direction === "row" ? children.reduce((sum, child) => sum + child.w, 0) + gap * Math.max(0, children.length - 1) : children.reduce((max, child) => Math.max(max, child.w), 0); const h = direction === "row" ? children.reduce((max, child) => Math.max(max, child.h), 0) : children.reduce((sum, child) => sum + child.h, 0) + gap * Math.max(0, children.length - 1); return { w, h, place(x, y, parent) { // Поперёк потока элементы центрируются: ряд фигур разной высоты, // прижатый к верхнему краю, читается как случайная россыпь. let cursor = direction === "row" ? x : y; for (const child of children) { if (direction === "row") { child.place(cursor, y + (h - child.h) / 2, parent); cursor += child.w + gap; } else { child.place(x + (w - child.w) / 2, cursor, parent); cursor += child.h + gap; } } } }; } function buildGrid(node, context) { const gap = GAPS[node.gap] ?? GAPS.m; const columns = node.columns; const children = (node.items ?? []).map((item) => buildNode(item, context)); const columnWidths = new Array(columns).fill(0); const rowHeights = []; children.forEach((child, index) => { const column = index % columns; const row = Math.floor(index / columns); columnWidths[column] = Math.max(columnWidths[column], child.w); rowHeights[row] = Math.max(rowHeights[row] ?? 0, child.h); }); const w = columnWidths.reduce((sum, value) => sum + value, 0) + gap * Math.max(0, columns - 1); const h = rowHeights.reduce((sum, value) => sum + value, 0) + gap * Math.max(0, rowHeights.length - 1); return { w, h, place(x, y, parent) { children.forEach((child, index) => { const column = index % columns; const row = Math.floor(index / columns); const offsetX = x + columnWidths.slice(0, column).reduce((sum, value) => sum + value + gap, 0); const offsetY = y + rowHeights.slice(0, row).reduce((sum, value) => sum + value + gap, 0); // Элемент центрируется в своей клетке сетки по обеим осям. child.place( offsetX + (columnWidths[column] - child.w) / 2, offsetY + (rowHeights[row] - child.h) / 2, parent ); }); } }; } // Общая сборка таблицы: и для table, и для swimlanes. Ширина живёт на колонке, // высота на строке — инвариант доски (w таблицы = сумма ширин в каждой строке) // нарушить структурно нельзя. function buildTableLike(spec, context) { const { columns, rows, fontSize, tableColor, title } = spec; for (const column of columns) { column.w = Math.max(column.w, TABLE_COLUMN_MIN_WIDTH); } for (const row of rows) { for (const cell of row.cells) { if (!cell.child) continue; const column = columns[cell.columnIndex]; column.w = Math.max(column.w, cell.child.w + 2 * CELL_PAD_X); } } for (const row of rows) { let height = TABLE_ROW_MIN_HEIGHT; for (const cell of row.cells) { const column = columns[cell.columnIndex]; height = Math.max(height, context.measure(cell.text, column.w, cell.fontSize ?? fontSize)); if (cell.child) height = Math.max(height, cell.child.h + 2 * CELL_PAD_Y); } row.h = height; } const w = columns.reduce((sum, column) => sum + column.w, 0); const h = rows.reduce((sum, row) => sum + row.h, 0); return { w, h, place(x, y, parent) { const cellKeys = rows.map((row) => row.cells.map(() => `c${++cellCounter}`)); const scene = context.emit({ kind: "table", parent, rect: { x, y, w, h }, style: { color: tableColor ?? ELEMENT_COLORS.white, fontSize }, ...(title ? { title: markdownToBlocks(title) } : {}), table: { columns: columns.map((column, index) => ({ key: `col${index + 1}`, w: column.w })), rows: rows.map((row, rowIndex) => ({ key: `row${rowIndex + 1}`, h: row.h, cells: row.cells.map((cell, cellIndex) => ({ key: cellKeys[rowIndex][cellIndex], columnKey: `col${cell.columnIndex + 1}`, text: cell.text, color: cell.color ?? ELEMENT_COLORS.white, opacity: cell.opacity ?? 1, fontSize: cell.fontSize ?? fontSize })) })) }, connectable: false }); context.register(spec.id, scene.key); let offsetY = y; rows.forEach((row, rowIndex) => { let offsetX = x; row.cells.forEach((cell, cellIndex) => { if (cell.child) { cell.child.place(offsetX + CELL_PAD_X, offsetY + CELL_PAD_Y, { nodeKey: scene.key, cellKey: cellKeys[rowIndex][cellIndex] }); } offsetX += columns[cell.columnIndex].w; }); offsetY += row.h; }); } }; } function buildTable(node, context) { const columnSpecs = node.columns.map((column) => ({ id: column.id, title: column.title, w: COLUMN_WIDTHS[column.width] ?? COLUMN_WIDTHS.m })); const indexOf = new Map(columnSpecs.map((column, index) => [column.id, index])); const rows = []; if (columnSpecs.some((column) => column.title)) { rows.push({ cells: columnSpecs.map((column, index) => ({ columnIndex: index, text: markdownToBlocks(`**${column.title ?? ""}**`), color: TABLE_HEADER_COLOR })) }); } for (const row of node.rows ?? []) { rows.push({ id: row.id, cells: columnSpecs.map((column, index) => ({ columnIndex: index, text: markdownToBlocks(row.cells?.[column.id] ?? ""), color: row.color ? ELEMENT_COLORS[row.color] : ELEMENT_COLORS.white, opacity: row.color ? 0.4 : 1, child: row.content?.[column.id] ? buildNode(row.content[column.id], context) : null })) }); } void indexOf; return buildTableLike({ id: node.id, title: node.title, columns: columnSpecs, rows, fontSize: FONT_SIZES.table }, context); } function buildSwimlanes(node, context) { const columns = [ { id: "lane", w: COLUMN_WIDTHS.m }, { id: "content", w: COLUMN_WIDTHS.l } ]; const rows = (node.lanes ?? []).map((lane) => { const laneColor = lane.color ? ELEMENT_COLORS[lane.color] : ELEMENT_COLORS.white; return { id: lane.id, cells: [ { columnIndex: 0, text: markdownToBlocks(lane.title ?? ""), color: laneColor, opacity: lane.color ? 0.4 : 1, fontSize: FONT_SIZES.lane }, { columnIndex: 1, text: markdownToBlocks(""), color: laneColor, opacity: lane.color ? 0.4 : 1, fontSize: FONT_SIZES.lane, child: lane.content ? buildNode(lane.content, context) : null } ] }; }); return buildTableLike({ id: node.id, title: node.title, columns, rows, fontSize: FONT_SIZES.lane }, context); } } // ===== FILE: src/converters/board-to-layout.js ===== const LINE_NAMES = Object.fromEntries(Object.entries(LINE_TYPES).map(([name, native]) => [native, name])); // Имя цвета ищется в палитре своего объекта: у стикера и у фигуры один и тот же // hex может называться по-разному, а одно имя — означать разные цвета. const nameMap = (palette) => new Map(Object.entries(palette).map(([n, hex]) => [hex.toUpperCase(), n])); const ELEMENT_NAMES = nameMap(ELEMENT_COLORS); const STICKER_NAMES = nameMap(STICKER_COLORS); // Допуски распознавания. Ряд считается рядом, пока элементы перекрываются по // вертикали не хуже, чем на ROW_TOLERANCE; сетка признаётся сеткой, пока левые // края колонок совпадают в пределах GRID_TOLERANCE. Значения подобраны так, // чтобы ручной сдвиг карточки на доске не менял распознанную структуру. const ROW_TOLERANCE = 60; const GRID_TOLERANCE = 40; function sceneToBoardLayout(scene) { const warnings = []; const nodes = scene?.nodes ?? []; const childrenOfCell = new Map(); const roots = []; for (const node of nodes) { if (!node.parent) { roots.push(node); continue; } const key = node.parent.cellKey; if (!childrenOfCell.has(key)) childrenOfCell.set(key, []); childrenOfCell.get(key).push(node); } const usedIds = new Map(); const keyToId = new Map(); const ctx = { childrenOfCell, warnings, usedIds, keyToId }; const content = groupNodes(roots, ctx); const edges = []; for (const edge of scene?.edges ?? []) { const from = ctx.keyToId.get(edge.from?.nodeKey); const to = ctx.keyToId.get(edge.to?.nodeKey); if (!from || !to) { warnings.push({ code: "edge-endpoint-lost", path: edge.key ?? "", severity: "warn" }); continue; } edges.push({ id: makeId("edge", ctx), from: { id: from, side: edge.from.side ?? "auto" }, to: { id: to, side: edge.to.side ?? "auto" }, ...(edge.arrow && edge.arrow !== "end" ? { arrow: edge.arrow === "start" ? "end" : edge.arrow } : {}), ...(edge.lineType ? { line: LINE_NAMES[edge.lineType] ?? "bezier" } : {}) }); if (edge.arrow === "start") { warnings.push({ code: "arrow-direction-normalized", path: edge.key ?? "", severity: "info" }); } } const layout = { $schema: "board-layout-v1", content: content ?? { type: "column", items: [] } }; if (edges.length) layout.edges = edges; return { layout, warnings }; } // --- распознаватели. Список закрыт: четыре случая, больше не добавлять ------- function groupNodes(list, ctx) { const nodes = [...list].sort((a, b) => (a.rect.y - b.rect.y) || (a.rect.x - b.rect.x)); if (!nodes.length) return null; if (nodes.length === 1) return convertNode(nodes[0], ctx); const rows = []; for (const node of nodes) { const row = rows.find((candidate) => Math.abs(candidate.y - node.rect.y) <= ROW_TOLERANCE); if (row) { row.items.push(node); row.y = Math.min(row.y, node.rect.y); } else rows.push({ y: node.rect.y, items: [node] }); } for (const row of rows) row.items.sort((a, b) => a.rect.x - b.rect.x); const widths = new Set(rows.map((row) => row.items.length)); const onlyCards = nodes.every((node) => node.kind !== "table"); const aligned = rows.length > 1 && widths.size === 1 && rows[0].items.length > 1 && rows.every((row) => row.items.every((item, index) => Math.abs(item.rect.x - rows[0].items[index].rect.x) <= GRID_TOLERANCE)); if (onlyCards && aligned) { return { type: "grid", columns: rows[0].items.length, items: rows.flatMap((row) => row.items.map((node) => convertNode(node, ctx))) }; } if (rows.length === 1) { return { type: "row", items: rows[0].items.map((node) => convertNode(node, ctx)) }; } return { type: "column", items: rows.map((row) => (row.items.length === 1 ? convertNode(row.items[0], ctx) : { type: "row", items: row.items.map((node) => convertNode(node, ctx)) })) }; } function convertNode(node, ctx) { if (node.kind === "table") return convertTable(node, ctx); return convertCard(node, ctx); } function convertCard(node, ctx) { const markdown = blocksToMarkdown(node.text).trim(); const blocks = node.text?.blocks ?? []; // Заголовком становится только первый абзац. Если карточка начинается со // списка, разрывать его на title и text нельзя — потеряется нумерация. const titleIsSeparate = blocks.length > 0 && blocks[0].type === "p"; const [firstLine, ...restLines] = markdown.split("\n"); const title = titleIsSeparate ? stripEmphasis(firstLine ?? "") : ""; const rest = (titleIsSeparate ? restLines.join("\n") : markdown).trim(); const id = makeId(title || firstLine || node.kind, ctx); ctx.keyToId.set(node.key, id); const VARIANTS = { figure: "figure", textArea: "text" }; const card = { type: "card", id, variant: VARIANTS[node.kind] ?? "sticker" }; if (node.kind === "figure" && node.style?.figureType) card.figure = node.style.figureType; const scope = node.kind === "sticker" ? "sticker" : "element"; const color = colorName(node.style?.color, scope); const defaults = node.kind === "sticker" ? ["blue"] : ["white", "none"]; if (color && !defaults.includes(color)) card.color = color; if (node.kind === "figure") { const stroke = colorName(node.style?.strokeColor, "element"); if (stroke && stroke !== "blue") card.stroke = stroke; } if (title) card.title = title; if (rest) card.text = rest; return card; } function convertTable(node, ctx) { const table = node.table ?? { columns: [], rows: [] }; const cellChildren = (cell) => ctx.childrenOfCell.get(cell.key) ?? []; // Свимлейн: две колонки, справа лежит вложенное содержимое. const isSwimlane = table.columns.length === 2 && table.rows.some((row) => cellChildren(row.cells[1] ?? {}).length > 0); if (isSwimlane) { const id = makeId("lanes", ctx); ctx.keyToId.set(node.key, id); const swimlaneTitle = blocksToMarkdown(node.title).trim(); return { type: "swimlanes", id, ...(swimlaneTitle ? { title: swimlaneTitle } : {}), lanes: table.rows.map((row) => { const titleCell = row.cells[0] ?? {}; const bodyCell = row.cells[1] ?? {}; const title = blocksToMarkdown(titleCell.text).trim(); const lane = { id: makeId(stripEmphasis(title) || "lane", ctx), title: stripEmphasis(title) }; const color = colorName(titleCell.color); if (color && color !== "white") lane.color = color; const content = groupNodes(cellChildren(bodyCell), ctx); if (content) lane.content = content; return lane; }) }; } const id = makeId("table", ctx); ctx.keyToId.set(node.key, id); // Заголовочная строка узнаётся по серому фону — так её рисует движок. const headerRow = table.rows[0]; const hasHeader = Boolean(headerRow) && headerRow.cells.length > 0 && headerRow.cells.every((cell) => sameColor(cell.color, TABLE_HEADER_COLOR)); const columns = table.columns.map((column, index) => { const title = hasHeader ? stripEmphasis(blocksToMarkdown(headerRow.cells[index]?.text).trim()) : ""; return title ? { id: `col${index + 1}`, title } : { id: `col${index + 1}` }; }); const bodyRows = hasHeader ? table.rows.slice(1) : table.rows; const rows = bodyRows.map((row, rowIndex) => { const cells = {}; const content = {}; row.cells.forEach((cell, index) => { const columnId = columns[index]?.id; if (!columnId) return; cells[columnId] = blocksToMarkdown(cell.text).trim(); const nested = groupNodes(cellChildren(cell), ctx); if (nested) content[columnId] = nested; }); const result = { id: makeId(`row-${rowIndex + 1}`, ctx), cells }; const color = colorName(row.cells[0]?.color); if (color && color !== "white") result.color = color; if (Object.keys(content).length) result.content = content; return result; }); const tableTitle = blocksToMarkdown(node.title).trim(); return { type: "table", id, ...(tableTitle ? { title: tableTitle } : {}), columns, rows }; } // --- вспомогательное --------------------------------------------------------- function sameColor(a, b) { return typeof a === "string" && typeof b === "string" && a.toUpperCase() === b.toUpperCase(); } function colorName(hex, scope = "element") { if (typeof hex !== "string") return null; const names = scope === "sticker" ? STICKER_NAMES : ELEMENT_NAMES; return names.get(hex.toUpperCase()) ?? null; } function stripEmphasis(value) { return value.replace(/\*\*/g, "").replace(/~~/g, "").replace(/(^|\s)\*(\S)/g, "$1$2").replace(/(\S)\*(\s|$)/g, "$1$2").trim(); } const TRANSLIT = { а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "e", ж: "zh", з: "z", и: "i", й: "i", к: "k", л: "l", м: "m", н: "n", о: "o", п: "p", р: "r", с: "s", т: "t", у: "u", ф: "f", х: "h", ц: "c", ч: "ch", ш: "sh", щ: "sch", ъ: "", ы: "y", ь: "", э: "e", ю: "yu", я: "ya" }; // Идентификаторы выводятся из содержимого, а не из UUID: LLM должна уметь // сказать «убери sber-billing», а не «убери n-4e9ccec0». function makeId(source, ctx) { const base = String(source ?? "") .toLowerCase() .split("") .map((char) => TRANSLIT[char] ?? char) .join("") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .split("-") .slice(0, 4) .join("-") .slice(0, 40) || "item"; const seen = ctx.usedIds.get(base) ?? 0; ctx.usedIds.set(base, seen + 1); return seen ? `${base}-${seen + 1}` : base; } // ===== FILE: src/converters/board-to-markdown.js ===== // Доска → Markdown-таблицы объектов. // // На доске объекты живут в двух видах, и различать их приходится по форме // таблицы, потому что больше не по чему: // - таблица «Поле | Значение» — это ОДИН объект, её строки это его свойства; // - таблица с заголовками колонок — набор объектов, по объекту на строку. // Таблицы одинаковой формы сливаются в одну Markdown-таблицу, чтобы 65 бланков // команд превратились в 65 строк, а не в 65 отдельных таблиц. const KEY_VALUE_HEADERS = [ ["поле", "значение"], ["field", "value"], ["свойство", "значение"], ["параметр", "значение"] ]; function boardLayoutToMarkdown(layout, opts = {}) { const collected = collect(layout); const parts = []; if (opts.title) parts.push(`# ${opts.title}`, ""); for (const group of collected.objectGroups) { parts.push(`## ${group.name}`, ""); parts.push(renderTable( ["Дорожка", "Объект", ...group.fields], group.objects.map((object) => [ object.lane, object.name, ...group.fields.map((field) => object.fields.get(field) ?? "") ]) ), ""); } for (const group of collected.rowGroups) { parts.push(`## ${group.name}`, ""); parts.push(renderTable( ["Дорожка", "Объект", ...group.columns], group.rows.map((row) => [row.lane, row.source, ...row.cells]) ), ""); } if (collected.cards.length) { parts.push("## Карточки", ""); parts.push(renderTable( ["Дорожка", "Тип", "Заголовок", "Текст", "Цвет"], collected.cards.map((card) => [card.lane, card.kind, card.title, card.text, card.color]) ), ""); } if (collected.edges.length) { parts.push("## Связи", ""); parts.push(renderTable( ["Откуда", "Куда", "Стрелка"], collected.edges.map((edge) => [edge.from, edge.to, edge.arrow]) ), ""); } if (!parts.length) return "На доске нет ни таблиц, ни карточек.\n"; return parts.join("\n").replace(/\n+$/, "") + "\n"; } function collect(layout) { const objects = []; const rowRecords = []; const cards = []; const titleById = new Map(); const walk = (node, lane) => { if (!node || typeof node !== "object") return; switch (node.type) { case "swimlanes": for (const swimlane of node.lanes ?? []) { walk(swimlane.content, firstLine(swimlane.title) || lane); } return; case "table": readTable(node, lane); return; case "card": titleById.set(node.id, firstLine(node.title) || firstLine(node.text) || node.id); cards.push({ lane: lane ?? "", kind: node.variant === "figure" ? (node.figure ?? "фигура") : (node.variant ?? "sticker"), title: inline(node.title), text: inline(node.text), color: node.color ?? "" }); return; default: for (const item of node.items ?? []) walk(item, lane); } }; const readTable = (table, lane) => { const columns = table.columns ?? []; const rows = table.rows ?? []; const source = firstLine(table.title) || table.id || ""; if (isKeyValue(columns)) { const fields = new Map(); for (const row of rows) { const key = inline(row.cells?.[columns[0].id]); if (!key) continue; fields.set(key, inline(row.cells?.[columns[1].id])); } // Имя объекта — из поля «Команда»/«Название», иначе из заголовка таблицы // после разделителя: «Ландшафт SDD · Знания Ops» → «Знания Ops». const named = fields.get("Команда") || fields.get("Название") || fields.get("Объект"); objects.push({ lane: lane ?? "", name: named || afterSeparator(source), group: beforeSeparator(source), fields }); } else { for (const row of rows) { rowRecords.push({ lane: lane ?? "", source: afterSeparator(source) || source, group: beforeSeparator(source), columns: columns.map((column) => column.title || column.id), cells: columns.map((column) => inline(row.cells?.[column.id])) }); } } for (const row of rows) { for (const nested of Object.values(row.content ?? {})) walk(nested, lane); } }; walk(layout?.content, null); return { objectGroups: groupBy(objects, (object) => object.group, (name, list) => ({ name: name || "Объекты", fields: unionFields(list), objects: list })), rowGroups: groupBy(rowRecords, (record) => `${record.group} ${record.columns.join("|")}`, (key, list) => ({ name: key.split("