188 lines
6.0 KiB
JavaScript
188 lines
6.0 KiB
JavaScript
import { BACKUP_SIGNATURE, BACKUP_VERSION } from './backup-core.js';
|
|
|
|
export const PART_SIZE_THRESHOLD = 500 * 1024 * 1024;
|
|
|
|
async function defaultDownloadBlob(blob, filename, revokeDelayMs = 0) {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
if (revokeDelayMs > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, revokeDelayMs));
|
|
}
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function normalizeEntryDate(timestamp) {
|
|
if (!timestamp || Number.isNaN(timestamp) || timestamp <= 0) {
|
|
return new Date();
|
|
}
|
|
const ms = timestamp < 1e12 ? timestamp * 1000 : timestamp;
|
|
const date = new Date(ms);
|
|
return Number.isNaN(date.getTime()) ? new Date() : date;
|
|
}
|
|
|
|
export class SharedBackupPartManager {
|
|
constructor(baseFilename, backupId, options = {}) {
|
|
this.baseFilename = baseFilename;
|
|
this.backupId = backupId;
|
|
this.options = {
|
|
source: 'app',
|
|
revokeDelayMs: 0,
|
|
interPartPauseMs: 500,
|
|
finalPartPauseMs: 500,
|
|
preserveAssetEntryDate: false,
|
|
downloadBlob: defaultDownloadBlob,
|
|
ZipCtor: null,
|
|
...options,
|
|
};
|
|
if (!this.options.ZipCtor) {
|
|
throw new Error('SharedBackupPartManager requires ZipCtor');
|
|
}
|
|
this.partIndex = 1;
|
|
this.currentZip = new this.options.ZipCtor();
|
|
this.currentSize = 0;
|
|
this.downloadedParts = [];
|
|
this.part1Zip = this.currentZip;
|
|
}
|
|
|
|
addFile(path, content) {
|
|
const data = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
|
this.currentZip.file(path, data);
|
|
this.currentSize += new Blob([data]).size;
|
|
}
|
|
|
|
async addAssetBlob(path, blob, metaPath, metaContent, createdAt) {
|
|
const metaStr = typeof metaContent === 'string' ? metaContent : JSON.stringify(metaContent, null, 2);
|
|
const newSize = blob.size + new Blob([metaStr]).size;
|
|
const fileOptions =
|
|
this.options.preserveAssetEntryDate && createdAt
|
|
? { date: normalizeEntryDate(createdAt) }
|
|
: undefined;
|
|
|
|
if (this.currentSize + newSize > PART_SIZE_THRESHOLD && this.currentSize > 0) {
|
|
await this.finalizePart();
|
|
this.startNewPart();
|
|
}
|
|
|
|
const assetsFolder = this.currentZip.folder('assets');
|
|
assetsFolder.file(metaPath, metaStr, fileOptions);
|
|
assetsFolder.file(path, blob, fileOptions);
|
|
this.currentSize += newSize;
|
|
}
|
|
|
|
async finalizePart() {
|
|
const partManifest = {
|
|
signature: BACKUP_SIGNATURE,
|
|
version: BACKUP_VERSION,
|
|
createdAt: Date.now(),
|
|
source: this.options.source,
|
|
backupId: this.backupId,
|
|
partIndex: this.partIndex,
|
|
totalParts: null,
|
|
isFinalPart: false,
|
|
schemaVersion: BACKUP_VERSION,
|
|
backupMode: 'incremental',
|
|
includes: {
|
|
prompts: false,
|
|
projects: false,
|
|
assets: true,
|
|
tasks: false,
|
|
knowledgeBase: false,
|
|
environment: false,
|
|
},
|
|
};
|
|
|
|
const zipToUse = this.partIndex === 1 ? this.part1Zip : this.currentZip;
|
|
zipToUse.file('manifest.json', JSON.stringify(partManifest, null, 2));
|
|
|
|
if (this.partIndex === 1) return;
|
|
|
|
const blob = await this.currentZip.generateAsync({
|
|
type: 'blob',
|
|
compression: 'DEFLATE',
|
|
compressionOptions: { level: 6 },
|
|
});
|
|
const filename = `${this.baseFilename}_part${this.partIndex}.zip`;
|
|
if (this.downloadedParts.length > 0 && this.options.interPartPauseMs > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, this.options.interPartPauseMs));
|
|
}
|
|
await this.options.downloadBlob(blob, filename, this.options.revokeDelayMs);
|
|
this.downloadedParts.push({ filename, size: blob.size });
|
|
}
|
|
|
|
startNewPart() {
|
|
this.partIndex += 1;
|
|
this.currentZip = new this.options.ZipCtor();
|
|
this.currentSize = 0;
|
|
}
|
|
|
|
async finalizeAll(manifest) {
|
|
const isMultiPart = this.partIndex > 1;
|
|
|
|
if (!isMultiPart) {
|
|
const finalManifest = {
|
|
...manifest,
|
|
backupId: this.backupId,
|
|
partIndex: 1,
|
|
totalParts: 1,
|
|
isFinalPart: true,
|
|
};
|
|
this.part1Zip.file('manifest.json', JSON.stringify(finalManifest, null, 2));
|
|
|
|
const blob = await this.part1Zip.generateAsync({
|
|
type: 'blob',
|
|
compression: 'DEFLATE',
|
|
compressionOptions: { level: 6 },
|
|
});
|
|
const filename = `${this.baseFilename}.zip`;
|
|
await this.options.downloadBlob(blob, filename, this.options.revokeDelayMs);
|
|
return { files: [{ filename, size: blob.size }], totalParts: 1, stats: manifest.stats };
|
|
}
|
|
|
|
const part1Manifest = {
|
|
...manifest,
|
|
backupId: this.backupId,
|
|
partIndex: 1,
|
|
totalParts: null,
|
|
isFinalPart: false,
|
|
};
|
|
this.part1Zip.file('manifest.json', JSON.stringify(part1Manifest, null, 2));
|
|
const part1Blob = await this.part1Zip.generateAsync({
|
|
type: 'blob',
|
|
compression: 'DEFLATE',
|
|
compressionOptions: { level: 6 },
|
|
});
|
|
const part1Filename = `${this.baseFilename}_part1.zip`;
|
|
await this.options.downloadBlob(part1Blob, part1Filename, this.options.revokeDelayMs);
|
|
this.downloadedParts.unshift({ filename: part1Filename, size: part1Blob.size });
|
|
|
|
if (this.currentSize > 0) {
|
|
const finalManifest = {
|
|
...manifest,
|
|
backupId: this.backupId,
|
|
partIndex: this.partIndex,
|
|
totalParts: this.partIndex,
|
|
isFinalPart: true,
|
|
};
|
|
this.currentZip.file('manifest.json', JSON.stringify(finalManifest, null, 2));
|
|
const blob = await this.currentZip.generateAsync({
|
|
type: 'blob',
|
|
compression: 'DEFLATE',
|
|
compressionOptions: { level: 6 },
|
|
});
|
|
const filename = `${this.baseFilename}_part${this.partIndex}.zip`;
|
|
if (this.options.finalPartPauseMs > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, this.options.finalPartPauseMs));
|
|
}
|
|
await this.options.downloadBlob(blob, filename, this.options.revokeDelayMs);
|
|
this.downloadedParts.push({ filename, size: blob.size });
|
|
}
|
|
|
|
return { files: this.downloadedParts, totalParts: this.partIndex, stats: manifest.stats };
|
|
}
|
|
}
|