diff --git a/README.md b/README.md index 93d6b36..4120924 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Arguments: Options: -V, --version output the version number - -s --save [path] Save directory + -s --save [path] Save location (directory/file) -f --full-name Show full name of the file while downloading, even if it long -h, --help display help for command @@ -82,13 +82,13 @@ interface IStreamProgress { class FastDownload implements IStreamProgress { - constructor(url: string, savePath: string, options?: TurboDownloaderOptions) { - } + constructor(url: string, savePath: string, options?: TurboDownloaderOptions); + + static async fetchFilename(url: string); } class CopyProgress implements IStreamProgress { - constructor(fromPath: string, toPath: string) { - } + constructor(fromPath: string, toPath: string); } ``` diff --git a/package-lock.json b/package-lock.json index 2859a46..171eb5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "chalk": "^5.3.0", "cli-progress": "^3.12.0", "commander": "^10.0.0", + "content-disposition": "^0.5.4", "execa": "^7.2.0", "fs-extra": "^11.1.1", "level": "^8.0.0", @@ -29,6 +30,7 @@ "@commitlint/config-conventional": "^17.7.0", "@semantic-release/exec": "^6.0.3", "@types/cli-progress": "^3.11.0", + "@types/content-disposition": "^0.5.8", "@types/fs-extra": "^11.0.1", "@types/node": "^20.4.9", "@types/progress-stream": "^2.0.2", @@ -1515,6 +1517,12 @@ "@types/node": "*" } }, + "node_modules/@types/content-disposition": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.8.tgz", + "integrity": "sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==", + "dev": true + }, "node_modules/@types/fs-extra": { "version": "11.0.1", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.1.tgz", @@ -2394,6 +2402,36 @@ "proto-list": "~1.2.1" } }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/conventional-changelog-angular": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-6.0.0.tgz", diff --git a/package.json b/package.json index b06ea0f..820ff36 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@commitlint/config-conventional": "^17.7.0", "@semantic-release/exec": "^6.0.3", "@types/cli-progress": "^3.11.0", + "@types/content-disposition": "^0.5.8", "@types/fs-extra": "^11.0.1", "@types/node": "^20.4.9", "@types/progress-stream": "^2.0.2", @@ -80,6 +81,7 @@ "chalk": "^5.3.0", "cli-progress": "^3.12.0", "commander": "^10.0.0", + "content-disposition": "^0.5.4", "execa": "^7.2.0", "fs-extra": "^11.1.1", "level": "^8.0.0", diff --git a/src/cli/cli.ts b/src/cli/cli.ts index f7ea1c8..21cf2ff 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -6,25 +6,41 @@ import {Command} from "commander"; import {packageJson} from "../const.js"; import pullFileCLI from "../download/index.js"; import {truncateText} from "../utils/truncate-text.js"; -import findDownloadDir from "./utils/find-download-dir.js"; +import {FastDownload} from "../index.js"; +import findDownloadDir, {downloadToDirectory} from "./utils/find-download-dir.js"; import {setCommand} from "./commands/set.js"; + const pullCommand = new Command(); pullCommand .version(packageJson.version) .description("Pull/copy files from remote server/local directory") .argument("[files...]", "Files to pull/copy") - .option("-s --save [path]", "Save directory") + .option("-s --save [path]", "Save location (directory/file)") .option("-f --full-name", "Show full name of the file while downloading, even if it long") - .action(async (files = [], {save, fullName}: { save?: string, fullName?: boolean }) => { + .action(async (files: string[] = [], {save, fullName}: { save?: string, fullName?: boolean }) => { + let specificFileName: null | string = null; + if (files.length === 0) { pullCommand.outputHelp(); process.exit(0); } + if (save && !await downloadToDirectory(save)) { + specificFileName = path.basename(save); + save = path.dirname(save); + } + const pullLogs: string[] = []; - for (const file of files) { - const fileName = path.basename(file); + for (const [index, file ] of Object.entries(files)) { + let fileName = path.basename(file); + + if (specificFileName) { + fileName = files.length > 1 ? specificFileName + index : specificFileName; + } else if (file.startsWith("http")) { + fileName = await FastDownload.fetchFilename(file); + } + const downloadTag = fullName ? fileName : truncateText(fileName); const downloadPath = path.join(save || await findDownloadDir(fileName), fileName); const fileFullPath = new URL(file, pathToFileURL(process.cwd())); diff --git a/src/cli/utils/find-download-dir.ts b/src/cli/utils/find-download-dir.ts index afb7ef8..2309afb 100644 --- a/src/cli/utils/find-download-dir.ts +++ b/src/cli/utils/find-download-dir.ts @@ -1,4 +1,5 @@ import path from "path"; +import fs from "fs-extra"; import {getWithDefault} from "../../settings/settings.js"; const DEFAULT_DOWNLOAD_DIR = process.cwd(); @@ -8,3 +9,12 @@ export default async function findDownloadDir(fileName: string) { const defaultLocation = await getWithDefault("default"); return downloadLocation || defaultLocation || DEFAULT_DOWNLOAD_DIR; } + +export async function downloadToDirectory(path: string) { + try { + const stats = await fs.lstat(path); + return stats.isDirectory(); + } catch { + return false; + } +} diff --git a/src/download/stream-progress/fast-download.ts b/src/download/stream-progress/fast-download.ts index 97dbb1f..81a5c7c 100644 --- a/src/download/stream-progress/fast-download.ts +++ b/src/download/stream-progress/fast-download.ts @@ -1,20 +1,24 @@ import TurboDownloader, {TurboDownloaderOptions} from "turbo-downloader"; import wretch from "wretch"; import fs from "fs-extra"; +import contentDisposition from "content-disposition"; import {IStreamProgress} from "./istream-progress.js"; +const DEFAULT_FILE_NAME = "file"; export default class FastDownload implements IStreamProgress { private _downloader?: TurboDownloader.default; private _redirectedURL?: string; - constructor(private _url: string, private _savePath: string, private _options?: Partial) { + constructor(private _url: string, private _savePath: string, private _options?: Partial) { } public async init() { + await this._fetchFileInfo(); await fs.ensureFile(this._savePath); + this._downloader = new FastDownload._TurboDownloaderClass({ - url: await this._getRedirectedURL(), + url: this._redirectedURL!, destFile: this._savePath, chunkSize: 50 * 1024 * 1024, concurrency: 8, @@ -23,19 +27,21 @@ export default class FastDownload implements IStreamProgress { }); } - private async _getRedirectedURL() { + private async _fetchFileInfo() { const {url} = await wretch(this._url) .head() .res() .catch(error => { throw new Error(`Error while getting file head: ${error.status}`); }); - return this._redirectedURL = url; + + this._redirectedURL = url; + + } public async progress(callback: (progressBytes: number, totalBytes: number) => void) { - if (!this._downloader) - throw new Error("Downloader is not initialized"); + if (!this._downloader) throw new Error("Downloader is not initialized"); await (this._downloader as any).download(callback); } @@ -43,4 +49,26 @@ export default class FastDownload implements IStreamProgress { if (TurboDownloader && "default" in TurboDownloader) return TurboDownloader.default; return TurboDownloader; } + + /** + * Fetches filename from `content-disposition` header. If it's not present, extract it from the `pathname` of the url + * @param {string} url + */ + public static async fetchFilename(url: string) { + const contentDispositionHeader = await wretch(url) + .head() + .res(response => response.headers.get("content-disposition")) + .catch(error => { + throw new Error(`Error while getting file head: ${error.status}`); + }); + + const parsed = new URL(url); + const defaultFilename = decodeURIComponent(parsed.pathname.split("/").pop() ?? DEFAULT_FILE_NAME); + + if (!contentDispositionHeader) + return defaultFilename; + + const {parameters} = contentDisposition.parse(contentDispositionHeader); + return parameters.filename ?? defaultFilename; + } }