Skip to content

Commit cc25675

Browse files
committed
Add provenance bulk download export
1 parent 7a5e94b commit cc25675

3 files changed

Lines changed: 230 additions & 2 deletions

File tree

src/components/downloader/DownloadButtons.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ import {
22
Button, Group, Tooltip,
33
} from '@mantine/core';
44
import {
5-
IconDatabaseExport, IconDeviceDesktopDown, IconMusicDown, IconTableExport,
5+
IconDatabaseExport, IconDeviceDesktopDown, IconDownload, IconMusicDown, IconTableExport,
66
} from '@tabler/icons-react';
77
import { useState } from 'react';
88
import { useDisclosure } from '@mantine/hooks';
99
import { DownloadTidy, download } from './DownloadTidy';
1010
import { ParticipantDataWithStatus } from '../../storage/types';
1111
import { useStorageEngine } from '../../storage/storageEngineHooks';
12-
import { downloadParticipantsAudioZip, downloadParticipantsScreenRecordingZip } from '../../utils/handleDownloadFiles';
12+
import { downloadParticipantsAudioZip, downloadParticipantsProvenanceZip, downloadParticipantsScreenRecordingZip } from '../../utils/handleDownloadFiles';
1313

1414
type ParticipantDataFetcher = ParticipantDataWithStatus[] | (() => Promise<ParticipantDataWithStatus[]>);
1515

