Initial TrueGrowth source import

This commit is contained in:
2026-07-07 09:36:36 +08:00
commit 3b6781d695
2283 changed files with 691996 additions and 0 deletions

View 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;

View 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 };
}

View 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>;
}

View 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 };
}
}