Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pro 4261 export attachments #10

Merged
merged 34 commits into from
Aug 30, 2023
Merged

Pro 4261 export attachments #10

merged 34 commits into from
Aug 30, 2023

Conversation

ValJed
Copy link
Contributor

@ValJed ValJed commented Aug 24, 2023

Summary

Manage attachments through stream to be generated in the zip files.
A JSON file is generated with attachments documents.
A folder with actual downloaded files, stream from http GET request is passed to archive lib for each attachment.
Requests are done 5 by 5.
Error notifications, if an error occurs we archive the file isn't generated.
If we have some attachments issues, file is generated but we warn the user.

What are the specific steps to test this change?

Run exports with images or other attachments.

What kind of change does this PR introduce?

  • Bug fix
  • New feature
  • Refactor
  • Documentation
  • Build-related changes
  • Other

Make sure the PR fulfills these requirements:

  • It includes a) the existing issue ID being resolved, b) a convincing reason for adding this feature, or c) a clear description of the bug it resolves
  • The changelog is updated
  • Related documentation has been updated
  • Related tests have been updated

@ValJed ValJed self-assigned this Aug 24, 2023
@linear
Copy link

linear bot commented Aug 24, 2023

PRO-4261 As an editor, I can download a ZIP file of my exported documents

When clicking on the download button of the export modal, after I selected the related docs that I want to include in the export, the process must be triggered.

image.png

Two new routes export must exist in the modules piece-type and page. Both routes will call a method export from the module doc-type .

This method should take the IDs of the documents the user want to export, as well as the related types that he wants to get. Only the types, not the IDs, since we decided that it would be too heavy for the export modal to get the amount of related docs when we batch a large amount of documents.

Generate files

This method will need to use the relate method from the module schema, this method fills documents with related ones.

Since we need to store documents in a flat EJSON array, it might be a good idea to add an option to this method in order to get a flat array of related documents instead of filling requested documents with their related docs, see how it could potentially be used with query builders.

We want one EJSON file for each db collection, for example aposDocs.json and aposAttachment.json, containing arrays of documents..

We want, for each document to export the draft and live versions (only draft if there is no live). The draft version must appear first to avoid situation where a live exists without draft which is forbidden.

Same thing for related documents, they must be written first in the EJSON file, otherwise the doc manager will yell when we'll import it.

We will create an attachment folder when actual attachments will be stored (images, files…).

Generate Zip

To generate the ZIP file we would prefer using the native node library.

Progress bar notification

This progress bar notification is triggered by wrapping your export function with the run method of the job module. Here is an example from the piece-type-importer module:

return self.apos.modules['apostrophecms/job'].run(
  req,
  (req, reporting, { notificationId }) => self.importRun(req, {
    progressNotifId: notificationId,
    filePath: file.path,
    reporting,
    totalPieces
  }),
  {}
);

The reporting object passed from the second argument function allows to set the total of the documents you want to import with the method setTotal, as well as to call the methods success or failure for every document that we tried to export or import.

Finally the ZIP file will be sent back to the client and the download triggered from the user's browser.

Permissions

For exporting documents, a user that has at least one per-type permission should be able to export anything. He won’t be able to see adminOnly documents, therefore should not be able to export these documents. It matches the way it works currently because in this case the user can see all drafts. In core, admin should be able to export adminOnly content, except for users.

For Reference

https://github.com/apostrophecms/tech-designs/blob/main/3.x/share-documents-across-sites/design.md

Acceptance Criteria

  • I'm an editor or admin on with the Download modal open, and my documents selected, and I hit "Download" and when I do, a ZIP file begins downloading.
  • The zip file downloads and I open it and I see the the draft and live versions (only draft if there is no live) in my unarchived folder.

@ValJed ValJed marked this pull request as draft August 24, 2023 13:22
@ValJed ValJed force-pushed the pro-4261-export-attachments branch from e1dc4c2 to a5f1016 Compare August 25, 2023 07:39
@ValJed ValJed marked this pull request as ready for review August 29, 2023 15:21
Copy link
Contributor

@ETLaurent ETLaurent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

neat! 👏
great implementation above the existing, happy to see that it fitted well.

some questions, some comments, few changes requested