@@ -18,6 +18,7 @@ export function DownloadButtons({
1818
}: { visibleParticipants: ParticipantDataFetcher; studyId: string, gap?: string, fileName?: string | null; hasAudio?: boolean; hasScreenRecording?: boolean; }) {
1919
const [openDownload, { open, close }] = useDisclosure(false);
2020
const [participants, setParticipants] = useState<ParticipantDataWithStatus[]>([]);
21+
const [loadingProvenance, setLoadingProvenance] = useState(false);
2122
const [loadingAudio, setLoadingAudio] = useState(false);
2223
const [loadingScreenRecording, setLoadingScreenRecording] = useState(false);
2324
const { storageEngine } = useStorageEngine();
@@ -56,6 +57,23 @@ export function DownloadButtons({
5657
}
5758
};
5859

60+
const handleDownloadProvenance = async () => {
61+
setLoadingProvenance(true);
62+
63+
try {
64+
const currParticipants = await fetchParticipants();
65+
if (!storageEngine) return;
66+
await downloadParticipantsProvenanceZip({
67+
storageEngine,
68+
participants: currParticipants,
69+
studyId,
70+
fileName,
71+
});
72+
} finally {
73+
setLoadingProvenance(false);
74+
}
75+
};
76+
5977
const handleDownloadScreenRecording = async () => {
6078
setLoadingScreenRecording(true);
6179

@@ -98,6 +116,17 @@ export function DownloadButtons({
98116
<IconTableExport />
99117
</Button>
100118
</Tooltip>
119+
<Tooltip label={`${tooltipText} provenance as ZIP`}>
120+
<Button
121+
variant="light"
122+
disabled={visibleParticipants.length === 0 && typeof visibleParticipants !== 'function'}
123+
onClick={handleDownloadProvenance}
124+
px={4}
125+
loading={loadingProvenance}
126+
>
127+
<IconDownload />
128+
</Button>
129+
</Tooltip>
101130
{hasAudio && (
102131
<Tooltip label={`${tooltipText} audio & transcripts as ZIP`}>
103132
<Button
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import {
2+
afterEach, describe, expect, test, vi,
3+
} from 'vitest';
4+
import { downloadParticipantsProvenanceZip } from './handleDownloadFiles';
5+
import type { StorageEngine } from '../storage/engines/types';
6+
import type { StoredAnswer } from '../store/types';
7+
8+
const mockZipState = vi.hoisted(() => {
9+
const instances: Array<{
10+
files: Record<string, unknown>;
11+
file: (name: string, content: unknown) => unknown;
12+
}> = [];
13+
14+
class MockJSZip {
15+
files: Record<string, unknown> = {};
16+
17+
file = vi.fn((name: string, content: unknown) => {
18+
this.files[name] = content;
19+
return this;
20+
});
21+
22+
generateAsync = vi.fn(async () => new Blob(['zip']));
23+
24+
constructor() {
25+
instances.push(this);
26+
}
27+
}
28+
29+
return { instances, MockJSZip };
30+
});
31+
32+
vi.mock('jszip', () => ({
33+
default: mockZipState.MockJSZip,
34+
}));
35+
36+
function makeStoredAnswer(overrides: Partial<StoredAnswer> = {}): StoredAnswer {
37+
return {
38+
answer: {},
39+
identifier: 'intro_0',
40+
componentName: 'intro',
41+
trialOrder: '0',
42+
correctAnswer: [],
43+
incorrectAnswers: {},
44+
startTime: 10,
45+
endTime: 20,
46+
windowEvents: [],
47+
timedOut: false,
48+
helpButtonClickedCount: 0,
49+
parameters: {},
50+
optionOrders: {},
51+
questionOrders: {},
52+
...overrides,
53+
};
54+
}
55+
56+
describe('provenance downloads', () => {
57+
afterEach(() => {
58+
mockZipState.instances.splice(0, mockZipState.instances.length);
59+
vi.restoreAllMocks();
60+
vi.unstubAllGlobals();
61+
});
62+
63+
test('downloads provenance as a zip alongside the expected per-answer file name', async () => {
64+
const anchor = {
65+
href: '',
66+
download: '',
67+
click: vi.fn(),
68+
remove: vi.fn(),
69+
} as unknown as HTMLAnchorElement;
70+
const createElement = vi.fn().mockReturnValue(anchor);
71+
const appendChild = vi.fn();
72+
vi.stubGlobal('document', {
73+
createElement,
74+
body: {
75+
appendChild,
76+
},
77+
});
78+
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:zip');
79+
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
80+
81+
const provenanceGraph = {
82+
aboveStimulus: undefined,
83+
belowStimulus: undefined,
84+
sidebar: undefined,
85+
stimulus: {
86+
root: 'root',
87+
nodes: {
88+
root: {
89+
id: 'root',
90+
createdOn: 10,
91+
children: [],
92+
},
93+
},
94+
},
95+
};
96+
97+
const storageEngine = {
98+
getProvenance: vi.fn().mockResolvedValue(provenanceGraph),
99+
} as unknown as StorageEngine;
100+
101+
await downloadParticipantsProvenanceZip({
102+
storageEngine,
103+
participants: [
104+
{
105+
participantId: 'p1',
106+
answers: {
107+
intro_0: makeStoredAnswer(),
108+
},
109+
},
110+
],
111+
studyId: 'study-1',
112+
});
113+
114+
expect(storageEngine.getProvenance).toHaveBeenCalledWith('intro_0', 'p1');
115+
expect(mockZipState.instances).toHaveLength(1);
116+
expect(mockZipState.instances[0].files['study-1_p1_intro_0_provenance.json']).toBe(
117+
JSON.stringify(provenanceGraph, null, 2),
118+
);
119+
expect(anchor.download).toBe('study-1_provenance.zip');
120+
expect(anchor.click).toHaveBeenCalled();
121+
});
122+
});

src/utils/handleDownloadFiles.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import JSZip from 'jszip';
22
import { StorageEngine } from '../storage/engines/types';
33
import { StudyConfig } from '../parser/types';
4+
import { getLegacyStoredAnswerProvenance } from '../store/provenance';
5+
import type { StoredAnswer } from '../store/types';
46

57
export async function handleTaskAudio({
68
storageEngine,
@@ -86,6 +88,42 @@ async function downloadZip(zip: JSZip, fileName: string) {
8688
URL.revokeObjectURL(url);
8789
}
8890

91+
async function downloadParticipantsProvenance({
92+
storageEngine,
93+
participantId,
94+
identifier,
95+
namePrefix,
96+
zip,
97+
answer,
98+
}: {
99+
storageEngine: StorageEngine;
100+
participantId: string;
101+
identifier: string;
102+
namePrefix: string;
103+
zip?: JSZip;
104+
answer?: StoredAnswer;
105+
}) {
106+
const provenanceZip = zip || new JSZip();
107+
108+
try {
109+
const legacyProvenance = answer ? getLegacyStoredAnswerProvenance(answer) : null;
110+
const provenance = legacyProvenance || await storageEngine.getProvenance(identifier, participantId);
111+
112+
if (provenance) {
113+
provenanceZip.file(
114+
`${namePrefix}_${participantId}_${identifier}_provenance.json`,
115+
JSON.stringify(provenance, null, 2),
116+
);
117+
}
118+
119+
if (!zip) {
120+
downloadZip(provenanceZip, `${namePrefix}_${participantId}_${identifier}_provenance.zip`);
121+
}
122+
} catch (error) {
123+
console.warn(`Failed to fetch provenance for ${identifier}:`, error);
124+
}
125+
}
126+
89127
async function downloadParticipantsAudio({
90128
storageEngine,
91129
participantId,
@@ -217,6 +255,45 @@ export async function downloadParticipantsScreenRecordingZip({
217255
await downloadZip(zip, `${namePrefix}_screenRecording.zip`);
218256
}
219257

258+
export async function downloadParticipantsProvenanceZip({
259+
storageEngine,
260+
participants,
261+
studyId,
262+
fileName,
263+
}: {
264+
storageEngine: StorageEngine;
265+
participants: Array<{
266+
participantId: string;
267+
answers: Record<string, StoredAnswer>;
268+
}>;
269+
studyId: string;
270+
fileName?: string | null;
271+
}) {
272+
const namePrefix = fileName || studyId;
273+
const zip = new JSZip();
274+
275+
const provenancePromises = participants.flatMap((participant) => {
276+
const entries = Object.entries(participant.answers)
277+
.filter(([, ans]) => ans.endTime > 0)
278+
.sort(([, a], [, b]) => a.startTime - b.startTime);
279+
280+
return entries.map(async ([identifier, ans]) => {
281+
await downloadParticipantsProvenance({
282+
storageEngine,
283+
participantId: participant.participantId,
284+
identifier: ans.identifier || identifier,
285+
namePrefix,
286+
zip,
287+
answer: ans,
288+
});
289+
});
290+
});
291+
292+
await Promise.all(provenancePromises);
293+
294+
await downloadZip(zip, `${namePrefix}_provenance.zip`);
295+
}
296+
220297
export async function downloadConfigFile({
221298
studyId,
222299
hash,

0 commit comments

Comments
 (0)