Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/access/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ export {
type SourceKind,
} from "./sources/manifest.js";
export { registerSource, type RegisterInput } from "./sources/register.js";
export { deriveId, isIdTaken, EmptyNameError } from "./sources/id.js";
export { deriveId, slugify, isIdTaken, EmptyNameError } from "./sources/id.js";
export { recordingId, baseId, type RecordingIdInput } from "./sources/recording-id.js";
export {
sourceState,
listSourceStates,
Expand Down
21 changes: 15 additions & 6 deletions packages/access/src/sources/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ export class EmptyNameError extends Error {
}
}

/**
* The slugging rule itself, with no opinion about extensions. Empty in, empty
* out — the caller decides whether that is an error (`deriveId`) or a reason
* to fall back to the timestamp (`recordingId`, plan 4.16).
*/
export function slugify(text: string): string {
// Fold accents: NFD splits a letter into its base + combining mark(s); strip
// the marks to leave the ASCII base (São → Sao). \p{M} matches any mark.
const folded = text.normalize("NFD").replace(/\p{M}/gu, "");
const lower = folded.toLowerCase();
const collapsed = lower.replace(/[^a-z0-9]+/g, "-");
return collapsed.replace(/^-+|-+$/g, "");
}

export function deriveId(name: string): string {
// A file source keeps its extension in the id (adr:0011): the directory is
// `raw/arquitetura-fenix.pdf/` and the citation is `src://arquitetura-fenix.pdf#p12`.
Expand All @@ -28,12 +42,7 @@ export function deriveId(name: string): string {
const extMatch = name.match(/\.[a-z]{1,8}$/i);
const base = extMatch ? name.slice(0, name.length - extMatch[0].length) : name;
const ext = extMatch ? extMatch[0] : "";
// Fold accents: NFD splits a letter into its base + combining mark(s); strip
// the marks to leave the ASCII base (São → Sao). \p{M} matches any mark.
const folded = base.normalize("NFD").replace(/\p{M}/gu, "");
const lower = folded.toLowerCase();
const collapsed = lower.replace(/[^a-z0-9]+/g, "-");
const trimmed = collapsed.replace(/^-+|-+$/g, "");
const trimmed = slugify(base);
if (trimmed === "") throw new EmptyNameError(name);
return trimmed + ext;
}
Expand Down
90 changes: 90 additions & 0 deletions packages/access/src/sources/recording-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { isIdTaken, slugify } from "./id.js";

/**
* A recording's id: what it was, plus the day it happened (plan 4.16,
* `adr:0011-sources-are-named-by-what-they-are`).
*
* `fenix-weekly-2026-07-31`. The date is part of the shape rather than a
* disambiguator that appears only on a clash — recurring meetings are the
* normal case, and a scheme where the first `fenix-weekly` has no date and the
* second gets a suffix produces ids whose meaning depends on the order they
* were created in.
*
* **A second recording the same day takes `-2`.** That is the opposite of the
* rule for a file, which is refused outright (3.6) so the user renames it
* rather than the application inventing `arquitetura-fenix (2).pdf` on their
* behalf. The difference is that a filename is a thing the user already has a
* name for and a recording is not: two `fenix-weekly` on one Tuesday is a
* normal Tuesday, and there is nothing to rename.
*/

/** How many same-day recordings can share an occasion before it is absurd. */
const MAX_SAME_DAY = 99;

/**
* How much of the occasion reaches the directory name.
*
* Unlike `deriveId`'s input — a filename the operating system already bounded
* — an occasion is free text from a form. Left unbounded, a pasted paragraph
* becomes a directory name that fails at `mkdir` on Windows' default path
* limit, at the moment the recording is being saved; and `isIdTaken` answers
* "not taken" on the way there, because `existsSync` returns false for a name
* that is too long. Capture must not be lost to a naming rule, so the name is
* trimmed rather than refused.
*/
const MAX_OCCASION_CHARS = 80;

export interface RecordingIdInput {
/** What is being recorded, as the user typed it. May be empty. */
occasion: string;
/** When capture started. */
at: Date;
}

/**
* The id, free in this project.
*
* **An empty occasion falls back to the timestamp rather than refusing.** A
* recording that started is worth more than a naming rule, and capture must
* never be blocked on a text field (`adr:0011`). The fallback is legible as a
* fallback, which is the point: somebody scanning `raw/` can see which
* recordings nobody named.
*/
export function recordingId(projectRoot: string, input: RecordingIdInput): string {
const base = baseId(input);
if (!isIdTaken(projectRoot, base)) return base;
for (let n = 2; n <= MAX_SAME_DAY; n++) {
const candidate = `${base}-${n}`;
if (!isIdTaken(projectRoot, candidate)) return candidate;
}
// A hundred recordings of one occasion on one day is not a naming problem.
// Falling through to the timestamp keeps capture possible, which is the rule
// this whole function is built around.
return `${base}-${timeOf(input.at)}`;
}

/** The id before any same-day suffix. Exported because 4.16's shape is testable alone. */
export function baseId(input: RecordingIdInput): string {
// `slugify`, not `deriveId`: `deriveId` keeps a trailing `.pdf` because a
// file's format is part of its identity. An occasion has no format, and
// "Vendor call re. arch" would otherwise keep `.arch` as though it were one.
// Trimmed after slugging and then re-trimmed of a trailing `-`, so a cut
// landing mid-word does not leave the id ending in a separator.
const occasion = slugify(input.occasion).slice(0, MAX_OCCASION_CHARS).replace(/-+$/, "");
const day = dayOf(input.at);
return occasion === "" ? `recording-${day}-${timeOf(input.at)}` : `${occasion}-${day}`;
}

/** `2026-07-31`, in local time — the day the person remembers having it. */
function dayOf(at: Date): string {
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`;
}

/** `140211`, for the unnamed fallback and the absurd-collision fallback. */
function timeOf(at: Date): string {
return `${pad(at.getHours())}${pad(at.getMinutes())}${pad(at.getSeconds())}`;
}

function pad(n: number): string {
return String(n).padStart(2, "0");
}
100 changes: 100 additions & 0 deletions packages/access/tests/recording-id.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { baseId, recordingId } from "../src/sources/recording-id.js";

let root: string;

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "ow-recid-"));
mkdirSync(join(root, "raw"), { recursive: true });
});

afterEach(() => rmSync(root, { recursive: true, force: true }));

function existing(id: string): void {
mkdirSync(join(root, "raw", id), { recursive: true });
}

/** 31 July 2026, 14:02:11 local. */
const at = new Date(2026, 6, 31, 14, 2, 11);

describe("baseId (4.16)", () => {
it("is the occasion plus the day it happened", () => {
expect(baseId({ occasion: "Fenix weekly", at })).toBe("fenix-weekly-2026-07-31");
});

it("carries the date even for the first of its name", () => {
// The date is part of the shape, not a disambiguator that shows up on a
// clash. Recurring meetings are the normal case, and a scheme where the
// first has no date produces ids whose meaning depends on creation order.
expect(baseId({ occasion: "Standup", at })).toBe("standup-2026-07-31");
});

it("slugs the way every other id is slugged", () => {
expect(baseId({ occasion: "Reunião de Análise", at })).toBe("reuniao-de-analise-2026-07-31");
});

it("does not mistake the end of an occasion for a file extension", () => {
// `deriveId` keeps a trailing `.pdf` because a file's format is part of its
// identity. An occasion has no format.
expect(baseId({ occasion: "Vendor call re. arch", at })).toBe("vendor-call-re-arch-2026-07-31");
});

it("falls back to the timestamp when nobody named it", () => {
// Capture must never be blocked on a text field: a recording that started
// is worth more than a naming rule.
expect(baseId({ occasion: "", at })).toBe("recording-2026-07-31-140211");
});

it("falls back when the name derives to nothing at all", () => {
expect(baseId({ occasion: "!!! ---", at })).toBe("recording-2026-07-31-140211");
});

it("pads the day and the time", () => {
const early = new Date(2026, 0, 5, 9, 3, 4);
expect(baseId({ occasion: "", at: early })).toBe("recording-2026-01-05-090304");
});
});

describe("recordingId (4.16)", () => {
it("is the base id when nothing has taken it", () => {
expect(recordingId(root, { occasion: "Fenix weekly", at })).toBe("fenix-weekly-2026-07-31");
});

it("takes -2 for a second the same day", () => {
// The opposite of the rule for a file, which is refused so the user
// renames it. Two `fenix-weekly` on one Tuesday is a normal Tuesday, and
// there is nothing to rename.
existing("fenix-weekly-2026-07-31");
expect(recordingId(root, { occasion: "Fenix weekly", at })).toBe("fenix-weekly-2026-07-31-2");
});

it("counts on past the second", () => {
existing("fenix-weekly-2026-07-31");
existing("fenix-weekly-2026-07-31-2");
existing("fenix-weekly-2026-07-31-3");
expect(recordingId(root, { occasion: "Fenix weekly", at })).toBe("fenix-weekly-2026-07-31-4");
});

it("does not collide with the same occasion on another day", () => {
existing("fenix-weekly-2026-07-31");
const nextWeek = new Date(2026, 7, 7, 14, 0, 0);
expect(recordingId(root, { occasion: "Fenix weekly", at: nextWeek })).toBe(
"fenix-weekly-2026-08-07",
);
});

it("still produces an id when a hundred share the day, rather than refusing", () => {
// Absurd, and capture still must not be blocked.
existing("standup-2026-07-31");
for (let n = 2; n <= 99; n++) existing(`standup-2026-07-31-${n}`);
expect(recordingId(root, { occasion: "Standup", at })).toBe("standup-2026-07-31-140211");
});

it("suffixes an unnamed recording too, if the second landed in the same second", () => {
existing("recording-2026-07-31-140211");
expect(recordingId(root, { occasion: "", at })).toBe("recording-2026-07-31-140211-2");
});
});
40 changes: 40 additions & 0 deletions packages/audio/src/atomic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { randomUUID } from "node:crypto";
import { renameSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";

/**
* Write a file so that no reader ever sees half of it.
*
* Every file this package writes into a recording's directory goes through
* here — the journal after every chunk, the time map, the timeline, the VTT,
* the text. Two properties matter and neither is free:
*
* **A reader never sees a partial file.** The content lands under a temporary
* name and the rename is what publishes it. `journal.json` is rewritten after
* every chunk, which makes it the file most likely to be caught mid-write by a
* machine going down, and a truncated journal reads as no journal — throwing
* away an hour of paid-for transcription.
*
* **The temporary name is unguessable, and refuses to reuse an entry.**
* `${target}.tmp` was predictable, and `writeFileSync`'s default flag follows a
* symlink and truncates whatever it finds — so anything able to plant an entry
* at that path before the write got an arbitrary file overwritten with content
* it partly controlled. `raw/` is content: it arrives with a clone, and the
* window between recording and finishing is hours by design. `wx` makes an
* existing entry an error rather than a target.
*
* It is `packages/access/src/write/atomic-write.ts` restated rather than
* shared, because the dependency runs the other way.
*/
export function writeAtomic(target: string, contents: string): void {
const temp = join(dirname(target), `.ow-tmp-${randomUUID()}`);
try {
writeFileSync(temp, contents, { encoding: "utf8", flag: "wx" });
// Rename replaces the *name*, so a symlink sitting at `target` is replaced
// by the file rather than written through.
renameSync(temp, target);
} catch (e) {
rmSync(temp, { force: true });
throw e;
}
}
75 changes: 75 additions & 0 deletions packages/audio/src/finish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { join } from "node:path";
import { writeAtomic } from "./atomic.js";
import { isComplete, type Journal } from "./journal.js";
import { RECORDING_TEXT_FILE, renderRecordingText } from "./recording-text.js";
import { sealRecording, sourceDir, type SealResult } from "./seal.js";
import { buildTimeline, writeTimeline, type Timeline } from "./timeline.js";
import type { TimeMap } from "./timemap.js";
import { renderVtt, VTT_FILE } from "./vtt.js";

/**
* Everything after the last chunk comes back: timeline, VTT, `text.md`, seal
* (plan 4.12, 4.13, 4.14, 4.18).
*
* The order is the point and it is the order
* `adr:0012-transcription-is-a-journalled-serial-pipeline` demands: the
* outputs are written first and the WAV is discarded last, checked against the
* journal rather than against the look of the directory. A crash anywhere in
* here leaves a recording that can be finished; a crash with the steps the
* other way round leaves one that cannot be recovered, because the meeting
* already happened.
*
* It is separate from `transcribeRecording` because it is also what runs when
* a *resumed* transcription completes — the last chunk of a run that started
* yesterday reaches exactly here.
*
* **`text.md` is written only when the journal is complete.** It is the file
* `sources/state.ts` reads to decide a source is `text-ready`, and it outranks
* everything the journal says — so writing it for a run that stopped at chunk
* four turns a half-transcribed recording into one that reads as finished,
* with its 690 MB WAV still on disk under a source nobody will look at again.
* That is exactly the failure `adr:0012` claims to convert from silent to
* visible. The timeline and the VTT carry no such meaning and are written
* either way, so a partial run is still inspectable.
*/

export interface FinishOptions {
/** The readable title from the source's manifest. */
title: string;
/** The project's `deleteWavAfterTranscription` setting. */
deleteWav?: boolean;
}

export interface FinishResult {
timeline: Timeline;
/** True once `text.md` is on disk — which only a complete journal produces. */
textReady: boolean;
seal: SealResult;
}

export function finishRecording(
projectRoot: string,
id: string,
journal: Journal,
map: TimeMap,
options: FinishOptions,
): FinishResult {
const dir = sourceDir(projectRoot, id);
const timeline = buildTimeline(journal, map);
writeTimeline(dir, timeline);
writeAtomic(join(dir, VTT_FILE), renderVtt(timeline));

const complete = isComplete(journal);
if (complete) {
writeAtomic(join(dir, RECORDING_TEXT_FILE), renderRecordingText(timeline, options));
}

// Last. `sealRecording` re-derives the directory, re-reads the journal from
// disk and re-checks every output rather than trusting that it was called at
// the right moment — it is the one step that cannot be undone, so it does
// not take the caller's word for anything.
const seal = sealRecording(projectRoot, id, journal, {
...(options.deleteWav !== undefined ? { deleteWav: options.deleteWav } : {}),
});
return { timeline, textReady: complete, seal };
}
5 changes: 5 additions & 0 deletions packages/audio/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ export * from "./absolute.js";
export * from "./journal.js";
export * from "./transcribe.js";
export * from "./stt/index.js";
export * from "./finish.js";
export * from "./recording-text.js";
export * from "./seal.js";
export * from "./timeline.js";
export * from "./vtt.js";
13 changes: 3 additions & 10 deletions packages/audio/src/journal.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { writeAtomic } from "./atomic.js";
import type { ProviderName, SttSegment } from "./stt/provider.js";
import type { Chunk } from "./timemap.js";

Expand Down Expand Up @@ -197,15 +198,7 @@ export function readJournal(dir: string): Journal | null {
* journal reads as no journal, which throws away everything already paid for.
*/
export function writeJournal(dir: string, journal: Journal): void {
const target = journalPath(dir);
const temp = `${target}.tmp`;
try {
writeFileSync(temp, `${JSON.stringify(journal, null, 2)}\n`, "utf8");
renameSync(temp, target);
} catch (e) {
rmSync(temp, { force: true });
throw e;
}
writeAtomic(journalPath(dir), `${JSON.stringify(journal, null, 2)}\n`);
}

/**
Expand Down
Loading
Loading