skillok meeting
955f445c6196281d40574259b51317a618aa5376
Перейти к файлу
Код
meeting srcmarkdown.ts
anc Переименование говорящих и экран «Новая запись» (#13) - Имя говорящего в расшифровке — кнопка: нажал, вписал «Ольга», Enter — подпись сменилась во всех его репликах, а имя целым словом — в конспекте, задачах и других результатах («Я» там не трогается). - «Записать встречу» в меню, «Записать» в шапке и на главной открывают экран «Новая запись»: что запишется, совет про наушники, предупреждение участникам с копированием. Запись начинается только кнопкой «Начать запись». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 87935b9 30 сентября 2026 г.
122 строки 5.5 КБ
markdown.ts
// Markdown заметки: отображение и деление на разделы.
//
// Заметка — единственный источник текста. В режиме просмотра она делится на
// разделы по заголовкам второго уровня (`## Расшифровка`, `## Задачи`), и у
// каждого раздела своя кнопка «Копировать» — копируется исходный Markdown
// раздела, а не HTML.

import { marked } from "marked";
import DOMPurify from "dompurify";

marked.setOptions({ gfm: true, breaks: false });

export function render(md: string): string {
  return DOMPurify.sanitize(marked.parse(md, { async: false }));
}

export interface Section {
  /** Заголовок раздела без `## `; у текста до первого заголовка — пусто. */
  heading: string;
  /** Исходный Markdown раздела вместе с заголовком. */
  source: string;
}

export function sections(md: string): Section[] {
  const out: Section[] = [];
  let current: Section = { heading: "", source: "" };
  let fence = false;
  for (const line of md.split("\n")) {
    if (/^(```|~~~)/.test(line)) fence = !fence;
    const h = !fence && /^## (.+)$/.exec(line);
    if (h) {
      if (current.source.trim()) out.push(current);
      current = { heading: h[1].trim(), source: "" };
    }
    current.source += line + "\n";
  }
  if (current.source.trim()) out.push(current);
  for (const s of out) s.source = s.source.trim() + "\n";
  return out;
}

/** Текст без разметки — для вставки туда, где Markdown не понимают. */
export function plain(md: string): string {
  const div = document.createElement("div");
  div.innerHTML = render(md);
  return (div.innerText || div.textContent || "").replace(/\n{3,}/g, "\n\n").trim();
}

/**
 * Переключает n-ю по счёту галочку списка задач (`- [ ]` ↔ `- [x]`) в
 * исходнике. Порядок тот же, что у галочек в отображении: код в ``` не в счёт.
 */
export function toggleTask(md: string, n: number): string {
  let fence = false;
  let seen = 0;
  return md
    .split("\n")
    .map((line) => {
      if (/^(```|~~~)/.test(line)) fence = !fence;
      if (fence) return line;
      const m = /^(\s*(?:[-*+]|\d+[.)])\s+)\[( |x|X)\]/.exec(line);
      if (!m) return line;
      if (seen++ !== n) return line;
      const mark = m[2] === " " ? "x" : " ";
      return m[1] + `[${mark}]` + line.slice(m[0].length);
    })
    .join("\n");
}

/** Кладёт раздел на место прежнего с тем же заголовком или в конец — как
 * `notes::upsert` в ядре. `source` начинается с `## заголовок`. */
export function upsertSection(md: string, heading: string, source: string): string {
  const parts = sections(md);
  const block = { heading, source: source.trim() + "\n" };
  const i = parts.findIndex((s) => s.heading === heading);
  if (i >= 0) parts[i] = block;
  else parts.push(block);
  return parts.map((s) => s.source.trim()).join("\n\n") + "\n";
}

/** Тело раздела без строки заголовка — для правки. */
export function sectionBody(source: string): string {
  return source.replace(/^## .*\n?/, "").trim();
}

/** Почему имя говорящего не подходит; `null` — подходит. Звёздочка, двоеточие
 * и перенос строки сломали бы разметку реплики «**00:03:21 Имя:** текст». */
export function speakerNameProblem(name: string): string | null {
  const n = name.trim();
  if (!n) return "Имя не может быть пустым";
  if (n.length > 40) return "Слишком длинное имя — до 40 знаков";
  if (/[*:\n]/.test(n)) return "В имени не должно быть «*» и «:»";
  return null;
}

/**
 * Переименовывает говорящего во всей заметке. В расшифровке меняется подпись
 * каждой его реплики. В остальных разделах (конспект, задачи…) — имя целым
 * словом; «Я» там не трогается: это обычное слово, а не только подпись.
 * Возвращает новую заметку и сколько реплик переименовано.
 */
export function renameSpeaker(md: string, heading: string, from: string, to: string): { note: string; lines: number } {
  const name = to.trim();
  const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const label = new RegExp(`^(\\*\\*\\d\\d:\\d\\d:\\d\\d )${escaped}(:\\*\\*)`, "gm");
  const word = new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, "gu");
  let lines = 0;
  const parts = sections(md).map((s) => {
    if (s.heading === heading) {
      const source = s.source.replace(label, (_, head: string, tail: string) => {
        lines++;
        return head + name + tail;
      });
      return source;
    }
    if (from === "Я" || !s.heading) return s.source;
    // Строка заголовка остаётся как была: от неё зависит вкладка.
    const nl = s.source.indexOf("\n");
    return s.source.slice(0, nl + 1) + s.source.slice(nl + 1).replace(word, name);
  });
  return { note: parts.map((p) => p.trim()).join("\n\n") + "\n", lines };
}