tag.
+ * Supports bold, italic, underline, and link attributes.
+ * @param {Object} delta - A Quill Delta object with an `ops` array.
+ * @returns {string} - A full HTML document string.
+ */
+ function deltaToHtml(delta) {
+ let html = '';
+ let lineBuffer = [];
+
+ const flushLine = () => {
+ html += '
' + (lineBuffer.join('') || ' ') + '
\n';
+ lineBuffer = [];
+ };
+
+ for (const op of delta.ops) {
+ if (typeof op.insert !== 'string') continue;
+
+ const lines = op.insert.split('\n');
+ lines.forEach((segment, i) => {
+ if (segment.length > 0) {
+ let content = segment
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+
+ if (op.attributes) {
+ if (op.attributes.bold) content = `${content}`;
+ if (op.attributes.italic) content = `${content}`;
+ if (op.attributes.underline) content = `${content}`;
+ if (op.attributes.link) content = `${content}`;
+ }
+ lineBuffer.push(content);
+ }
+ if (i < lines.length - 1) flushLine();
+ });
+ }
+ if (lineBuffer.length > 0) flushLine();
+
+ return `\n\n\n${html}\n`;
+ }
+
+ /**
+ * Exports a single document to the archive based on its type.
+ * - Type 0 (PDF): exports annotations, comments (with votes), document_data, and the PDF file.
+ * - Type 1 (HTML) / Type 2 (Modal): exports edits, plain text, HTML, and document_data.
+ * - Type 4 (ZIP): exports the zip file and document_data.
+ * @param {Object} server - The server instance providing database models.
+ * @param {Object} doc - The document record from the database.
+ * @param {string} docFolder - The target folder path inside the archive.
+ * @param {Object} archive - The archiver instance to append files to.
+ * @returns {Promise}
+ */
+ async function processDocumentForExport(server, doc, docFolder, includeNonConsentingEdits, archive) {
+ // document_data for all types, at the doc level
+ const documentData = await server.db.models.document_data.findAll({
+ where: { documentId: doc.id, deleted: false },
+ raw: true,
+ });
+ if (documentData.length > 0) {
+ archive.append(JSON.stringify(documentData, null, 2), { name: `${docFolder}/document_data.json` });
+ }
+
+ switch (doc.type) {
+ case 0: { // PDF
+ const [annotations, comments] = await Promise.all([
+ server.db.models.annotation.findAll({ where: { documentId: doc.id }, raw: true }),
+ server.db.models.comment.findAll({ where: { documentId: doc.id }, raw: true }),
+ ]);
+ const commentVotes = await server.db.models.comment_vote.findAll({
+ where: { commentId: comments.map(c => c.id), deleted: false },
+ raw: true,
+ });
+ const commentsWithVotes = comments.map(c => ({
+ ...c,
+ votes: commentVotes.filter(v => v.commentId === c.id),
+ }));
+ if (annotations.length > 0) {
+ archive.append(JSON.stringify(annotations, null, 2), { name: `${docFolder}/annotations.json` });
+ }
+ if (commentsWithVotes.length > 0) {
+ archive.append(JSON.stringify(commentsWithVotes, null, 2), { name: `${docFolder}/comments.json` });
+ }
+ const pdfPath = path.join(storageDir, `${doc.hash}.pdf`);
+ if (fs.existsSync(pdfPath)) {
+ archive.file(pdfPath, { name: `${docFolder}/document.pdf` });
+ } else {
+ console.warn(`[DocumentExport] PDF not found for document ${doc.hash}`);
+ }
+ break;
+ }
+
+ case 1: // HTML
+ case 2: { // MODAL
+ // fetch all edits for this document, ordered chronologically
+ let allEdits = await server.db.models.document_edit.findAll({
+ where: { documentId: doc.id, deleted: false },
+ order: [['createdAt', 'ASC']],
+ raw: true,
+ });
+
+ // filter by consent unless the option is enabled
+ if (!includeNonConsentingEdits) {
+ const editorUserIds = [...new Set(allEdits.map(e => e.userId).filter(Boolean))];
+ const editorUsers = await server.db.models.user.findAll({
+ where: { id: editorUserIds },
+ attributes: ['id', 'acceptDataSharing'],
+ raw: true,
+ });
+ const consentedUserIds = new Set(
+ editorUsers.filter(u => u.acceptDataSharing).map(u => u.id)
+ );
+ allEdits = allEdits.filter(e => !e.userId || consentedUserIds.has(e.userId));
+ }
+
+ // group edits by studySessionId (null = template)
+ const sessionGroups = new Map();
+ for (const edit of allEdits) {
+ const key = edit.studySessionId ?? '__template__';
+ if (!sessionGroups.has(key)) sessionGroups.set(key, []);
+ sessionGroups.get(key).push(edit);
+ }
+
+ // fetch study sessions to resolve hashes
+ const sessionIds = [...sessionGroups.keys()].filter(k => k !== '__template__');
+ const sessions = sessionIds.length > 0
+ ? await server.db.models.study_session.findAll({
+ where: { id: sessionIds },
+ attributes: ['id', 'hash'],
+ raw: true,
+ })
+ : [];
+ const sessionHashMap = new Map(sessions.map(s => [s.id, s.hash]));
+
+ for (const [key, edits] of sessionGroups.entries()) {
+ const isTemplate = key === '__template__';
+ const delta = dbToDelta(edits);
+
+ // skip empty content
+ const text = deltaToText(delta);
+ if (!text.trim()) continue;
+
+ const subFolder = isTemplate
+ ? `${docFolder}/template`
+ : `${docFolder}/${sessionHashMap.get(key) ?? key}`;
+
+ archive.append(text, { name: `${subFolder}/text.txt` });
+ archive.append(deltaToHtml(delta), { name: `${subFolder}/html.html` });
+ archive.append(JSON.stringify(edits, null, 2), { name: `${subFolder}/edits.json` });
+ }
+ break;
+ }
+
+ case 4: { // ZIP — unchanged
+ const zipPath = path.join(storageDir, `${doc.hash}.zip`);
+ if (fs.existsSync(zipPath)) {
+ archive.file(zipPath, { name: `${docFolder}/document.zip` });
+ } else {
+ console.warn(`[DocumentExport] ZIP not found for document ${doc.hash}`);
+ }
+ break;
+ }
+
+ default:
+ console.warn(`[DocumentExport] Unhandled document type ${doc.type} for document ${doc.hash}, skipping.`);
+ }
+ }
+
+ /**
+ * Main export function for the "documents" export type.
+ * Fetches all studies and steps for a project, collects unique documents,
+ * filters by owner data sharing consent, and exports each document to the archive.
+ * @param {Object} server - The server instance providing database models.
+ * @param {number|string} projectId - The ID of the project to export.
+ * @param {string} baseFolderName - The root folder name inside the ZIP archive.
+ * @param {Object} archive - The archiver instance to append files to.
+ * @param {Array} userIds - List of user IDs to filter documents by.
+ * @param {Array} documentTypes - List of document types to include (0=PDF, 1=HTML, 2=Modal, 4=ZIP).
+ * @returns {Promise}
+ */
+ async function processDocumentBasedExport(server, projectId, userIds, documentTypes, includeNonConsentingEdits, baseFolderName, archive) {
+ const docs = await server.db.models.document.findAll({
+ where: { projectId, userId: userIds, deleted: false, parentDocumentId: null },
+ });
+
+ if (docs.length === 0) {
+ console.warn(`[DocumentExport] No documents found for project ${projectId}`);
+ return;
+ }
+
+ const filteredDocs = docs.filter(doc =>
+ documentTypes.includes(doc.type) || documentTypes.includes(String(doc.type))
+ );
+
+ if (filteredDocs.length === 0) {
+ console.warn(`[DocumentExport] No documents matching selected types found for project ${projectId}`);
+ return;
+ }
+
+ for (const doc of filteredDocs) {
+ const docFolder = `${baseFolderName}/${doc.hash}`;
+ await processDocumentForExport(server, doc, docFolder, includeNonConsentingEdits, archive);
+ }
+ }
};
\ No newline at end of file
diff --git a/frontend/src/components/dashboard/projects/ExportModal.vue b/frontend/src/components/dashboard/projects/ExportModal.vue
index 23fbc1f7e..d6364bdf4 100644
--- a/frontend/src/components/dashboard/projects/ExportModal.vue
+++ b/frontend/src/components/dashboard/projects/ExportModal.vue
@@ -59,13 +59,14 @@
Total Study Sessions: {{ studySessions.length }}
-
You have selected one or more students who didn't accept data sharing.
@@ -21,14 +21,16 @@
Summary:
- You are about to download submissions for
- {{ submissionSelection.length }} student(s).
+ You are about to download
+ submissions
+ documents
+ for {{ userSelection.length }} users(s).
@@ -44,13 +46,12 @@ import BasicLoading from "@/basic/Loading.vue";
*
* The final confirmation step within the ExportModal.
* This component provides a summary of the selected
- * submissions intended for download, as well as some
+ * data intended for download, as well as some
* warnings for the user, if they selected generate aliases
* or students who didn't accept data sharing.
*
* @author Mélissa Loew
*/
-
export default {
name: "StepConfirmDownload",
components: { BasicLoading },
@@ -63,14 +64,18 @@ export default {
type: Boolean,
default: false
},
- submissionSelection: {
+ userSelection: {
type: Array,
required: true
+ },
+ exportType: {
+ type: String,
+ default: 'submissions'
}
},
computed: {
hasDeclinedSharingSelected() {
- return this.submissionSelection.some(row => row.acceptDataSharing === 'No');
+ return this.userSelection.some(row => row.acceptDataSharing === 'No');
}
}
}
diff --git a/frontend/src/components/dashboard/projects/export/StepOptionsDocuments.vue b/frontend/src/components/dashboard/projects/export/StepOptionsDocuments.vue
new file mode 100644
index 000000000..4b7932eff
--- /dev/null
+++ b/frontend/src/components/dashboard/projects/export/StepOptionsDocuments.vue
@@ -0,0 +1,87 @@
+
+
+
+
+ Document Types to Include
+
+
+
+
+
+
+
+
+
+ Editor Documents Consent
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/dashboard/projects/export/StepOptions.vue b/frontend/src/components/dashboard/projects/export/StepOptionsSubmissions.vue
similarity index 96%
rename from frontend/src/components/dashboard/projects/export/StepOptions.vue
rename to frontend/src/components/dashboard/projects/export/StepOptionsSubmissions.vue
index accb2b697..c1bfaa213 100644
--- a/frontend/src/components/dashboard/projects/export/StepOptions.vue
+++ b/frontend/src/components/dashboard/projects/export/StepOptionsSubmissions.vue
@@ -36,7 +36,7 @@