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..e167e085f94 100644 --- a/specifyweb/attachment_gw/views.py +++ b/specifyweb/attachment_gw/views.py @@ -1,10 +1,15 @@ +import os +import re import hmac 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 +from datetime import datetime import requests from django.conf import settings @@ -12,7 +17,9 @@ 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 ..notifications.models import Message from specifyweb.middleware.general import require_http_methods from specifyweb.specify.views import login_maybe_required, openapi @@ -296,6 +303,68 @@ 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 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) + + attachmentLocations = r['attachmentlocations'] + origFileNames = r['origfilenames'] + + filename = 'attachments_%s.zip' % datetime.now().isoformat() + 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: + 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: + fileNameAppearances = {} + 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: + 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] + downloadExtension = os.path.splitext(downloadFileName)[1] + downloadFileName = f'{downloadOrigName}_{fileNameAppearances[downloadFileName]}{downloadExtension}' + with open(os.path.join(output_dir, downloadFileName), 'wb') as f: + f.write(response.content) + + basename = re.sub(r'\.zip$', '', output_file) + shutil.make_archive(basename, 'zip', output_dir, logger=logger) + finally: + shutil.rmtree(output_dir) + @transaction.atomic() @login_maybe_required @require_http_methods(['GET', 'POST', 'HEAD']) diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx index c04af2ee868..2507bddd7c2 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx @@ -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 a12ea0e15d4..47f58270434 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx @@ -21,6 +21,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 +42,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 as SerializedResource), [serialized]), + false + ); + return (
{typeof handleViewRecord === 'function' && @@ -61,6 +75,19 @@ 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 ae50ae7f084..ee9d693c03e 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -5,16 +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 { 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 } 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'; @@ -23,11 +29,13 @@ const haltIncrementSize = 300; export function RecordSetAttachments({ records, onFetch: handleFetch, + name, }: { readonly records: RA | undefined>; readonly onFetch: | ((index: number) => Promise | void>) | undefined; + readonly name: string | undefined; }): JSX.Element { const fetchedCount = React.useRef(0); @@ -81,6 +89,52 @@ export function RecordSetAttachments({ ); const attachmentsRef = React.useRef(attachments); + 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!).search}`, true) + } + ) + return; + } + const attachmentLocations = attachmentsRef.current.attachments + .map((attachment) => attachment.attachmentLocation) + .filter((name): name is string => name !== null); + const origFilenames = attachmentsRef.current.attachments + .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 loading = React.useContext(LoadingContext); + if (typeof attachments === 'object') attachmentsRef.current = attachments; /* @@ -98,6 +152,7 @@ export function RecordSetAttachments({ ); const isComplete = fetchedCount.current === records.length; + const downloadAllAttachmentsDisabled = !isComplete || attachments?.attachments.length === 0; return ( <> @@ -110,7 +165,18 @@ export function RecordSetAttachments({ {showAttachments && ( {commonText.close()} + <> + loading(handleDownloadAllAttachments())} + > + {attachmentsText.downloadAll()} + + + {commonText.close()} + + } className={{ container: dialogClassNames.wideContainer, diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromIds.tsx index b93501785aa..a5e7e99e6f6 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' ? ( diff --git a/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx b/specifyweb/frontend/js_src/lib/components/Molecules/FilePicker.tsx index f70445b563d..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, - text: string + data: Blob | string, + isUrl?: boolean ): Promise => new Promise((resolve) => { let fileDownloaded = false; @@ -145,18 +146,29 @@ export const downloadFile = async ( iframe.classList.add('absolute', 'hidden'); iframe.addEventListener('load', () => { if (iframe.contentWindow === null || fileDownloaded) return; + let dataUrl: string | undefined; const element = iframe.contentWindow.document.createElement('a'); - element.setAttribute( - 'href', - `data:text/plain;charset=utf-8,${encodeURIComponent(text)}` - ); - element.setAttribute('download', fileName); + 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 if (data instanceof Blob) { + dataUrl = URL.createObjectURL(data); + element.setAttribute('href', dataUrl); + element.setAttribute('download', fileName); + } element.style.display = 'none'; iframe.contentWindow.document.body.append(element); element.click(); fileDownloaded = true; + if (dataUrl !== undefined) URL.revokeObjectURL(dataUrl); globalThis.setTimeout(() => { iframe.remove(); resolve(); 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 diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index 0e4c8202b79..8c75dd61f2f 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -676,4 +676,10 @@ export const attachmentsText = createDictionary({ під час читання файлу сталася помилка. `, }, + downloadAll: { + 'en-us': 'Download All', + }, + downloadAllDescription: { + 'en-us': 'Download all found attachments', + }, } as const); diff --git a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts index 0f2963b21b6..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'; +export type MimeType = + | 'application/json' + | 'application/octet-stream' + | 'text/plain' + | 'text/xml'; export type AjaxResponseObject = { /* @@ -15,6 +19,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 +119,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 +141,22 @@ export async function ajax( ...(typeof accept === 'string' ? { Accept: accept } : {}), }, }) - .then(async (response) => Promise.all([response, response.text()])) + .then(async (response) => + Promise.all([ + response, + acceptBlobResponse ? response.blob() : response.text(), + ]) + ) .then( - ([response, text]: readonly [Response, string]) => { + ([response, text]: readonly [Response, Blob | string]) => { 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