Skip to content
Open
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
86 changes: 85 additions & 1 deletion electron/ai-edition/project-recordings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DocumentService } from "./document-service";
import { findProjectRecordings, removeRecordingSets } from "./project-recordings";
import {
findProjectRecordings,
listRecordings,
removeRecordingSets,
resolveRecordingIds,
} from "./project-recordings";

async function touch(filePath: string, content = "x"): Promise<string> {
await fs.writeFile(filePath, content);
Expand Down Expand Up @@ -137,4 +142,83 @@ describe("project recordings", () => {
await expect(fs.access(video)).rejects.toThrow();
await expect(fs.access(cursor)).rejects.toThrow();
});

describe("listing every take", () => {
async function at(filePath: string, isoDate: string): Promise<string> {
await touch(filePath);
const when = new Date(isoDate);
await fs.utimes(filePath, when, when);
return filePath;
}

it("lists each take once with its files, newest first, and who uses it", async () => {
const older = await at(path.join(recordingsDir, "recording-1.mp4"), "2026-01-01");
await at(`${older}.cursor.json`, "2026-01-01");
const newer = await at(path.join(recordingsDir, "recording-2.mp4"), "2026-02-01");
await touch(path.join(recordingsDir, "media-links.registry.json"));
await touch(path.join(recordingsDir, "notes.txt"));
await projectWith("Demo", older);
await projectWith("Demo v2", older);

const { takes, unreadable } = await listRecordings(
await documents.readAllProjectsReport(),
// The same folder twice, as when the current folder is the default one.
[recordingsDir, `${recordingsDir}${path.sep}`],
);

expect(takes.map((take) => take.stem)).toEqual(["recording-2", "recording-1"]);
expect(takes[0].usedBy).toEqual([]);
expect(takes[0].files).toEqual([newer]);
expect(takes[1].usedBy.sort()).toEqual(["Demo", "Demo v2"]);
expect(takes[1].files).toHaveLength(2);
expect(takes[1].id).toBe(path.join(recordingsDir, "recording-1"));
expect(unreadable).toBe(0);
});

it("counts project files that do not parse", async () => {
await touch(path.join(recordingsDir, "recording-3.mp4"));
await fs.mkdir(path.join(root, "projects"), { recursive: true });
await touch(path.join(root, "projects", "broken.openscreen"), "{ not json");

const { takes, unreadable } = await listRecordings(await documents.readAllProjectsReport(), [
recordingsDir,
]);

expect(takes).toHaveLength(1);
expect(unreadable).toBe(1);
});

it("skips a recordings folder that is gone", async () => {
const { takes } = await listRecordings(await documents.readAllProjectsReport(), [
path.join(root, "unplugged"),
]);

expect(takes).toEqual([]);
});
});

describe("resolving the ids a delete names", () => {
it("resolves a take in a recordings folder to its files", async () => {
const video = await touch(path.join(recordingsDir, "recording-4.mp4"));

const sets = await resolveRecordingIds(
[path.join(recordingsDir, "recording-4")],
[recordingsDir],
);

expect(sets.map((set) => set.files)).toEqual([[video]]);
});

it.each([
["a take outside the recordings folders", () => path.join(elsewhere, "recording-4")],
["a file name instead of a take", () => path.join(recordingsDir, "recording-4.mp4")],
["something that is not a take", () => path.join(recordingsDir, "media-links")],
["a path that climbs out", () => path.join(recordingsDir, "..", "recording-4")],
])("refuses %s", async (_label, id) => {
await touch(path.join(elsewhere, "recording-4.mp4"));
await touch(path.join(root, "recording-4.mp4"));

await expect(resolveRecordingIds([id()], [recordingsDir])).rejects.toThrow(/Not a recording/);
});
});
});
90 changes: 88 additions & 2 deletions electron/ai-edition/project-recordings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface RecordingSet {
stem: string;
files: string[];
sizeBytes: number;
/** The oldest modification time among its files, in ms. */
recordedAt: number;
}

