Skip to content

Commit e40650a

Browse files
committed
fix(delete): keep a project's recordings while another project file does not parse
A project that fails to parse is left out of the list the delete checks for other users of a recording, so a take it shares could be trashed with the project being deleted. While any project file is unreadable, the dialog offers no Trash and says why, and the delete itself refuses to remove recordings.
1 parent 426d8ce commit e40650a

24 files changed

Lines changed: 135 additions & 18 deletions

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,11 +386,20 @@ export class DocumentService {
386386

387387
/** Every project that parses, unrelinked and unannounced: a listing, not an open. */
388388
async readAllProjects(): Promise<AxcutDocument[]> {
389+
return (await this.readAllProjectsReport()).documents;
390+
}
391+
392+
/**
393+
* `readAllProjects`, plus the project files it skipped. A caller deciding whether no project
394+
* uses a file needs those: a project that does not parse may still be using it.
395+
*/
396+
async readAllProjectsReport(): Promise<{ documents: AxcutDocument[]; unreadable: string[] }> {
389397
await this.ensureProjectsDir();
390398
const entries = await fs.readdir(this.projectsRoot);
391399
// ensureProjectsDir (above) already migrated any legacy `.axcut` files.
392400
const projectFiles = entries.filter((name) => name.endsWith(PROJECT_FILE_EXTENSION));
393401
const documents: AxcutDocument[] = [];
402+
const unreadable: string[] = [];
394403
for (const name of projectFiles) {
395404
const filePath = path.join(this.projectsRoot, name);
396405
try {
@@ -399,9 +408,10 @@ export class DocumentService {
399408
// ponytail: skip unreadable files rather than failing the whole list.
400409
// A future migration pass can recover them.
401410
console.warn(`[ai-edition] failed to read ${filePath}:`, error);
411+
unreadable.push(filePath);
402412
}
403413
}
404-
return documents;
414+
return { documents, unreadable };
405415
}
406416

407417
/**

‎electron/ai-edition/project-recordings.test.ts‎

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,11 @@ describe("project recordings", () => {
5050
await touch(path.join(recordingsDir, "recording-123.mp4"));
5151
const id = await projectWith("Mine", video);
5252

53-
const { owned, keptFor } = await findProjectRecordings(id, await documents.readAllProjects(), [
54-
recordingsDir,
55-
]);
53+
const { owned, keptFor } = await findProjectRecordings(
54+
id,
55+
await documents.readAllProjectsReport(),
56+
[recordingsDir],
57+
);
5658

5759
expect(owned).toHaveLength(1);
5860
expect(owned[0].files.sort()).toEqual([video, ...companions].sort());
@@ -66,19 +68,52 @@ describe("project recordings", () => {
6668
const id = await projectWith("Mine", shared, own);
6769
await projectWith("Demo v2", shared);
6870

69-
const { owned, keptFor } = await findProjectRecordings(id, await documents.readAllProjects(), [
70-
recordingsDir,
71-
]);
71+
const { owned, keptFor } = await findProjectRecordings(
72+
id,
73+
await documents.readAllProjectsReport(),
74+
[recordingsDir],
75+
);
7276

7377
expect(owned.map((set) => set.stem)).toEqual(["recording-2"]);
7478
expect(keptFor).toEqual(["Demo v2"]);
7579
});
7680

81+
it("keeps every recording while a project file does not parse", async () => {
82+
const video = await touch(path.join(recordingsDir, "recording-9.mp4"));
83+
const id = await projectWith("Mine", video);
84+
// Could be a project using recording-9: a newer schema, or a save cut short.
85+
await touch(path.join(root, "projects", "broken.openscreen"), "{ not json");
86+
87+
const { owned, keptFor, unreadable } = await findProjectRecordings(
88+
id,
89+
await documents.readAllProjectsReport(),
90+
[recordingsDir],
91+
);
92+
93+
expect(owned).toEqual([]);
94+
expect(keptFor).toEqual([]);
95+
expect(unreadable).toBe(1);
96+
});
97+
98+
it("reports no unreadable projects when it has no recordings to keep", async () => {
99+
const imported = await touch(path.join(elsewhere, "clip.mp4"));
100+
const id = await projectWith("Imported", imported);
101+
await touch(path.join(root, "projects", "broken.openscreen"), "{ not json");
102+
103+
const { unreadable } = await findProjectRecordings(
104+
id,
105+
await documents.readAllProjectsReport(),
106+
[recordingsDir],
107+
);
108+
109+
expect(unreadable).toBe(0);
110+
});
111+
77112
it("never owns a video outside the recordings folders", async () => {
78113
const imported = await touch(path.join(elsewhere, "recording-5.mp4"));
79114
const id = await projectWith("Imported", imported);
80115

81-
const { owned } = await findProjectRecordings(id, await documents.readAllProjects(), [
116+
const { owned } = await findProjectRecordings(id, await documents.readAllProjectsReport(), [
82117
recordingsDir,
83118
]);
84119

@@ -89,7 +124,7 @@ describe("project recordings", () => {
89124
const video = await touch(path.join(recordingsDir, "recording-7.mp4"));
90125
const cursor = await touch(`${video}.cursor.json`);
91126
const id = await projectWith("Mine", video);
92-
const { owned } = await findProjectRecordings(id, await documents.readAllProjects(), [
127+
const { owned } = await findProjectRecordings(id, await documents.readAllProjectsReport(), [
93128
recordingsDir,
94129
]);
95130

‎electron/ai-edition/project-recordings.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export interface ProjectRecordings {
1616
owned: RecordingSet[];
1717
/** Titles of the other projects that use some of this project's recordings. */
1818
keptFor: string[];
19+
/**
20+
* Project files that did not parse. Any of them may use these recordings, so while there
21+
* are some the project owns none: its recordings are all kept.
22+
*/
23+
unreadable: number;
1924
}
2025

2126
export type RecordingsDisposal = "keep" | "trash" | "permanent";
@@ -83,13 +88,17 @@ async function readRecordingSet(key: string): Promise<RecordingSet | null> {
8388
*/
8489
export async function findProjectRecordings(
8590
projectId: string,
86-
documents: readonly AxcutDocument[],
91+
projects: { documents: readonly AxcutDocument[]; unreadable: readonly string[] },
8792
recordingsDirs: readonly string[],
8893
): Promise<ProjectRecordings> {
94+
const { documents } = projects;
8995
const target = documents.find((doc) => doc.project.id === projectId);
90-
if (!target) return { owned: [], keptFor: [] };
96+
if (!target) return { owned: [], keptFor: [], unreadable: 0 };
9197

9298
const keys = documentRecordingKeys(target, recordingsDirs);
99+
if (keys.size > 0 && projects.unreadable.length > 0) {
100+
return { owned: [], keptFor: [], unreadable: projects.unreadable.length };
101+
}
93102
const keptFor = new Set<string>();
94103
for (const doc of documents) {
95104
if (doc.project.id === projectId) continue;
@@ -107,7 +116,7 @@ export async function findProjectRecordings(
107116
const set = await readRecordingSet(key);
108117
if (set) owned.push(set);
109118
}
110-
return { owned, keptFor: [...keptFor] };
119+
return { owned, keptFor: [...keptFor], unreadable: 0 };
111120
}
112121

113122
/** Throws on the first file that cannot be removed, so a failed trash can be retried permanently. */

‎electron/native-bridge/services/aiEditionService.deleteRecordings.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ describe("AiEditionService — deleting a project's recordings", () => {
5757
count: 1,
5858
sizeBytes: 5 + 2,
5959
keptFor: [],
60+
unreadable: 0,
6061
});
6162
});
6263

@@ -78,6 +79,17 @@ describe("AiEditionService — deleting a project's recordings", () => {
7879
).toBeNull();
7980
});
8081

