From a76b10a43ff6ec24cb60e891afeebdeac9f11586 Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Sat, 1 Aug 2026 12:52:29 +0200 Subject: [PATCH] feat(archive): add tar.bz2 converter via 7za Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 16 +++++++++++++ package.json | 1 + src/converters/archive.js | 42 +++++++++++++++++++++++++++++++-- test/converters/archive.test.js | 28 ++++++++++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index fb8aef6..94b01ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@phosphor-icons/react": "^2.1.10", "@prisma/client": "^6.19.3", + "7zip-min": "^3.0.1", "adm-zip": "^0.6.0", "archiver": "^8.0.0", "docx": "^9.7.1", @@ -1821,6 +1822,21 @@ "node": ">=10.0.0" } }, + "node_modules/7zip-bin": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.1.1.tgz", + "integrity": "sha512-sAP4LldeWNz0lNzmTird3uWfFDWWTeg6V/MsmyyLR9X1idwKBWIgt/ZvinqQldJm3LecKEs1emkbquO6PCiLVQ==", + "license": "MIT" + }, + "node_modules/7zip-min": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/7zip-min/-/7zip-min-3.0.1.tgz", + "integrity": "sha512-WB4VCA/KSKzxhj+BAp8fI3ZYMMAftclkXlUTckuiDacsqyubQxxG3lGcpBcgzWWuJqnfQncEq1xrJpPLSxqsxw==", + "license": "MIT", + "dependencies": { + "7zip-bin": "5.1.1" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", diff --git a/package.json b/package.json index a541dff..c6b30b7 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dependencies": { "@phosphor-icons/react": "^2.1.10", "@prisma/client": "^6.19.3", + "7zip-min": "^3.0.1", "adm-zip": "^0.6.0", "archiver": "^8.0.0", "docx": "^9.7.1", diff --git a/src/converters/archive.js b/src/converters/archive.js index b14e261..86827f8 100644 --- a/src/converters/archive.js +++ b/src/converters/archive.js @@ -5,6 +5,7 @@ import os from 'node:os'; import AdmZip from 'adm-zip'; import { ZipArchive } from 'archiver'; import * as tar from 'tar'; +import _7z from '7zip-min'; import { register } from './registry.js'; const MAX_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024; @@ -70,8 +71,45 @@ async function createTarGz(srcDir, outputPath, quality) { await tar.create({ file: outputPath, cwd: srcDir, gzip: { level: quality ?? 6 } }, entries); } -const EXTRACTORS = { zip: extractZip, tar: extractTarLike, 'tar.gz': extractTarLike }; -const CREATORS = { zip: createZip, tar: createTar, 'tar.gz': createTarGz }; +function bzip2Level(quality) { + return Math.max(1, quality ?? 9); +} + +async function extractTarBz2(inputPath, destDir) { + const decompressDir = await fs.mkdtemp(path.join(os.tmpdir(), 'archive-bz2-')); + try { + await _7z.unpack(inputPath, decompressDir); + const [tarName] = await fs.readdir(decompressDir); + await tar.extract({ file: path.join(decompressDir, tarName), cwd: destDir }); + } finally { + await fs.rm(decompressDir, { recursive: true, force: true }); + } +} + +async function createTarBz2(srcDir, outputPath, quality) { + const buildDir = await fs.mkdtemp(path.join(os.tmpdir(), 'archive-bz2-')); + try { + const tarPath = path.join(buildDir, 'archive.tar'); + const entries = await fs.readdir(srcDir); + await tar.create({ file: tarPath, cwd: srcDir }, entries); + await _7z.cmd(['a', '-tbzip2', `-mx=${bzip2Level(quality)}`, outputPath, tarPath]); + } finally { + await fs.rm(buildDir, { recursive: true, force: true }); + } +} + +const EXTRACTORS = { + zip: extractZip, + tar: extractTarLike, + 'tar.gz': extractTarLike, + 'tar.bz2': extractTarBz2, +}; +const CREATORS = { + zip: createZip, + tar: createTar, + 'tar.gz': createTarGz, + 'tar.bz2': createTarBz2, +}; async function convert(inputPath, outputPath, options, sourceFormat, targetFormat) { const { quality } = options ?? {}; diff --git a/test/converters/archive.test.js b/test/converters/archive.test.js index ab3ec04..217ad0f 100644 --- a/test/converters/archive.test.js +++ b/test/converters/archive.test.js @@ -133,3 +133,31 @@ describe('archive converters — tar / tar.gz', () => { await fs.rm(listDir, { recursive: true, force: true }); }); }); + +describe('archive converters — tar.bz2', () => { + it('registers tar.bz2 as a target for tar and vice versa', () => { + expect(listTargetFormats('tar')).toContain('tar.bz2'); + expect(listTargetFormats('tar.bz2')).toContain('tar'); + }); + + it( + 'converts zip -> tar.bz2 -> tar, preserving content', + async () => { + const zipPath = path.join(tmpDir, 'for-bz2.zip'); + buildZipFixture(zipPath); + + const bz2Path = path.join(tmpDir, 'output.tar.bz2'); + await resolve('zip', 'tar.bz2').convert(zipPath, bz2Path, { quality: 9 }); + + const backToTarPath = path.join(tmpDir, 'from-bz2.tar'); + await resolve('tar.bz2', 'tar').convert(bz2Path, backToTarPath); + + const listDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bz2-check-')); + await tar.extract({ file: backToTarPath, cwd: listDir }); + expect(await fs.readFile(path.join(listDir, 'hello.txt'), 'utf8')).toBe('hello world'); + expect(await fs.readFile(path.join(listDir, 'nested', 'inner.txt'), 'utf8')).toBe('nested content'); + await fs.rm(listDir, { recursive: true, force: true }); + }, + 20000 + ); +});