fix(ziptools,gziptools): Use fflate synchronous APIs for ZIP and GZIP operations for Deno compatibility; add TEntryFilter type and small docs/tests cleanup

This commit is contained in:
2025-11-25 13:37:27 +00:00
parent 5cc030d433
commit 11bbddc763
12 changed files with 1300 additions and 795 deletions

View File

@@ -1,12 +1,11 @@
import * as plugins from './plugins.js';
import type {
IArchiveCreationOptions,
IArchiveEntry,
IArchiveExtractionOptions,
IArchiveEntryInfo,
IArchiveInfo,
TArchiveFormat,
TCompressionLevel,
TEntryFilter,
} from './interfaces.js';
import { Bzip2Tools } from './classes.bzip2tools.js';
@@ -16,166 +15,48 @@ import { ZipTools } from './classes.ziptools.js';
import { ArchiveAnalyzer, type IAnalyzedResult } from './classes.archiveanalyzer.js';
/**
* Main class for archive manipulation
* Pending directory entry for async resolution
*/
interface IPendingDirectory {
sourcePath: string;
archiveBase?: string;
}
/**
* Main class for archive manipulation with fluent API
* Supports TAR, ZIP, GZIP, and BZIP2 formats
*
* @example Extraction from URL
* ```typescript
* await SmartArchive.create()
* .url('https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz')
* .stripComponents(1)
* .extract('./node_modules/lodash');
* ```
*
* @example Creation with thenable
* ```typescript
* const archive = await SmartArchive.create()
* .format('tar.gz')
* .compression(9)
* .entry('config.json', JSON.stringify(config))
* .directory('./src');
* ```
*/
export class SmartArchive {
// ============================================
// STATIC FACTORY METHODS - EXTRACTION
// STATIC ENTRY POINT
// ============================================
/**
* Create SmartArchive from a URL
* Create a new SmartArchive instance for fluent configuration
*/
public static async fromUrl(urlArg: string): Promise<SmartArchive> {
const smartArchiveInstance = new SmartArchive();
smartArchiveInstance.sourceUrl = urlArg;
return smartArchiveInstance;
}
/**
* Create SmartArchive from a local file path
*/
public static async fromFile(filePathArg: string): Promise<SmartArchive> {
const smartArchiveInstance = new SmartArchive();
smartArchiveInstance.sourceFilePath = filePathArg;
return smartArchiveInstance;
}
/**
* Create SmartArchive from a readable stream
*/
public static async fromStream(
streamArg: plugins.stream.Readable | plugins.stream.Duplex | plugins.stream.Transform
): Promise<SmartArchive> {
const smartArchiveInstance = new SmartArchive();
smartArchiveInstance.sourceStream = streamArg;
return smartArchiveInstance;
}
/**
* Create SmartArchive from an in-memory buffer
*/
public static async fromBuffer(buffer: Buffer): Promise<SmartArchive> {
const smartArchiveInstance = new SmartArchive();
smartArchiveInstance.sourceStream = plugins.stream.Readable.from(buffer);
return smartArchiveInstance;
public static create(): SmartArchive {
return new SmartArchive();
}
// ============================================
// STATIC FACTORY METHODS - CREATION
// ============================================
/**
* Create a new archive from a directory
*/
public static async fromDirectory(
directoryPath: string,
options: IArchiveCreationOptions
): Promise<SmartArchive> {
const smartArchiveInstance = new SmartArchive();
smartArchiveInstance.creationOptions = options;
const tarTools = new TarTools();
if (options.format === 'tar' || options.format === 'tar.gz' || options.format === 'tgz') {
if (options.format === 'tar') {
const pack = await tarTools.packDirectory(directoryPath);
pack.finalize();
smartArchiveInstance.archiveBuffer = await SmartArchive.streamToBuffer(pack);
} else {
smartArchiveInstance.archiveBuffer = await tarTools.packDirectoryToTarGz(
directoryPath,
options.compressionLevel
);
}
} else if (options.format === 'zip') {
const zipTools = new ZipTools();
const fileTree = await plugins.listFileTree(directoryPath, '**/*');
const entries: IArchiveEntry[] = [];
for (const filePath of fileTree) {
const absolutePath = plugins.path.join(directoryPath, filePath);
const content = await plugins.fsPromises.readFile(absolutePath);
entries.push({
archivePath: filePath,
content,
});
}
smartArchiveInstance.archiveBuffer = await zipTools.createZip(entries, options.compressionLevel);
} else {
throw new Error(`Unsupported format for directory packing: ${options.format}`);
}
return smartArchiveInstance;
}
/**
* Create a new archive from an array of entries
*/
public static async fromFiles(
files: IArchiveEntry[],
options: IArchiveCreationOptions
): Promise<SmartArchive> {
const smartArchiveInstance = new SmartArchive();
smartArchiveInstance.creationOptions = options;
if (options.format === 'tar' || options.format === 'tar.gz' || options.format === 'tgz') {
const tarTools = new TarTools();
if (options.format === 'tar') {
smartArchiveInstance.archiveBuffer = await tarTools.packFiles(files);
} else {
smartArchiveInstance.archiveBuffer = await tarTools.packFilesToTarGz(files, options.compressionLevel);
}
} else if (options.format === 'zip') {
const zipTools = new ZipTools();
smartArchiveInstance.archiveBuffer = await zipTools.createZip(files, options.compressionLevel);
} else if (options.format === 'gz') {
if (files.length !== 1) {
throw new Error('GZIP format only supports a single file');
}
const gzipTools = new GzipTools();
let content: Buffer;
if (typeof files[0].content === 'string') {
content = Buffer.from(files[0].content);
} else if (Buffer.isBuffer(files[0].content)) {
content = files[0].content;
} else {
throw new Error('GZIP format requires string or Buffer content');
}
smartArchiveInstance.archiveBuffer = await gzipTools.compress(content, options.compressionLevel);
} else {
throw new Error(`Unsupported format: ${options.format}`);
}
return smartArchiveInstance;
}
/**
* Start building an archive incrementally using a builder pattern
*/
public static create(options: IArchiveCreationOptions): SmartArchive {
const smartArchiveInstance = new SmartArchive();
smartArchiveInstance.creationOptions = options;
smartArchiveInstance.pendingEntries = [];
return smartArchiveInstance;
}
/**
* Helper to convert a stream to buffer
*/
private static async streamToBuffer(stream: plugins.stream.Readable): Promise<Buffer> {
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}
// ============================================
// INSTANCE PROPERTIES
// TOOLS (public for internal use)
// ============================================
public tarTools = new TarTools();
@@ -184,129 +65,235 @@ export class SmartArchive {
public bzip2Tools = new Bzip2Tools(this);
public archiveAnalyzer = new ArchiveAnalyzer(this);
public sourceUrl?: string;
public sourceFilePath?: string;
public sourceStream?: plugins.stream.Readable | plugins.stream.Duplex | plugins.stream.Transform;
// ============================================
// SOURCE STATE (extraction mode)
// ============================================
private sourceUrl?: string;
private sourceFilePath?: string;
private sourceStream?: plugins.stream.Readable | plugins.stream.Duplex | plugins.stream.Transform;
// ============================================
// CREATION STATE
// ============================================
private archiveBuffer?: Buffer;
private creationOptions?: IArchiveCreationOptions;
private pendingEntries?: IArchiveEntry[];
private creationFormat?: TArchiveFormat;
private _compressionLevel: TCompressionLevel = 6;
private pendingEntries: IArchiveEntry[] = [];
private pendingDirectories: IPendingDirectory[] = [];
// ============================================
// FLUENT STATE
// ============================================
private _mode: 'extract' | 'create' | null = null;
private _filters: TEntryFilter[] = [];
private _excludePatterns: RegExp[] = [];
private _includePatterns: RegExp[] = [];
private _stripComponents: number = 0;
private _overwrite: boolean = false;
private _fileName?: string;
constructor() {}
// ============================================
// BUILDER METHODS (for incremental creation)
// SOURCE METHODS (set extraction mode)
// ============================================
/**
* Add a file to the archive (builder pattern)
* Load archive from URL
*/
public addFile(archivePath: string, content: string | Buffer): this {
if (!this.pendingEntries) {
throw new Error('addFile can only be called on archives created with SmartArchive.create()');
}
public url(urlArg: string): this {
this.ensureNotInCreateMode('url');
this._mode = 'extract';
this.sourceUrl = urlArg;
return this;
}
/**
* Load archive from file path
*/
public file(pathArg: string): this {
this.ensureNotInCreateMode('file');
this._mode = 'extract';
this.sourceFilePath = pathArg;
return this;
}
/**
* Load archive from readable stream
*/
public stream(streamArg: plugins.stream.Readable | plugins.stream.Duplex | plugins.stream.Transform): this {
this.ensureNotInCreateMode('stream');
this._mode = 'extract';
this.sourceStream = streamArg;
return this;
}
/**
* Load archive from buffer
*/
public buffer(bufferArg: Buffer): this {
this.ensureNotInCreateMode('buffer');
this._mode = 'extract';
this.sourceStream = plugins.stream.Readable.from(bufferArg);
return this;
}
// ============================================
// FORMAT METHODS (set creation mode)
// ============================================
/**
* Set output format for archive creation
*/
public format(fmt: TArchiveFormat): this {
this.ensureNotInExtractMode('format');
this._mode = 'create';
this.creationFormat = fmt;
return this;
}
/**
* Set compression level (0-9)
*/
public compression(level: TCompressionLevel): this {
this._compressionLevel = level;
return this;
}
// ============================================
// CONTENT METHODS (creation mode)
// ============================================
/**
* Add a single file entry to the archive
*/
public entry(archivePath: string, content: string | Buffer): this {
this.ensureNotInExtractMode('entry');
if (!this._mode) this._mode = 'create';
this.pendingEntries.push({ archivePath, content });
return this;
}
/**
* Add a SmartFile to the archive (builder pattern)
* Add multiple entries to the archive
*/
public addSmartFile(file: plugins.smartfile.SmartFile, archivePath?: string): this {
if (!this.pendingEntries) {
throw new Error('addSmartFile can only be called on archives created with SmartArchive.create()');
public entries(entriesArg: Array<{ archivePath: string; content: string | Buffer }>): this {
this.ensureNotInExtractMode('entries');
if (!this._mode) this._mode = 'create';
for (const e of entriesArg) {
this.pendingEntries.push({ archivePath: e.archivePath, content: e.content });
}
return this;
}
/**
* Add an entire directory to the archive (queued, resolved at build time)
*/
public directory(sourcePath: string, archiveBase?: string): this {
this.ensureNotInExtractMode('directory');
if (!this._mode) this._mode = 'create';
this.pendingDirectories.push({ sourcePath, archiveBase });
return this;
}
/**
* Add a SmartFile to the archive
*/
public addSmartFile(fileArg: plugins.smartfile.SmartFile, archivePath?: string): this {
this.ensureNotInExtractMode('addSmartFile');
if (!this._mode) this._mode = 'create';
this.pendingEntries.push({
archivePath: archivePath || file.relative,
content: file,
archivePath: archivePath || fileArg.relative,
content: fileArg,
});
return this;
}
/**
* Add a StreamFile to the archive (builder pattern)
* Add a StreamFile to the archive
*/
public addStreamFile(file: plugins.smartfile.StreamFile, archivePath?: string): this {
if (!this.pendingEntries) {
throw new Error('addStreamFile can only be called on archives created with SmartArchive.create()');
}
public addStreamFile(fileArg: plugins.smartfile.StreamFile, archivePath?: string): this {
this.ensureNotInExtractMode('addStreamFile');
if (!this._mode) this._mode = 'create';
this.pendingEntries.push({
archivePath: archivePath || file.relativeFilePath,
content: file,
archivePath: archivePath || fileArg.relativeFilePath,
content: fileArg,
});
return this;
}
/**
* Build the archive from pending entries
*/
public async build(): Promise<SmartArchive> {
if (!this.pendingEntries || !this.creationOptions) {
throw new Error('build can only be called on archives created with SmartArchive.create()');
}
// ============================================
// FILTER METHODS (both modes)
// ============================================
const built = await SmartArchive.fromFiles(this.pendingEntries, this.creationOptions);
this.archiveBuffer = built.archiveBuffer;
this.pendingEntries = undefined;
/**
* Filter entries by predicate function
*/
public filter(predicate: TEntryFilter): this {
this._filters.push(predicate);
return this;
}
/**
* Include only entries matching the pattern
*/
public include(pattern: string | RegExp): this {
const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
this._includePatterns.push(regex);
return this;
}
/**
* Exclude entries matching the pattern
*/
public exclude(pattern: string | RegExp): this {
const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
this._excludePatterns.push(regex);
return this;
}
// ============================================
// EXTRACTION METHODS
// EXTRACTION OPTIONS
// ============================================
/**
* Get the original archive stream
* Strip N leading path components from extracted files
*/
public async toStream(): Promise<plugins.stream.Readable> {
if (this.archiveBuffer) {
return plugins.stream.Readable.from(this.archiveBuffer);
}
if (this.sourceStream) {
return this.sourceStream;
}
if (this.sourceUrl) {
const response = await plugins.smartrequest.SmartRequest.create()
.url(this.sourceUrl)
.get();
const webStream = response.stream();
return plugins.stream.Readable.fromWeb(webStream as any);
}
if (this.sourceFilePath) {
return plugins.fs.createReadStream(this.sourceFilePath);
}
throw new Error('No archive source configured');
public stripComponents(n: number): this {
this._stripComponents = n;
return this;
}
/**
* Get archive as a Buffer
* Overwrite existing files during extraction
*/
public async toBuffer(): Promise<Buffer> {
if (this.archiveBuffer) {
return this.archiveBuffer;
}
const stream = await this.toStream();
return SmartArchive.streamToBuffer(stream);
public overwrite(value: boolean = true): this {
this._overwrite = value;
return this;
}
/**
* Write archive to a file
* Set output filename for single-file archives (gz, bz2)
*/
public async toFile(filePath: string): Promise<void> {
const buffer = await this.toBuffer();
await plugins.fsPromises.mkdir(plugins.path.dirname(filePath), { recursive: true });
await plugins.fsPromises.writeFile(filePath, buffer);
public fileName(name: string): this {
this._fileName = name;
return this;
}
// ============================================
// TERMINAL METHODS - EXTRACTION
// ============================================
/**
* Extract archive to filesystem
* Extract archive to filesystem directory
*/
public async extractToDirectory(
targetDir: string,
options?: Partial<IArchiveExtractionOptions>
): Promise<void> {
public async extract(targetDir: string): Promise<void> {
this.ensureExtractionSource();
const done = plugins.smartpromise.defer<void>();
const streamFileStream = await this.extractToStream();
const streamFileStream = await this.toStreamFiles();
streamFileStream.pipe(
new plugins.smartstream.SmartDuplex({
@@ -314,27 +301,28 @@ export class SmartArchive {
writeFunction: async (streamFileArg: plugins.smartfile.StreamFile) => {
const innerDone = plugins.smartpromise.defer<void>();
const streamFile = streamFileArg;
let relativePath = streamFile.relativeFilePath || options?.fileName || 'extracted_file';
let relativePath = streamFile.relativeFilePath || this._fileName || 'extracted_file';
// Apply stripComponents if specified
if (options?.stripComponents && options.stripComponents > 0) {
// Apply stripComponents
if (this._stripComponents > 0) {
const parts = relativePath.split('/');
relativePath = parts.slice(options.stripComponents).join('/');
relativePath = parts.slice(this._stripComponents).join('/');
if (!relativePath) {
innerDone.resolve();
return;
}
}
// Apply filter if specified
if (options?.filter) {
// Apply filter
const filterFn = this.buildFilterFunction();
if (filterFn) {
const entryInfo: IArchiveEntryInfo = {
path: relativePath,
size: 0,
isDirectory: false,
isFile: true,
};
if (!options.filter(entryInfo)) {
if (!filterFn(entryInfo)) {
innerDone.resolve();
return;
}
@@ -363,7 +351,9 @@ export class SmartArchive {
/**
* Extract archive to a stream of StreamFile objects
*/
public async extractToStream(): Promise<plugins.smartstream.StreamIntake<plugins.smartfile.StreamFile>> {
public async toStreamFiles(): Promise<plugins.smartstream.StreamIntake<plugins.smartfile.StreamFile>> {
this.ensureExtractionSource();
const streamFileIntake = new plugins.smartstream.StreamIntake<plugins.smartfile.StreamFile>({
objectMode: true,
});
@@ -377,7 +367,7 @@ export class SmartArchive {
}
};
const archiveStream = await this.toStream();
const archiveStream = await this.getSourceStream();
const createAnalyzedStream = () => this.archiveAnalyzer.getAnalyzedStream();
const createUnpackStream = () =>
@@ -447,20 +437,43 @@ export class SmartArchive {
/**
* Extract archive to an array of SmartFile objects (in-memory)
*/
public async extractToSmartFiles(): Promise<plugins.smartfile.SmartFile[]> {
const streamFiles = await this.extractToStream();
public async toSmartFiles(): Promise<plugins.smartfile.SmartFile[]> {
this.ensureExtractionSource();
const streamFiles = await this.toStreamFiles();
const smartFiles: plugins.smartfile.SmartFile[] = [];
const filterFn = this.buildFilterFunction();
const pendingConversions: Promise<void>[] = [];
return new Promise((resolve, reject) => {
streamFiles.on('data', async (streamFile: plugins.smartfile.StreamFile) => {
try {
const smartFile = await streamFile.toSmartFile();
smartFiles.push(smartFile);
} catch (err) {
reject(err);
}
streamFiles.on('data', (streamFile: plugins.smartfile.StreamFile) => {
// Track all async conversions to ensure they complete before resolving
const conversion = (async () => {
try {
const smartFile = await streamFile.toSmartFile();
// Apply filter if configured
if (filterFn) {
const passes = filterFn({
path: smartFile.relative,
size: smartFile.contents.length,
isDirectory: false,
isFile: true,
});
if (!passes) return;
}
smartFiles.push(smartFile);
} catch (err) {
reject(err);
}
})();
pendingConversions.push(conversion);
});
streamFiles.on('end', async () => {
// Wait for all conversions to complete before resolving
await Promise.all(pendingConversions);
resolve(smartFiles);
});
streamFiles.on('end', () => resolve(smartFiles));
streamFiles.on('error', reject);
});
}
@@ -469,7 +482,8 @@ export class SmartArchive {
* Extract a single file from the archive by path
*/
public async extractFile(filePath: string): Promise<plugins.smartfile.SmartFile | null> {
const streamFiles = await this.extractToStream();
this.ensureExtractionSource();
const streamFiles = await this.toStreamFiles();
return new Promise((resolve, reject) => {
let found = false;
@@ -497,14 +511,114 @@ export class SmartArchive {
}
// ============================================
// ANALYSIS METHODS
// TERMINAL METHODS - OUTPUT
// ============================================
/**
* Build and finalize the archive, returning this instance
*/
public async build(): Promise<SmartArchive> {
await this.doBuild();
return this;
}
/**
* Internal build implementation (avoids thenable recursion)
*/
private async doBuild(): Promise<void> {
if (this._mode === 'extract') {
// For extraction mode, nothing to build
return;
}
if (this.archiveBuffer) {
// Already built
return;
}
// For creation mode, build the archive buffer
this.ensureCreationFormat();
await this.resolveDirectories();
const entries = this.getFilteredEntries();
if (this.creationFormat === 'tar' || this.creationFormat === 'tar.gz' || this.creationFormat === 'tgz') {
if (this.creationFormat === 'tar') {
this.archiveBuffer = await this.tarTools.packFiles(entries);
} else {
this.archiveBuffer = await this.tarTools.packFilesToTarGz(entries, this._compressionLevel);
}
} else if (this.creationFormat === 'zip') {
this.archiveBuffer = await this.zipTools.createZip(entries, this._compressionLevel);
} else if (this.creationFormat === 'gz') {
if (entries.length !== 1) {
throw new Error('GZIP format only supports a single file');
}
let content: Buffer;
if (typeof entries[0].content === 'string') {
content = Buffer.from(entries[0].content);
} else if (Buffer.isBuffer(entries[0].content)) {
content = entries[0].content;
} else {
throw new Error('GZIP format requires string or Buffer content');
}
this.archiveBuffer = await this.gzipTools.compress(content, this._compressionLevel);
} else {
throw new Error(`Unsupported format: ${this.creationFormat}`);
}
}
/**
* Build archive and return as Buffer
*/
public async toBuffer(): Promise<Buffer> {
if (this._mode === 'create' && !this.archiveBuffer) {
await this.doBuild();
}
if (this.archiveBuffer) {
return this.archiveBuffer;
}
// For extraction mode, get the source as buffer
const stream = await this.getSourceStream();
return this.streamToBuffer(stream);
}
/**
* Build archive and write to file
*/
public async toFile(filePath: string): Promise<void> {
const buffer = await this.toBuffer();
await plugins.fsPromises.mkdir(plugins.path.dirname(filePath), { recursive: true });
await plugins.fsPromises.writeFile(filePath, buffer);
}
/**
* Get archive as a readable stream
*/
public async toStream(): Promise<plugins.stream.Readable> {
if (this._mode === 'create' && !this.archiveBuffer) {
await this.doBuild();
}
if (this.archiveBuffer) {
return plugins.stream.Readable.from(this.archiveBuffer);
}
return this.getSourceStream();
}
// ============================================
// TERMINAL METHODS - ANALYSIS
// ============================================
/**
* Analyze the archive and return metadata
*/
public async analyze(): Promise<IArchiveInfo> {
const stream = await this.toStream();
this.ensureExtractionSource();
const stream = await this.getSourceStream();
const firstChunk = await this.readFirstChunk(stream);
const fileType = await plugins.fileType.fileTypeFromBuffer(firstChunk);
@@ -544,11 +658,12 @@ export class SmartArchive {
}
/**
* List all entries in the archive without extracting
* List all entries in the archive
*/
public async listEntries(): Promise<IArchiveEntryInfo[]> {
public async list(): Promise<IArchiveEntryInfo[]> {
this.ensureExtractionSource();
const entries: IArchiveEntryInfo[] = [];
const streamFiles = await this.extractToStream();
const streamFiles = await this.toStreamFiles();
return new Promise((resolve, reject) => {
streamFiles.on('data', (streamFile: plugins.smartfile.StreamFile) => {
@@ -568,12 +683,171 @@ export class SmartArchive {
* Check if a specific file exists in the archive
*/
public async hasFile(filePath: string): Promise<boolean> {
const entries = await this.listEntries();
this.ensureExtractionSource();
const entries = await this.list();
return entries.some((e) => e.path === filePath || e.path.endsWith(filePath));
}
// ============================================
// PRIVATE HELPERS
// ============================================
/**
* Helper to read first chunk from stream
* Ensure we're not in create mode when calling extraction methods
*/
private ensureNotInCreateMode(methodName: string): void {
if (this._mode === 'create') {
throw new Error(
`Cannot call .${methodName}() in creation mode. ` +
`Use extraction methods (.url(), .file(), .stream(), .buffer()) for extraction mode.`
);
}
}
/**
* Ensure we're not in extract mode when calling creation methods
*/
private ensureNotInExtractMode(methodName: string): void {
if (this._mode === 'extract') {
throw new Error(
`Cannot call .${methodName}() in extraction mode. ` +
`Use .format() for creation mode.`
);
}
}
/**
* Ensure an extraction source is configured
*/
private ensureExtractionSource(): void {
if (!this.sourceUrl && !this.sourceFilePath && !this.sourceStream && !this.archiveBuffer) {
throw new Error(
'No source configured. Call .url(), .file(), .stream(), or .buffer() first.'
);
}
}
/**
* Ensure a format is configured for creation
*/
private ensureCreationFormat(): void {
if (!this.creationFormat) {
throw new Error('No format specified. Call .format() before creating archive.');
}
}
/**
* Get the source stream
*/
private async getSourceStream(): Promise<plugins.stream.Readable> {
if (this.archiveBuffer) {
return plugins.stream.Readable.from(this.archiveBuffer);
}
if (this.sourceStream) {
return this.sourceStream;
}
if (this.sourceUrl) {
const response = await plugins.smartrequest.SmartRequest.create()
.url(this.sourceUrl)
.get();
const webStream = response.stream();
return plugins.stream.Readable.fromWeb(webStream as any);
}
if (this.sourceFilePath) {
return plugins.fs.createReadStream(this.sourceFilePath);
}
throw new Error('No archive source configured');
}
/**
* Build a combined filter function from all configured filters
*/
private buildFilterFunction(): TEntryFilter | undefined {
const hasFilters =
this._filters.length > 0 ||
this._includePatterns.length > 0 ||
this._excludePatterns.length > 0;
if (!hasFilters) {
return undefined;
}
return (entry: IArchiveEntryInfo) => {
// Check include patterns (if any specified, at least one must match)
if (this._includePatterns.length > 0) {
const included = this._includePatterns.some((p) => p.test(entry.path));
if (!included) return false;
}
// Check exclude patterns (none must match)
for (const pattern of this._excludePatterns) {
if (pattern.test(entry.path)) return false;
}
// Check custom filters (all must pass)
for (const filter of this._filters) {
if (!filter(entry)) return false;
}
return true;
};
}
/**
* Resolve pending directories to entries
*/
private async resolveDirectories(): Promise<void> {
for (const dir of this.pendingDirectories) {
const files = await plugins.listFileTree(dir.sourcePath, '**/*');
for (const filePath of files) {
const archivePath = dir.archiveBase
? plugins.path.join(dir.archiveBase, filePath)
: filePath;
const absolutePath = plugins.path.join(dir.sourcePath, filePath);
const content = await plugins.fsPromises.readFile(absolutePath);
this.pendingEntries.push({
archivePath,
content,
});
}
}
this.pendingDirectories = [];
}
/**
* Get entries filtered by include/exclude patterns
*/
private getFilteredEntries(): IArchiveEntry[] {
const filterFn = this.buildFilterFunction();
if (!filterFn) {
return this.pendingEntries;
}
return this.pendingEntries.filter((entry) =>
filterFn({
path: entry.archivePath,
size: 0,
isDirectory: false,
isFile: true,
})
);
}
/**
* Convert a stream to buffer
*/
private async streamToBuffer(stream: plugins.stream.Readable): Promise<Buffer> {
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}
/**
* Read first chunk from stream
*/
private async readFirstChunk(stream: plugins.stream.Readable): Promise<Buffer> {
return new Promise((resolve, reject) => {