skillok meeting

Переименование говорящих и экран «Новая запись» (#13)

- Имя говорящего в расшифровке — кнопка: нажал, вписал «Ольга», Enter — подпись сменилась во всех его репликах, а имя целым словом — в конспекте, задачах и других результатах («Я» там не трогается). - «Записать встречу» в меню, «Записать» в шапке и на главной открывают экран «Новая запись»: что запишется, совет про наушники, предупреждение участникам с копированием. Запись начинается только кнопкой «Начать запись». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

anc · 30 сентября 2026 г. 87935b95adabf6d8cf19d1519e1834acf6fab5ff
7 файлов
  • src/main.ts +4 −1
    Показать разницуСкрыть разницу
    1 −// Оболочка. Маршруты: #/ — главная, #/m/<id>[/<вкладка>] — запись, #/settings.
    1 +// Оболочка. Маршруты: #/ — главная, #/new — новая запись, #/m/<id>[/<вкладка>] — запись, #/settings.
    2 2
    3 3 import { renderHome } from "./views/home";
    4 4 import { renderMeeting } from "./views/meeting";
    5 +import { renderNew } from "./views/new";
    5 6 import { renderSettings } from "./views/settings";
    6 7 import { refreshNav, setCrumb, startNav } from "./nav";
    7 8 import { errorText, files } from "./api";
    22 23 try {
    23 24 if (section === "m" && id) {
    24 25 await renderMeeting(main, scope, decodeURIComponent(id), tab ? decodeURIComponent(tab) : null);
    26 + } else if (section === "new") {
    27 + await renderNew(main, scope);
    25 28 } else if (section === "settings") {
    26 29 await renderSettings(main);
    27 30 } else {
  • Показать разницуСкрыть разницу
    82 82 export function sectionBody(source: string): string {
    83 83 return source.replace(/^## .*\n?/, "").trim();
    84 84 }
    85 +
    86 +/** Почему имя говорящего не подходит; `null` — подходит. Звёздочка, двоеточие
    87 + * и перенос строки сломали бы разметку реплики «**00:03:21 Имя:** текст». */
    88 +export function speakerNameProblem(name: string): string | null {
    89 + const n = name.trim();
    90 + if (!n) return "Имя не может быть пустым";
    91 + if (n.length > 40) return "Слишком длинное имя — до 40 знаков";
    92 + if (/[*:\n]/.test(n)) return "В имени не должно быть «*» и «:»";
    93 + return null;
    94 +}
    95 +
    96 +/**
    97 + * Переименовывает говорящего во всей заметке. В расшифровке меняется подпись
    98 + * каждой его реплики. В остальных разделах (конспект, задачи…) — имя целым
    99 + * словом; «Я» там не трогается: это обычное слово, а не только подпись.
    100 + * Возвращает новую заметку и сколько реплик переименовано.
    101 + */
    102 +export function renameSpeaker(md: string, heading: string, from: string, to: string): { note: string; lines: number } {
    103 + const name = to.trim();
    104 + const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    105 + const label = new RegExp(`^(\\*\\*\\d\\d:\\d\\d:\\d\\d )${escaped}(:\\*\\*)`, "gm");
    106 + const word = new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, "gu");
    107 + let lines = 0;
    108 + const parts = sections(md).map((s) => {
    109 + if (s.heading === heading) {
    110 + const source = s.source.replace(label, (_, head: string, tail: string) => {
    111 + lines++;
    112 + return head + name + tail;
    113 + });
    114 + return source;
    115 + }
    116 + if (from === "Я" || !s.heading) return s.source;
    117 + // Строка заголовка остаётся как была: от неё зависит вкладка.
    118 + const nl = s.source.indexOf("\n");
    119 + return s.source.slice(0, nl + 1) + s.source.slice(nl + 1).replace(word, name);
    120 + });
    121 + return { note: parts.map((p) => p.trim()).join("\n\n") + "\n", lines };
    122 +}
  • src/nav.ts +4 −8
    Показать разницуСкрыть разницу
    6 6
    7 7 import { api, elapsed, files, type Meeting } from "./api";
    8 8 import { esc } from "./dom";
    9 −import { addAudio, startRecording, state } from "./state";
    9 +import { addAudio, state } from "./state";
    10 10
    11 11 let meetings: Meeting[] = [];
    12 12 let captureSupported = true;
    56 56 ? link(`#/m/${encodeURIComponent(r.meeting_id)}`, r.paused ? "Запись на паузе" : "Идёт запись", ICONS.record, false,
    57 57 `<span class="mn-count" data-strong data-drawer-timer>${elapsed(r.started_at)}</span>`)
    58 58 : captureSupported
    59 − ? `<button type="button" class="mn-link" data-nav-record><span class="mn-icon">${ICONS.mic}</span><span class="mn-label">Записать встречу</span></button>`
    59 + ? link("#/new", "Записать встречу", ICONS.mic, section === "new")
    60 60 : "";
    61 61 return `
    62 62 <div class="drawer-top">
    95 95 <span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${r.paused ? "Пауза" : "Запись"}
    96 96 <span class="mono-num" data-hdr-timer>${elapsed(r.started_at)}</span></a>`;
    97 97 }
    98 − return captureSupported ? `<button class="ui-btn ui-btn-sm" data-nav-record>Записать</button>` : "";
    98 + return captureSupported ? `<a class="ui-btn ui-btn-sm" href="#/new">Записать</a>` : "";
    99 99 }
    100 100
    101 101 function crumbs(): string {
    102 102 const { section, id } = route();
    103 − const here = section === "settings" ? "Настройки" : id ? crumb ?? "" : "";
    103 + const here = section === "settings" ? "Настройки" : section === "new" ? "Новая запись" : id ? crumb ?? "" : "";
    104 104 return `<a class="hdr-crumb${here ? "" : " hdr-crumb-strong"}" href="#/">Skillok Meet</a>${
    105 105 here ? `<span class="hdr-slash">/</span><span class="hdr-crumb hdr-crumb-strong" aria-current="page">${esc(here)}</span>` : ""
    106 106 }`;
    113 113 if (body) body.innerHTML = drawerBody();
    114 114 document.getElementById("crumbs")!.innerHTML = crumbs();
    115 115 document.getElementById("hdr-right")!.innerHTML = headerRight();
    116 − document.querySelectorAll("[data-nav-record]").forEach((b) => b.addEventListener("click", () => {
    117 − closeAll();
    118 − void startRecording();
    119 − }));
    120 116 document.querySelector("[data-nav-pick]")?.addEventListener("click", async () => {
    121 117 closeAll();
    122 118 await addAudio(await files.pickAudio());
  • Показать разницуСкрыть разницу
    119 119 .page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
    120 120
    121 121 .link-btn {
    122 + display: inline-flex; align-items: center;
    122 123 padding: 0 2px; min-height: 28px;
    123 124 font: inherit; font-size: var(--text-sm);
    124 125 color: var(--color-accent-text); background: none; border: 0; cursor: pointer;
    158 159 .hero.drop { padding: 24px 32px; gap: 4px; border-style: dashed; border-color: var(--color-border-light); background: transparent; }
    159 160 .hero.is-live { border-color: var(--color-accent); box-shadow: inset 3px 0 0 var(--color-accent); }
    160 161
    162 +/* ── Новая запись ── */
    163 +.prep { gap: 20px; }
    164 +.prep h1 { font-size: var(--text-3xl); }
    165 +.prep-block { display: grid; gap: 8px; justify-items: start; width: 100%; }
    166 +.prep-list { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; }
    167 +.prep-list li { display: flex; align-items: baseline; gap: 10px; }
    168 +.prep-list .dot { background: var(--color-accent); transform: translateY(-1px); }
    169 +.prep-warning {
    170 + margin: 0; padding: 10px 14px; max-width: 60ch;
    171 + border-left: 2px solid var(--color-accent); background: var(--color-bg-secondary); color: var(--kv-body);
    172 +}
    173 +
    161 174 /* ── Список записей на главной ── */
    162 175 .records { display: grid; gap: 12px; }
    163 176 .records-head { display: flex; align-items: center; justify-content: space-between; }
    263 276 .tr-text { display: grid; gap: 2px; }
    264 277 .tr-who { font-size: var(--text-sm); font-weight: 700; color: var(--color-accent-text); }
    265 278 .tr-who.is-me { color: var(--color-text-primary); }
    279 +button.tr-who {
    280 + justify-self: start; padding: 0; font: inherit; font-size: var(--text-sm); font-weight: 700;
    281 + background: none; border: 0; cursor: pointer; text-align: left;
    282 +}
    283 +button.tr-who:hover { text-decoration: underline; text-underline-offset: 3px; }
    284 +.tr-who-input { justify-self: start; width: min(240px, 100%); padding: 2px 6px; font-size: var(--text-sm); font-weight: 700; }
    266 285 .tr-text p { color: var(--kv-body); line-height: 1.6; }
    267 286
    268 287 /* Следующий шаг под расшифровкой */
  • src/views/home.ts +2 −3
    Показать разницуСкрыть разницу
    5 5 import { esc } from "../dom";
    6 6 import { bindRecording, recordingPanel } from "../recording";
    7 7 import type { Scope } from "../scope";
    8 −import { addAudio, onChange, startRecording, state } from "../state";
    8 +import { addAudio, onChange, state } from "../state";
    9 9
    10 10 function hero(view: SettingsView): string {
    11 11 if (state.recording) {
    24 24 <section class="hero">
    25 25 <h1>Запишите встречу</h1>
    26 26 <p class="hero-text">Zoom, Google Meet, Телемост — что угодно. Запишутся собеседники и вы, потом — расшифровка, конспект и задачи.</p>
    27 − <button class="ui-btn hero-btn" data-record>Записать</button>
    27 + <a class="ui-btn hero-btn" href="#/new">Записать</a>
    28 28 <p class="small muted">Лучше в наушниках — так голоса собеседников не попадут в микрофон второй раз.</p>
    29 29 </section>
    30 30 <section class="hero drop">
    87 87 });
    88 88 root.querySelector<HTMLElement>("[data-nothing]")!.hidden = shown > 0;
    89 89 });
    90 − root.querySelector("[data-record]")?.addEventListener("click", startRecording);
    91 90 root.querySelectorAll("[data-pick]").forEach((b) => b.addEventListener("click", async () => addAudio(await files.pickAudio())));
    92 91 // После «Остановить» переход к записи делает подписка ниже.
    93 92 bindRecording(root, scope, view.settings.warning_text, () => undefined);
  • src/views/meeting.ts +51 −4
    Показать разницуСкрыть разницу
    10 10 type Command, type MeetingDetail,
    11 11 } from "../api";
    12 12 import { $, esc } from "../dom";
    13 −import { plain, render, sectionBody, sections, toggleTask, upsertSection } from "../markdown";
    13 +import { plain, renameSpeaker, render, sectionBody, sections, speakerNameProblem, toggleTask, upsertSection } from "../markdown";
    14 14 import { bindRecording, recordingPanel } from "../recording";
    15 15 import type { Scope } from "../scope";
    16 16 import { refreshNav, setCrumb } from "../nav";
    74 74 */
    75 75 function transcriptView(body: string): string {
    76 76 const paras = body.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
    77 − const rows = transcriptRows(paras);
    77 + const rows = transcriptRows(paras, true);
    78 78 return rows === null ? `<div class="md">${render(body)}</div>` : `<div class="tr">${rows}</div>`;
    79 79 }
    80 80
    81 81 /** Реплики строками таблицы; `null` — если хоть одна не похожа на реплику. */
    82 −function transcriptRows(paras: string[]): string | null {
    82 +function transcriptRows(paras: string[], renamable = false): string | null {
    83 83 const rows = paras.map((p) => LINE.exec(p));
    84 84 if (!rows.length || rows.some((r) => !r)) return paras.length ? null : "";
    85 85 return rows
    89 89 <div class="tr-row">
    90 90 <span class="tr-time mono-num">${esc(time)}</span>
    91 91 <div class="tr-text">
    92 − ${who ? `<span class="tr-who${who === "Я" ? " is-me" : ""}">${esc(who)}</span>` : ""}
    92 + ${
    93 + !who
    94 + ? ""
    95 + : renamable
    96 + ? `<button type="button" class="tr-who${who === "Я" ? " is-me" : ""}" data-speaker="${esc(who)}" title="Переименовать «${esc(who)}» во всей записи">${esc(who)}</button>`
    97 + : `<span class="tr-who${who === "Я" ? " is-me" : ""}">${esc(who)}</span>`
    98 + }
    93 99 <p>${esc(text)}</p>
    94 100 </div>
    95 101 </div>`;
    251 257 return `
    252 258 <div class="pane-tools">
    253 259 <button class="link-btn" data-edit>Править</button>
    260 + ${isTranscript ? `<span class="xs muted">Имя говорящего — нажмите, чтобы переименовать</span>` : ""}
    254 261 <span class="spacer"></span>
    255 262 ${
    256 263 isTranscript
    476 483 });
    477 484 });
    478 485
    486 + // Имя говорящего — кнопка: нажал, вписал «Ольга», Enter — имя сменилось
    487 + // во всей записи. Esc — отмена.
    488 + root.querySelectorAll<HTMLButtonElement>("[data-speaker]").forEach((btn) =>
    489 + btn.addEventListener("click", () => {
    490 + const from = btn.dataset.speaker!;
    491 + const input = document.createElement("input");
    492 + input.className = "input tr-who-input";
    493 + input.value = from;
    494 + input.setAttribute("aria-label", `Новое имя для «${from}»`);
    495 + btn.replaceWith(input);
    496 + input.focus();
    497 + input.select();
    498 + let done = false;
    499 + const finish = async (apply: boolean) => {
    500 + if (done) return;
    501 + done = true;
    502 + const to = input.value.trim();
    503 + if (!apply || to === from) {
    504 + draw();
    505 + return;
    506 + }
    507 + const problem = speakerNameProblem(to);
    508 + if (problem) {
    509 + toast(problem);
    510 + draw();
    511 + return;
    512 + }
    513 + const r = renameSpeaker(d.note, TRANSCRIPT, from, to);
    514 + await saveNote(r.note);
    515 + draw();
    516 + toast(`«${from}» теперь «${to}» — реплик: ${r.lines}`, "ok");
    517 + };
    518 + input.addEventListener("keydown", (e) => {
    519 + if (e.key === "Enter") void finish(true);
    520 + if (e.key === "Escape") void finish(false);
    521 + });
    522 + input.addEventListener("blur", () => void finish(true));
    523 + }),
    524 + );
    525 +
    479 526 root.querySelector("[data-open-folder]")?.addEventListener("click", () => api.openFolder(id).catch(toast));
    480 527
    481 528 root.querySelector("[data-delete]")?.addEventListener("click", async () => {
  • Показать разницуСкрыть разницу
    1 +// Экран «Новая запись»: «Записать встречу» ведёт сюда, а не запускает запись
    2 +// сразу (решение владельца 01.10.2026). Здесь видно, что запишется, совет про
    3 +// наушники и предупреждение участникам; запись начинается только кнопкой
    4 +// «Начать запись».
    5 +
    6 +import { api } from "../api";
    7 +import { esc } from "../dom";
    8 +import type { Scope } from "../scope";
    9 +import { copy, startRecording, state } from "../state";
    10 +
    11 +export async function renderNew(root: HTMLElement, scope: Scope): Promise<void> {
    12 + // Запись уже идёт — вторую не начать, показываем идущую.
    13 + if (state.recording) {
    14 + location.replace(`#/m/${encodeURIComponent(state.recording.meeting_id)}`);
    15 + return;
    16 + }
    17 + const view = await api.getSettings();
    18 + if (!scope.alive) return;
    19 +
    20 + if (!view.capture_supported) {
    21 + root.innerHTML = `
    22 + <div class="page">
    23 + <section class="card">
    24 + <h1>Новая запись</h1>
    25 + <p>На этой системе запись не поддерживается. Добавьте готовый аудиофайл — перетащите его в окно или выберите через меню.</p>
    26 + </section>
    27 + </div>`;
    28 + return;
    29 + }
    30 +
    31 + root.innerHTML = `
    32 + <div class="page">
    33 + <section class="card prep">
    34 + <h1>Новая запись</h1>
    35 + <div class="prep-block">
    36 + <p class="field-label">Запишется</p>
    37 + <ul class="prep-list">
    38 + <li><span class="dot"></span>ваш микрофон</li>
    39 + <li><span class="dot"></span>весь звук компьютера — голоса собеседников в Zoom, Google Meet, Телемосте и где угодно</li>
    40 + </ul>
    41 + </div>
    42 + <p class="muted">Лучше в наушниках — так голоса собеседников не попадут в микрофон второй раз.</p>
    43 + <div class="prep-block">
    44 + <p class="field-label">Предупредите участников</p>
    45 + <blockquote class="prep-warning">${esc(view.settings.warning_text)}</blockquote>
    46 + <div class="actions">
    47 + <button class="link-btn" data-copy-warning>Скопировать для чата встречи</button>
    48 + <a class="link-btn" href="#/settings">Изменить текст</a>
    49 + </div>
    50 + </div>
    51 + ${
    52 + view.openrouter_key
    53 + ? ""
    54 + : `<p class="notice">Записать можно и без ИИ. Чтобы потом расшифровать, подключите его в <a href="#/settings">настройках</a>.</p>`
    55 + }
    56 + <button class="ui-btn hero-btn" data-start>Начать запись</button>
    57 + </section>
    58 + </div>`;
    59 + root.querySelector("[data-copy-warning]")!.addEventListener("click", () =>
    60 + copy(view.settings.warning_text, "Предупреждение скопировано — вставьте его в чат встречи"),
    61 + );
    62 + root.querySelector<HTMLButtonElement>("[data-start]")!.addEventListener("click", async (e) => {
    63 + const b = e.currentTarget as HTMLButtonElement;
    64 + b.disabled = true;
    65 + b.textContent = "Начинаю…";
    66 + await startRecording();
    67 + // Не началась — ошибку уже показало сообщение внизу, кнопку возвращаем.
    68 + if (scope.alive && !state.recording) {
    69 + b.disabled = false;
    70 + b.textContent = "Начать запись";
    71 + }
    72 + });
    73 +}