82+
it("deletes nothing when a project file stopped parsing after the dialog asked", async () => {
83+
await fs.writeFile(path.join(root, "projects", "broken.openscreen"), "{ not json");
84+
85+
await expect(service().deleteProject(projectId, "trash")).resolves.toMatchObject({
86+
success: false,
87+
});
88+
expect(trashItem).not.toHaveBeenCalled();
89+
await expect(fs.access(video)).resolves.toBeUndefined();
90+
await expect(documents.getProject(projectId)).resolves.toBeDefined();
91+
});
92+
8193
it("keeps everything when the Trash refuses, then deletes permanently on request", async () => {
8294
trashItem.mockRejectedValueOnce(new Error("no trash on this filesystem"));
8395

‎electron/native-bridge/services/aiEditionService.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,25 +158,27 @@ export class AiEditionService {
158158

159159
async projectRecordings(projectId: string): Promise<AiEditionProjectRecordingsResult> {
160160
const recordings = this.options.recordings;
161-
if (!recordings) return { success: true, count: 0, sizeBytes: 0, keptFor: [] };
161+
if (!recordings) return { success: true, count: 0, sizeBytes: 0, keptFor: [], unreadable: 0 };
162162
try {
163-
const { owned, keptFor } = await findProjectRecordings(
163+
const { owned, keptFor, unreadable } = await findProjectRecordings(
164164
projectId,
165-
await this.options.documents.readAllProjects(),
165+
await this.options.documents.readAllProjectsReport(),
166166
recordings.dirs(),
167167
);
168168
return {
169169
success: true,
170170
count: owned.length,
171171
sizeBytes: owned.reduce((sum, set) => sum + set.sizeBytes, 0),
172172
keptFor,
173+
unreadable,
173174
};
174175
} catch (error) {
175176
return {
176177
success: false,
177178
count: 0,
178179
sizeBytes: 0,
179180
keptFor: [],
181+
unreadable: 0,
180182
error: error instanceof Error ? error.message : String(error),
181183
};
182184
}
@@ -191,11 +193,19 @@ export class AiEditionService {
191193
// the user can retry, instead of leaving takes that nothing points at any more.
192194
if (disposal !== "keep" && recordings) {
193195
try {
194-
const { owned } = await findProjectRecordings(
196+
const { owned, unreadable } = await findProjectRecordings(
195197
projectId,
196-
await this.options.documents.readAllProjects(),
198+
await this.options.documents.readAllProjectsReport(),
197199
recordings.dirs(),
198200
);
201+
// A project file broke since the dialog asked: deleting the project alone would
202+
// quietly drop the recordings the user chose to remove, so delete nothing.
203+
if (unreadable > 0) {
204+
return {
205+
success: false,
206+
error: `${unreadable} project file(s) could not be read, so recordings they may use were not deleted`,
207+
};
208+
}
199209
await removeRecordingSets(owned, disposal, recordings.trashItem);
200210
await forgetMediaLinks(
201211
recordings.registryDir(),

‎src/components/ai-edition/Modals.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,17 @@ export function OpenProjectModal({
365365
})}
366366
</div>
367367
) : null}
368+
{!trashFailed && recordings && recordings.unreadable > 0 ? (
369+
<div
370+
style={{
371+
font: "400 12px/1.4 var(--font-body)",
372+
color: "var(--muted)",
373+
marginTop: 4,
374+
}}
375+
>
376+
{t("openProjectDialog.recordingsKeptUnreadable")}
377+
</div>
378+
) : null}
368379
</div>
369380
<button
370381
type="button"

‎src/components/ai-edition/OpenProjectModal.test.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const noRecordings: AiEditionProjectRecordingsResult = {
2525
count: 0,
2626
sizeBytes: 0,
2727
keptFor: [],
28+
unreadable: 0,
2829
};
2930

3031
function renderModal(
@@ -57,6 +58,7 @@ const ownRecordings: AiEditionProjectRecordingsResult = {
5758
count: 2,
5859
sizeBytes: 24 * 1024 * 1024,
5960
keptFor: [],
61+
unreadable: 0,
6062
};
6163

6264
describe("OpenProjectModal delete", () => {
@@ -118,6 +120,17 @@ describe("OpenProjectModal delete", () => {
118120
expect(await screen.findByText(/used by Demo v2 are kept/i)).toBeInTheDocument();
119121
});
120122

123+
it("offers no Trash and says why when a project file could not be read", async () => {
124+
const { onDelete } = renderModal({ recordings: { ...noRecordings, unreadable: 1 } });
125+
126+
fireEvent.click(screen.getAllByRole("button", { name: /delete project/i })[0]);
127+
128+
expect(await screen.findByText(/couldn't be read/i)).toBeInTheDocument();
129+
expect(screen.queryByRole("button", { name: /move its recordings/i })).not.toBeInTheDocument();
130+
fireEvent.click(screen.getByRole("button", { name: /^delete$/i }));
131+
await waitFor(() => expect(onDelete).toHaveBeenCalledWith("proj_one", "keep"));
132+
});
133+
121134
it("asks to delete permanently when the Trash refuses", async () => {
122135
const { onDelete } = renderModal({
123136
recordings: ownRecordings,

‎src/i18n/locales/ar/editor.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@
342342
"alsoTrash": "نقل تسجيلاته أيضًا إلى سلة المهملات ({{size}})",
343343
"alsoRecycle": "نقل تسجيلاته أيضًا إلى سلة المحذوفات ({{size}})",
344344
"recordingsKeptFor": "يتم الاحتفاظ بالتسجيلات التي يستخدمها {{projects}}.",
345+
"recordingsKeptUnreadable": "تعذّرت قراءة بعض المشاريع، لذا يتم الاحتفاظ بتسجيلات هذا المشروع.",
345346
"trashFailed": "تعذّر نقل التسجيلات إلى سلة المهملات. هل تريد حذفها نهائيًا؟",
346347
"recycleFailed": "تعذّر نقل التسجيلات إلى سلة المحذوفات. هل تريد حذفها نهائيًا؟",
347348
"deletePermanently": "حذف نهائي"

‎src/i18n/locales/cs/editor.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@
342342
"alsoTrash": "Přesunout také jeho nahrávky do koše ({{size}})",
343343
"alsoRecycle": "Přesunout také jeho nahrávky do koše ({{size}})",
344344
"recordingsKeptFor": "Nahrávky používané projektem {{projects}} zůstanou zachovány.",
345+
"recordingsKeptUnreadable": "Některé projekty nešlo přečíst, proto nahrávky tohoto projektu zůstanou zachovány.",
345346
"trashFailed": "Nahrávky se nepodařilo přesunout do koše. Smazat je trvale?",
346347
"recycleFailed": "Nahrávky se nepodařilo přesunout do koše. Smazat je trvale?",
347348
"deletePermanently": "Smazat trvale"

‎src/i18n/locales/de/editor.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@
342342
"alsoTrash": "Auch die Aufnahmen in den Papierkorb legen ({{size}})",
343343
"alsoRecycle": "Auch die Aufnahmen in den Papierkorb verschieben ({{size}})",
344344
"recordingsKeptFor": "Aufnahmen, die {{projects}} verwendet, bleiben erhalten.",
345+
"recordingsKeptUnreadable": "Einige Projekte konnten nicht gelesen werden, daher bleiben die Aufnahmen dieses Projekts erhalten.",
345346
"trashFailed": "Die Aufnahmen konnten nicht in den Papierkorb gelegt werden. Endgültig löschen?",
346347
"recycleFailed": "Die Aufnahmen konnten nicht in den Papierkorb verschoben werden. Endgültig löschen?",
347348
"deletePermanently": "Endgültig löschen"

0 commit comments

Comments
 (0)