Skip to content
Merged
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,13 +369,14 @@ The user's notes are `timeline_notes` rows (`backend/models/timeline_note.py`) a
- **Reading them.** The detail read carries `timeline_notes_list_signature` (`count:hash` of each note's id and revision), not the notes, and the page fetches the list when it changes (`frontend/src/lib/notes/useTimelineNotes.ts`), and after its own writes when nothing polls. Every write that changes a listed note makes a new revision, so the signature changes whenever the list does.
- **Placement.** One rule, implemented twice: `place_timeline_notes` (`backend/utils/timeline_notes.py`) for prompts, and `placeTimelineNotes` (`frontend/src/lib/notes/placement.ts`) for the live and finished transcripts, both pinned by `backend/tests/fixtures/timeline_note_placement_cases.json`. A note at t (a selection at its last millisecond) follows the last non-blank line, in list order, starting at or before t. A note before the first line opens the transcript.
- **Views** place notes on their whole line list. A note after a hidden line shows after the visible line before it, and a note ends the run of grouped lines it follows. The views sort lines by start, while the prompts keep the stored order, which every writer emits by start: lines tied on start may sort either way without moving a note past a non-blank line (a property test in `placement.test.ts`). A list stored out of start order, which `PUT /transcripts/{id}/segments` accepts, would place a note differently in the views than in the prompts.
- **Highlights.** A selection's `quoted_text` is a snapshot of the words the user saw, and its `start_ms`/`end_ms` are where they were. The processed view finds them again at read time (memoised on lines and notes), never writing anything back (`frontend/src/lib/notes/resolveSelection.ts`). The rule: look only among the words timed within 2 s of the span, compare them case-folded with punctuation stripped, and take the run with the fewest word edits from the quote, ties to the run with fewer part-word matches, then to the run nearest the span's middle. A quote keeps just the characters selected, so it may start or end inside a word: its first word also matches the end of a word, its last word the start of one, and a one-word quote any part of one, with no edit counted. All words equal is *exact*; a score `1 − edits/quote words` of at least 0.5 is *fuzzy*; anything less is *unanchored*, and nothing is marked. A reprocess, a line edit or Find & Replace therefore never moves a note or its placement, which reads only the stored span, and the prompts keep the stored quote. Utterance ids and character offsets are never stored on a note, since a reprocess replaces both.
- **Prompts.** An anchored note is its own line after the line it follows, folded so it cannot pass for transcript. Head and tail notes go in `# User Notes Context`, quoted with `> `; chat puts them first and last. Speaker inference reads every note as a timestamped list. Generated notes end with `## User Notes`, which speaker renames never rewrite.
- **Meeting Edge** refreshes at once, ahead of its throttle, when `timeline_notes_signature` changes. It hashes each note's id and content (kind, text, anchors, quote, author, creation time), not its revision as the list signature does, so an edit that changes none of these refreshes nothing.
- **Revisions.** An edit names its base revision, and a delete may (`?expected_revision=`). A stale one is a 409 `revision_conflict` with the current note.
- **Write tokens.** A write may carry a client-chosen `write_token`, and the note reports the one that made its revision (`last_write_token`), so a client recognises its own write. An edit with a token always makes a new revision, fencing off an earlier write still in transit.
- **Tombstones.** A delete leaves a `timeline_note_tombstones` row with its token, so later writes to that id get a 410 (`note_deleted_by_you` or `note_deleted_elsewhere`) and a retried create never revives the note. A delete with a token of an unknown id leaves a `never_held` tombstone (kept an hour, at most 20 per recording), so a create whose answer was lost cannot outlive the user's clear. Client-id creates and deletes take `pg_advisory_xact_lock(hashtext(id))`. Backups omit tombstones, and a restored note whose id is tombstoned gets a fresh id.
- **Errors** carry `detail: {code, message}`. Clients branch on `code`, pinned by `backend/tests/fixtures/timeline_note_error_codes.json`. The notes box's sync states are tabulated in `frontend/src/lib/notes/headNoteSync.ts`, and the live note box's in `frontend/src/lib/notes/noteOutbox.ts`. The live box's outbox is one per recording for the life of the page, so notes on their way and the box's text outlive the live view. A note on a line from its ⋯ menu, in the live or the processed transcript, goes through the same outbox as a `selection` note quoting that line. So does a note on selected text, whose `start_ms`/`end_ms` are estimated: word times don't reach the client, so each word's time is interpolated by character offset across its line's span (`frontend/src/lib/notes/selectionRange.ts`), and the span covers every word the selection touches. Its `quoted_text` is the text as selected.
- **Unsaved notes in the browser.** Each outbox is also kept in IndexedDB (`nojoin-notes`), one record per account id and recording holding each unsaved note with its id and write token, the box's text and any discard whose delete is unconfirmed (`frontend/src/lib/notes/notePersistence.ts`). The id comes from `/users/me` and survives a rename. Once the session names the account, opening the recording again reads its record back and sends each note under the same id and token, so a create that landed before a reload returns its note rather than a second one, and a 409 or 410 is handled as on the first attempt; after a 409 the note goes out under its new id only once the record has it, or once a write under way has gone unanswered for 2 s (`NEW_ID_WAIT_MS`): a tab that closes or crashes before that write answers can leave the old id stored (a close no longer asks first, as the note has gone out), and since the server refuses that id again whatever the token, a reload makes a second copy of the note (it takes an id clash with another recording, a stalled store and the tab closing or crashing, together). A page writes only while its session is the account it read as, no sign-out has stopped storing, and no cleanup has removed that account's records since. Each `/users/me` drops every other account's records. The tab tells the others over a BroadcastChannel (`nojoin-notes`), as it does a sign-out, which stops every tab storing until a sign-in is accepted in it; a tab told of either repeats the removal for its own account, after any write it had begun, so a write that landed between the removal and the ping doesn't outlive it; without BroadcastChannel, other tabs go by their own sessions. Two tabs on one recording share the record, and the last to write wins. The record is therefore only a stopgap for a crash, a killed tab or an ended session: the leave warning (`noteOutboxes.ts`) is armed whenever anything is unsent, whatever is stored (browsers that never show it, such as iOS Safari, have only the stored copy), and the sign-in dialog's way to the sign-in page goes through it. Records carry a `version`: an earlier version's record is upgraded one step at a time through `RECORD_MIGRATIONS` and left alone where a step is missing, a later version's is neither read nor written, and a note of a kind this version cannot send is written back unchanged. A new kind is therefore not a new version: `selection` notes add `startMs`, `endMs` and `quotedText` to their own stored notes only, and an older build keeps them as they are. A browser without IndexedDB, or one that refuses, keeps the outbox in memory only. While any outbox holds something unsaved, the page holds sign-in in place as a capture does (Session expiry, under [Browser Capture Stack](#browser-capture-stack)), so a device that only takes notes asks for the password instead of reloading to `/login`.
- **Unsaved notes in the browser.** Each outbox is also kept in IndexedDB (`nojoin-notes`), one record per account id and recording holding each unsaved note with its id and write token, the box's text and any discard whose delete is unconfirmed (`frontend/src/lib/notes/notePersistence.ts`). The id comes from `/users/me` and survives a rename. Once the session names the account, opening the recording again reads its record back and sends each note under the same id and token, so a create that landed before a reload returns its note rather than a second one, and a 409 or 410 is handled as on the first attempt; after a 409 the note goes out under its new id only once the record has it, or once a write under way has gone unanswered for 2 s (`NEW_ID_WAIT_MS`): a tab that closes or crashes before that write answers can leave the old id stored (a close no longer asks first, as the note has gone out), and since the server refuses that id again whatever the token, a reload makes a second copy of the note (it takes an id clash with another recording, a stalled store and the tab closing or crashing, together). A page writes only while its session is the account it read as, no sign-out has stopped storing, and no cleanup has removed that account's records since. Each `/users/me` drops every other account's records. The tab tells the others over a BroadcastChannel (`nojoin-notes`), as it does a sign-out, which stops every tab storing until a sign-in is accepted in it; a tab told of either repeats the removal for its own account, after any write it had begun, so a write that landed between the removal and the ping doesn't outlive it; without BroadcastChannel, other tabs go by their own sessions. Two tabs on one recording share the record, and the last to write wins. The record is therefore only a stopgap for a crash, a killed tab or an ended session: the leave warning (`noteOutboxes.ts`) is armed whenever anything is unsent, whatever is stored, and so is an edit or delete of a saved note from its Save or Delete until it is confirmed or its editor closes, a refused one still open included (`holdPendingNoteWrite`, in memory only, never retried; it holds sign-in in place too) (browsers that never show it, such as iOS Safari, have only the stored copy), and the sign-in dialog's way to the sign-in page goes through it. Records carry a `version`: an earlier version's record is upgraded one step at a time through `RECORD_MIGRATIONS` and left alone where a step is missing, a later version's is neither read nor written, and a note of a kind this version cannot send is written back unchanged. A new kind is therefore not a new version: `selection` notes add `startMs`, `endMs` and `quotedText` to their own stored notes only, and an older build keeps them as they are. A browser without IndexedDB, or one that refuses, keeps the outbox in memory only. While any outbox holds something unsaved, the page holds sign-in in place as a capture does (Session expiry, under [Browser Capture Stack](#browser-capture-stack)), so a device that only takes notes asks for the password instead of reloading to `/login`.
- **Unsent marks.** The recordings rail marks each recording with something unsaved in this browser (`useUnsentRecordings` in `frontend/src/lib/notes/useNoteOutbox.ts`). A recording this page holds an outbox for goes by that outbox; any other by whether the signed-in account has a record for it (`NoteStore.recordingsOf`), read when the page loads and again whenever this page's outboxes change, so another tab's writes show at the next read. Nothing is sent from the rail: opening the recording restores and sends as above, because a sender in the background would act on notes another tab may have open. Deleting a recording for good or discarding it here (`forgetRecordingNotes` in `noteOutboxes.ts`, called by the recording actions, the batch bar and a recording page that finds its recording gone for its owner) drops the page's outbox for it once any write of it under way has finished, removes the signed-in account's record, and tells the other tabs (`forgot` on the `nojoin-notes` channel), which drop their outbox for it and remove the record again. A record for a recording deleted on another device is never marked. Once per page load and account, when the rail's list first loads, `useForgetNotesOfDeletedRecordings` (`frontend/src/lib/notes/deletedRecordingNotes.ts`) checks the account's records whose recording is neither listed nor held by an outbox here, at most 20, two at a time, with `GET /recordings/{id}` (issue #636). A 404 `Recording not found` between two `/users/me` answers naming the record's account forgets it through `forgetRecordingNotes`, since under another account's session cookie the owner's recordings answer 404 too. Any other answer keeps it, a binned recording's 200 included; `backend/tests/test_recording_detail_after_delete.py` pins those answers.

### Imported Media Input
Expand Down
2 changes: 2 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ In the transcript:
- Notes you placed during the meeting ([Live Notes](#live-notes)) show on their own line after the line they follow. When a line has two or more saved notes, they fold into a count, such as **2 notes**, which shows them when you click or tap it; a single note shows as it is. A note you write on this page stays shown once it saves, and one not saved yet always shows.
- The **⋯** button beside a speaker's name opens the line's menu: **Note on this line**, **Edit text**, **Change speaker** and **Play from here**, which plays on rather than stopping at the line's end. It shows when you point at the line or tab to it, and always on a touch screen, where pressing and holding the speaker's name also opens it. In the menu, the arrow keys move and Escape closes it. A note on a line quotes the line's words; Enter saves it and Shift+Enter starts a new line. One that can't be saved waits on its line with **Retry** and **Discard**.
- Select some text, in one line or across several, for a bar with **Add note** and **Copy**. A note on selected text quotes those words, and its time runs from the first word you touched to the last, estimated from the line's start and end. Escape or a click elsewhere puts the bar away. A note you have started typing stays open, whatever you click or select, until you save or cancel it.
- The words a note is on are underlined in the transcript, on a light tint. After processing or a reprocess the words may read a little differently: Nojoin looks for them again near the note's time, and words found with changes are underlined with dots instead, the original words shown when you point at them. When they can no longer be found, nothing is marked, and the note shows its quote struck through, says the words changed, and quotes what the transcript says at that time now.
- Click underlined words, or the pencil beside a saved note, to edit the note or delete it. An edit you have started typing stays open until you save or cancel it. If the note was changed on another device meanwhile, Nojoin shows its new text and keeps yours in the box: save again to replace it. From **Save note** or **Confirm delete** until the change is saved or you cancel it, Nojoin asks before you close the page.

**Export** saves the notes, analytics and transcript as one PDF, Word or text file, or the audio as MP3 ([Exporting A Meeting](#exporting-a-meeting)).

Expand Down
3 changes: 3 additions & 0 deletions frontend/scripts/check-contrast.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ const PAIRINGS = [
{ label: "action text hover on card", fg: "action-text-hover", bg: ["surface-page", "surface-card"], min: AA_TEXT },
{ label: "tint text on tint (on card)", fg: "action-tint-fg", bg: ["surface-page", "surface-card", "action-tint"], min: AA_TEXT },
{ label: "tint text on hover tint (on card)", fg: "action-tint-fg", bg: ["surface-page", "surface-card", "action-tint-hover"], min: AA_TEXT },
// A note's words in the transcript: the underline is what marks them, since
// the tint alone barely shows on a light card.
{ label: "note underline on tint (on card)", fg: "action-text", bg: ["surface-page", "surface-card", "action-tint"], min: AA_NON_TEXT },
{ label: "action border vs card", fg: "action-border", bg: ["surface-page", "surface-card"], min: HAIRLINE },

// Danger family, held to the same rules as the action family.
Expand Down
Loading
Loading