Initial TrueGrowth source import
This commit is contained in:
26
apps/web/public/sw-debug/shared/backup-core.d.ts
vendored
Normal file
26
apps/web/public/sw-debug/shared/backup-core.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
export const BACKUP_SIGNATURE: string;
|
||||
export const BACKUP_VERSION: number;
|
||||
export const MIN_SUPPORTED_BACKUP_VERSION: number;
|
||||
|
||||
export function getExtensionFromMimeType(mimeType: string): string;
|
||||
export function getCandidateExtensions(mimeType?: string): string[];
|
||||
export function normalizeBackupAssetType(type?: string, mimeType?: string): 'IMAGE' | 'VIDEO' | 'AUDIO';
|
||||
export function normalizeCacheMediaType(type?: string, mimeType?: string): 'image' | 'video' | 'audio';
|
||||
export function sanitizeFileName(name: unknown): string;
|
||||
export function generateIdFromUrl(url: string): string;
|
||||
export function appendUrlHashToBackupName(baseName: string, url?: string): string;
|
||||
export function ensureUniqueBackupName(baseName: string, usedNames: Set<string>): string;
|
||||
export function hasExportableTaskMedia(result: unknown): boolean;
|
||||
export function formatTimestampForFilename(timestamp?: number): string;
|
||||
export function buildAssetExportBaseName(assetId: string, createdAt?: number): string;
|
||||
export function mergePromptData(input: any): any;
|
||||
export function filterCompletedMediaTasks(tasks: any[]): any[];
|
||||
export function buildFolderPathMap(folders: any[]): Map<string, string>;
|
||||
export function collectFolderPathsFromBoardPaths(boardPaths: any[]): string[];
|
||||
export function getFolderDepth(...args: any[]): number;
|
||||
export function sortFoldersByDepth(folders: any[]): any[];
|
||||
export function getFolderKey(...args: any[]): string;
|
||||
export function findBinaryFile(files: any[], candidates: string[]): any;
|
||||
export function validateBackupManifest(manifest: any): any;
|
||||
export function exportKnowledgeBaseData(...args: any[]): any;
|
||||
export function importKnowledgeBaseData(...args: any[]): any;
|
||||
453
apps/web/public/sw-debug/shared/backup-core.js
Normal file
453
apps/web/public/sw-debug/shared/backup-core.js
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Shared backup/restore core for both main app and sw-debug.
|
||||
* This module must stay browser-native and framework-agnostic.
|
||||
*/
|
||||
|
||||
export const BACKUP_SIGNATURE = 'aitu-backup';
|
||||
export const BACKUP_VERSION = 4;
|
||||
export const MIN_SUPPORTED_BACKUP_VERSION = 2;
|
||||
|
||||
export function getExtensionFromMimeType(mimeType) {
|
||||
const mimeToExt = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
'image/svg+xml': '.svg',
|
||||
'video/mp4': '.mp4',
|
||||
'video/webm': '.webm',
|
||||
'video/quicktime': '.mov',
|
||||
'audio/mpeg': '.mp3',
|
||||
'audio/mp3': '.mp3',
|
||||
'audio/wav': '.wav',
|
||||
'audio/x-wav': '.wav',
|
||||
'audio/ogg': '.ogg',
|
||||
'audio/mp4': '.m4a',
|
||||
'audio/x-m4a': '.m4a',
|
||||
'audio/aac': '.aac',
|
||||
'audio/flac': '.flac',
|
||||
'audio/x-flac': '.flac',
|
||||
};
|
||||
return mimeToExt[mimeType] || '';
|
||||
}
|
||||
|
||||
export function getCandidateExtensions(mimeType) {
|
||||
return Array.from(new Set([
|
||||
mimeType ? getExtensionFromMimeType(mimeType) : '',
|
||||
'.jpg',
|
||||
'.png',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.svg',
|
||||
'.mp4',
|
||||
'.webm',
|
||||
'.mov',
|
||||
'.mp3',
|
||||
'.wav',
|
||||
'.ogg',
|
||||
'.m4a',
|
||||
'.aac',
|
||||
'.flac',
|
||||
'',
|
||||
].filter((ext) => typeof ext === 'string')));
|
||||
}
|
||||
|
||||
export function normalizeBackupAssetType(type, mimeType) {
|
||||
if (type === 'AUDIO' || type === 'audio' || mimeType?.startsWith('audio/')) {
|
||||
return 'AUDIO';
|
||||
}
|
||||
if (type === 'VIDEO' || type === 'video' || mimeType?.startsWith('video/')) {
|
||||
return 'VIDEO';
|
||||
}
|
||||
return 'IMAGE';
|
||||
}
|
||||
|
||||
export function normalizeCacheMediaType(type, mimeType) {
|
||||
if (type === 'AUDIO' || type === 'audio' || mimeType?.startsWith('audio/')) {
|
||||
return 'audio';
|
||||
}
|
||||
if (type === 'VIDEO' || type === 'video' || mimeType?.startsWith('video/')) {
|
||||
return 'video';
|
||||
}
|
||||
return 'image';
|
||||
}
|
||||
|
||||
export function sanitizeFileName(name) {
|
||||
return (
|
||||
String(name || '')
|
||||
.replace(/[<>:"/\\|?*]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim() || 'unnamed'
|
||||
);
|
||||
}
|
||||
|
||||
export function generateIdFromUrl(url) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < url.length; i++) {
|
||||
const char = url.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash &= hash;
|
||||
}
|
||||
return `cache-${Math.abs(hash).toString(36)}`;
|
||||
}
|
||||
|
||||
export function appendUrlHashToBackupName(baseName, url) {
|
||||
if (typeof url !== 'string' || url.trim().length === 0) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
const hashSuffix = generateIdFromUrl(url).replace(/^cache-/, '');
|
||||
return hashSuffix ? `${baseName}_${hashSuffix}` : baseName;
|
||||
}
|
||||
|
||||
export function ensureUniqueBackupName(baseName, usedNames) {
|
||||
let candidate = baseName;
|
||||
let suffix = 2;
|
||||
|
||||
while (usedNames.has(candidate)) {
|
||||
candidate = `${baseName}_${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
usedNames.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function hasExportableTaskMedia(result) {
|
||||
if (!result) return false;
|
||||
if (typeof result.url === 'string' && result.url.trim().length > 0) return true;
|
||||
if (Array.isArray(result.urls) && result.urls.some((url) => typeof url === 'string' && url.trim().length > 0)) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(result.clips) && result.clips.some((clip) => typeof clip?.audioUrl === 'string' && clip.audioUrl.trim().length > 0)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function formatTimestampForFilename(timestamp) {
|
||||
const date =
|
||||
typeof timestamp === 'number' && Number.isFinite(timestamp)
|
||||
? new Date(timestamp)
|
||||
: new Date();
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'unknown-time';
|
||||
}
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||
const ss = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${y}${m}${d}_${hh}${mm}${ss}`;
|
||||
}
|
||||
|
||||
export function buildAssetExportBaseName(assetId, createdAt) {
|
||||
return `${formatTimestampForFilename(createdAt)}_${sanitizeFileName(assetId)}`;
|
||||
}
|
||||
|
||||
export function mergePromptData({
|
||||
promptHistory = [],
|
||||
videoPromptHistory = [],
|
||||
imagePromptHistory = [],
|
||||
presetSettings,
|
||||
deletedPromptContents = [],
|
||||
promptHistoryOverrides = [],
|
||||
allTasks = [],
|
||||
}) {
|
||||
const completedTasks = allTasks.filter((task) => task?.status === 'completed');
|
||||
|
||||
const imageTaskPrompts = completedTasks
|
||||
.filter((task) => task?.type === 'image' && task?.params?.prompt)
|
||||
.map((task) => ({
|
||||
id: `task_${task.id}`,
|
||||
content: task.params.prompt.trim(),
|
||||
timestamp: task.completedAt || task.createdAt || Date.now(),
|
||||
}))
|
||||
.filter((item) => item.content && item.content.length > 0);
|
||||
|
||||
const videoTaskPrompts = completedTasks
|
||||
.filter((task) => task?.type === 'video' && task?.params?.prompt)
|
||||
.map((task) => ({
|
||||
id: `task_${task.id}`,
|
||||
content: task.params.prompt.trim(),
|
||||
timestamp: task.completedAt || task.createdAt || Date.now(),
|
||||
}))
|
||||
.filter((item) => item.content && item.content.length > 0);
|
||||
|
||||
const mergedImagePromptHistory = [...imagePromptHistory];
|
||||
const mergedVideoPromptHistory = [...videoPromptHistory];
|
||||
|
||||
const existingImageContents = new Set(mergedImagePromptHistory.map((item) => item.content));
|
||||
for (const item of imageTaskPrompts) {
|
||||
if (!existingImageContents.has(item.content)) {
|
||||
mergedImagePromptHistory.push(item);
|
||||
existingImageContents.add(item.content);
|
||||
}
|
||||
}
|
||||
|
||||
const existingVideoContents = new Set(mergedVideoPromptHistory.map((item) => item.content));
|
||||
for (const item of videoTaskPrompts) {
|
||||
if (!existingVideoContents.has(item.content)) {
|
||||
mergedVideoPromptHistory.push(item);
|
||||
existingVideoContents.add(item.content);
|
||||
}
|
||||
}
|
||||
|
||||
const createPresetSettings = () => ({
|
||||
pinnedPrompts: [],
|
||||
deletedPrompts: [],
|
||||
});
|
||||
const normalizedPresetSettings = {};
|
||||
for (const type of ['image', 'video', 'audio', 'text', 'agent', 'ppt-common', 'ppt-slide']) {
|
||||
const settings = presetSettings?.[type] || {};
|
||||
normalizedPresetSettings[type] = {
|
||||
pinnedPrompts: Array.isArray(settings.pinnedPrompts) ? settings.pinnedPrompts : [],
|
||||
deletedPrompts: Array.isArray(settings.deletedPrompts) ? settings.deletedPrompts : [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
promptHistory,
|
||||
videoPromptHistory: mergedVideoPromptHistory,
|
||||
imagePromptHistory: mergedImagePromptHistory,
|
||||
presetSettings: presetSettings ? normalizedPresetSettings : {
|
||||
image: createPresetSettings(),
|
||||
video: createPresetSettings(),
|
||||
audio: createPresetSettings(),
|
||||
text: createPresetSettings(),
|
||||
agent: createPresetSettings(),
|
||||
'ppt-common': createPresetSettings(),
|
||||
'ppt-slide': createPresetSettings(),
|
||||
},
|
||||
deletedPromptContents: Array.isArray(deletedPromptContents) ? deletedPromptContents : [],
|
||||
promptHistoryOverrides: Array.isArray(promptHistoryOverrides) ? promptHistoryOverrides : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function filterCompletedMediaTasks(allTasks = []) {
|
||||
return allTasks.filter(
|
||||
(task) =>
|
||||
task?.status === 'completed' &&
|
||||
(task?.type === 'image' || task?.type === 'video' || task?.type === 'audio') &&
|
||||
hasExportableTaskMedia(task?.result)
|
||||
);
|
||||
}
|
||||
|
||||
export function validateBackupManifest(manifest, options = {}) {
|
||||
const maxVersion = options.maxVersion || BACKUP_VERSION;
|
||||
const minVersion = options.minVersion || MIN_SUPPORTED_BACKUP_VERSION;
|
||||
|
||||
if (!manifest || typeof manifest !== 'object') {
|
||||
throw new Error('无效的备份文件:manifest 格式错误');
|
||||
}
|
||||
if (manifest.signature !== BACKUP_SIGNATURE) {
|
||||
throw new Error('无效的备份文件:签名不匹配');
|
||||
}
|
||||
if (
|
||||
typeof manifest.version !== 'number' ||
|
||||
manifest.version < minVersion ||
|
||||
manifest.version > maxVersion
|
||||
) {
|
||||
throw new Error(`不支持的备份版本: ${manifest.version}`);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function buildFolderPathMap(folders = []) {
|
||||
const pathMap = new Map();
|
||||
const folderMap = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
|
||||
const getPath = (folderId) => {
|
||||
if (pathMap.has(folderId)) return pathMap.get(folderId);
|
||||
const folder = folderMap.get(folderId);
|
||||
if (!folder) return '';
|
||||
const parentPath = folder.parentId ? getPath(folder.parentId) : '';
|
||||
const safeName = sanitizeFileName(folder.name);
|
||||
const fullPath = parentPath ? `${parentPath}/${safeName}` : safeName;
|
||||
pathMap.set(folderId, fullPath);
|
||||
return fullPath;
|
||||
};
|
||||
|
||||
for (const folder of folders) {
|
||||
getPath(folder.id);
|
||||
}
|
||||
|
||||
return pathMap;
|
||||
}
|
||||
|
||||
export function collectFolderPathsFromBoardPaths(boardPaths = []) {
|
||||
const folderPaths = new Set();
|
||||
|
||||
for (const filePath of boardPaths) {
|
||||
const relativePath = String(filePath || '').replace(/^projects\//, '');
|
||||
const parts = relativePath.split('/');
|
||||
if (parts.length > 1) {
|
||||
let currentPath = '';
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
||||
folderPaths.add(currentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(folderPaths).sort((a, b) => {
|
||||
const depthA = a.split('/').length;
|
||||
const depthB = b.split('/').length;
|
||||
return depthA - depthB || a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
export function getFolderDepth(folder, folderMap) {
|
||||
let depth = 0;
|
||||
let currentParentId = folder?.parentId;
|
||||
while (currentParentId) {
|
||||
depth += 1;
|
||||
currentParentId = folderMap.get(currentParentId)?.parentId || null;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
export function sortFoldersByDepth(folders = []) {
|
||||
const folderMap = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
return [...folders].sort((a, b) => {
|
||||
const depthA = getFolderDepth(a, folderMap);
|
||||
const depthB = getFolderDepth(b, folderMap);
|
||||
return depthA - depthB || (a.order || 0) - (b.order || 0) || a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
export function getFolderKey(name, parentId) {
|
||||
return `${parentId || 'root'}::${name}`;
|
||||
}
|
||||
|
||||
export function findBinaryFile(assetsFolder, metaRelativePath, mimeType) {
|
||||
const basePath = metaRelativePath.replace('.meta.json', '');
|
||||
const extensions = getCandidateExtensions(mimeType);
|
||||
|
||||
for (const ext of extensions) {
|
||||
const file = assetsFolder.file(basePath + ext);
|
||||
if (file && !file.dir) return file;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function exportKnowledgeBaseData(adapter) {
|
||||
const [directories, tags, noteMetas, noteTags, images] = await Promise.all([
|
||||
adapter.getAllDirectories(),
|
||||
adapter.getAllTags(),
|
||||
adapter.getAllNoteMetas(),
|
||||
adapter.getAllNoteTags(),
|
||||
adapter.getAllNoteImages ? adapter.getAllNoteImages() : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const notes = await Promise.all(
|
||||
noteMetas.map(async (meta) => {
|
||||
const content = await adapter.getNoteContentById(meta.id);
|
||||
return {
|
||||
...meta,
|
||||
content: content ?? meta.content ?? '',
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
exportedAt: Date.now(),
|
||||
directories,
|
||||
notes,
|
||||
tags,
|
||||
noteTags,
|
||||
images,
|
||||
};
|
||||
}
|
||||
|
||||
export async function importKnowledgeBaseData(data, adapter) {
|
||||
let dirCount = 0;
|
||||
let noteCount = 0;
|
||||
let tagCount = 0;
|
||||
let imageCount = 0;
|
||||
const directoryIdMap = new Map();
|
||||
|
||||
const existingDirectories = await adapter.getAllDirectories();
|
||||
const existingDirectoriesById = new Map(existingDirectories.map((dir) => [dir.id, dir]));
|
||||
const existingDirectoriesByName = new Map(existingDirectories.map((dir) => [dir.name, dir]));
|
||||
|
||||
for (const dir of data.directories || []) {
|
||||
const existingById = existingDirectoriesById.get(dir.id) ?? await adapter.getDirectoryById(dir.id);
|
||||
if (existingById) {
|
||||
directoryIdMap.set(dir.id, existingById.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingByName = existingDirectoriesByName.get(dir.name);
|
||||
if (existingByName) {
|
||||
directoryIdMap.set(dir.id, existingByName.id);
|
||||
|
||||
if ((dir.isDefault && !existingByName.isDefault) || existingByName.order !== dir.order) {
|
||||
await adapter.putDirectory(existingByName.id, {
|
||||
...existingByName,
|
||||
isDefault: existingByName.isDefault || dir.isDefault,
|
||||
order: existingByName.order ?? dir.order,
|
||||
updatedAt: Math.max(existingByName.updatedAt, dir.updatedAt),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await adapter.putDirectory(dir.id, dir);
|
||||
existingDirectoriesById.set(dir.id, dir);
|
||||
existingDirectoriesByName.set(dir.name, dir);
|
||||
directoryIdMap.set(dir.id, dir.id);
|
||||
dirCount += 1;
|
||||
}
|
||||
|
||||
for (const tag of data.tags || []) {
|
||||
const existing = await adapter.getTagById(tag.id);
|
||||
if (!existing) {
|
||||
await adapter.putTag(tag.id, tag);
|
||||
tagCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const note of data.notes || []) {
|
||||
const existing = await adapter.getNoteById(note.id);
|
||||
if (!existing) {
|
||||
const { content, ...meta } = note;
|
||||
const directoryId = directoryIdMap.get(note.directoryId) || note.directoryId;
|
||||
await adapter.putNoteMeta(note.id, {
|
||||
...meta,
|
||||
directoryId,
|
||||
});
|
||||
if (content) {
|
||||
await adapter.putNoteContent(note.id, {
|
||||
id: note.id,
|
||||
noteId: note.id,
|
||||
content,
|
||||
});
|
||||
}
|
||||
noteCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const noteTag of data.noteTags || []) {
|
||||
const existing = await adapter.getNoteTagById(noteTag.id);
|
||||
if (!existing) {
|
||||
await adapter.putNoteTag(noteTag.id, noteTag);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(data.images) && adapter.getNoteImageById && adapter.putNoteImage) {
|
||||
for (const image of data.images) {
|
||||
const existing = await adapter.getNoteImageById(image.id);
|
||||
if (!existing) {
|
||||
await adapter.putNoteImage(image.id, image);
|
||||
imageCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { dirCount, noteCount, tagCount, imageCount };
|
||||
}
|
||||
26
apps/web/public/sw-debug/shared/backup-part-manager-core.d.ts
vendored
Normal file
26
apps/web/public/sw-debug/shared/backup-part-manager-core.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
export const PART_SIZE_THRESHOLD: number;
|
||||
|
||||
export interface SharedBackupPartManagerOptions {
|
||||
source?: string;
|
||||
revokeDelayMs?: number;
|
||||
interPartPauseMs?: number;
|
||||
finalPartPauseMs?: number;
|
||||
preserveAssetEntryDate?: boolean;
|
||||
downloadBlob?: (blob: Blob, filename: string, revokeDelayMs?: number) => Promise<void>;
|
||||
ZipCtor?: new () => unknown;
|
||||
}
|
||||
|
||||
export class SharedBackupPartManager {
|
||||
constructor(baseFilename: string, backupId: string, options?: SharedBackupPartManagerOptions);
|
||||
addFile(path: string, content: unknown): void;
|
||||
addAssetBlob(
|
||||
path: string,
|
||||
blob: Blob,
|
||||
metaPath: string,
|
||||
metaContent: unknown,
|
||||
createdAt?: number
|
||||
): Promise<void>;
|
||||
finalizePart(): Promise<void>;
|
||||
startNewPart(): void;
|
||||
finalizeAll(manifest: unknown): Promise<unknown>;
|
||||
}
|
||||
187
apps/web/public/sw-debug/shared/backup-part-manager-core.js
Normal file
187
apps/web/public/sw-debug/shared/backup-part-manager-core.js
Normal file
@@ -0,0 +1,187 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user