diff --git a/components.d.ts b/components.d.ts index 3e65c3cc5..33a75bf23 100644 --- a/components.d.ts +++ b/components.d.ts @@ -129,6 +129,7 @@ declare module '@vue/runtime-core' { MenuLayout: typeof import('./src/components/MenuLayout.vue')['default'] MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default'] MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default'] + MultiLinkDownloader: typeof import('./src/tools/multi-link-downloader/multi-link-downloader.vue')['default'] NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default'] NCheckbox: typeof import('naive-ui')['NCheckbox'] NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] diff --git a/locales/en.yml b/locales/en.yml index b195dbd85..1cef7e153 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -392,3 +392,7 @@ tools: text-to-binary: title: Text to ASCII binary description: Convert text to its ASCII binary representation and vice-versa. + + multi-link-downloader: + title: Multi link downloader + description: Asynchronously downloads from multiple links into a zip file while a single link downloads directly. \ No newline at end of file diff --git a/package.json b/package.json index 5c991cff6..5c2d2e5d1 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "ibantools": "^4.3.3", "js-base64": "^3.7.6", "json5": "^2.2.3", + "jszip": "^3.10.1", "jwt-decode": "^3.1.2", "libphonenumber-js": "^1.10.28", "lodash": "^4.17.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3798ae171..ab1a598c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ dependencies: json5: specifier: ^2.2.3 version: 2.2.3 + jszip: + specifier: ^3.10.1 + version: 3.10.1 jwt-decode: specifier: ^3.1.2 version: 3.1.2 @@ -4674,6 +4677,9 @@ packages: browserslist: 4.22.1 dev: true + /core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + /country-code-lookup@0.1.0: resolution: {integrity: sha512-IOI66HEG+8bXfWPy+sTzuN7161vmDZOHg1wgIPFf3WfD73FeLajnn6C+fnxOIa9RL1WRBDMXQQWW/FOaOYaQ3w==} dev: false @@ -6195,6 +6201,9 @@ packages: dev: true optional: true + /immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -6501,6 +6510,9 @@ packages: is-docker: 2.2.1 dev: true + /isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + /isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} dev: true @@ -6695,6 +6707,14 @@ packages: engines: {node: '>=0.10.0'} dev: true + /jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + /kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} dev: true @@ -6735,6 +6755,11 @@ packages: resolution: {integrity: sha512-1eAgjLrZA0+2Wgw4hs+4Q/kEBycxQo8ZLYnmOvZ3AlM8ImAVAJgDPlZtISLEzD1vunc2q8s2Pn7XwB7I8U3Kzw==} dev: false + /lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + dependencies: + immediate: 3.0.6 + /lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} dev: true @@ -7341,6 +7366,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + /pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + /param-case@2.1.1: resolution: {integrity: sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==} dependencies: @@ -7580,6 +7608,7 @@ packages: engines: {node: ^14.13.1 || >=16.0.0} dev: true + /pretty-format@29.6.2: resolution: {integrity: sha512-1q0oC8eRveTg5nnBEWMXAU2qpv65Gnuf2eCQzSjxpWFkPaPARwqZZDGuNE0zPAZfTCHzIk3A8dIjwlQKKLphyg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7589,6 +7618,9 @@ packages: react-is: 18.2.0 dev: true + /process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + /prosemirror-changeset@2.2.1: resolution: {integrity: sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==} dependencies: @@ -7841,6 +7873,17 @@ packages: type-fest: 0.6.0 dev: true + /readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + /readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -8047,6 +8090,9 @@ packages: isarray: 2.0.5 dev: true + /safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + /safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} dev: true @@ -8170,6 +8216,9 @@ packages: is-primitive: 3.0.1 dev: false + /setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + /shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -8376,6 +8425,11 @@ packages: es-abstract: 1.22.3 dev: true + /string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + dependencies: + safe-buffer: 5.1.2 + /string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} dependencies: diff --git a/src/tools/index.ts b/src/tools/index.ts index 388cfaf49..444d6b10e 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,6 +1,7 @@ import { tool as base64FileConverter } from './base64-file-converter'; import { tool as base64StringConverter } from './base64-string-converter'; import { tool as basicAuthGenerator } from './basic-auth-generator'; +import { tool as multiLinkDownloader } from './multi-link-downloader'; import { tool as emailNormalizer } from './email-normalizer'; import { tool as asciiTextDrawer } from './ascii-text-drawer'; @@ -188,7 +189,11 @@ export const toolsByCategory: ToolCategory[] = [ }, { name: 'Data', - components: [phoneParserAndFormatter, ibanValidatorAndParser], + components: [ + phoneParserAndFormatter, + ibanValidatorAndParser, + multiLinkDownloader, + ], }, ]; diff --git a/src/tools/multi-link-downloader/index.ts b/src/tools/multi-link-downloader/index.ts new file mode 100644 index 000000000..da6ef6cbe --- /dev/null +++ b/src/tools/multi-link-downloader/index.ts @@ -0,0 +1,12 @@ +import { FileDownload } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'Multi link downloader', + path: '/multi-link-downloader', + description: '', + keywords: ['multi', 'link', 'downloader'], + component: () => import('./multi-link-downloader.vue'), + icon: FileDownload, + createdAt: new Date('2024-10-18'), +}); diff --git a/src/tools/multi-link-downloader/multi-link-downloader.service.ts b/src/tools/multi-link-downloader/multi-link-downloader.service.ts new file mode 100644 index 000000000..d22aa612d --- /dev/null +++ b/src/tools/multi-link-downloader/multi-link-downloader.service.ts @@ -0,0 +1,108 @@ +import JSZip from 'jszip'; + +export async function downloadLinks(links: string): Promise { + // Split links by newline and filter out empty ones + const linksArray: string[] = links.split('\n').filter(link => link.trim() !== ''); + + // Helper function to handle duplicate filenames + function getUniqueFileName(existingNames: Set, originalName: string): string { + let fileName = originalName; + let fileExtension = ''; + + // Split filename and extension (if any) + const lastDotIndex = originalName.lastIndexOf('.'); + if (lastDotIndex !== -1) { + fileName = originalName.substring(0, lastDotIndex); + fileExtension = originalName.substring(lastDotIndex); + } + + let counter = 1; + let uniqueName = originalName; + + // Append a counter to the filename if it already exists in the map + while (existingNames.has(uniqueName)) { + uniqueName = `${fileName} (${counter})${fileExtension}`; + counter++; + } + + existingNames.add(uniqueName); + return uniqueName; + } + + if (linksArray.length === 1) { + // Single link: download directly + const linkUrl: string = linksArray[0]; + try { + const response: Response = await fetch(linkUrl); + if (!response.ok) { + throw new Error(`Failed to fetch ${linkUrl}`); + } + + // Get file as blob + const blob: Blob = await response.blob(); + + // Extract filename from URL + const fileName: string = linkUrl.split('/').pop() || 'downloaded_file'; + + // Trigger download + const a: HTMLAnchorElement = document.createElement('a'); + const downloadUrl: string = window.URL.createObjectURL(blob); + a.href = downloadUrl; + a.download = fileName; + document.body.appendChild(a); + a.click(); + + // Clean up + document.body.removeChild(a); + window.URL.revokeObjectURL(downloadUrl); + } + catch (error) { + console.error('Error downloading the file:', error); + } + } + else if (linksArray.length > 1) { + // Multiple links: create a zip file + const zip = new JSZip(); + const fileNamesSet = new Set(); // To track file names for duplicates + + await Promise.all( + linksArray.map(async (linkUrl: string) => { + try { + const response: Response = await fetch(linkUrl); + if (!response.ok) { + throw new Error(`Failed to fetch ${linkUrl}`); + } + const blob: Blob = await response.blob(); + + // Extract filename from URL + let fileName: string = linkUrl.split('/').pop() || 'file'; + + // Get unique filename if duplicate exists + fileName = getUniqueFileName(fileNamesSet, fileName); + + // Add file to the zip + zip.file(fileName, blob); + } + catch (error) { + console.error(`Error downloading file from ${linkUrl}:`, error); + } + }), + ); + + // Generate the zip file and trigger download + zip.generateAsync({ type: 'blob' }).then((zipBlob: Blob) => { + const downloadUrl: string = window.URL.createObjectURL(zipBlob); + + // Trigger download of the zip file + const a: HTMLAnchorElement = document.createElement('a'); + a.href = downloadUrl; + a.download = 'downloaded_files.zip'; + document.body.appendChild(a); + a.click(); + + // Clean up + document.body.removeChild(a); + window.URL.revokeObjectURL(downloadUrl); + }); + } +} diff --git a/src/tools/multi-link-downloader/multi-link-downloader.vue b/src/tools/multi-link-downloader/multi-link-downloader.vue new file mode 100644 index 000000000..9e1c72684 --- /dev/null +++ b/src/tools/multi-link-downloader/multi-link-downloader.vue @@ -0,0 +1,52 @@ + + +