Переименование говорящих и экран «Новая запись» (#13)
- Имя говорящего в расшифровке — кнопка: нажал, вписал «Ольга», Enter — подпись сменилась во всех его репликах, а имя целым словом — в конспекте, задачах и других результатах («Я» там не трогается). - «Записать встречу» в меню, «Записать» в шапке и на главной открывают экран «Новая запись»: что запишется, совет про наушники, предупреждение участникам с копированием. Запись начинается только кнопкой «Начать запись». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Родитель:
3938872
· сравнить с родителем
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 { -
src/markdown.ts +38
Показать разницуСкрыть разницу
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 () => { -
src/views/new.ts +73
Показать разницуСкрыть разницу
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 +}