Skip to content

Commit 426d8ce

Browse files
committed
feat(delete): add delete recordings option
1 parent f09670c commit 426d8ce

39 files changed

Lines changed: 850 additions & 56 deletions

File tree

‎electron/ai-edition/document-service.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ export class DocumentService {
385385
}
386386

387387
/** Every project that parses, unrelinked and unannounced: a listing, not an open. */
388-
private async readAllProjects(): Promise<AxcutDocument[]> {
388+
async readAllProjects(): Promise<AxcutDocument[]> {
389389
await this.ensureProjectsDir();
390390
const entries = await fs.readdir(this.projectsRoot);
391391
// ensureProjectsDir (above) already migrated any legacy `.axcut` files.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { DocumentService } from "./document-service";
6+
import { findProjectRecordings, removeRecordingSets } from "./project-recordings";
7+
8+
async function touch(filePath: string, content = "x"): Promise<string> {
9+
await fs.writeFile(filePath, content);
10+
return filePath;
11+
}
12+
13+
describe("project recordings", () => {
14+
let root: string;
15+
let recordingsDir: string;
16+
let elsewhere: string;
17+
let documents: DocumentService;
18+
19+
beforeEach(async () => {
20+
root = await fs.mkdtemp(path.join(os.tmpdir(), "openscreen-project-recordings-"));
21+
recordingsDir = path.join(root, "recordings");
22+
elsewhere = path.join(root, "Desktop");
23+
await fs.mkdir(recordingsDir);
24+
await fs.mkdir(elsewhere);
25+
documents = new DocumentService(path.join(root, "projects"), root);
26+
});
27+
28+
afterEach(async () => {
29+
await fs.rm(root, { recursive: true, force: true });
30+
});
31+
32+
async function projectWith(title: string, ...videoPaths: string[]): Promise<string> {
33+
const doc = await documents.createProject(title);
34+
for (const videoPath of videoPaths) {
35+
await documents.addAsset(doc.project.id, {
36+
path: videoPath,
37+
label: path.basename(videoPath),
38+
});
39+
}
40+
return doc.project.id;
41+
}
42+
43+
it("owns every file recorded beside the screen video", async () => {
44+
const video = await touch(path.join(recordingsDir, "recording-12.mp4"), "12345");
45+
const companions = [
46+
await touch(`${video}.cursor.json`),
47+
await touch(path.join(recordingsDir, "recording-12.session.json")),
48+
await touch(path.join(recordingsDir, "recording-12-webcam.webm")),
49+
];
50+
await touch(path.join(recordingsDir, "recording-123.mp4"));
51+
const id = await projectWith("Mine", video);
52+
53+
const { owned, keptFor } = await findProjectRecordings(id, await documents.readAllProjects(), [
54+
recordingsDir,
55+
]);
56+
57+
expect(owned).toHaveLength(1);
58+
expect(owned[0].files.sort()).toEqual([video, ...companions].sort());
59+
expect(owned[0].sizeBytes).toBe(5 + 3);
60+
expect(keptFor).toEqual([]);
61+
});
62+
63+
it("keeps a recording another project uses, and names that project", async () => {
64+
const shared = await touch(path.join(recordingsDir, "recording-1.mp4"));
65+
const own = await touch(path.join(recordingsDir, "recording-2.mp4"));
66+
const id = await projectWith("Mine", shared, own);
67+
await projectWith("Demo v2", shared);
68+
69+
const { owned, keptFor } = await findProjectRecordings(id, await documents.readAllProjects(), [
70+
recordingsDir,
71+
]);
72+
73+
expect(owned.map((set) => set.stem)).toEqual(["recording-2"]);
74+
expect(keptFor).toEqual(["Demo v2"]);
75+
});
76+
77+
it("never owns a video outside the recordings folders", async () => {
78+
const imported = await touch(path.join(elsewhere, "recording-5.mp4"));
79+
const id = await projectWith("Imported", imported);
80+
81+
const { owned } = await findProjectRecordings(id, await documents.readAllProjects(), [
82+
recordingsDir,
83+
]);
84+
85+
expect(owned).toEqual([]);
86+
});
87+
88+
it("trashes each file, or removes it for good when asked", async () => {
89+
const video = await touch(path.join(recordingsDir, "recording-7.mp4"));
90+
const cursor = await touch(`${video}.cursor.json`);
91+
const id = await projectWith("Mine", video);
92+
const { owned } = await findProjectRecordings(id, await documents.readAllProjects(), [
93+
recordingsDir,
94+
]);
95+
96+
const trashItem = vi.fn(async (_filePath: string) => {});
97+
await removeRecordingSets(owned, "trash", trashItem);
98+
expect(trashItem.mock.calls.map(([p]) => p).sort()).toEqual([video, cursor].sort());
99+
await expect(fs.access(video)).resolves.toBeUndefined();
100+
101+
await removeRecordingSets(owned, "permanent", trashItem);
102+
await expect(fs.access(video)).rejects.toThrow();
103+
await expect(fs.access(cursor)).rejects.toThrow();
104+
});
105+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import fs from "node:fs/promises";
2+
import path from "node:path";
3+
import type { AxcutDocument } from "../../src/lib/ai-edition/schema";
4+
5+
const RECORDING_STEM = /^recording-\d+/;
6+
7+
/** One take: the screen video and every file recorded beside it (cursor, session, camera). */
8+
export interface RecordingSet {
9+
dir: string;
10+
stem: string;
11+
files: string[];
12+
sizeBytes: number;
13+
}
14+
15+
export interface ProjectRecordings {
16+
owned: RecordingSet[];
17+
/** Titles of the other projects that use some of this project's recordings. */
18+
keptFor: string[];
19+
}
20+
21+
export type RecordingsDisposal = "keep" | "trash" | "permanent";
22+
23+
function documentMediaPaths(doc: AxcutDocument): string[] {
24+
return doc.assets.flatMap((asset) =>
25+
[asset.originalPath, asset.proxyPath, asset.waveformPath, asset.cameraTrack?.sourcePath].filter(
26+
(p): p is string => typeof p === "string" && p.length > 0,
27+
),
28+
);
29+
}
30+
31+
// `path.relative` compares case-insensitively on Windows, which a string compare would not.
32+
function sameDir(a: string, b: string): boolean {
33+
return path.relative(path.resolve(a), path.resolve(b)) === "";
34+
}
35+
36+
function recordingKey(filePath: string, recordingsDirs: readonly string[]): string | null {
37+
const dir = path.dirname(path.resolve(filePath));
38+
const managedDir = recordingsDirs.find((candidate) => sameDir(candidate, dir));
39+
if (!managedDir) return null;
40+
const stem = RECORDING_STEM.exec(path.basename(filePath))?.[0];
41+
return stem ? path.join(path.resolve(managedDir), stem) : null;
42+
}
43+
44+
function documentRecordingKeys(doc: AxcutDocument, recordingsDirs: readonly string[]): Set<string> {
45+
const keys = new Set<string>();
46+
for (const mediaPath of documentMediaPaths(doc)) {
47+
const key = recordingKey(mediaPath, recordingsDirs);
48+
if (key) keys.add(key);
49+
}
50+
return keys;
51+
}
52+
53+
async function readRecordingSet(key: string): Promise<RecordingSet | null> {
54+
const dir = path.dirname(key);
55+
const stem = path.basename(key);
56+
let names: string[];
57+
try {
58+
names = await fs.readdir(dir);
59+
} catch {
60+
return null;
61+
}
62+
const files: string[] = [];
63+
let sizeBytes = 0;
64+
for (const name of names) {
65+
// `recording-12` must not pick up `recording-123`.
66+
if (name !== stem && !name.startsWith(`${stem}.`) && !name.startsWith(`${stem}-`)) continue;
67+
const filePath = path.join(dir, name);
68+
try {
69+
const stat = await fs.stat(filePath);
70+
if (!stat.isFile()) continue;
71+
files.push(filePath);
72+
sizeBytes += stat.size;
73+
} catch {
74+
// Gone between the listing and the stat: nothing left to delete.
75+
}
76+
}
77+
return files.length > 0 ? { dir, stem, files, sizeBytes } : null;
78+
}
79+
80+
/**
81+
* The recordings `projectId` owns: takes in one of OpenScreen's recordings folders that no
82+
* other project uses. A video imported from anywhere else is never the project's to delete.
83+
*/
84+
export async function findProjectRecordings(
85+
projectId: string,
86+
documents: readonly AxcutDocument[],
87+
recordingsDirs: readonly string[],
88+
): Promise<ProjectRecordings> {
89+
const target = documents.find((doc) => doc.project.id === projectId);
90+
if (!target) return { owned: [], keptFor: [] };
91+
92+
const keys = documentRecordingKeys(target, recordingsDirs);
93+
const keptFor = new Set<string>();
94+
for (const doc of documents) {
95+
if (doc.project.id === projectId) continue;
96+
const otherKeys = documentRecordingKeys(doc, recordingsDirs);
97+
for (const key of keys) {
98+
if (otherKeys.has(key)) {
99+
keys.delete(key);
100+
keptFor.add(doc.project.title);
101+
}
102+
}
103+
}
104+
105+
const owned: RecordingSet[] = [];
106+
for (const key of keys) {
107+
const set = await readRecordingSet(key);
108+
if (set) owned.push(set);
109+
}
110+
return { owned, keptFor: [...keptFor] };
111+
}
112+
113+
/** Throws on the first file that cannot be removed, so a failed trash can be retried permanently. */
114+
export async function removeRecordingSets(
115+
sets: readonly RecordingSet[],
116+
disposal: Exclude<RecordingsDisposal, "keep">,
117+
trashItem: (filePath: string) => Promise<void>,
118+
): Promise<void> {
119+
for (const set of sets) {
120+
for (const filePath of set.files) {
121+
if (disposal === "trash") {
122+
await trashItem(filePath);
123+
} else {
124+
await fs.rm(filePath, { force: true });
125+
}
126+
}
127+
}
128+
}

‎electron/ipc/handlers.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5222,6 +5222,10 @@ export function registerIpcHandlers(
52225222
}
52235223
},
52245224
getAiEditionDocuments: () => aiEditionDocuments,
5225+
getRecordingsDirs: () => ({
5226+
dirs: getAllowedReadDirs(),
5227+
registryDir: DEFAULT_RECORDINGS_DIR,
5228+
}),
52255229
getStylePresets: () => stylePresets,
52265230
getAiEditionLlmConfig,
52275231
runAiEditionChat: (projectId, sessionId, message, document, sink) =>

‎electron/ipc/nativeBridge.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export interface NativeBridgeContext {
6060
*/
6161
getNativeWindowHandle?: (sender: import("electron").WebContents) => Buffer | null;
6262
getAiEditionDocuments: () => DocumentService;
63+
/** OpenScreen's recordings folders, the current one first. Absent: deleting a project keeps its recordings. */
64+
getRecordingsDirs?: () => { dirs: string[]; registryDir: string };
6365
/** The one shared style preset service — it serialises writes per instance. */
6466
getStylePresets: () => StylePresetService;
6567
getAiEditionLlmConfig: () => import("../ai-edition/llm-config-store").LlmConfigStore;
@@ -239,6 +241,13 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
239241
// hit the macOS Keychain) while wiring the bridge at startup.
240242
llmConfig: context.getAiEditionLlmConfig,
241243
mcp: context.getMcpController?.(),
244+
recordings: context.getRecordingsDirs
245+
? {
246+
dirs: () => context.getRecordingsDirs?.().dirs ?? [],
247+
registryDir: () => context.getRecordingsDirs?.().registryDir ?? "",
248+
trashItem: (filePath) => shell.trashItem(filePath),
249+
}
250+
: undefined,
242251
runChat: context.runAiEditionChat,
243252
undoLastToolBatch: context.undoAiEditionToolBatch,
244253
rewindToMessage: context.rewindToMessage,
@@ -558,7 +567,15 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
558567
case "document.delete":
559568
return createSuccessResponse(
560569
requestId,
561-
await aiEditionService.deleteProject(request.payload.projectId),
570+
await aiEditionService.deleteProject(
571+
request.payload.projectId,
572+
request.payload.recordings,
573+
),
574+
);
575+
case "document.recordings":
576+
return createSuccessResponse(
577+
requestId,
578+
await aiEditionService.projectRecordings(request.payload.projectId),
562579
);
563580
case "document.addAsset":
564581
return createSuccessResponse(

‎electron/media/mediaLinksRegistry.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
computeFingerprint,
77
findMediaLinksByFingerprint,
88
findRelocatedMediaByStoredPath,
9+
forgetMediaLinks,
910
registerMediaLinks,
1011
whenRegistryIdle,
1112
} from "./mediaLinksRegistry";
@@ -59,6 +60,22 @@ describe("mediaLinksRegistry", () => {
5960
await rmBestEffort(tempDir);
6061
});
6162

63+
describe("forgetMediaLinks", () => {
64+
it("drops only the entries of the deleted recordings", async () => {
65+
const kept = path.join(tempDir, "recording-1.mp4");
66+
const deleted = path.join(tempDir, "recording-2.mp4");
67+
await writeFileOfSize(kept, 1024, "k");
68+
await writeFileOfSize(deleted, 2048, "d");
69+
await registerMediaLinks(tempDir, kept, { cursorTelemetryPath: `${kept}.cursor.json` });
70+
await registerMediaLinks(tempDir, deleted, { cursorTelemetryPath: `${deleted}.cursor.json` });
71+
72+
await forgetMediaLinks(tempDir, [deleted, `${deleted}.cursor.json`]);
73+
74+
expect(await findMediaLinksByFingerprint(tempDir, kept)).not.toBeNull();
75+
expect(await findMediaLinksByFingerprint(tempDir, deleted)).toBeNull();
76+
});
77+
});
78+
6279
describe("computeFingerprint", () => {
6380
it("never reads more than the head+tail sample regardless of file size", async () => {
6481
const bigPath = path.join(tempDir, "big.webm");

‎electron/media/mediaLinksRegistry.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,20 @@ export async function registerMediaLinks(
256256
});
257257
}
258258

259+
/** Drops the entries of deleted recordings, matched by the path they were last seen at. */
260+
export async function forgetMediaLinks(
261+
baseDir: string,
262+
deletedPaths: readonly string[],
263+
): Promise<void> {
264+
if (deletedPaths.length === 0) return;
265+
const deleted = deletedPaths.map((p) => path.resolve(p));
266+
const isDeleted = (p: string) => deleted.some((d) => path.relative(d, path.resolve(p)) === "");
267+
await updateRegistry(baseDir, (file) => ({
268+
version: 1,
269+
entries: file.entries.filter((e) => !isDeleted(e.lastKnownPath)),
270+
}));
271+
}
272+
259273
export interface MediaLinksLookup {
260274
webcamVideoPath?: string;
261275
webcamOffsetMs?: number;

0 commit comments

Comments
 (0)