i18n/en.json Outdated
Comment on lines 3 to 4
"exportFileGenerationError": "The {{ extension }} file generation failed",
"exportAttachmentError": "Some attachments could not be added to the {{ extension }} file",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alphabetical order, as you like it ;)

const position = i * max;
return [
...chunk,
...attachments.slice(position, position + max)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we're sure we don't leave a promise out in the process?
unit-test it could be good to ensure that

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes because of this line:
const length = Math.ceil(attachments.length / max);
we create as many arrays as needed chunks. The ceil allows to be sure we have a last array to put the last promises even if the don't fill it.
Could be tested indeed.

Comment on lines 57 to 62
for (const chunk of chunkedPromises) {
const results = await Promise.allSettled(chunk);
for (const { status } of results) {
if (status === 'rejected' && !attachmentError) {
attachmentError = true;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can use .some rather than another for..of:

Suggested change
for (const chunk of chunkedPromises) {
const results = await Promise.allSettled(chunk);
for (const { status } of results) {
if (status === 'rejected' && !attachmentError) {
attachmentError = true;
}
for (const chunk of chunkedPromises) {
const results = await Promise.allSettled(chunk);
if (results.some(result => result.status === 'rejected')) {
attachmentError = true;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one, updated


archive.append(content, { name: filename });
// If one image download fail should we stop the entire process?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no I don't think so, and your attachmentError is a good way of telling that one or several attachment failed, and still being able to generate the archive

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment removed.

});
}
} catch (err) {
console.log(err);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
console.log(err);
self.apos.error('error', error.message);

(let's rename err to error to be consistent in this module 🙏 )

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Destructured to get message.


const docs = await self.apos.doc.db
const docsIdsUniq = [ ...new Set(docsIds) ];
const docs = await self.apos[collection].db
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines +164 to +168
/* ( */
/* type === 'relationship' && */
/* field.withType && */
/* self.apos.modules[field.withType].options.relatedDocument === false */
/* ) */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a TODO: handle 'export: false' option

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added (but we should choose another option name)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am handling this in fetchActualDocs, actually:

image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use another option name since this one is already used by piece-type-exporter and could occur unclear conflicts.
Also I think it would be more efficient to perform this check before the DB request if we can.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok I'll handle that in #13

}) {
return schema.flatMap(field => {
const fieldValue = doc[field.name];
const shouldRecurse = recursion < MAX_RECURSION;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const shouldRecurse = recursion < MAX_RECURSION;
const shouldRecurse = recursion <= MAX_RECURSION;

Copy link
Contributor Author

@ValJed ValJed Aug 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hum since the recursion starts at 0, if I put <= I'll have 11 call of the function if I have a MAX_RECURSION of 10.
But semantically you're right, the first call isn't a recursion 🤔
Hum the universe is so complex, I need to let my body float in its cold matter, nothing makes sense.
let me modify this line of code

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah but a 1 recursion means that we are passing a 2nd time in the function.
to me, "recursion" = "re-called".

that's what's been done in apiRoutes

const moduleOptions = window.apos.modules[this.moduleName];
const label = this.checked.length > 1 ? moduleOptions.pluralLabel : moduleOptions.label;
const moduleOptions = apos.modules[this.moduleName];
const label = this.checked?.length > 1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const label = this.checked?.length > 1
const label = this.count > 1

if possible to use the computed below 👇

@@ -141,7 +141,11 @@ export default {
},
checked: {
type: Array,
default: () => []
default: null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you set it to null?
having an array by default avoids us having to make loads of conditions when checking the length

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because checked is used only in batch operations, otherwise we user this.doc.
We need a way to now we are in batch operation or in a single piece operation.
I could make a check on this.doc instead maybe.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated to use a data key selectedDocIds to store ids from this.doc or from this.checked

@ValJed ValJed requested a review from ETLaurent August 30, 2023 13:05
@@ -1,5 +1,7 @@
{
"export": "Download {{ type }}",
"exportAttachmentError": "Some attachments could not be added to the {{ extension }} file",
"exportFileGenerationError": "The {{ extension }} file generation failed",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

below exported 🔤

@ValJed ValJed merged commit a988c32 into main Aug 30, 2023
0 of 9 checks passed
@ETLaurent ETLaurent deleted the pro-4261-export-attachments branch August 30, 2023 14:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants