From 902b76205cfb85aba2606ace5b1e8df8d4a77dc4 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 6 Jan 2025 11:30:29 -0600 Subject: [PATCH 01/22] WIP --- specifyweb/attachment_gw/urls.py | 1 + specifyweb/attachment_gw/views.py | 39 ++++++++++++++++++- .../FormSliders/AttachmentsCollection.tsx | 27 +++++++++++-- .../js_src/lib/localization/attachments.ts | 9 +++++ 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/specifyweb/attachment_gw/urls.py b/specifyweb/attachment_gw/urls.py index 8b3c6578339..dcabc8f0203 100644 --- a/specifyweb/attachment_gw/urls.py +++ b/specifyweb/attachment_gw/urls.py @@ -7,6 +7,7 @@ url(r'^get_upload_params/$', views.get_upload_params), url(r'^get_token/$', views.get_token), url(r'^proxy/$', views.proxy), + url(r'^download_all/$', views.download_all), url(r'^dataset/$', views.datasets), url(r'^dataset/(?P\d+)/$', views.dataset), diff --git a/specifyweb/attachment_gw/views.py b/specifyweb/attachment_gw/views.py index 29eeeb67225..5eedc184a58 100644 --- a/specifyweb/attachment_gw/views.py +++ b/specifyweb/attachment_gw/views.py @@ -2,6 +2,8 @@ import json import logging import time +import shutil +from tempfile import mkdtemp from os.path import splitext from uuid import uuid4 from xml.etree import ElementTree @@ -12,7 +14,8 @@ StreamingHttpResponse from django.db import transaction from django.utils.translation import gettext as _ -from django.views.decorators.cache import cache_control +from django.views.decorators.cache import cache_control, never_cache +from django.views.decorators.http import require_POST from specifyweb.middleware.general import require_http_methods from specifyweb.specify.views import login_maybe_required, openapi @@ -296,6 +299,40 @@ def proxy(request): (chunk for chunk in response.iter_content(512 * 1024)), content_type=response.headers['Content-Type']) +@require_POST +@login_maybe_required +@never_cache +def download_all(request): + """ + Download all attachments + """ + # TODO: none of this works yet, just a rough idea + try: + spquery = json.load(request) + except ValueError as e: + return HttpResponseBadRequest(e) + recordIds = spquery['recordIds'], + + query, __ = build_query(session, collection, user, tableid, field_specs, recordsetid, replace_nulls=True) + model = models.models_by_tableid[tableid] + id_field = getattr(model, model._id) + query = query.filter(id_field.in_(recordIds)) + + output_dir = mkdtemp() + + try: + for row in query.yield_per(1): + # Write attachment file to output_dir + pass + + basename = re.sub(r'\.zip$', '', output_file) + shutil.make_archive(basename, 'zip', output_dir, logger=logger) + finally: + shutil.rmtree(output_dir) + + + return HttpResponse('OK', content_type='text/plain') + @transaction.atomic() @login_maybe_required @require_http_methods(['GET', 'POST', 'HEAD']) diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx index 623c0167e52..deb946faffa 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx @@ -18,6 +18,8 @@ import type { CollectionObjectAttachment, } from '../DataModel/types'; import { Dialog } from '../Molecules/Dialog'; +import { ping } from '../../utils/ajax/ping'; +import { keysToLowerCase } from '../../utils/utils'; export function AttachmentsCollection({ collection, @@ -58,6 +60,17 @@ export function AttachmentsCollection({ (attachment) => attachment.attachmentLocation === null ); + const handleDownloadAllAttachments = () => { + const recordIds = attachments.map((attachment) => attachment.id); + void ping('/attachments_gw/download_all', { + method: 'POST', + body: keysToLowerCase({ + recordIds: recordIds, + }), + errorMode: 'dismissible', + }); + }; + return attachments.length > 0 ? ( <> - {commonText.close()} - + <> + + {attachmentsText.downloadAll()} + + + {commonText.close()} + + } header={attachmentsText.attachments()} icon={icons.gallery} diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index 0e4c8202b79..06d607eaf1f 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -676,4 +676,13 @@ export const attachmentsText = createDictionary({ під час читання файлу сталася помилка. `, }, + + downloadAll: { + 'en-us': 'Download All', + 'de-ch': '', + 'es-es': '', + 'fr-fr': '', + 'ru-ru': '', + 'uk-ua': '', + }, } as const); From 9410f2779779100756d778327738aa00647ed701 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 6 Jan 2025 17:34:15 +0000 Subject: [PATCH 02/22] Lint code with ESLint and Prettier Triggered by 902b76205cfb85aba2606ace5b1e8df8d4a77dc4 on branch refs/heads/issue-609 --- .../lib/components/FormSliders/AttachmentsCollection.tsx | 6 +++--- specifyweb/frontend/js_src/lib/localization/attachments.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx index deb946faffa..067e3ca629d 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx @@ -4,8 +4,10 @@ import { useBooleanState } from '../../hooks/useBooleanState'; import { useCachedState } from '../../hooks/useCachedState'; import { attachmentsText } from '../../localization/attachments'; import { commonText } from '../../localization/common'; +import { ping } from '../../utils/ajax/ping'; import type { RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; +import { keysToLowerCase } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { icons } from '../Atoms/Icons'; import { defaultAttachmentScale } from '../Attachments'; @@ -18,8 +20,6 @@ import type { CollectionObjectAttachment, } from '../DataModel/types'; import { Dialog } from '../Molecules/Dialog'; -import { ping } from '../../utils/ajax/ping'; -import { keysToLowerCase } from '../../utils/utils'; export function AttachmentsCollection({ collection, @@ -65,7 +65,7 @@ export function AttachmentsCollection({ void ping('/attachments_gw/download_all', { method: 'POST', body: keysToLowerCase({ - recordIds: recordIds, + recordIds, }), errorMode: 'dismissible', }); diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index 06d607eaf1f..0bc2dbd3b28 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -676,7 +676,7 @@ export const attachmentsText = createDictionary({ під час читання файлу сталася помилка. `, }, - + downloadAll: { 'en-us': 'Download All', 'de-ch': '', From dd484546016f9f0bc5cb73c113dccf932f34f57c Mon Sep 17 00:00:00 2001 From: alesan99 Date: Tue, 7 Jan 2025 14:59:22 -0600 Subject: [PATCH 03/22] WIP use attachmentLocations instead --- specifyweb/attachment_gw/views.py | 24 ++++++++++++------- .../FormSliders/AttachmentsCollection.tsx | 8 +++++-- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/specifyweb/attachment_gw/views.py b/specifyweb/attachment_gw/views.py index 5eedc184a58..b943054d57c 100644 --- a/specifyweb/attachment_gw/views.py +++ b/specifyweb/attachment_gw/views.py @@ -308,25 +308,33 @@ def download_all(request): """ # TODO: none of this works yet, just a rough idea try: - spquery = json.load(request) + r = json.load(request) except ValueError as e: return HttpResponseBadRequest(e) - recordIds = spquery['recordIds'], + # recordIds = spquery['recordIds'], - query, __ = build_query(session, collection, user, tableid, field_specs, recordsetid, replace_nulls=True) - model = models.models_by_tableid[tableid] - id_field = getattr(model, model._id) - query = query.filter(id_field.in_(recordIds)) + # query, __ = build_query(session, collection, user, tableid, field_specs, recordsetid, replace_nulls=True) + # model = models.models_by_tableid[tableid] + # id_field = getattr(model, model._id) + # query = query.filter(id_field.in_(recordIds)) + + attachmentLocations = r['attachmentLocations'] + collection = r['collection'] output_dir = mkdtemp() try: - for row in query.yield_per(1): + for attachmentLocation in attachmentLocations: # Write attachment file to output_dir + # Use collection and attachmentLocation to download + # EX: + # https://assets-test.specifycloud.org/fileget?coll=sp7demofish&type=T&filename=32a915ea-5866-45d5-921d-64a68d75b03e.jpg&scale=123 pass - basename = re.sub(r'\.zip$', '', output_file) + basename = 'attachments.zip' shutil.make_archive(basename, 'zip', output_dir, logger=logger) + + # Send zip as a notification? It may be possible to automatically send it as a download? Maybe not a good idea if server is under load. finally: shutil.rmtree(output_dir) diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx index 067e3ca629d..632f4495a23 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx @@ -61,11 +61,15 @@ export function AttachmentsCollection({ ); const handleDownloadAllAttachments = () => { - const recordIds = attachments.map((attachment) => attachment.id); + const attachmentLocations: string[] = attachments + .map((attachment) => attachment.attachmentLocation) + .filter((location): location is string => location !== null); + void ping('/attachments_gw/download_all', { method: 'POST', body: keysToLowerCase({ - recordIds, + collection, // TODO: Use id? This is just needed to get the url + attachmentLocations, }), errorMode: 'dismissible', }); From 551bf58a1bf403a8c3266cd6762ab486df4f3475 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Tue, 7 Jan 2025 21:02:58 +0000 Subject: [PATCH 04/22] Lint code with ESLint and Prettier Triggered by dd484546016f9f0bc5cb73c113dccf932f34f57c on branch refs/heads/issue-609 --- .../js_src/lib/components/FormSliders/AttachmentsCollection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx index 632f4495a23..11e49028a02 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx @@ -61,7 +61,7 @@ export function AttachmentsCollection({ ); const handleDownloadAllAttachments = () => { - const attachmentLocations: string[] = attachments + const attachmentLocations: readonly string[] = attachments .map((attachment) => attachment.attachmentLocation) .filter((location): location is string => location !== null); From 8e9154b7f612848c7564de8d02443b2d3f52f1f2 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 8 Jan 2025 12:30:48 -0600 Subject: [PATCH 05/22] Add functional download all button and archive notification --- specifyweb/attachment_gw/views.py | 68 +++++++++++++------ .../Attachments/RecordSetAttachment.tsx | 47 ++++++++++++- .../FormSliders/AttachmentsCollection.tsx | 2 +- .../Notifications/NotificationRenderers.tsx | 28 ++++++++ .../js_src/lib/localization/notifications.ts | 16 +++++ 5 files changed, 138 insertions(+), 23 deletions(-) diff --git a/specifyweb/attachment_gw/views.py b/specifyweb/attachment_gw/views.py index b943054d57c..7f02a485df1 100644 --- a/specifyweb/attachment_gw/views.py +++ b/specifyweb/attachment_gw/views.py @@ -1,3 +1,6 @@ +import os +import traceback +import re import hmac import json import logging @@ -7,6 +10,8 @@ from os.path import splitext from uuid import uuid4 from xml.etree import ElementTree +from datetime import datetime +from threading import Thread import requests from django.conf import settings @@ -16,6 +21,7 @@ from django.utils.translation import gettext as _ from django.views.decorators.cache import cache_control, never_cache from django.views.decorators.http import require_POST +from ..notifications.models import Message from specifyweb.middleware.general import require_http_methods from specifyweb.specify.views import login_maybe_required, openapi @@ -306,41 +312,61 @@ def download_all(request): """ Download all attachments """ - # TODO: none of this works yet, just a rough idea try: r = json.load(request) except ValueError as e: return HttpResponseBadRequest(e) - # recordIds = spquery['recordIds'], - # query, __ = build_query(session, collection, user, tableid, field_specs, recordsetid, replace_nulls=True) - # model = models.models_by_tableid[tableid] - # id_field = getattr(model, model._id) - # query = query.filter(id_field.in_(recordIds)) - - attachmentLocations = r['attachmentLocations'] - collection = r['collection'] + logger.info('Downloading attachments for %s', r) + + user = request.specify_user + collection = request.specify_collection + + attachmentLocations = r['attachmentlocations'] + origFilenames = r['origfilenames'] + # collection = r['collection'] + + filename = 'attachments_%s.zip' % datetime.now().isoformat() + path = os.path.join(settings.DEPOSITORY_DIR, filename) + + def do_export(): + try: + make_attachment_zip(attachmentLocations, origFilenames, path) + except Exception as e: + tb = traceback.format_exc() + logger.error('make_attachment_zip failed: %s', tb) + Message.objects.create(user=user, content=json.dumps({ + 'type': 'attachment-archive-failed', + 'exception': str(e), + 'traceback': tb if settings.DEBUG else None, + })) + else: + Message.objects.create(user=user, content=json.dumps({ + 'type': 'attachment-archive-complete', + 'file': filename + })) + + # # Send zip as a notification? It may be possible to automatically send it as a download? Maybe not a good idea if server is under load. + # # Or stream the zip file back to the client with StreamingHttpResponse + + thread = Thread(target=do_export) + thread.daemon = True + thread.start() + return HttpResponse('OK', content_type='text/plain') +def make_attachment_zip(attachmentLocations, origFilenames, output_file): output_dir = mkdtemp() - try: for attachmentLocation in attachmentLocations: - # Write attachment file to output_dir - # Use collection and attachmentLocation to download - # EX: - # https://assets-test.specifycloud.org/fileget?coll=sp7demofish&type=T&filename=32a915ea-5866-45d5-921d-64a68d75b03e.jpg&scale=123 - pass + response = requests.get(server_urls['read'] + '?' + f'coll=Paleo&type=O&filename={attachmentLocation}') + with open(os.path.join(output_dir, attachmentLocation), 'wb') as f: + f.write(response.content) - basename = 'attachments.zip' + basename = re.sub(r'\.zip$', '', output_file) shutil.make_archive(basename, 'zip', output_dir, logger=logger) - - # Send zip as a notification? It may be possible to automatically send it as a download? Maybe not a good idea if server is under load. finally: shutil.rmtree(output_dir) - - return HttpResponse('OK', content_type='text/plain') - @transaction.atomic() @login_maybe_required @require_http_methods(['GET', 'POST', 'HEAD']) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index ae50ae7f084..96c404e84f2 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -17,6 +17,8 @@ import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import { defaultAttachmentScale } from '.'; import { AttachmentGallery } from './Gallery'; import { getAttachmentRelationship } from './utils'; +import { ping } from '../../utils/ajax/ping'; +import { keysToLowerCase } from '../../utils/utils'; const haltIncrementSize = 300; @@ -81,6 +83,39 @@ export function RecordSetAttachments({ ); const attachmentsRef = React.useRef(attachments); + + // const handleDownloadAllAttachments = () => { + // const attachmentLocations: readonly string[] = attachments + // .map((attachment) => attachment.attachmentLocation) + // .filter((location): location is string => location !== null); + + // void ping('/attachments_gw/download_all', { + // method: 'POST', + // body: keysToLowerCase({ + // collection, // TODO: Use id? This is just needed to get the url + // attachmentLocations, + // }), + // errorMode: 'dismissible', + // }); + // }; + const handleDownloadAllAttachments = (): void => { + const attachmentLocations: readonly string[] = attachmentsRef.current.attachments + .map((attachment) => attachment.attachmentLocation) + .filter((location): location is string => location !== null); + const origFilenames: readonly string[] = attachmentsRef.current.attachments + .map((attachment) => attachment.origFilename) + .filter((filename): filename is string => filename !== null); + + void ping('/attachment_gw/download_all/', { + method: 'POST', + body: keysToLowerCase({ + attachmentLocations, + origFilenames, + }), + errorMode: 'dismissible', + }); + }; + if (typeof attachments === 'object') attachmentsRef.current = attachments; /* @@ -110,7 +145,17 @@ export function RecordSetAttachments({ {showAttachments && ( {commonText.close()} + <> + + {attachmentsText.downloadAll()} + + + {commonText.close()} + + } className={{ container: dialogClassNames.wideContainer, diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx index 11e49028a02..a54b0ee5a5e 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx @@ -65,7 +65,7 @@ export function AttachmentsCollection({ .map((attachment) => attachment.attachmentLocation) .filter((location): location is string => location !== null); - void ping('/attachments_gw/download_all', { + void ping('/attachment_gw/download_all/', { method: 'POST', body: keysToLowerCase({ collection, // TODO: Use id? This is just needed to get the url diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx index 78ca2adfb10..f2ecfa05eab 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx @@ -120,6 +120,34 @@ export const notificationRenderers: IR< ); }, + 'attachment-archive-complete'(notification) { + return ( + <> + {notificationsText.attachmentArchiveCompleted()} + + {notificationsText.download()} + + + ); + }, + 'attachment-archive-failed'(notification) { + return ( + <> + {notificationsText.attachmentArchiveFailed()} + + {notificationsText.exception()} + + + ); + }, 'dataset-ownership-transferred'(notification) { return ( transferred the ownership of the dataset to From d529864a6b9de4d1e87a8ba7ae7cb366392fd25f Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 8 Jan 2025 15:28:01 -0600 Subject: [PATCH 06/22] Add download button to individual attachment cells Fix trying to download unavailable attachments WIP checkboxes? --- specifyweb/attachment_gw/views.py | 5 +-- .../js_src/lib/components/Atoms/Icons.tsx | 2 +- .../lib/components/Attachments/Cell.tsx | 32 +++++++++++++++++++ .../Attachments/RecordSetAttachment.tsx | 15 --------- .../FormSliders/AttachmentsCollection.tsx | 31 ++---------------- 5 files changed, 39 insertions(+), 46 deletions(-) diff --git a/specifyweb/attachment_gw/views.py b/specifyweb/attachment_gw/views.py index 7f02a485df1..814862ab75a 100644 --- a/specifyweb/attachment_gw/views.py +++ b/specifyweb/attachment_gw/views.py @@ -359,8 +359,9 @@ def make_attachment_zip(attachmentLocations, origFilenames, output_file): try: for attachmentLocation in attachmentLocations: response = requests.get(server_urls['read'] + '?' + f'coll=Paleo&type=O&filename={attachmentLocation}') - with open(os.path.join(output_dir, attachmentLocation), 'wb') as f: - f.write(response.content) + if response.status_code == 200: + with open(os.path.join(output_dir, attachmentLocation), 'wb') as f: + f.write(response.content) basename = re.sub(r'\.zip$', '', output_file) shutil.make_archive(basename, 'zip', output_dir, logger=logger) diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx index c04af2ee868..a8fe0904f7e 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx @@ -44,7 +44,7 @@ export const icons = { arrowsExpand: , // This icon is not from Heroicons. It was drawn by @grantfitzsimmons arrowsMove: , - backspace: , + download: , ban: , bell: , // A placeholder for an icon diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx index a12ea0e15d4..c6f33a9ffa4 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx @@ -6,6 +6,7 @@ import { commonText } from '../../localization/common'; import { f } from '../../utils/functools'; import type { GetSet } from '../../utils/types'; import { Button } from '../Atoms/Button'; +// import { Input } from '../Atoms/Form'; import { LoadingContext } from '../Core/Contexts'; import { fetchRelated } from '../DataModel/collection'; import type { AnySchema, SerializedResource } from '../DataModel/helperTypes'; @@ -21,6 +22,11 @@ import { TableIcon } from '../Molecules/TableIcon'; import { hasTablePermission } from '../Permissions/helpers'; import { AttachmentPreview } from './Preview'; import { getAttachmentRelationship, tablesWithAttachments } from './utils'; +import { fetchOriginalUrl } from './attachments'; +import { useAsyncState } from '../../hooks/useAsyncState'; +import { serializeResource } from '../DataModel/serializers'; +import { Link } from '../Atoms/Link'; +import { notificationsText } from '../../localization/notifications'; export function AttachmentCell({ attachment, @@ -37,6 +43,15 @@ export function AttachmentCell({ }): JSX.Element { const table = f.maybe(attachment.tableID ?? undefined, getAttachmentTable); + const serialized = React.useMemo( + () => serializeResource(attachment), + [attachment] + ); + const [originalUrl] = useAsyncState( + React.useCallback(async () => fetchOriginalUrl(serialized), [serialized]), + false + ); + return (
{typeof handleViewRecord === 'function' && @@ -61,6 +76,23 @@ export function AttachmentCell({ handleOpen(); }} /> + {/* */} + {typeof originalUrl === 'string' && ( + + )}
); } diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index 96c404e84f2..ecea36ae07e 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -83,21 +83,6 @@ export function RecordSetAttachments({ ); const attachmentsRef = React.useRef(attachments); - - // const handleDownloadAllAttachments = () => { - // const attachmentLocations: readonly string[] = attachments - // .map((attachment) => attachment.attachmentLocation) - // .filter((location): location is string => location !== null); - - // void ping('/attachments_gw/download_all', { - // method: 'POST', - // body: keysToLowerCase({ - // collection, // TODO: Use id? This is just needed to get the url - // attachmentLocations, - // }), - // errorMode: 'dismissible', - // }); - // }; const handleDownloadAllAttachments = (): void => { const attachmentLocations: readonly string[] = attachmentsRef.current.attachments .map((attachment) => attachment.attachmentLocation) diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx index a54b0ee5a5e..623c0167e52 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/AttachmentsCollection.tsx @@ -4,10 +4,8 @@ import { useBooleanState } from '../../hooks/useBooleanState'; import { useCachedState } from '../../hooks/useCachedState'; import { attachmentsText } from '../../localization/attachments'; import { commonText } from '../../localization/common'; -import { ping } from '../../utils/ajax/ping'; import type { RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; -import { keysToLowerCase } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { icons } from '../Atoms/Icons'; import { defaultAttachmentScale } from '../Attachments'; @@ -60,21 +58,6 @@ export function AttachmentsCollection({ (attachment) => attachment.attachmentLocation === null ); - const handleDownloadAllAttachments = () => { - const attachmentLocations: readonly string[] = attachments - .map((attachment) => attachment.attachmentLocation) - .filter((location): location is string => location !== null); - - void ping('/attachment_gw/download_all/', { - method: 'POST', - body: keysToLowerCase({ - collection, // TODO: Use id? This is just needed to get the url - attachmentLocations, - }), - errorMode: 'dismissible', - }); - }; - return attachments.length > 0 ? ( <> - - {attachmentsText.downloadAll()} - - - {commonText.close()} - - + + {commonText.close()} + } header={attachmentsText.attachments()} icon={icons.gallery} From e7b0fc56f645650b8f55b43fd5abf8381e5ad8ae Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 13 Jan 2025 14:56:40 -0600 Subject: [PATCH 07/22] Fix missing icon Fix translations --- .../frontend/js_src/lib/components/Atoms/Icons.tsx | 3 ++- .../js_src/lib/components/Attachments/Cell.tsx | 5 ----- .../js_src/lib/localization/attachments.ts | 5 ----- .../js_src/lib/localization/notifications.ts | 14 ++------------ 4 files changed, 4 insertions(+), 23 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx index a8fe0904f7e..2507bddd7c2 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx @@ -44,7 +44,7 @@ export const icons = { arrowsExpand: , // This icon is not from Heroicons. It was drawn by @grantfitzsimmons arrowsMove: , - download: , + backspace: , ban: , bell: , // A placeholder for an icon @@ -78,6 +78,7 @@ export const icons = { documentReport: , documentSearch: , dotsVertical: , + download: , duplicate: , exclamation: , exclamationCircle: , diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx index c6f33a9ffa4..4acb087c8a8 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx @@ -6,7 +6,6 @@ import { commonText } from '../../localization/common'; import { f } from '../../utils/functools'; import type { GetSet } from '../../utils/types'; import { Button } from '../Atoms/Button'; -// import { Input } from '../Atoms/Form'; import { LoadingContext } from '../Core/Contexts'; import { fetchRelated } from '../DataModel/collection'; import type { AnySchema, SerializedResource } from '../DataModel/helperTypes'; @@ -76,10 +75,6 @@ export function AttachmentCell({ handleOpen(); }} /> - {/* */} {typeof originalUrl === 'string' && ( Date: Wed, 15 Jan 2025 12:47:17 -0600 Subject: [PATCH 08/22] Stream zip file to client instead of using a notification Fix downloading files with the same filename Add ability to recieve and download binary files on the frontend --- specifyweb/attachment_gw/views.py | 69 +++++++++---------- .../lib/components/Attachments/Cell.tsx | 2 +- .../Attachments/RecordSetAttachment.tsx | 42 +++++++---- .../lib/components/Molecules/FilePicker.tsx | 20 ++++-- .../Notifications/NotificationRenderers.tsx | 28 -------- .../js_src/lib/localization/notifications.ts | 6 -- .../frontend/js_src/lib/utils/ajax/index.ts | 15 ++-- .../js_src/lib/utils/ajax/response.ts | 8 +++ 8 files changed, 94 insertions(+), 96 deletions(-) diff --git a/specifyweb/attachment_gw/views.py b/specifyweb/attachment_gw/views.py index 814862ab75a..c5e0f6f875c 100644 --- a/specifyweb/attachment_gw/views.py +++ b/specifyweb/attachment_gw/views.py @@ -310,57 +310,52 @@ def proxy(request): @never_cache def download_all(request): """ - Download all attachments + Download all attachments from a list of attachment locations and put them into a zip file. """ try: r = json.load(request) except ValueError as e: return HttpResponseBadRequest(e) - logger.info('Downloading attachments for %s', r) - - user = request.specify_user - collection = request.specify_collection - attachmentLocations = r['attachmentlocations'] - origFilenames = r['origfilenames'] - # collection = r['collection'] + origFileNames = r['origfilenames'] filename = 'attachments_%s.zip' % datetime.now().isoformat() path = os.path.join(settings.DEPOSITORY_DIR, filename) - def do_export(): - try: - make_attachment_zip(attachmentLocations, origFilenames, path) - except Exception as e: - tb = traceback.format_exc() - logger.error('make_attachment_zip failed: %s', tb) - Message.objects.create(user=user, content=json.dumps({ - 'type': 'attachment-archive-failed', - 'exception': str(e), - 'traceback': tb if settings.DEBUG else None, - })) - else: - Message.objects.create(user=user, content=json.dumps({ - 'type': 'attachment-archive-complete', - 'file': filename - })) - - # # Send zip as a notification? It may be possible to automatically send it as a download? Maybe not a good idea if server is under load. - # # Or stream the zip file back to the client with StreamingHttpResponse - - thread = Thread(target=do_export) - thread.daemon = True - thread.start() - return HttpResponse('OK', content_type='text/plain') - -def make_attachment_zip(attachmentLocations, origFilenames, output_file): + make_attachment_zip(attachmentLocations, origFileNames, get_collection(request), path) + + def file_iterator(file_path, chunk_size=512 * 1024): + with open(file_path, 'rb') as f: + while chunk := f.read(chunk_size): + yield chunk + os.remove(file_path) + + response = StreamingHttpResponse( + file_iterator(path), + content_type='application/octet-stream') + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + +def make_attachment_zip(attachmentLocations, origFileNames, collection, output_file): output_dir = mkdtemp() try: - for attachmentLocation in attachmentLocations: - response = requests.get(server_urls['read'] + '?' + f'coll=Paleo&type=O&filename={attachmentLocation}') + for i, attachmentLocation in enumerate(attachmentLocations): + data = { + 'filename': attachmentLocation, + 'coll': collection, + 'type': 'O', + 'token': generate_token(get_timestamp(), attachmentLocation) + } + response = requests.get(server_urls['read'], params=data) if response.status_code == 200: - with open(os.path.join(output_dir, attachmentLocation), 'wb') as f: + downloadFileName = origFileNames[i] + if os.path.exists(os.path.join(output_dir, downloadFileName)): + downloadOrigName = os.path.splitext(downloadFileName)[0] + downloadName = os.path.splitext(attachmentLocation)[0] + downloadExtension = os.path.splitext(downloadFileName)[1] + downloadFileName = f'{downloadOrigName}_{downloadName}{downloadExtension}' + with open(os.path.join(output_dir, downloadFileName), 'wb') as f: f.write(response.content) basename = re.sub(r'\.zip$', '', output_file) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx index 4acb087c8a8..47f58270434 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx @@ -47,7 +47,7 @@ export function AttachmentCell({ [attachment] ); const [originalUrl] = useAsyncState( - React.useCallback(async () => fetchOriginalUrl(serialized), [serialized]), + React.useCallback(async () => fetchOriginalUrl(serialized as SerializedResource), [serialized]), false ); diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index ecea36ae07e..3bb093c8818 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -17,8 +17,10 @@ import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import { defaultAttachmentScale } from '.'; import { AttachmentGallery } from './Gallery'; import { getAttachmentRelationship } from './utils'; -import { ping } from '../../utils/ajax/ping'; +import { ajax } from '../../utils/ajax/index'; import { keysToLowerCase } from '../../utils/utils'; +import { downloadFile } from '../Molecules/FilePicker'; +import { Http } from '../../utils/ajax/definitions'; const haltIncrementSize = 300; @@ -83,22 +85,34 @@ export function RecordSetAttachments({ ); const attachmentsRef = React.useRef(attachments); - const handleDownloadAllAttachments = (): void => { + const handleDownloadAllAttachments = async (): Promise => { + if (attachmentsRef.current === undefined) return; const attachmentLocations: readonly string[] = attachmentsRef.current.attachments .map((attachment) => attachment.attachmentLocation) - .filter((location): location is string => location !== null); + .filter((name): name is string => name !== null); const origFilenames: readonly string[] = attachmentsRef.current.attachments .map((attachment) => attachment.origFilename) - .filter((filename): filename is string => filename !== null); - - void ping('/attachment_gw/download_all/', { - method: 'POST', - body: keysToLowerCase({ - attachmentLocations, - origFilenames, - }), - errorMode: 'dismissible', - }); + .filter((name): name is string => name !== null); + + try { + const response = await ajax('/attachment_gw/download_all/', { + method: 'POST', + body: keysToLowerCase({ + attachmentLocations, + origFilenames, + }), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/octet-stream', + }, + }); + + if (response.status === Http.OK) { + downloadFile('attachments.zip', response.data); + } + } catch (error) { + console.error('Attachment archive download failed', error); + } }; if (typeof attachments === 'object') attachmentsRef.current = attachments; @@ -132,7 +146,7 @@ export function RecordSetAttachments({ buttons={ <> {attachmentsText.downloadAll()} diff --git a/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx b/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx index f70445b563d..b3b42c1dd9f 100644 --- a/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx +++ b/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx @@ -137,7 +137,7 @@ export function FilePicker({ */ export const downloadFile = async ( fileName: string, - text: string + data: string | Blob, ): Promise => new Promise((resolve) => { let fileDownloaded = false; @@ -145,18 +145,26 @@ export const downloadFile = async ( iframe.classList.add('absolute', 'hidden'); iframe.addEventListener('load', () => { if (iframe.contentWindow === null || fileDownloaded) return; + let url; const element = iframe.contentWindow.document.createElement('a'); - element.setAttribute( - 'href', - `data:text/plain;charset=utf-8,${encodeURIComponent(text)}` - ); - element.setAttribute('download', fileName); + if (typeof data === 'string') { + element.setAttribute( + 'href', + `data:text/plain;charset=utf-8,${encodeURIComponent(data as string)}` + ); + element.setAttribute('download', fileName); + } else { + url = window.URL.createObjectURL(data); + element.setAttribute('href', url); + element.setAttribute('download', fileName); + } element.style.display = 'none'; iframe.contentWindow.document.body.append(element); element.click(); fileDownloaded = true; + if (url !== undefined) window.URL.revokeObjectURL(url); globalThis.setTimeout(() => { iframe.remove(); resolve(); diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx index f2ecfa05eab..78ca2adfb10 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx @@ -120,34 +120,6 @@ export const notificationRenderers: IR< ); }, - 'attachment-archive-complete'(notification) { - return ( - <> - {notificationsText.attachmentArchiveCompleted()} - - {notificationsText.download()} - - - ); - }, - 'attachment-archive-failed'(notification) { - return ( - <> - {notificationsText.attachmentArchiveFailed()} - - {notificationsText.exception()} - - - ); - }, 'dataset-ownership-transferred'(notification) { return ( transferred the ownership of the dataset to diff --git a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts index 0f2963b21b6..c0f6af01cfb 100644 --- a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts +++ b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts @@ -7,7 +7,7 @@ import { handleAjaxResponse } from './response'; // FEATURE: make all back-end endpoints accept JSON -export type MimeType = 'application/json' | 'text/plain' | 'text/xml'; +export type MimeType = 'application/json' | 'text/plain' | 'text/xml' | 'application/octet-stream'; export type AjaxResponseObject = { /* @@ -15,6 +15,7 @@ export type AjaxResponseObject = { * Parser is selected based on the value of options.headers.Accept: * - application/json - json * - text/xml - xml + * - application/octet-stream - binary data * - else (i.e text/plain) - string */ readonly data: RESPONSE_TYPE; @@ -114,6 +115,7 @@ export async function ajax( } if (method === 'GET' && typeof pendingRequests[url] === 'object') return pendingRequests[url] as Promise>; + const acceptBlobResponse = accept === 'application/octet-stream'; pendingRequests[url] = fetch(url, { ...options, method, @@ -135,16 +137,21 @@ export async function ajax( ...(typeof accept === 'string' ? { Accept: accept } : {}), }, }) - .then(async (response) => Promise.all([response, response.text()])) + .then(async (response) => + (acceptBlobResponse ? + Promise.all([response, response.blob()]) : + Promise.all([response, response.text()]) + )) .then( - ([response, text]: readonly [Response, string]) => { + ([response, text]: readonly [Response, string | Blob]) => { extractAppResourceId(url, response); return handleAjaxResponse({ expectedErrors, accept, errorMode, response, - text, + text: typeof text === 'string' ? text : "", + data: typeof text === 'string' ? undefined : text, }); }, // This happens when request is aborted (i.e, page is restarting) diff --git a/specifyweb/frontend/js_src/lib/utils/ajax/response.ts b/specifyweb/frontend/js_src/lib/utils/ajax/response.ts index 908ab2dd91e..179d6f2f373 100644 --- a/specifyweb/frontend/js_src/lib/utils/ajax/response.ts +++ b/specifyweb/frontend/js_src/lib/utils/ajax/response.ts @@ -14,12 +14,14 @@ export function handleAjaxResponse({ response, errorMode, text, + data, }: { readonly expectedErrors: RA; readonly accept: MimeType | undefined; readonly response: Response; readonly errorMode: AjaxErrorMode; readonly text: string; + readonly data?: unknown; }): AjaxResponseObject { // BUG: silence all errors if the page begun reloading try { @@ -49,6 +51,12 @@ export function handleAjaxResponse({ statusText: `Failed parsing XML response: ${parsed}`, responseText: text, }; + } else if (response.ok && accept === 'application/octet-stream') { + return { + data: data as unknown as RESPONSE_TYPE, + response, + status: response.status, + }; } else return { // Assuming that RESPONSE_TYPE extends string From 5a5a746ffdb1deab9fe409d38ba457c43415b3c1 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 15 Jan 2025 12:52:48 -0600 Subject: [PATCH 09/22] Cleanup imports --- specifyweb/attachment_gw/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/specifyweb/attachment_gw/views.py b/specifyweb/attachment_gw/views.py index c5e0f6f875c..449f0788980 100644 --- a/specifyweb/attachment_gw/views.py +++ b/specifyweb/attachment_gw/views.py @@ -1,5 +1,4 @@ import os -import traceback import re import hmac import json @@ -11,7 +10,6 @@ from uuid import uuid4 from xml.etree import ElementTree from datetime import datetime -from threading import Thread import requests from django.conf import settings From f8a5f8f6fe333638940db1a293e3098df2c4b35e Mon Sep 17 00:00:00 2001 From: alesan99 Date: Tue, 21 Jan 2025 10:49:41 -0600 Subject: [PATCH 10/22] Misc. code improvements --- .../lib/components/Attachments/RecordSetAttachment.tsx | 4 ++-- .../frontend/js_src/lib/components/Molecules/FilePicker.tsx | 6 +++--- specifyweb/frontend/js_src/lib/utils/ajax/index.ts | 4 +--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index 3bb093c8818..2a13429be8f 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -87,10 +87,10 @@ export function RecordSetAttachments({ const handleDownloadAllAttachments = async (): Promise => { if (attachmentsRef.current === undefined) return; - const attachmentLocations: readonly string[] = attachmentsRef.current.attachments + const attachmentLocations = attachmentsRef.current.attachments .map((attachment) => attachment.attachmentLocation) .filter((name): name is string => name !== null); - const origFilenames: readonly string[] = attachmentsRef.current.attachments + const origFilenames = attachmentsRef.current.attachments .map((attachment) => attachment.origFilename) .filter((name): name is string => name !== null); diff --git a/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx b/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx index b3b42c1dd9f..aa0191e1892 100644 --- a/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx +++ b/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx @@ -145,7 +145,7 @@ export const downloadFile = async ( iframe.classList.add('absolute', 'hidden'); iframe.addEventListener('load', () => { if (iframe.contentWindow === null || fileDownloaded) return; - let url; + let url: string | undefined; const element = iframe.contentWindow.document.createElement('a'); if (typeof data === 'string') { element.setAttribute( @@ -154,7 +154,7 @@ export const downloadFile = async ( ); element.setAttribute('download', fileName); } else { - url = window.URL.createObjectURL(data); + url = URL.createObjectURL(data); element.setAttribute('href', url); element.setAttribute('download', fileName); } @@ -164,7 +164,7 @@ export const downloadFile = async ( element.click(); fileDownloaded = true; - if (url !== undefined) window.URL.revokeObjectURL(url); + if (url !== undefined) URL.revokeObjectURL(url); globalThis.setTimeout(() => { iframe.remove(); resolve(); diff --git a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts index c0f6af01cfb..a120c12217f 100644 --- a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts +++ b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts @@ -138,9 +138,7 @@ export async function ajax( }, }) .then(async (response) => - (acceptBlobResponse ? - Promise.all([response, response.blob()]) : - Promise.all([response, response.text()]) + Promise.all([response, acceptBlobResponse ? response.blob() : response.text()] )) .then( ([response, text]: readonly [Response, string | Blob]) => { From 930ea7c34b78824f496d5bed30bd9d0208a7dfe2 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Tue, 21 Jan 2025 16:53:33 +0000 Subject: [PATCH 11/22] Lint code with ESLint and Prettier Triggered by f8a5f8f6fe333638940db1a293e3098df2c4b35e on branch refs/heads/issue-609 --- .../Attachments/RecordSetAttachment.tsx | 8 ++++---- .../lib/components/Molecules/FilePicker.tsx | 4 ++-- .../frontend/js_src/lib/utils/ajax/index.ts | 19 +++++++++++++------ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index 2a13429be8f..be55adc310c 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -5,22 +5,22 @@ import { useBooleanState } from '../../hooks/useBooleanState'; import { useCachedState } from '../../hooks/useCachedState'; import { attachmentsText } from '../../localization/attachments'; import { commonText } from '../../localization/common'; +import { Http } from '../../utils/ajax/definitions'; +import { ajax } from '../../utils/ajax/index'; import { f } from '../../utils/functools'; import type { RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; +import { keysToLowerCase } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { serializeResource } from '../DataModel/serializers'; import type { CollectionObjectAttachment } from '../DataModel/types'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; +import { downloadFile } from '../Molecules/FilePicker'; import { defaultAttachmentScale } from '.'; import { AttachmentGallery } from './Gallery'; import { getAttachmentRelationship } from './utils'; -import { ajax } from '../../utils/ajax/index'; -import { keysToLowerCase } from '../../utils/utils'; -import { downloadFile } from '../Molecules/FilePicker'; -import { Http } from '../../utils/ajax/definitions'; const haltIncrementSize = 300; diff --git a/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx b/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx index aa0191e1892..4e51a9bbe40 100644 --- a/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx +++ b/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx @@ -137,7 +137,7 @@ export function FilePicker({ */ export const downloadFile = async ( fileName: string, - data: string | Blob, + data: Blob | string ): Promise => new Promise((resolve) => { let fileDownloaded = false; @@ -150,7 +150,7 @@ export const downloadFile = async ( if (typeof data === 'string') { element.setAttribute( 'href', - `data:text/plain;charset=utf-8,${encodeURIComponent(data as string)}` + `data:text/plain;charset=utf-8,${encodeURIComponent(data)}` ); element.setAttribute('download', fileName); } else { diff --git a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts index a120c12217f..34e423571f6 100644 --- a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts +++ b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts @@ -7,7 +7,11 @@ import { handleAjaxResponse } from './response'; // FEATURE: make all back-end endpoints accept JSON -export type MimeType = 'application/json' | 'text/plain' | 'text/xml' | 'application/octet-stream'; +export type MimeType = + | 'application/json' + | 'application/octet-stream' + | 'text/plain' + | 'text/xml'; export type AjaxResponseObject = { /* @@ -137,18 +141,21 @@ export async function ajax( ...(typeof accept === 'string' ? { Accept: accept } : {}), }, }) - .then(async (response) => - Promise.all([response, acceptBlobResponse ? response.blob() : response.text()] - )) + .then(async (response) => + Promise.all([ + response, + acceptBlobResponse ? response.blob() : response.text(), + ]) + ) .then( - ([response, text]: readonly [Response, string | Blob]) => { + ([response, text]: readonly [Response, Blob | string]) => { extractAppResourceId(url, response); return handleAjaxResponse({ expectedErrors, accept, errorMode, response, - text: typeof text === 'string' ? text : "", + text: typeof text === 'string' ? text : '', data: typeof text === 'string' ? undefined : text, }); }, From 503338c81edfea75f0b3a42ac9bff300e1602bd1 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Tue, 21 Jan 2025 12:31:48 -0600 Subject: [PATCH 12/22] Add loading bar WIP add tool tip to download all button when disabled WIP use record set name as file name --- .../Attachments/RecordSetAttachment.tsx | 16 +++++++++++++--- .../FormSliders/RecordSelectorFromIds.tsx | 2 +- .../js_src/lib/localization/attachments.ts | 4 +++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index 2a13429be8f..133c8652ac1 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { LocalizedString } from 'typesafe-i18n'; import { useAsyncState } from '../../hooks/useAsyncState'; import { useBooleanState } from '../../hooks/useBooleanState'; @@ -21,17 +22,20 @@ import { ajax } from '../../utils/ajax/index'; import { keysToLowerCase } from '../../utils/utils'; import { downloadFile } from '../Molecules/FilePicker'; import { Http } from '../../utils/ajax/definitions'; +import { LoadingContext } from '../Core/Contexts'; const haltIncrementSize = 300; export function RecordSetAttachments({ records, onFetch: handleFetch, + title, }: { readonly records: RA | undefined>; readonly onFetch: | ((index: number) => Promise | void>) | undefined; + readonly title: LocalizedString | undefined; }): JSX.Element { const fetchedCount = React.useRef(0); @@ -85,6 +89,7 @@ export function RecordSetAttachments({ ); const attachmentsRef = React.useRef(attachments); + const downloadAllAttachmentsDisabled = fetchedCount.current !== records.length || records.length <= 1 || fetchedCount.current <= 1; const handleDownloadAllAttachments = async (): Promise => { if (attachmentsRef.current === undefined) return; const attachmentLocations = attachmentsRef.current.attachments @@ -108,12 +113,16 @@ export function RecordSetAttachments({ }); if (response.status === Http.OK) { - downloadFile('attachments.zip', response.data); + downloadFile(`${title}_attachments.zip`, response.data); + } else { + console.error('Attachment archive download failed', response); } } catch (error) { console.error('Attachment archive download failed', error); } + return Promise.resolve(); }; + const loading = React.useContext(LoadingContext); if (typeof attachments === 'object') attachmentsRef.current = attachments; @@ -146,8 +155,9 @@ export function RecordSetAttachments({ buttons={ <> loading(handleDownloadAllAttachments())} + title={attachmentsText.downloadAllDescription()} > {attachmentsText.downloadAll()} diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx index b93501785aa..a65c1f6f348 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx @@ -233,7 +233,7 @@ export function RecordSelectorFromIds({ {hasAttachments && !hasSeveralResourceType && !resource?.isNew() ? ( - + ) : undefined} {table.view === 'GeologicTimePeriod' ? ( diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index b4ab8380641..a0e73147934 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -676,8 +676,10 @@ export const attachmentsText = createDictionary({ під час читання файлу сталася помилка. `, }, - downloadAll: { 'en-us': 'Download All', }, + downloadAllDescription: { + 'en-us': 'Download all fetched attachments', + }, } as const); From 3c2aa19cea120c9e311b6d54a5677faf35f3e0c6 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Tue, 21 Jan 2025 18:39:04 +0000 Subject: [PATCH 13/22] Lint code with ESLint and Prettier Triggered by e598352855a48389beac24f8c83a88559fe44fc3 on branch refs/heads/issue-609 --- .../lib/components/Attachments/RecordSetAttachment.tsx | 6 +++--- .../lib/components/FormSliders/RecordSelectorFromIds.tsx | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index acb542bba73..191a8628ab0 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -13,6 +13,7 @@ import type { RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; import { keysToLowerCase } from '../../utils/utils'; import { Button } from '../Atoms/Button'; +import { LoadingContext } from '../Core/Contexts'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { serializeResource } from '../DataModel/serializers'; @@ -22,7 +23,6 @@ import { downloadFile } from '../Molecules/FilePicker'; import { defaultAttachmentScale } from '.'; import { AttachmentGallery } from './Gallery'; import { getAttachmentRelationship } from './utils'; -import { LoadingContext } from '../Core/Contexts'; const haltIncrementSize = 300; @@ -120,7 +120,7 @@ export function RecordSetAttachments({ } catch (error) { console.error('Attachment archive download failed', error); } - return Promise.resolve(); + }; const loading = React.useContext(LoadingContext); @@ -156,8 +156,8 @@ export function RecordSetAttachments({ <> loading(handleDownloadAllAttachments())} title={attachmentsText.downloadAllDescription()} + onClick={() => loading(handleDownloadAllAttachments())} > {attachmentsText.downloadAll()} diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx index a65c1f6f348..61cd23fad74 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx @@ -233,7 +233,11 @@ export function RecordSelectorFromIds({ {hasAttachments && !hasSeveralResourceType && !resource?.isNew() ? ( - + ) : undefined} {table.view === 'GeologicTimePeriod' ? ( From 6b12230f3ca4a3a8f2356b07379f298a3e30cc6d Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 22 Jan 2025 10:08:28 -0600 Subject: [PATCH 14/22] Use record set name as zip file name Improve handling of files with same file name --- specifyweb/attachment_gw/views.py | 7 ++++--- .../lib/components/Attachments/RecordSetAttachment.tsx | 9 ++++----- .../lib/components/FormSliders/RecordSelectorFromIds.tsx | 4 +++- .../js_src/lib/components/FormSliders/RecordSet.tsx | 5 +++++ .../frontend/js_src/lib/localization/attachments.ts | 2 +- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/specifyweb/attachment_gw/views.py b/specifyweb/attachment_gw/views.py index 449f0788980..aee762caf71 100644 --- a/specifyweb/attachment_gw/views.py +++ b/specifyweb/attachment_gw/views.py @@ -338,6 +338,7 @@ def file_iterator(file_path, chunk_size=512 * 1024): def make_attachment_zip(attachmentLocations, origFileNames, collection, output_file): output_dir = mkdtemp() try: + fileNameAppearances = {} for i, attachmentLocation in enumerate(attachmentLocations): data = { 'filename': attachmentLocation, @@ -348,11 +349,11 @@ def make_attachment_zip(attachmentLocations, origFileNames, collection, output_f response = requests.get(server_urls['read'], params=data) if response.status_code == 200: downloadFileName = origFileNames[i] - if os.path.exists(os.path.join(output_dir, downloadFileName)): + fileNameAppearances[downloadFileName] = fileNameAppearances.get(downloadFileName, 0) + 1 + if fileNameAppearances[downloadFileName] > 1: downloadOrigName = os.path.splitext(downloadFileName)[0] - downloadName = os.path.splitext(attachmentLocation)[0] downloadExtension = os.path.splitext(downloadFileName)[1] - downloadFileName = f'{downloadOrigName}_{downloadName}{downloadExtension}' + downloadFileName = f'{downloadOrigName}_{fileNameAppearances[downloadFileName]}{downloadExtension}' with open(os.path.join(output_dir, downloadFileName), 'wb') as f: f.write(response.content) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index acb542bba73..4af222df8c5 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import type { LocalizedString } from 'typesafe-i18n'; import { useAsyncState } from '../../hooks/useAsyncState'; import { useBooleanState } from '../../hooks/useBooleanState'; @@ -29,13 +28,13 @@ const haltIncrementSize = 300; export function RecordSetAttachments({ records, onFetch: handleFetch, - title, + recordSetName, }: { readonly records: RA | undefined>; readonly onFetch: | ((index: number) => Promise | void>) | undefined; - readonly title: LocalizedString | undefined; + readonly recordSetName: string | undefined; }): JSX.Element { const fetchedCount = React.useRef(0); @@ -113,7 +112,7 @@ export function RecordSetAttachments({ }); if (response.status === Http.OK) { - downloadFile(`${title}_attachments.zip`, response.data); + downloadFile(`Attachments - ${recordSetName || new Date().toDateString()}.zip`, response.data); } else { console.error('Attachment archive download failed', response); } @@ -156,7 +155,7 @@ export function RecordSetAttachments({ <> loading(handleDownloadAllAttachments())} + onClick={(): void => loading(handleDownloadAllAttachments())} title={attachmentsText.downloadAllDescription()} > {attachmentsText.downloadAll()} diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx index a65c1f6f348..73129ce9fcd 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx @@ -35,6 +35,7 @@ export function RecordSelectorFromIds({ defaultIndex, table, viewName, + recordSetName, title, headerButtons, dialog, @@ -58,6 +59,7 @@ export function RecordSelectorFromIds({ */ readonly ids: RA; readonly newResource: SpecifyResource | undefined; + readonly recordSetName?: string | undefined; readonly title: LocalizedString | undefined; readonly headerButtons?: JSX.Element; readonly dialog: 'modal' | 'nonModal' | false; @@ -233,7 +235,7 @@ export function RecordSelectorFromIds({ {hasAttachments && !hasSeveralResourceType && !resource?.isNew() ? ( - + ) : undefined} {table.view === 'GeologicTimePeriod' ? ( diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx index 86ba9f041f8..3b8e60eef2e 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx @@ -375,6 +375,11 @@ function RecordSet({ isLoading={isLoading} newResource={currentRecord.isNew() ? currentRecord : undefined} table={currentRecord.specifyTable} + recordSetName={ + recordSet.isNew() + ? undefined + : recordSet.get('name') + } title={ recordSet.isNew() ? undefined diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index a0e73147934..8c75dd61f2f 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -680,6 +680,6 @@ export const attachmentsText = createDictionary({ 'en-us': 'Download All', }, downloadAllDescription: { - 'en-us': 'Download all fetched attachments', + 'en-us': 'Download all found attachments', }, } as const); From fd3caf7edb3347ff3cd5191eabd03efee1c6a489 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 22 Jan 2025 16:15:04 +0000 Subject: [PATCH 15/22] Lint code with ESLint and Prettier Triggered by 8b1dea48738ad30e9a1250f3449389c64b85bfaa on branch refs/heads/issue-609 --- .../js_src/lib/components/FormSliders/RecordSet.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx index 3b8e60eef2e..9703a809521 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx @@ -374,12 +374,8 @@ function RecordSet({ isInRecordSet isLoading={isLoading} newResource={currentRecord.isNew() ? currentRecord : undefined} + recordSetName={recordSet.isNew() ? undefined : recordSet.get('name')} table={currentRecord.specifyTable} - recordSetName={ - recordSet.isNew() - ? undefined - : recordSet.get('name') - } title={ recordSet.isNew() ? undefined From 3f92de514d28916307fbb5095fb90da751f2cb0f Mon Sep 17 00:00:00 2001 From: alesan99 Date: Thu, 23 Jan 2025 11:31:23 -0600 Subject: [PATCH 16/22] Download All button directly downloads if there is only one attachment Improve zip filename --- .../Attachments/RecordSetAttachment.tsx | 25 ++++++++++++++----- .../FormSliders/RecordSelectorFromIds.tsx | 4 +-- .../lib/components/FormSliders/RecordSet.tsx | 1 - .../lib/components/Molecules/FilePicker.tsx | 18 +++++++------ 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index 506e5b8b1bb..367e0643f75 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -13,28 +13,29 @@ import { filterArray } from '../../utils/types'; import { keysToLowerCase } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { LoadingContext } from '../Core/Contexts'; -import type { AnySchema } from '../DataModel/helperTypes'; +import type { AnySchema, SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { serializeResource } from '../DataModel/serializers'; -import type { CollectionObjectAttachment } from '../DataModel/types'; +import type { CollectionObjectAttachment, Attachment } from '../DataModel/types'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import { downloadFile } from '../Molecules/FilePicker'; import { defaultAttachmentScale } from '.'; import { AttachmentGallery } from './Gallery'; import { getAttachmentRelationship } from './utils'; +import { fetchOriginalUrl } from './attachments'; const haltIncrementSize = 300; export function RecordSetAttachments({ records, onFetch: handleFetch, - recordSetName, + name, }: { readonly records: RA | undefined>; readonly onFetch: | ((index: number) => Promise | void>) | undefined; - readonly recordSetName: string | undefined; + readonly name: string | undefined; }): JSX.Element { const fetchedCount = React.useRef(0); @@ -88,9 +89,19 @@ export function RecordSetAttachments({ ); const attachmentsRef = React.useRef(attachments); - const downloadAllAttachmentsDisabled = fetchedCount.current !== records.length || records.length <= 1 || fetchedCount.current <= 1; const handleDownloadAllAttachments = async (): Promise => { if (attachmentsRef.current === undefined) return; + if (attachments?.attachments.length === 1) { + const attachment = attachmentsRef.current.attachments[0]; + if (attachment === undefined) return; + const serialized = serializeResource(attachment) + fetchOriginalUrl(serialized as SerializedResource).then( + (url) => { + downloadFile(attachment.origFilename, `/attachment_gw/proxy/${new URL(url as string).search}`, true) + } + ) + return; + } const attachmentLocations = attachmentsRef.current.attachments .map((attachment) => attachment.attachmentLocation) .filter((name): name is string => name !== null); @@ -112,7 +123,8 @@ export function RecordSetAttachments({ }); if (response.status === Http.OK) { - downloadFile(`Attachments - ${recordSetName || new Date().toDateString()}.zip`, response.data); + const fileName = `Attachments - ${(name || new Date().toDateString()).replace(/:/g, '')}.zip` + downloadFile(fileName, response.data); } else { console.error('Attachment archive download failed', response); } @@ -140,6 +152,7 @@ export function RecordSetAttachments({ ); const isComplete = fetchedCount.current === records.length; + const downloadAllAttachmentsDisabled = !isComplete || attachments?.attachments.length === 0; return ( <> diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx index 9f3b8d10438..9b3b84ebd8f 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx @@ -35,7 +35,6 @@ export function RecordSelectorFromIds({ defaultIndex, table, viewName, - recordSetName, title, headerButtons, dialog, @@ -59,7 +58,6 @@ export function RecordSelectorFromIds({ */ readonly ids: RA; readonly newResource: SpecifyResource | undefined; - readonly recordSetName?: string | undefined; readonly title: LocalizedString | undefined; readonly headerButtons?: JSX.Element; readonly dialog: 'modal' | 'nonModal' | false; @@ -237,7 +235,7 @@ export function RecordSelectorFromIds({ !resource?.isNew() ? ( ) : undefined} diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx index 9703a809521..86ba9f041f8 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx @@ -374,7 +374,6 @@ function RecordSet({ isInRecordSet isLoading={isLoading} newResource={currentRecord.isNew() ? currentRecord : undefined} - recordSetName={recordSet.isNew() ? undefined : recordSet.get('name')} table={currentRecord.specifyTable} title={ recordSet.isNew() diff --git a/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx b/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx index 4e51a9bbe40..196ffb556c6 100644 --- a/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx +++ b/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx @@ -137,7 +137,8 @@ export function FilePicker({ */ export const downloadFile = async ( fileName: string, - data: Blob | string + data: Blob | string, + isUrl?: boolean ): Promise => new Promise((resolve) => { let fileDownloaded = false; @@ -145,17 +146,20 @@ export const downloadFile = async ( iframe.classList.add('absolute', 'hidden'); iframe.addEventListener('load', () => { if (iframe.contentWindow === null || fileDownloaded) return; - let url: string | undefined; + let dataUrl: string | undefined; const element = iframe.contentWindow.document.createElement('a'); - if (typeof data === 'string') { + if (isUrl === true) { + element.setAttribute('href', data as string); + element.setAttribute('download', fileName); + } else if (typeof data === 'string') { element.setAttribute( 'href', `data:text/plain;charset=utf-8,${encodeURIComponent(data)}` ); element.setAttribute('download', fileName); - } else { - url = URL.createObjectURL(data); - element.setAttribute('href', url); + } else if (data instanceof Blob) { + dataUrl = URL.createObjectURL(data); + element.setAttribute('href', dataUrl); element.setAttribute('download', fileName); } @@ -164,7 +168,7 @@ export const downloadFile = async ( element.click(); fileDownloaded = true; - if (url !== undefined) URL.revokeObjectURL(url); + if (dataUrl !== undefined) URL.revokeObjectURL(dataUrl); globalThis.setTimeout(() => { iframe.remove(); resolve(); From 8e38152f346a1b6d26c80ba939e75a8960301387 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Thu, 23 Jan 2025 17:35:18 +0000 Subject: [PATCH 17/22] Lint code with ESLint and Prettier Triggered by 3f92de514d28916307fbb5095fb90da751f2cb0f on branch refs/heads/issue-609 --- .../lib/components/Attachments/RecordSetAttachment.tsx | 8 ++++---- .../lib/components/FormSliders/RecordSelectorFromIds.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index 367e0643f75..8588b788f41 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -16,13 +16,13 @@ import { LoadingContext } from '../Core/Contexts'; import type { AnySchema, SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { serializeResource } from '../DataModel/serializers'; -import type { CollectionObjectAttachment, Attachment } from '../DataModel/types'; +import type { Attachment,CollectionObjectAttachment } from '../DataModel/types'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import { downloadFile } from '../Molecules/FilePicker'; import { defaultAttachmentScale } from '.'; +import { fetchOriginalUrl } from './attachments'; import { AttachmentGallery } from './Gallery'; import { getAttachmentRelationship } from './utils'; -import { fetchOriginalUrl } from './attachments'; const haltIncrementSize = 300; @@ -97,7 +97,7 @@ export function RecordSetAttachments({ const serialized = serializeResource(attachment) fetchOriginalUrl(serialized as SerializedResource).then( (url) => { - downloadFile(attachment.origFilename, `/attachment_gw/proxy/${new URL(url as string).search}`, true) + downloadFile(attachment.origFilename, `/attachment_gw/proxy/${new URL(url!).search}`, true) } ) return; @@ -123,7 +123,7 @@ export function RecordSetAttachments({ }); if (response.status === Http.OK) { - const fileName = `Attachments - ${(name || new Date().toDateString()).replace(/:/g, '')}.zip` + const fileName = `Attachments - ${(name || new Date().toDateString()).replaceAll(':', '')}.zip` downloadFile(fileName, response.data); } else { console.error('Attachment archive download failed', response); diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx index 9b3b84ebd8f..a5e7e99e6f6 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx @@ -234,8 +234,8 @@ export function RecordSelectorFromIds({ !hasSeveralResourceType && !resource?.isNew() ? ( ) : undefined} From 3cc24bdecbfbc2decf734780f4ba27d3dd12dd12 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Fri, 24 Jan 2025 08:22:29 -0600 Subject: [PATCH 18/22] Fix unhandled errors for missing fields --- specifyweb/attachment_gw/views.py | 5 ++++- .../lib/components/Attachments/RecordSetAttachment.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/specifyweb/attachment_gw/views.py b/specifyweb/attachment_gw/views.py index aee762caf71..e167e085f94 100644 --- a/specifyweb/attachment_gw/views.py +++ b/specifyweb/attachment_gw/views.py @@ -322,6 +322,9 @@ def download_all(request): path = os.path.join(settings.DEPOSITORY_DIR, filename) make_attachment_zip(attachmentLocations, origFileNames, get_collection(request), path) + + if not os.path.exists(path): + return HttpResponseBadRequest('Attachment archive not found') def file_iterator(file_path, chunk_size=512 * 1024): with open(file_path, 'rb') as f: @@ -348,7 +351,7 @@ def make_attachment_zip(attachmentLocations, origFileNames, collection, output_f } response = requests.get(server_urls['read'], params=data) if response.status_code == 200: - downloadFileName = origFileNames[i] + downloadFileName = origFileNames[i] if i < len(origFileNames) else attachmentLocation fileNameAppearances[downloadFileName] = fileNameAppearances.get(downloadFileName, 0) + 1 if fileNameAppearances[downloadFileName] > 1: downloadOrigName = os.path.splitext(downloadFileName)[0] diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index 8588b788f41..ee9d693c03e 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -106,7 +106,7 @@ export function RecordSetAttachments({ .map((attachment) => attachment.attachmentLocation) .filter((name): name is string => name !== null); const origFilenames = attachmentsRef.current.attachments - .map((attachment) => attachment.origFilename) + .map((attachment) => attachment.origFilename ?? attachment.attachmentLocation) .filter((name): name is string => name !== null); try { From 22743ac3172a45ad8290d80faaff73e8d9e6a293 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 27 Jan 2025 14:08:37 +0000 Subject: [PATCH 19/22] Lint code with ESLint and Prettier Triggered by 9b86f9e55b60cd49331962bc6d8b58a06155aad9 on branch refs/heads/issue-609 --- .../js_src/lib/components/RouterCommands/SwitchCollection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/RouterCommands/SwitchCollection.tsx b/specifyweb/frontend/js_src/lib/components/RouterCommands/SwitchCollection.tsx index aeb54a2407e..ef201ec09d8 100644 --- a/specifyweb/frontend/js_src/lib/components/RouterCommands/SwitchCollection.tsx +++ b/specifyweb/frontend/js_src/lib/components/RouterCommands/SwitchCollection.tsx @@ -42,8 +42,8 @@ export function SwitchCollectionCommand(): null { body: collectionId!.toString(), errorMode: 'dismissible', }) - .then(clearAllCache) - .then(() => globalThis.location.replace(nextUrl)), + .then(clearAllCache) + .then(() => globalThis.location.replace(nextUrl)), [collectionId, nextUrl] ), true From a8933f03a6e1da66bf54bbddf6ef34b653038ac8 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Tue, 4 Feb 2025 12:04:11 -0600 Subject: [PATCH 20/22] Add better error handling --- specifyweb/attachment_gw/views.py | 5 ++- .../Attachments/RecordSetAttachment.tsx | 41 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/specifyweb/attachment_gw/views.py b/specifyweb/attachment_gw/views.py index e167e085f94..df45d80d2ce 100644 --- a/specifyweb/attachment_gw/views.py +++ b/specifyweb/attachment_gw/views.py @@ -321,7 +321,10 @@ def download_all(request): filename = 'attachments_%s.zip' % datetime.now().isoformat() path = os.path.join(settings.DEPOSITORY_DIR, filename) - make_attachment_zip(attachmentLocations, origFileNames, get_collection(request), path) + try: + make_attachment_zip(attachmentLocations, origFileNames, get_collection(request), path) + except Exception as e: + return HttpResponseBadRequest(e) if not os.path.exists(path): return HttpResponseBadRequest('Attachment archive not found') diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index ee9d693c03e..be41623e757 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -109,29 +109,26 @@ export function RecordSetAttachments({ .map((attachment) => attachment.origFilename ?? attachment.attachmentLocation) .filter((name): name is string => name !== null); - try { - const response = await ajax('/attachment_gw/download_all/', { - method: 'POST', - body: keysToLowerCase({ - attachmentLocations, - origFilenames, - }), - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/octet-stream', - }, - }); - - if (response.status === Http.OK) { - const fileName = `Attachments - ${(name || new Date().toDateString()).replaceAll(':', '')}.zip` - downloadFile(fileName, response.data); - } else { - console.error('Attachment archive download failed', response); - } - } catch (error) { - console.error('Attachment archive download failed', error); + const response = await ajax('/attachment_gw/download_all/', { + method: 'POST', + body: keysToLowerCase({ + attachmentLocations, + origFilenames, + }), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/octet-stream', + }, + errorMode: 'silent', + }); + + if (response.status === Http.OK) { + const fileName = `Attachments - ${(name || new Date().toDateString()).replaceAll(':', '')}.zip` + downloadFile(fileName, response.data); + } else { + throw new Error(`Attachment archive download failed: ${response}`); } - + }; const loading = React.useContext(LoadingContext); From 5a9c1cea37e037ac583a94099817e6784944db08 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 5 Feb 2025 06:19:18 +0000 Subject: [PATCH 21/22] Lint code with ESLint and Prettier Triggered by ae2628d74883e404438927456181a87bd7ced0a1 on branch refs/heads/issue-609 --- .../Preferences/UserDefinitions.tsx | 7 +-- .../lib/components/QueryComboBox/index.tsx | 47 ++++++++++--------- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx index 044bbd9fc25..78e891d0dff 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx @@ -65,9 +65,10 @@ const isDarkMode = ({ }: PreferencesVisibilityContext): boolean => isDarkMode || isRedirecting; // Navigator may not be defined in some environments, like non-browser environments -const altKeyName = typeof navigator !== 'undefined' && navigator?.userAgent?.includes('Mac') - ? 'Option' - : 'Alt'; +const altKeyName = + typeof navigator !== 'undefined' && navigator?.userAgent?.includes('Mac') + ? 'Option' + : 'Alt'; /** * Have to be careful as preferences may be used before schema is loaded diff --git a/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx b/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx index 81f98171382..76a9b506749 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx @@ -264,29 +264,32 @@ export function QueryComboBox({ (typeof typeSearch === 'object' ? typeSearch?.table : undefined) ?? field.relatedTable; - const [fetchedTreeDefinition] = useAsyncState( - React.useCallback(async () => { - if (resource?.specifyTable === tables.Determination) { - return resource.collection?.related?.specifyTable === tables.CollectionObject - ? (resource.collection?.related as SpecifyResource) - .rgetPromise('collectionObjectType') - .then( - ( - collectionObjectType: - | SpecifyResource - | undefined - ) => collectionObjectType?.get('taxonTreeDef') - ) - : undefined; - } else if (resource?.specifyTable === tables.Taxon) { - const definition = resource.get('definition') - const parentDefinition = (resource?.independentResources?.parent as SpecifyResource)?.get?.('definition'); - return definition || parentDefinition; + const [fetchedTreeDefinition] = useAsyncState( + React.useCallback(async () => { + if (resource?.specifyTable === tables.Determination) { + return resource.collection?.related?.specifyTable === + tables.CollectionObject + ? (resource.collection?.related as SpecifyResource) + .rgetPromise('collectionObjectType') + .then( + ( + collectionObjectType: + | SpecifyResource + | undefined + ) => collectionObjectType?.get('taxonTreeDef') + ) + : undefined; + } else if (resource?.specifyTable === tables.Taxon) { + const definition = resource.get('definition'); + const parentDefinition = ( + resource?.independentResources?.parent as SpecifyResource + )?.get?.('definition'); + return definition || parentDefinition; } - return undefined; - }, [resource, resource?.collection?.related?.get('collectionObjectType')]), - false - ); + return undefined; + }, [resource, resource?.collection?.related?.get('collectionObjectType')]), + false + ); // Tree Definition passed by a parent QCBX in the component tree const parentTreeDefinition = React.useContext(TreeDefinitionContext); From e1e47642f98ffc3e7e54c6cba50f79c3d38f9b2e Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 5 Feb 2025 10:35:23 -0600 Subject: [PATCH 22/22] Fix ajax error handling in blob responses --- specifyweb/frontend/js_src/lib/utils/ajax/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts index 34e423571f6..aff1677cedd 100644 --- a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts +++ b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts @@ -144,7 +144,7 @@ export async function ajax( .then(async (response) => Promise.all([ response, - acceptBlobResponse ? response.blob() : response.text(), + acceptBlobResponse && response.ok ? response.blob() : response.text(), ]) ) .then(