export interface ProjectRecordings {
Expand Down Expand Up @@ -57,15 +59,23 @@ function documentRecordingKeys(doc: AxcutDocument, recordingsDirs: readonly stri

async function readRecordingSet(key: string): Promise<RecordingSet | null> {
const dir = path.dirname(key);
const stem = path.basename(key);
let names: string[];
try {
names = await fs.readdir(dir);
} catch {
return null;
}
return collectRecordingSet(dir, path.basename(key), names);
}

async function collectRecordingSet(
dir: string,
stem: string,
names: readonly string[],
): Promise<RecordingSet | null> {
const files: string[] = [];
let sizeBytes = 0;
let recordedAt = Number.POSITIVE_INFINITY;
for (const name of names) {
// `recording-12` must not pick up `recording-123`.
if (name !== stem && !name.startsWith(`${stem}.`) && !name.startsWith(`${stem}-`)) continue;
Expand All @@ -75,11 +85,12 @@ async function readRecordingSet(key: string): Promise<RecordingSet | null> {
if (!stat.isFile()) continue;
files.push(filePath);
sizeBytes += stat.size;
recordedAt = Math.min(recordedAt, stat.mtimeMs);
} catch {
// Gone between the listing and the stat: nothing left to delete.
}
}
return files.length > 0 ? { dir, stem, files, sizeBytes } : null;
return files.length > 0 ? { dir, stem, files, sizeBytes, recordedAt } : null;
}

/**
Expand Down Expand Up @@ -135,3 +146,78 @@ export async function removeRecordingSets(
}
}
}

/** A take in the recordings folders, and the projects that use it. */
export interface RecordingTake extends RecordingSet {
/** `dir` joined with `stem`: what a delete names the take by. */
id: string;
usedBy: string[];
}

export interface RecordingsListing {
takes: RecordingTake[];
/** Project files that did not parse: any take may be in use by one of them. */
unreadable: number;
}

function uniqueDirs(recordingsDirs: readonly string[]): string[] {
const dirs: string[] = [];
for (const dir of recordingsDirs) {
if (!dirs.some((seen) => sameDir(seen, dir))) dirs.push(path.resolve(dir));
}
return dirs;
}

/** Every take in OpenScreen's recordings folders, newest first. */
export async function listRecordings(
projects: { documents: readonly AxcutDocument[]; unreadable: readonly string[] },
recordingsDirs: readonly string[],
): Promise<RecordingsListing> {
const usedBy = new Map<string, string[]>();
for (const doc of projects.documents) {
for (const key of documentRecordingKeys(doc, recordingsDirs)) {
usedBy.set(key, [...(usedBy.get(key) ?? []), doc.project.title]);
}
}

const takes: RecordingTake[] = [];
for (const dir of uniqueDirs(recordingsDirs)) {
let names: string[];
try {
names = await fs.readdir(dir);
} catch {
// A folder that is gone (an unplugged drive) has nothing to list.
continue;
}
const stems = new Set(
names.map((name) => RECORDING_STEM.exec(name)?.[0]).filter((s): s is string => !!s),
);
for (const stem of stems) {
const set = await collectRecordingSet(dir, stem, names);
if (!set) continue;
const id = path.join(dir, stem);
takes.push({ ...set, id, usedBy: usedBy.get(id) ?? [] });
}
}
takes.sort((a, b) => b.recordedAt - a.recordedAt);
return { takes, unreadable: projects.unreadable.length };
}

/**
* The takes `ids` name, each checked to be a take in a recordings folder: the ids come from
* the renderer, and a delete must never reach a file outside those folders.
*/
export async function resolveRecordingIds(
ids: readonly string[],
recordingsDirs: readonly string[],
): Promise<RecordingSet[]> {
const sets: RecordingSet[] = [];
for (const id of ids) {
const stem = path.basename(id);
if (RECORDING_STEM.exec(stem)?.[0] !== stem) throw new Error(`Not a recording: ${id}`);
if (recordingKey(id, recordingsDirs) === null) throw new Error(`Not a recording: ${id}`);
const set = await readRecordingSet(path.resolve(id));
if (set) sets.push(set);
}
return sets;
}
38 changes: 31 additions & 7 deletions electron/ipc/nativeBridge.deleteProject.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Deleting a project takes the recordings disposal from the renderer, and every value but
// "trash" would remove files for good, so the bridge accepts only the known ones.
// "trash" would remove files for good, so the bridge accepts only the known ones. Deleting takes
// from the recordings manager is held to the same rule.

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NativeBridgeResponse } from "../../src/native/contracts";
Expand All @@ -17,18 +18,21 @@ vi.mock("../native-bridge/services/compositorViewService", () => ({

const service = vi.hoisted(() => ({
deleteProject: vi.fn(async () => ({ success: true })),
deleteRecordings: vi.fn(async () => ({ success: true })),
}));
vi.mock("../native-bridge/services/aiEditionService", () => ({
AiEditionService: class {
deleteProject = service.deleteProject;
deleteRecordings = service.deleteRecordings;
},
}));

let invoke: (payload: unknown) => Promise<NativeBridgeResponse>;
let invoke: (payload: unknown, action?: string) => Promise<NativeBridgeResponse>;

beforeEach(() => {
electron.handle.mockClear();
service.deleteProject.mockClear();
service.deleteRecordings.mockClear();
registerNativeBridgeHandlers({
getPlatform: () => "linux",
getAiEditionDocuments: () => ({}),
Expand All @@ -38,11 +42,8 @@ beforeEach(() => {
event: unknown,
request: unknown,
) => Promise<NativeBridgeResponse>;
invoke = (payload) =>
handler(
{ sender: {} },
{ domain: "aiEdition", action: "document.delete", payload, requestId: "r" },
);
invoke = (payload, action = "document.delete") =>
handler({ sender: {} }, { domain: "aiEdition", action, payload, requestId: "r" });
});

describe("native bridge project delete", () => {
Expand All @@ -60,3 +61,26 @@ describe("native bridge project delete", () => {
expect(service.deleteProject).toHaveBeenCalledWith("p", value);
});
});

describe("native bridge recordings delete", () => {
it.each([
{ ids: ["a"], disposal: undefined },
{ ids: ["a"], disposal: "keep" },
{ ids: ["a"], disposal: "Trash" },
{ ids: "a", disposal: "trash" },
{ ids: [1], disposal: "trash" },
{ disposal: "permanent" },
])("rejects %o as a bad request", async (payload) => {
const response = await invoke(payload, "recordings.delete");

expect(response.ok ? undefined : response.error?.code).toBe("INVALID_REQUEST");
expect(service.deleteRecordings).not.toHaveBeenCalled();
});

it.each(["trash", "permanent"])("passes disposal=%s on", async (disposal) => {
const response = await invoke({ ids: ["a", "b"], disposal }, "recordings.delete");

expect(response.ok).toBe(true);
expect(service.deleteRecordings).toHaveBeenCalledWith(["a", "b"], disposal);
});
});
20 changes: 20 additions & 0 deletions electron/ipc/nativeBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,26 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
requestId,
await aiEditionService.projectRecordings(request.payload.projectId),
);
case "recordings.list":
return createSuccessResponse(requestId, await aiEditionService.listRecordings());
case "recordings.delete": {
const { ids, disposal }: { ids: unknown; disposal: unknown } = request.payload;
if (
!Array.isArray(ids) ||
!ids.every((id) => typeof id === "string") ||
(disposal !== "trash" && disposal !== "permanent")
) {
return createErrorResponse(
requestId,
"INVALID_REQUEST",
"Invalid recordings delete request.",
);
}
return createSuccessResponse(
requestId,
await aiEditionService.deleteRecordings(ids, disposal),
);
}
case "document.addAsset":
return createSuccessResponse(
requestId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,66 @@ describe("AiEditionService — deleting a project's recordings", () => {
await expect(fs.access(cursor)).rejects.toThrow();
await expect(documents.getProject(projectId)).rejects.toThrow();
});

describe("managing every recording", () => {
it("lists the take with the project that uses it", async () => {
const result = await service().listRecordings();

expect(result).toMatchObject({ success: true, unreadable: 0 });
expect(result.recordings).toHaveLength(1);
expect(result.recordings[0]).toMatchObject({
id: path.join(recordingsDir, "recording-42"),
usedBy: ["Take"],
sizeBytes: 5 + 2,
});
});

it("trashes the named takes and forgets their links", async () => {
const other = path.join(recordingsDir, "recording-43.mp4");
await fs.writeFile(other, "other");
await fs.writeFile(path.join(root, "probe.mp4"), "video");

await expect(
service().deleteRecordings([path.join(recordingsDir, "recording-42")], "trash"),
).resolves.toEqual({ success: true });

expect(trashItem).toHaveBeenCalledTimes(2);
await expect(fs.access(video)).rejects.toThrow();
await expect(fs.access(other)).resolves.toBeUndefined();
expect(
await findMediaLinksByFingerprint(recordingsDir, path.join(root, "probe.mp4")),
).toBeNull();
});

it("refuses an id outside the recordings folders and deletes nothing", async () => {
const outside = path.join(root, "recording-42.mp4");
await fs.writeFile(outside, "video");

await expect(
service().deleteRecordings([path.join(root, "recording-42")], "permanent"),
).resolves.toMatchObject({ success: false, trashFailed: false });
await expect(fs.access(outside)).resolves.toBeUndefined();
});

it("keeps the links of files the Trash refused, then deletes them permanently", async () => {
await fs.writeFile(path.join(root, "probe.mp4"), "video");
trashItem.mockRejectedValueOnce(new Error("no trash on this filesystem"));
const id = path.join(recordingsDir, "recording-42");

await expect(service().deleteRecordings([id], "trash")).resolves.toMatchObject({
success: false,
trashFailed: true,
});
await expect(fs.access(video)).resolves.toBeUndefined();
expect(
await findMediaLinksByFingerprint(recordingsDir, path.join(root, "probe.mp4")),
).not.toBeNull();

await expect(service().deleteRecordings([id], "permanent")).resolves.toEqual({
success: true,
});
await expect(fs.access(video)).rejects.toThrow();
await expect(fs.access(cursor)).rejects.toThrow();
});
});
});
Loading
Loading