feat: add deployment-aware remote backups
This commit is contained in:
1 parent
b8de75b46c
commit
b0680cda7e
24 files changed
+8561
-336
No files matched your search
@@ -30,6 +30,15 @@
|
||||
# VITE_ENABLE_PROMPT_GARDEN_IMPORT=1
|
||||
# VITE_PROMPT_GARDEN_BASE_URL=https://garden.always200.com
|
||||
|
||||
# Google Drive 远程 ZIP 备份配置
|
||||
# 用于数据管理中的“授权 Google Drive”按钮。Client ID 是应用的 OAuth
|
||||
# 公开标识,由部署者在 Google Cloud Console 创建“Web 应用”OAuth 客户端后获得。
|
||||
# 普通用户不需要理解或填写它;自部署时需要把当前访问域名加入该 OAuth
|
||||
# 客户端的 Authorized JavaScript origins,例如 http://localhost:18181
|
||||
# 或 https://your-domain.example。
|
||||
# VITE_GOOGLE_DRIVE_CLIENT_ID=your-google-oauth-web-client-id.apps.googleusercontent.com
|
||||
# Desktop 版暂不支持 Google Drive,推荐使用 Cloudflare R2 / S3 / WebDAV。
|
||||
|
||||
# DeepSeek API 配置
|
||||
# VITE_DEEPSEEK_API_KEY=sk-your-deepseek-api-key-here
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ const {
|
||||
DEFAULT_CONFIG
|
||||
} = require('./config/update-config');
|
||||
const { createGlobalDispatcherFromProxyDecision } = require('./config/proxy-dispatcher');
|
||||
const { setupRemoteStorageHandlers } = require('./remote-storage');
|
||||
const path = require('path');
|
||||
|
||||
// 确定正确的配置文件路径
|
||||
@@ -854,6 +855,10 @@ function createFavoriteErrorResponse(error) {
|
||||
function setupIPC() {
|
||||
console.log('[Main Process] Setting up high-level service IPC handlers...');
|
||||
setupPreferenceHandlers();
|
||||
setupRemoteStorageHandlers(ipcMain, {
|
||||
createSuccessResponse,
|
||||
createErrorResponse,
|
||||
});
|
||||
|
||||
// LLM Service handlers
|
||||
ipcMain.handle('llm-testConnection', async (event, provider) => {
|
||||
|
||||
@@ -23,11 +23,13 @@
|
||||
"electron-builder": "^26.8.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1044.0",
|
||||
"@prompt-optimizer/core": "workspace:*",
|
||||
"dotenv": "^17.3.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "6.8.3",
|
||||
"undici": "^7.24.6"
|
||||
"undici": "^7.24.6",
|
||||
"webdav": "^5.10.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.promptoptimizer.desktop",
|
||||
@@ -38,6 +40,7 @@
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"remote-storage.js",
|
||||
"config/**/*",
|
||||
"web-dist/**/*",
|
||||
"node_modules/**/*"
|
||||
|
||||
@@ -20,6 +20,8 @@ const IPC_EVENTS = {
|
||||
UPDATE_DOWNLOAD_STARTED: 'updater-download-started'
|
||||
};
|
||||
|
||||
const REMOTE_STORAGE_CHANNEL = 'remote-storage:invoke';
|
||||
|
||||
// 简单的超时包装器,避免过度设计
|
||||
const withTimeout = (promise, timeoutMs = 30000) => {
|
||||
return Promise.race([
|
||||
@@ -1085,6 +1087,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
}
|
||||
},
|
||||
|
||||
remoteStorage: {
|
||||
invoke: async (request) => {
|
||||
const result = await ipcRenderer.invoke(REMOTE_STORAGE_CHANNEL, request);
|
||||
if (!result.success) {
|
||||
throw createIpcError(result.error);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
},
|
||||
|
||||
// Context Repository interface
|
||||
context: {
|
||||
list: async () => {
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
const {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} = require('@aws-sdk/client-s3');
|
||||
|
||||
const REMOTE_STORAGE_CHANNEL = 'remote-storage:invoke';
|
||||
const JSON_MIME_TYPE = 'application/json';
|
||||
const CLOUDFLARE_R2_DEFAULT_BACKUP_PREFIX = 'prompt-optimizer-backups/';
|
||||
let webDavModulePromise = null;
|
||||
|
||||
const joinRemotePath = (...parts) =>
|
||||
parts
|
||||
.map((part) => String(part || '').replace(/^\/+|\/+$/g, ''))
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
|
||||
const normalizeObjectPath = (path) => joinRemotePath(path);
|
||||
|
||||
const parentPathOf = (path) => {
|
||||
const normalized = normalizeObjectPath(path);
|
||||
const index = normalized.lastIndexOf('/');
|
||||
return index === -1 ? '' : normalized.slice(0, index);
|
||||
};
|
||||
|
||||
const normalizeDirectoryPrefix = (path) => {
|
||||
const normalized = normalizeObjectPath(path);
|
||||
return normalized ? `${normalized}/` : '';
|
||||
};
|
||||
|
||||
const copyUint8Array = (bytes) => {
|
||||
const view = new Uint8Array(bytes.byteLength);
|
||||
view.set(bytes);
|
||||
return view;
|
||||
};
|
||||
|
||||
const toUint8Array = (body) => {
|
||||
if (body === undefined || body === null) return new Uint8Array(0);
|
||||
if (body instanceof Uint8Array) return copyUint8Array(body);
|
||||
if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0));
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
const view = new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
|
||||
return copyUint8Array(view);
|
||||
}
|
||||
if (typeof body === 'string') return new TextEncoder().encode(body);
|
||||
if (Array.isArray(body)) return new Uint8Array(body);
|
||||
throw new Error('Remote storage body must be a string, ArrayBuffer, or Uint8Array');
|
||||
};
|
||||
|
||||
const toArrayBuffer = (bytes) => {
|
||||
const view = toUint8Array(bytes);
|
||||
return view.buffer;
|
||||
};
|
||||
|
||||
const streamToUint8Array = async (stream) => {
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for await (const chunk of stream) {
|
||||
const bytes = toUint8Array(chunk);
|
||||
chunks.push(bytes);
|
||||
total += bytes.byteLength;
|
||||
}
|
||||
const result = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const responseBodyToArrayBuffer = async (body) => {
|
||||
if (!body) return new ArrayBuffer(0);
|
||||
if (body instanceof ArrayBuffer) return body.slice(0);
|
||||
if (body instanceof Uint8Array || ArrayBuffer.isView(body)) return toArrayBuffer(body);
|
||||
if (typeof body === 'string') return toArrayBuffer(body);
|
||||
|
||||
if (typeof body.transformToByteArray === 'function') {
|
||||
return toArrayBuffer(await body.transformToByteArray());
|
||||
}
|
||||
|
||||
if (typeof body.arrayBuffer === 'function') {
|
||||
return await body.arrayBuffer();
|
||||
}
|
||||
|
||||
if (typeof body.transformToString === 'function') {
|
||||
return toArrayBuffer(await body.transformToString());
|
||||
}
|
||||
|
||||
if (typeof body[Symbol.asyncIterator] === 'function') {
|
||||
return toArrayBuffer(await streamToUint8Array(body));
|
||||
}
|
||||
|
||||
throw new Error('Remote storage download returned an unsupported response body');
|
||||
};
|
||||
|
||||
const isNotFoundError = (error) => {
|
||||
const value = error || {};
|
||||
return value?.$metadata?.httpStatusCode === 404 ||
|
||||
value?.status === 404 ||
|
||||
value?.statusCode === 404 ||
|
||||
value?.name === 'NotFound' ||
|
||||
value?.name === 'NoSuchKey' ||
|
||||
value?.Code === 'NoSuchKey' ||
|
||||
value?.code === 'NoSuchKey' ||
|
||||
value?.code === 'ERR_OBJECT_NOT_FOUND';
|
||||
};
|
||||
|
||||
const errorMessage = (error) => error?.message || String(error);
|
||||
|
||||
const toCloudflareR2S3Config = (config) => ({
|
||||
kind: 's3-compatible',
|
||||
endpoint: String(config.accountId || '').trim()
|
||||
? `https://${String(config.accountId || '').trim()}.r2.cloudflarestorage.com`
|
||||
: '',
|
||||
region: 'auto',
|
||||
bucket: config.bucket,
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
prefix: CLOUDFLARE_R2_DEFAULT_BACKUP_PREFIX,
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
const createDefaultDependencies = () => ({
|
||||
S3Client,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
openExternal: async (url) => {
|
||||
const { shell } = require('electron');
|
||||
await shell.openExternal(url);
|
||||
},
|
||||
createWebDavClient: async (endpoint, options) => {
|
||||
if (!webDavModulePromise) {
|
||||
webDavModulePromise = Promise.resolve().then(() => require('webdav'));
|
||||
}
|
||||
const webdav = await webDavModulePromise;
|
||||
return webdav.createClient(endpoint, options);
|
||||
},
|
||||
});
|
||||
|
||||
class S3RemoteObjectStore {
|
||||
constructor(config, dependencies) {
|
||||
this.config = config;
|
||||
this.dependencies = dependencies;
|
||||
this.assertConfigured();
|
||||
this.client = new dependencies.S3Client({
|
||||
endpoint: config.endpoint,
|
||||
region: config.region || 'auto',
|
||||
forcePathStyle: config.forcePathStyle !== false,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async exists(path) {
|
||||
return Boolean(await this.head(path));
|
||||
}
|
||||
|
||||
async head(path) {
|
||||
const normalized = normalizeObjectPath(path);
|
||||
try {
|
||||
const response = await this.client.send(new this.dependencies.HeadObjectCommand({
|
||||
Bucket: this.config.bucket,
|
||||
Key: this.keyForPath(normalized),
|
||||
}));
|
||||
return {
|
||||
path: normalized,
|
||||
sizeBytes: typeof response.ContentLength === 'number' ? response.ContentLength : undefined,
|
||||
updatedAt: response.LastModified instanceof Date ? response.LastModified.toISOString() : undefined,
|
||||
contentType: typeof response.ContentType === 'string' ? response.ContentType : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) return null;
|
||||
throw new Error(`S3 metadata lookup failed: ${errorMessage(error)}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async put(path, body, options = {}) {
|
||||
const normalized = normalizeObjectPath(path);
|
||||
const bytes = toUint8Array(body);
|
||||
const contentType = options.contentType || JSON_MIME_TYPE;
|
||||
try {
|
||||
await this.client.send(new this.dependencies.PutObjectCommand({
|
||||
Bucket: this.config.bucket,
|
||||
Key: this.keyForPath(normalized),
|
||||
Body: bytes,
|
||||
ContentType: contentType,
|
||||
}));
|
||||
} catch (error) {
|
||||
throw new Error(`S3 upload failed: ${errorMessage(error)}`, { cause: error });
|
||||
}
|
||||
return {
|
||||
path: normalized,
|
||||
sizeBytes: bytes.byteLength,
|
||||
updatedAt: new Date().toISOString(),
|
||||
contentType,
|
||||
};
|
||||
}
|
||||
|
||||
async get(path) {
|
||||
const normalized = normalizeObjectPath(path);
|
||||
try {
|
||||
const response = await this.client.send(new this.dependencies.GetObjectCommand({
|
||||
Bucket: this.config.bucket,
|
||||
Key: this.keyForPath(normalized),
|
||||
}));
|
||||
return responseBodyToArrayBuffer(response.Body);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
throw new Error(`S3 object not found: ${normalized}`, { cause: error });
|
||||
}
|
||||
throw new Error(`S3 download failed: ${errorMessage(error)}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async list(prefix) {
|
||||
const entries = [];
|
||||
let continuationToken;
|
||||
try {
|
||||
do {
|
||||
const response = await this.client.send(new this.dependencies.ListObjectsV2Command({
|
||||
Bucket: this.config.bucket,
|
||||
Prefix: this.listPrefixForPath(prefix),
|
||||
ContinuationToken: continuationToken,
|
||||
}));
|
||||
for (const object of response.Contents || []) {
|
||||
const key = object.Key || '';
|
||||
const path = this.pathFromKey(key);
|
||||
if (!path) continue;
|
||||
entries.push({
|
||||
path,
|
||||
sizeBytes: typeof object.Size === 'number' ? object.Size : undefined,
|
||||
updatedAt: object.LastModified instanceof Date ? object.LastModified.toISOString() : undefined,
|
||||
});
|
||||
}
|
||||
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
|
||||
} while (continuationToken);
|
||||
} catch (error) {
|
||||
throw new Error(`S3 list failed: ${errorMessage(error)}`, { cause: error });
|
||||
}
|
||||
return entries.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
async delete(path) {
|
||||
try {
|
||||
await this.client.send(new this.dependencies.DeleteObjectCommand({
|
||||
Bucket: this.config.bucket,
|
||||
Key: this.keyForPath(path),
|
||||
}));
|
||||
} catch (error) {
|
||||
throw new Error(`S3 delete failed: ${errorMessage(error)}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
prefix() {
|
||||
return joinRemotePath(this.config.prefix || 'prompt-optimizer-backups');
|
||||
}
|
||||
|
||||
keyForPath(path) {
|
||||
return joinRemotePath(this.prefix(), path);
|
||||
}
|
||||
|
||||
listPrefixForPath(path) {
|
||||
return normalizeDirectoryPrefix(this.keyForPath(path));
|
||||
}
|
||||
|
||||
pathFromKey(key) {
|
||||
const prefix = this.prefix();
|
||||
return key === prefix
|
||||
? ''
|
||||
: key.startsWith(`${prefix}/`)
|
||||
? key.slice(prefix.length + 1)
|
||||
: key;
|
||||
}
|
||||
|
||||
assertConfigured() {
|
||||
if (!this.config.endpoint || !this.config.bucket || !this.config.accessKeyId || !this.config.secretAccessKey) {
|
||||
throw new Error('S3 endpoint, bucket, access key, and secret key are required');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WebDavRemoteObjectStore {
|
||||
constructor(config, dependencies) {
|
||||
this.config = config;
|
||||
this.dependencies = dependencies;
|
||||
this.clientPromise = null;
|
||||
}
|
||||
|
||||
async exists(path) {
|
||||
return Boolean(await this.head(path));
|
||||
}
|
||||
|
||||
async head(path) {
|
||||
const normalized = normalizeObjectPath(path);
|
||||
const client = await this.getClient();
|
||||
try {
|
||||
const stat = await client.stat(this.filePath(normalized));
|
||||
return this.toEntry(normalized, stat);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) return null;
|
||||
throw new Error(`WebDAV metadata lookup failed: ${errorMessage(error)}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async put(path, body, options = {}) {
|
||||
const normalized = normalizeObjectPath(path);
|
||||
const bytes = toUint8Array(body);
|
||||
const client = await this.getClient();
|
||||
await this.ensureDirectoryPath(parentPathOf(normalized));
|
||||
try {
|
||||
await client.putFileContents(this.filePath(normalized), Buffer.from(bytes), {
|
||||
contentLength: bytes.byteLength,
|
||||
overwrite: true,
|
||||
headers: options.contentType ? { 'Content-Type': options.contentType } : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`WebDAV upload failed: ${errorMessage(error)}`, { cause: error });
|
||||
}
|
||||
return {
|
||||
path: normalized,
|
||||
sizeBytes: bytes.byteLength,
|
||||
updatedAt: new Date().toISOString(),
|
||||
contentType: options.contentType,
|
||||
};
|
||||
}
|
||||
|
||||
async get(path) {
|
||||
const normalized = normalizeObjectPath(path);
|
||||
const client = await this.getClient();
|
||||
try {
|
||||
const contents = await client.getFileContents(this.filePath(normalized), { format: 'binary' });
|
||||
return responseBodyToArrayBuffer(contents);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
throw new Error(`WebDAV object not found: ${normalized}`, { cause: error });
|
||||
}
|
||||
throw new Error(`WebDAV download failed: ${errorMessage(error)}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async list(prefix) {
|
||||
const normalizedPrefix = normalizeObjectPath(prefix);
|
||||
const client = await this.getClient();
|
||||
await this.ensureDirectoryPath(normalizedPrefix);
|
||||
try {
|
||||
const contents = await client.getDirectoryContents(this.directoryPath(normalizedPrefix), {
|
||||
deep: true,
|
||||
details: true,
|
||||
});
|
||||
const items = Array.isArray(contents) ? contents : contents?.data || [];
|
||||
return items
|
||||
.filter((item) => item && item.type !== 'directory')
|
||||
.map((item) => this.toEntryFromRemotePath(item.filename || item.basename || '', item))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
} catch (error) {
|
||||
throw new Error(`WebDAV list failed: ${errorMessage(error)}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async delete(path) {
|
||||
const normalized = normalizeObjectPath(path);
|
||||
const client = await this.getClient();
|
||||
try {
|
||||
await client.deleteFile(this.filePath(normalized));
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) return;
|
||||
throw new Error(`WebDAV delete failed: ${errorMessage(error)}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async ensureDirectoryPath(path) {
|
||||
const client = await this.getClient();
|
||||
const normalized = normalizeObjectPath(path);
|
||||
try {
|
||||
await client.createDirectory(this.directoryPath(''), { recursive: true });
|
||||
if (normalized) {
|
||||
await client.createDirectory(this.directoryPath(normalized), { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
throw new Error(`WebDAV directory creation failed: ${errorMessage(error)}`, { cause: error });
|
||||
}
|
||||
if (!/exists|already/i.test(errorMessage(error))) {
|
||||
throw new Error(`WebDAV directory creation failed: ${errorMessage(error)}`, { cause: error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getClient() {
|
||||
if (!this.clientPromise) {
|
||||
if (!this.config.endpoint) throw new Error('WebDAV endpoint is required');
|
||||
this.clientPromise = Promise.resolve(this.dependencies.createWebDavClient(
|
||||
this.config.endpoint,
|
||||
{
|
||||
username: this.config.username || undefined,
|
||||
password: this.config.password || undefined,
|
||||
},
|
||||
));
|
||||
}
|
||||
return this.clientPromise;
|
||||
}
|
||||
|
||||
directoryPath(path) {
|
||||
return `/${joinRemotePath(this.config.directory || 'prompt-optimizer-backups', path)}`;
|
||||
}
|
||||
|
||||
filePath(path) {
|
||||
return this.directoryPath(path);
|
||||
}
|
||||
|
||||
toEntry(path, item = {}) {
|
||||
const sizeValue = item.size ?? item.props?.getcontentlength;
|
||||
const updatedValue = item.lastmod ?? item.lastModified ?? item.props?.getlastmodified;
|
||||
return {
|
||||
path: normalizeObjectPath(path),
|
||||
sizeBytes: typeof sizeValue === 'number' ? sizeValue : Number.isFinite(Number(sizeValue)) ? Number(sizeValue) : undefined,
|
||||
updatedAt: updatedValue ? new Date(updatedValue).toISOString() : undefined,
|
||||
contentType: item.mime || item.props?.getcontenttype || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
toEntryFromRemotePath(remotePath, item) {
|
||||
const root = this.directoryPath('').replace(/\/+$/g, '');
|
||||
const decodedRemotePath = decodeURIComponent(String(remotePath || ''));
|
||||
const withoutRoot = decodedRemotePath.startsWith(`${root}/`)
|
||||
? decodedRemotePath.slice(root.length + 1)
|
||||
: decodedRemotePath.replace(/^\/+/, '');
|
||||
if (!withoutRoot) return null;
|
||||
return this.toEntry(withoutRoot, item);
|
||||
}
|
||||
}
|
||||
|
||||
const createRemoteObjectStore = (provider, dependencies = createDefaultDependencies()) => {
|
||||
if (!provider || typeof provider !== 'object') {
|
||||
throw new Error('Remote storage provider config is required');
|
||||
}
|
||||
if (provider.kind === 'google-drive') {
|
||||
throw new Error('Google Drive remote backup is only supported in the Web version');
|
||||
}
|
||||
if (provider.kind === 'cloudflare-r2') {
|
||||
return new S3RemoteObjectStore(toCloudflareR2S3Config(provider), dependencies);
|
||||
}
|
||||
if (provider.kind === 's3-compatible') {
|
||||
return new S3RemoteObjectStore(provider, dependencies);
|
||||
}
|
||||
if (provider.kind === 'webdav') {
|
||||
return new WebDavRemoteObjectStore(provider, dependencies);
|
||||
}
|
||||
throw new Error(`Remote storage provider is not supported by Desktop IPC: ${provider.kind}`);
|
||||
};
|
||||
|
||||
const handleRemoteStorageOperation = async (request, dependencies = createDefaultDependencies()) => {
|
||||
if (!request || typeof request !== 'object') {
|
||||
throw new Error('Remote storage request is required');
|
||||
}
|
||||
const store = createRemoteObjectStore(request.provider, dependencies);
|
||||
const operation = request.operation;
|
||||
const path = normalizeObjectPath(request.path || '');
|
||||
|
||||
if (operation === 'authorize') {
|
||||
if (typeof store.authorize === 'function') await store.authorize();
|
||||
return null;
|
||||
}
|
||||
if (operation === 'head') return store.head(path);
|
||||
if (operation === 'exists') return store.exists(path);
|
||||
if (operation === 'put') {
|
||||
return store.put(path, request.body, { contentType: request.contentType });
|
||||
}
|
||||
if (operation === 'get') return store.get(path);
|
||||
if (operation === 'getText') {
|
||||
return new TextDecoder().decode(await store.get(path));
|
||||
}
|
||||
if (operation === 'list') return store.list(path);
|
||||
if (operation === 'delete') {
|
||||
await store.delete(path);
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported remote storage operation: ${operation}`);
|
||||
};
|
||||
|
||||
function setupRemoteStorageHandlers(ipcMain, helpers = {}, dependencies) {
|
||||
const createSuccessResponse = helpers.createSuccessResponse || ((data) => ({ success: true, data }));
|
||||
const createErrorResponse = helpers.createErrorResponse || ((error) => ({
|
||||
success: false,
|
||||
error: { message: error?.message || String(error) },
|
||||
}));
|
||||
|
||||
ipcMain.handle(REMOTE_STORAGE_CHANNEL, async (_event, request) => {
|
||||
try {
|
||||
const result = await handleRemoteStorageOperation(request, dependencies);
|
||||
return createSuccessResponse(result);
|
||||
} catch (error) {
|
||||
return createErrorResponse(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REMOTE_STORAGE_CHANNEL,
|
||||
createDefaultDependencies,
|
||||
createRemoteObjectStore,
|
||||
handleRemoteStorageOperation,
|
||||
setupRemoteStorageHandlers,
|
||||
};
|
||||
@@ -33,6 +33,7 @@
|
||||
"lint:fix": "eslint src --ext .ts,.vue --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1044.0",
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -57,15 +57,18 @@
|
||||
:ghost="false"
|
||||
:round="true"
|
||||
/>
|
||||
<ActionButtonUI
|
||||
icon="💾"
|
||||
:text="$t('nav.dataManager')"
|
||||
@click="emit('open-data-manager')"
|
||||
type="default"
|
||||
size="medium"
|
||||
:ghost="false"
|
||||
:round="true"
|
||||
/>
|
||||
<NBadge :show="backupReminderDue" dot processing>
|
||||
<ActionButtonUI
|
||||
icon="💾"
|
||||
:text="$t('nav.dataManager')"
|
||||
@click="emit('open-data-manager')"
|
||||
:type="backupReminderDue ? 'warning' : 'default'"
|
||||
size="medium"
|
||||
:ghost="false"
|
||||
:round="true"
|
||||
:title="backupReminderDue ? $t('dataManager.backupReminder.tooltip') : $t('nav.dataManager')"
|
||||
/>
|
||||
</NBadge>
|
||||
<ActionButtonUI
|
||||
icon="🔣"
|
||||
:text="$t('nav.variableManager')"
|
||||
@@ -219,15 +222,17 @@ import ActionButtonUI from '../ActionButton.vue'
|
||||
import ThemeToggleUI from '../ThemeToggleUI.vue'
|
||||
import LanguageSwitchDropdown from '../LanguageSwitchDropdown.vue'
|
||||
import UpdaterIcon from '../UpdaterIcon.vue'
|
||||
import { NButton, NPopover, NTag } from 'naive-ui'
|
||||
import { NBadge, NButton, NPopover, NTag } from 'naive-ui'
|
||||
|
||||
interface Props {
|
||||
appVersion: string
|
||||
favoritesActive?: boolean
|
||||
backupReminderDue?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
favoritesActive: false,
|
||||
backupReminderDue: false,
|
||||
})
|
||||
|
||||
// ========================
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
@open-data-manager="showDataManager = true"
|
||||
@open-variables="handleOpenVariableManager()"
|
||||
:favorites-active="isFavoritesRoute"
|
||||
:backup-reminder-due="dataBackupReminderDue"
|
||||
:app-version="appVersion"
|
||||
@open-website="openOfficialWebsite"
|
||||
@open-docs="openDocumentationSite"
|
||||
@@ -331,6 +332,10 @@ import type { TemplateManagerTemplateType } from '../../composables/prompt/useTe
|
||||
|
||||
// Data Transformation
|
||||
import { DataTransformer } from '../../utils/data-transformer'
|
||||
import {
|
||||
DATA_BACKUP_STATUS_EVENT,
|
||||
isDataBackupReminderDue,
|
||||
} from '../../utils/data-backup-reminder'
|
||||
|
||||
// Types
|
||||
import type { ModelSelectOption, TestAreaPanelInstance } from '../../types'
|
||||
@@ -639,6 +644,7 @@ const servicesForContextEditor = computed(() => services?.value || null);
|
||||
// 6. 创建所有必要的引用
|
||||
const promptService = shallowRef<IPromptService | null>(null);
|
||||
const showDataManager = ref(false);
|
||||
const dataBackupReminderDue = ref(isDataBackupReminderDue());
|
||||
|
||||
type ContextWorkspaceExpose = {
|
||||
// Vue ComponentPublicInstance 会自动 unwrap expose 里的 Ref,因此这里使用已解包的类型
|
||||
@@ -2272,11 +2278,18 @@ const handleVisibilityChange = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDataBackupReminder = () => {
|
||||
dataBackupReminderDue.value = isDataBackupReminderDue()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Route-level lazy loading can break after a new deployment when this tab is still running an old main bundle.
|
||||
// Prompt user to refresh instead of auto-reloading.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
window.addEventListener(DATA_BACKUP_STATUS_EVENT, refreshDataBackupReminder);
|
||||
window.addEventListener('storage', refreshDataBackupReminder);
|
||||
refreshDataBackupReminder();
|
||||
}
|
||||
removeRouterErrorHandler = routerInstance.onError((error) => {
|
||||
if (!isChunkLoadFailure(error)) return;
|
||||
@@ -2384,6 +2397,8 @@ onBeforeUnmount(async () => {
|
||||
window.removeEventListener('pagehide', handlePagehide)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection)
|
||||
window.removeEventListener(DATA_BACKUP_STATUS_EVENT, refreshDataBackupReminder)
|
||||
window.removeEventListener('storage', refreshDataBackupReminder)
|
||||
if (hasRegisteredGlobalHistoryRefresh) {
|
||||
window.removeEventListener(
|
||||
'prompt-optimizer:history-refresh',
|
||||
|
||||
@@ -105,8 +105,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, inject, nextTick, onMounted, onUnmounted, ref, type CSSProperties } from 'vue'
|
||||
import { routerKey } from 'vue-router'
|
||||
import type { LocationQueryRaw } from 'vue-router'
|
||||
import { routerKey, type LocationQueryRaw } from 'vue-router'
|
||||
import { NButton, NDropdown, NIcon, NModal, type DropdownOption } from 'naive-ui'
|
||||
import { Bookmark, ClearAll, DotsVertical, ExternalLink, Plant2, FileImport } from '@vicons/tabler'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -237,9 +237,12 @@ const messages = {
|
||||
},
|
||||
"dataManager": {
|
||||
"title": "Data Manager",
|
||||
"backupReminder": {
|
||||
"tooltip": "No data export in over 10 days. Export a local copy."
|
||||
},
|
||||
"export": {
|
||||
"title": "Export Data",
|
||||
"description": "Export a complete app backup with configuration, history, contexts, favorites, and image resources.",
|
||||
"description": "Export the selected app data, including settings, history, contexts, favorites, and related images.",
|
||||
"scopeTitle": "Export contents",
|
||||
"button": "Export Data",
|
||||
"success": "Data exported successfully",
|
||||
@@ -248,19 +251,20 @@ const messages = {
|
||||
},
|
||||
"import": {
|
||||
"title": "Import Data",
|
||||
"description": "Import a complete backup package. Legacy JSON backups are still supported.",
|
||||
"selectFile": "Click to select file or drag file here",
|
||||
"supportFormat": "Supports .po-backup.zip packages and legacy .json files",
|
||||
"description": "Import data from a local file. Legacy JSON backups are still supported.",
|
||||
"selectFile": "Click to select a local data file or drag it here",
|
||||
"supportFormat": "Supports current export files and legacy .json files",
|
||||
"scopeTitle": "Import contents",
|
||||
"packagePreview": "Package contains: {sections}",
|
||||
"legacyJsonPreview": "Legacy JSON backups contain app main data only",
|
||||
"favoriteMergeStrategy": "Favorites merge strategy",
|
||||
"skipDuplicate": "Skip existing favorites",
|
||||
"skipDuplicateHint": "Keep current data when the same favorite ID already exists",
|
||||
"overwriteDuplicate": "Overwrite existing favorites",
|
||||
"overwriteDuplicateHint": "Replace current data with imported data on matching favorite IDs",
|
||||
"createCopy": "Create copies",
|
||||
"createCopyHint": "Create new favorite copies when imported IDs already exist",
|
||||
"settingsTitle": "Import settings",
|
||||
"packagePreview": "Import file contains: {sections}",
|
||||
"legacyJsonPreview": "Legacy JSON files contain app data only",
|
||||
"favoriteMergeStrategy": "Duplicate favorites handling",
|
||||
"skipDuplicate": "Keep existing favorites",
|
||||
"skipDuplicateHint": "Keep current data when the same prompt content already exists",
|
||||
"overwriteDuplicate": "Replace existing favorites",
|
||||
"overwriteDuplicateHint": "Use imported data when the same prompt content already exists",
|
||||
"createCopy": "Import as copies",
|
||||
"createCopyHint": "Create a new copy when the same prompt content already exists",
|
||||
"noSelectedContent": "Select at least one content type to import",
|
||||
"changeFile": "Change File",
|
||||
"button": "Import Data",
|
||||
@@ -274,12 +278,12 @@ const messages = {
|
||||
"successWithRefresh": "Data imported successfully, page will refresh to apply all changes"
|
||||
},
|
||||
"sections": {
|
||||
"appData": "App main data",
|
||||
"appDataHint": "History, model configuration, templates, contexts, and user settings",
|
||||
"appDataImages": "Include history/result image resources",
|
||||
"appDataImagesHint": "Restores images referenced by history or results; current workspace sessions are not included",
|
||||
"appData": "App data",
|
||||
"appDataHint": "History, models, templates, contexts, and user settings",
|
||||
"appDataImages": "Related images",
|
||||
"appDataImagesHint": "Images referenced by history and results; current workspace sessions are not included",
|
||||
"favoritesBundle": "Favorites",
|
||||
"favoritesBundleHint": "Favorite data, categories, tags, prompt assets, examples, version chains, and favorite images"
|
||||
"favoritesBundleHint": "Favorites, categories, tags, prompt assets, examples, versions, and favorite images"
|
||||
},
|
||||
"contexts": {
|
||||
"title": "Context Collections Management",
|
||||
@@ -305,10 +309,156 @@ const messages = {
|
||||
"importModeRequired": "Please select import mode"
|
||||
},
|
||||
"backupWorkspace": {
|
||||
"title": "Data Backup",
|
||||
"description": "Export or import the full app dataset for migration, backup or recovery.",
|
||||
"exportTitle": "Export Current App Data",
|
||||
"importTitle": "Import Backup File"
|
||||
"title": "Local Import/Export",
|
||||
"description": "Export the current app data to a local file, or import data from one.",
|
||||
"exportTitle": "Export Current Data",
|
||||
"importTitle": "Import Local Data"
|
||||
},
|
||||
"remote": {
|
||||
"title": "Remote Backup",
|
||||
"webRecommendation": "Google Drive is recommended for Web. S3/R2/MinIO and WebDAV require browser CORS support on the server.",
|
||||
"desktopRecommendation": "Google Drive is not supported on Desktop yet. Use Cloudflare R2, S3/MinIO, or WebDAV instead.",
|
||||
"provider": "Backup provider",
|
||||
"providers": {
|
||||
"googleDrive": "Google Drive (Recommended)",
|
||||
"cloudflareR2": "Cloudflare R2",
|
||||
"s3Compatible": "S3 / R2 / MinIO",
|
||||
"webdav": "WebDAV"
|
||||
},
|
||||
"authorizeGoogleDrive": "Authorize Google Drive",
|
||||
"authorizedGoogleDrive": "Google Drive Authorized",
|
||||
"authorizeSuccess": "Google Drive authorized",
|
||||
"authorizeFailed": "Google Drive authorization failed: {message}",
|
||||
"authorizeUnavailable": "This provider does not need a separate authorization step",
|
||||
"googleDrivePath": "Google Drive backup path: {path}",
|
||||
"configuration": "Configuration",
|
||||
"configured": "Configured",
|
||||
"setupRequired": "Setup Needed",
|
||||
"showSetupGuide": "Show Setup",
|
||||
"hideSetupGuide": "Hide Setup",
|
||||
"showAllSetupSteps": "Show All Steps",
|
||||
"hideExtraSetupSteps": "Current Step Only",
|
||||
"cloudflareR2SetupSummary": "Set up R2 in 3 steps: Account ID, bucket/CORS, and account API token.",
|
||||
"cloudflareR2ConfiguredSummary": "Cloudflare R2 is configured. Bucket: {bucket}, object prefix: {prefix}",
|
||||
"cloudflareLinks": {
|
||||
"dashboard": "Open Cloudflare",
|
||||
"buckets": "R2 Buckets",
|
||||
"apiTokens": "R2 API Tokens",
|
||||
"docs": "R2 Docs"
|
||||
},
|
||||
"cloudflareSteps": {
|
||||
"account": {
|
||||
"title": "Enter Account ID",
|
||||
"description": "Open the dashboard and copy the Account ID from https://dash.cloudflare.com/[AccountID]/home/overview.",
|
||||
"action": "Open Cloudflare Dashboard"
|
||||
},
|
||||
"bucket": {
|
||||
"title": "Create a dedicated bucket and configure CORS",
|
||||
"description": "Open R2 Overview, create a bucket with the name below, then paste the copied CORS config in Settings - CORS policy.",
|
||||
"action": "Open R2 Overview",
|
||||
"corsAction": "Copy CORS Config"
|
||||
},
|
||||
"credentials": {
|
||||
"title": "Create an account API token",
|
||||
"description": "Create an account API token with Object Read/Write, scoped only to the bucket from step 2.",
|
||||
"tokenAction": "Open R2 API Tokens",
|
||||
"docsAction": "View R2 Docs"
|
||||
}
|
||||
},
|
||||
"accountId": "Account ID",
|
||||
"cloudflareAccountHint": "Find it in the Cloudflare dashboard account panel.",
|
||||
"cloudflareBucketHint": "Use a dedicated bucket, for example prompt-optimizer-backups.",
|
||||
"cloudflareEndpoint": "R2 endpoint will be: {endpoint}",
|
||||
"cloudflareBackupPath": "R2 backup path: {path}",
|
||||
"cloudflareBucketPreview": "R2 bucket: {bucket}",
|
||||
"cloudflareObjectPrefix": "Object prefix: {prefix}",
|
||||
"copyCors": "Copy CORS Config",
|
||||
"copyCorsSuccess": "Recommended CORS config copied",
|
||||
"copyCorsFailed": "Failed to copy CORS config",
|
||||
"cloudflareCorsHint": "Browser direct R2 access requires bucket CORS. Copy the config and paste it into the R2 bucket settings.",
|
||||
"endpoint": "Endpoint",
|
||||
"bucket": "Bucket",
|
||||
"region": "Region",
|
||||
"prefix": "Prefix",
|
||||
"accessKey": "Access Key",
|
||||
"secretKey": "Secret Key",
|
||||
"forcePathStyle": "Use path-style S3 URLs",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"directory": "Directory",
|
||||
"connectionStatus": {
|
||||
"unconfigured": "Not Configured",
|
||||
"unverified": "Not Verified",
|
||||
"checking": "Connecting",
|
||||
"connected": "Connected",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"connectionSetupTitle": "Connect this backup provider",
|
||||
"connectionReadyTitle": "This backup provider is connected",
|
||||
"connectionMissingConfig": "Complete the required configuration first.",
|
||||
"connectionNeedsCheck": "Configuration is saved. Click “Save and Connect” to verify it.",
|
||||
"connectionChecking": "Checking remote storage: list, write, read, and test-file cleanup...",
|
||||
"connectionFailedGeneric": "Remote storage check did not pass",
|
||||
"connectionSuccess": "Remote storage connected",
|
||||
"connectionFailed": "Remote storage connection failed: {message}",
|
||||
"connectFirst": "Save and connect the current backup provider first.",
|
||||
"saveAndConnect": "Save and Connect",
|
||||
"editConfiguration": "Edit Configuration",
|
||||
"hideConfiguration": "Hide Configuration",
|
||||
"lastCheckedAt": "Last checked: {time}",
|
||||
"connectionSummary": {
|
||||
"r2": "Bucket: {bucket}, path: {path}",
|
||||
"s3": "Bucket: {bucket}, prefix: {path}",
|
||||
"webdav": "Directory: {path}"
|
||||
},
|
||||
"s3DesktopHelp": "Desktop connects to S3 through the main process, so browser CORS is not required.",
|
||||
"s3WebHelp": "Web direct S3 access requires the server to allow this page through CORS.",
|
||||
"webdavDesktopHelp": "Desktop connects to WebDAV through the main process, so WebDAV CORS is not required.",
|
||||
"webdavWebHelp": "Web direct WebDAV access requires the server to allow this page through CORS.",
|
||||
"backupNow": "Create Backup",
|
||||
"backupSuccess": "Remote backup completed. Uploaded {uploaded} new image assets and skipped {skipped} existing assets.",
|
||||
"backupFailed": "Remote backup failed: {message}",
|
||||
"refreshList": "Refresh Remote Backup List",
|
||||
"listFailed": "Failed to load remote backup list: {message}",
|
||||
"restoreFrom": "Restore from remote backup",
|
||||
"noBackups": "No remote backups",
|
||||
"restoreSelected": "Restore Selected Backup",
|
||||
"restoreFailed": "Failed to restore remote backup: {message}",
|
||||
"cleanupAssets": "Clean Unreferenced Images",
|
||||
"cleanupConfirm": "This will delete {count} remote image assets not referenced by any remote backup and free about {size}. Continue?",
|
||||
"cleanupNothing": "No unreferenced remote image assets to clean",
|
||||
"cleanupSuccess": "Cleaned {count} remote image assets and freed about {size}",
|
||||
"cleanupPartial": "Remote image cleanup completed: deleted {deleted}, failed {failed}",
|
||||
"cleanupFailed": "Remote image cleanup failed: {message}",
|
||||
"progress": {
|
||||
"working": "Working on remote backup...",
|
||||
"list": {
|
||||
"start": "Loading remote backups...",
|
||||
"list": "Scanning remote snapshots..."
|
||||
},
|
||||
"backup": {
|
||||
"start": "Preparing backup...",
|
||||
"prepare": "Exporting app data and favorites...",
|
||||
"scan": "Scanning image resources {item} {count}",
|
||||
"asset-check": "Checking remote image asset: {item} {count}",
|
||||
"asset-upload": "Uploading image asset: {item} {count}",
|
||||
"metadata-upload": "Uploading backup data: {item} {count}",
|
||||
"manifest-upload": "Committing backup manifest...",
|
||||
"done": "Backup complete. Uploaded {uploaded} image assets and skipped {skipped} existing assets."
|
||||
},
|
||||
"restore": {
|
||||
"start": "Preparing restore...",
|
||||
"restore-validate": "Validating remote backup resource: {item} {count}",
|
||||
"restore-write": "Writing local data: {item} {count}",
|
||||
"done": "Restore complete"
|
||||
},
|
||||
"cleanup": {
|
||||
"start": "Analyzing remote image cleanup...",
|
||||
"cleanup-analyze": "Scanning snapshot references and remote image assets...",
|
||||
"cleanup-delete": "Deleting unreferenced image: {item} {count}",
|
||||
"done": "Cleanup complete"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"title": "Storage Usage",
|
||||
@@ -326,7 +476,7 @@ const messages = {
|
||||
"refresh": "Refresh",
|
||||
"refreshFailed": "Failed to refresh storage usage"
|
||||
},
|
||||
"warning": "Importing a complete backup will overwrite or update existing history, model configuration, contexts, favorites, and user settings. Make sure important data is backed up first."
|
||||
"warning": "Importing data may overwrite or update existing history, model configuration, contexts, favorites, and user settings. Make sure important data is backed up first."
|
||||
}
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -237,30 +237,34 @@ const messages = {
|
||||
},
|
||||
"dataManager": {
|
||||
"title": "数据管理",
|
||||
"backupReminder": {
|
||||
"tooltip": "已超过 10 天未导出数据,建议导出一份本地副本"
|
||||
},
|
||||
"export": {
|
||||
"title": "导出数据",
|
||||
"description": "导出完整应用备份,包含配置、历史、上下文、收藏夹和图片资源。",
|
||||
"description": "按所选内容导出当前应用数据,包含配置、历史、上下文、收藏夹和相关图片。",
|
||||
"scopeTitle": "导出内容",
|
||||
"button": "导出数据",
|
||||
"success": "数据导出成功",
|
||||
"partialSuccess": "数据导出完成,但有 {count} 个图片资源缺失",
|
||||
"partialSuccess": "数据已导出,但有 {count} 个图片资源缺失",
|
||||
"failed": "数据导出失败"
|
||||
},
|
||||
"import": {
|
||||
"title": "导入数据",
|
||||
"description": "导入完整备份资源包,旧版 JSON 备份仍可导入。",
|
||||
"selectFile": "点击选择文件或拖拽文件到此处",
|
||||
"supportFormat": "支持 .po-backup.zip 资源包和历史 .json 文件",
|
||||
"description": "从本地文件导入数据,兼容旧版 JSON 文件。",
|
||||
"selectFile": "点击选择本地数据文件或拖拽到此处",
|
||||
"supportFormat": "支持当前导出文件,也兼容历史 .json 文件",
|
||||
"scopeTitle": "导入内容",
|
||||
"packagePreview": "资源包包含:{sections}",
|
||||
"legacyJsonPreview": "旧版 JSON 备份仅包含应用主数据",
|
||||
"favoriteMergeStrategy": "收藏夹合并策略",
|
||||
"skipDuplicate": "跳过已有收藏",
|
||||
"skipDuplicateHint": "遇到相同收藏 ID 时保留当前数据",
|
||||
"overwriteDuplicate": "覆盖已有收藏",
|
||||
"overwriteDuplicateHint": "遇到相同收藏 ID 时使用导入数据替换",
|
||||
"createCopy": "创建副本",
|
||||
"createCopyHint": "遇到相同收藏 ID 时生成新的收藏副本",
|
||||
"settingsTitle": "导入设置",
|
||||
"packagePreview": "导入文件包含:{sections}",
|
||||
"legacyJsonPreview": "旧版 JSON 文件仅包含应用数据",
|
||||
"favoriteMergeStrategy": "重复收藏处理方式",
|
||||
"skipDuplicate": "保留已有收藏",
|
||||
"skipDuplicateHint": "遇到相同提示词内容时保留当前数据",
|
||||
"overwriteDuplicate": "替换已有收藏",
|
||||
"overwriteDuplicateHint": "遇到相同提示词内容时使用导入数据",
|
||||
"createCopy": "导入为副本",
|
||||
"createCopyHint": "遇到相同提示词内容时新增一份副本",
|
||||
"noSelectedContent": "请选择要导入的内容",
|
||||
"changeFile": "更换文件",
|
||||
"button": "导入数据",
|
||||
@@ -274,12 +278,12 @@ const messages = {
|
||||
"successWithRefresh": "数据导入成功,页面将刷新以应用所有更改"
|
||||
},
|
||||
"sections": {
|
||||
"appData": "应用主数据",
|
||||
"appDataHint": "历史记录、模型配置、模板、上下文和用户设置",
|
||||
"appDataImages": "包含历史/结果图片资源",
|
||||
"appDataImagesHint": "仅用于恢复历史或结果中引用的图片,不包含当前工作区会话",
|
||||
"appData": "应用数据",
|
||||
"appDataHint": "历史记录、模型、模板、上下文和用户设置",
|
||||
"appDataImages": "相关图片",
|
||||
"appDataImagesHint": "历史和结果中引用的图片,不包含当前工作区会话",
|
||||
"favoritesBundle": "收藏夹",
|
||||
"favoritesBundleHint": "收藏数据、分类、标签、提示词资产、示例、版本链和收藏图片资源"
|
||||
"favoritesBundleHint": "收藏、分类、标签、提示词资产、示例、版本链和收藏图片"
|
||||
},
|
||||
"contexts": {
|
||||
"title": "上下文集合管理",
|
||||
@@ -305,10 +309,156 @@ const messages = {
|
||||
"importModeRequired": "请选择导入模式"
|
||||
},
|
||||
"backupWorkspace": {
|
||||
"title": "数据备份",
|
||||
"description": "导出或导入整个应用数据,用于迁移、备份或恢复当前应用状态。",
|
||||
"exportTitle": "导出当前应用数据",
|
||||
"importTitle": "导入备份文件"
|
||||
"title": "本地导入/导出",
|
||||
"description": "将当前应用数据导出到本地文件,或从本地文件导入数据。",
|
||||
"exportTitle": "导出当前数据",
|
||||
"importTitle": "导入本地数据"
|
||||
},
|
||||
"remote": {
|
||||
"title": "远程备份",
|
||||
"webRecommendation": "Web 版推荐使用 Google Drive;S3/R2/MinIO 和 WebDAV 需要服务端允许浏览器跨域。",
|
||||
"desktopRecommendation": "桌面版暂不支持 Google Drive;推荐使用 Cloudflare R2、S3/MinIO 或 WebDAV。",
|
||||
"provider": "备份方式",
|
||||
"providers": {
|
||||
"googleDrive": "Google Drive(推荐)",
|
||||
"cloudflareR2": "Cloudflare R2",
|
||||
"s3Compatible": "S3 / R2 / MinIO",
|
||||
"webdav": "WebDAV"
|
||||
},
|
||||
"authorizeGoogleDrive": "授权 Google Drive",
|
||||
"authorizedGoogleDrive": "已授权 Google Drive",
|
||||
"authorizeSuccess": "Google Drive 授权成功",
|
||||
"authorizeFailed": "Google Drive 授权失败:{message}",
|
||||
"authorizeUnavailable": "当前备份方式不需要单独授权",
|
||||
"googleDrivePath": "Google Drive 备份路径为:{path}",
|
||||
"configuration": "配置方式",
|
||||
"configured": "已配置",
|
||||
"setupRequired": "待配置",
|
||||
"showSetupGuide": "显示配置方式",
|
||||
"hideSetupGuide": "收起配置方式",
|
||||
"showAllSetupSteps": "显示全部步骤",
|
||||
"hideExtraSetupSteps": "只看当前步骤",
|
||||
"cloudflareR2SetupSummary": "按 3 步完成 R2:Account ID、Bucket/CORS、帐户 API 令牌。",
|
||||
"cloudflareR2ConfiguredSummary": "Cloudflare R2 已配置。Bucket:{bucket},对象前缀:{prefix}",
|
||||
"cloudflareLinks": {
|
||||
"dashboard": "打开 Cloudflare",
|
||||
"buckets": "R2 Buckets",
|
||||
"apiTokens": "R2 API Tokens",
|
||||
"docs": "R2 文档"
|
||||
},
|
||||
"cloudflareSteps": {
|
||||
"account": {
|
||||
"title": "填写 Account ID",
|
||||
"description": "打开控制台后,从地址 https://dash.cloudflare.com/[AccountID]/home/overview 复制 Account ID。",
|
||||
"action": "打开 Cloudflare 控制台"
|
||||
},
|
||||
"bucket": {
|
||||
"title": "创建专用 Bucket 并配置 CORS",
|
||||
"description": "打开 R2 Overview,创建下方名称的 bucket;进入该 bucket 的 设置 - CORS 策略,粘贴复制的配置。",
|
||||
"action": "打开 R2 Overview",
|
||||
"corsAction": "复制 CORS 配置"
|
||||
},
|
||||
"credentials": {
|
||||
"title": "创建帐户 API 令牌",
|
||||
"description": "创建帐户 API 令牌,权限选对象读/写,并仅绑定上一步的 bucket。",
|
||||
"tokenAction": "打开 R2 API Tokens",
|
||||
"docsAction": "查看 R2 文档"
|
||||
}
|
||||
},
|
||||
"accountId": "Account ID",
|
||||
"cloudflareAccountHint": "在 Cloudflare Dashboard 右侧账户信息中查看。",
|
||||
"cloudflareBucketHint": "建议使用专用 bucket,例如 prompt-optimizer-backups。",
|
||||
"cloudflareEndpoint": "R2 Endpoint 将自动使用:{endpoint}",
|
||||
"cloudflareBackupPath": "R2 备份路径为:{path}",
|
||||
"cloudflareBucketPreview": "R2 Bucket:{bucket}",
|
||||
"cloudflareObjectPrefix": "对象前缀:{prefix}",
|
||||
"copyCors": "复制 CORS 配置",
|
||||
"copyCorsSuccess": "已复制推荐 CORS 配置",
|
||||
"copyCorsFailed": "复制 CORS 配置失败",
|
||||
"cloudflareCorsHint": "浏览器直连 R2 需要在 bucket 中配置 CORS;复制配置后到 R2 bucket 设置里粘贴即可。",
|
||||
"endpoint": "服务地址",
|
||||
"bucket": "Bucket",
|
||||
"region": "Region",
|
||||
"prefix": "路径前缀",
|
||||
"accessKey": "Access Key",
|
||||
"secretKey": "Secret Key",
|
||||
"forcePathStyle": "使用 path-style S3 地址",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"directory": "目录",
|
||||
"connectionStatus": {
|
||||
"unconfigured": "未配置",
|
||||
"unverified": "未验证",
|
||||
"checking": "连接中",
|
||||
"connected": "已连接",
|
||||
"failed": "连接失败"
|
||||
},
|
||||
"connectionSetupTitle": "连接当前备份方式",
|
||||
"connectionReadyTitle": "当前备份方式已连接",
|
||||
"connectionMissingConfig": "请先补全必要配置。",
|
||||
"connectionNeedsCheck": "配置已保存,请点击“保存并连接”确认可用。",
|
||||
"connectionChecking": "正在检测远程存储:列表、写入、读取和清理测试文件...",
|
||||
"connectionFailedGeneric": "远程存储检测未通过",
|
||||
"connectionSuccess": "远程存储连接成功",
|
||||
"connectionFailed": "远程存储连接失败:{message}",
|
||||
"connectFirst": "请先保存并连接当前备份方式。",
|
||||
"saveAndConnect": "保存并连接",
|
||||
"editConfiguration": "修改配置",
|
||||
"hideConfiguration": "收起配置",
|
||||
"lastCheckedAt": "上次检测:{time}",
|
||||
"connectionSummary": {
|
||||
"r2": "Bucket:{bucket},路径:{path}",
|
||||
"s3": "Bucket:{bucket},路径前缀:{path}",
|
||||
"webdav": "目录:{path}"
|
||||
},
|
||||
"s3DesktopHelp": "桌面版通过主进程连接 S3,不需要浏览器 CORS。",
|
||||
"s3WebHelp": "Web 版直连 S3 需要服务端允许当前网页跨域访问。",
|
||||
"webdavDesktopHelp": "桌面版通过主进程连接 WebDAV,不需要 WebDAV 服务提供 CORS。",
|
||||
"webdavWebHelp": "Web 版直连 WebDAV 需要服务端允许当前网页跨域访问。",
|
||||
"backupNow": "创建备份",
|
||||
"backupSuccess": "远程备份成功,新增 {uploaded} 个图片资源,跳过 {skipped} 个已有资源",
|
||||
"backupFailed": "远程备份失败:{message}",
|
||||
"refreshList": "刷新远程备份列表",
|
||||
"listFailed": "获取远程备份列表失败:{message}",
|
||||
"restoreFrom": "从远程备份还原",
|
||||
"noBackups": "暂无远程备份",
|
||||
"restoreSelected": "还原选中备份",
|
||||
"restoreFailed": "还原远程备份失败:{message}",
|
||||
"cleanupAssets": "清理未引用图片",
|
||||
"cleanupConfirm": "将删除 {count} 个未被任何远程备份引用的图片资源,预计释放 {size}。是否继续?",
|
||||
"cleanupNothing": "没有可清理的远程图片资源",
|
||||
"cleanupSuccess": "已清理 {count} 个远程图片资源,释放约 {size}",
|
||||
"cleanupPartial": "远程图片清理完成:已删除 {deleted} 个,失败 {failed} 个",
|
||||
"cleanupFailed": "远程图片清理失败:{message}",
|
||||
"progress": {
|
||||
"working": "正在处理远程备份...",
|
||||
"list": {
|
||||
"start": "正在读取远程备份列表...",
|
||||
"list": "正在扫描远程快照..."
|
||||
},
|
||||
"backup": {
|
||||
"start": "正在准备备份...",
|
||||
"prepare": "正在导出应用数据和收藏数据...",
|
||||
"scan": "正在扫描图片资源 {item} {count}",
|
||||
"asset-check": "正在检查远程图片是否已存在:{item} {count}",
|
||||
"asset-upload": "正在上传图片资源:{item} {count}",
|
||||
"metadata-upload": "正在上传备份数据:{item} {count}",
|
||||
"manifest-upload": "正在提交备份清单...",
|
||||
"done": "备份完成,新增 {uploaded} 个图片资源,跳过 {skipped} 个已有资源"
|
||||
},
|
||||
"restore": {
|
||||
"start": "正在准备还原...",
|
||||
"restore-validate": "正在校验远程备份资源:{item} {count}",
|
||||
"restore-write": "正在写入本地数据:{item} {count}",
|
||||
"done": "还原完成"
|
||||
},
|
||||
"cleanup": {
|
||||
"start": "正在分析可清理图片...",
|
||||
"cleanup-analyze": "正在扫描快照引用和远程图片资源...",
|
||||
"cleanup-delete": "正在删除未引用图片:{item} {count}",
|
||||
"done": "清理完成"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"title": "存储占用",
|
||||
@@ -326,7 +476,7 @@ const messages = {
|
||||
"refresh": "刷新",
|
||||
"refreshFailed": "刷新存储占用失败"
|
||||
},
|
||||
"warning": "导入完整备份将覆盖或更新现有历史记录、模型配置、上下文、收藏夹和用户设置;请先确认已备份重要数据。"
|
||||
"warning": "导入数据会覆盖或更新现有历史记录、模型配置、上下文、收藏夹和用户设置;请先确认重要数据已备份。"
|
||||
}
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -237,30 +237,34 @@ const messages = {
|
||||
},
|
||||
"dataManager": {
|
||||
"title": "資料管理",
|
||||
"backupReminder": {
|
||||
"tooltip": "已超過 10 天未匯出資料,建議匯出一份本機副本"
|
||||
},
|
||||
"export": {
|
||||
"title": "匯出資料",
|
||||
"description": "匯出完整應用備份,包含配置、歷史、情境、收藏和圖片資源。",
|
||||
"description": "依所選內容匯出目前應用資料,包含配置、歷史、情境、收藏和相關圖片。",
|
||||
"scopeTitle": "匯出內容",
|
||||
"button": "匯出資料",
|
||||
"success": "資料匯出成功",
|
||||
"partialSuccess": "資料匯出完成,但有 {count} 個圖片資源缺失",
|
||||
"partialSuccess": "資料已匯出,但有 {count} 個圖片資源缺失",
|
||||
"failed": "資料匯出失敗"
|
||||
},
|
||||
"import": {
|
||||
"title": "匯入資料",
|
||||
"description": "匯入完整備份資源包,舊版 JSON 備份仍可匯入。",
|
||||
"selectFile": "點選選擇檔案或拖曳檔案到此處",
|
||||
"supportFormat": "支援 .po-backup.zip 資源包與歷史 .json 檔案",
|
||||
"description": "從本機檔案匯入資料,兼容舊版 JSON 檔案。",
|
||||
"selectFile": "點選選擇本機資料檔案或拖曳到此處",
|
||||
"supportFormat": "支援目前匯出檔案,也兼容歷史 .json 檔案",
|
||||
"scopeTitle": "匯入內容",
|
||||
"packagePreview": "資源包包含:{sections}",
|
||||
"legacyJsonPreview": "舊版 JSON 備份僅包含應用主資料",
|
||||
"favoriteMergeStrategy": "收藏合併策略",
|
||||
"skipDuplicate": "跳過已有收藏",
|
||||
"skipDuplicateHint": "遇到相同收藏 ID 時保留目前資料",
|
||||
"overwriteDuplicate": "覆蓋已有收藏",
|
||||
"overwriteDuplicateHint": "遇到相同收藏 ID 時使用匯入資料取代",
|
||||
"createCopy": "建立副本",
|
||||
"createCopyHint": "遇到相同收藏 ID 時產生新的收藏副本",
|
||||
"settingsTitle": "匯入設定",
|
||||
"packagePreview": "匯入檔案包含:{sections}",
|
||||
"legacyJsonPreview": "舊版 JSON 檔案僅包含應用資料",
|
||||
"favoriteMergeStrategy": "重複收藏處理方式",
|
||||
"skipDuplicate": "保留已有收藏",
|
||||
"skipDuplicateHint": "遇到相同提示詞內容時保留目前資料",
|
||||
"overwriteDuplicate": "取代已有收藏",
|
||||
"overwriteDuplicateHint": "遇到相同提示詞內容時使用匯入資料",
|
||||
"createCopy": "匯入為副本",
|
||||
"createCopyHint": "遇到相同提示詞內容時新增一份副本",
|
||||
"noSelectedContent": "請選擇要匯入的內容",
|
||||
"changeFile": "更換檔案",
|
||||
"button": "匯入資料",
|
||||
@@ -274,12 +278,12 @@ const messages = {
|
||||
"successWithRefresh": "資料匯入成功,頁面將重新整理以套用所有變更"
|
||||
},
|
||||
"sections": {
|
||||
"appData": "應用主資料",
|
||||
"appDataHint": "歷史記錄、模型配置、模板、情境和使用者設定",
|
||||
"appDataImages": "包含歷史/結果圖片資源",
|
||||
"appDataImagesHint": "僅用於還原歷史或結果中引用的圖片,不包含目前工作區會話",
|
||||
"appData": "應用資料",
|
||||
"appDataHint": "歷史記錄、模型、模板、情境和使用者設定",
|
||||
"appDataImages": "相關圖片",
|
||||
"appDataImagesHint": "歷史和結果中引用的圖片,不包含目前工作區會話",
|
||||
"favoritesBundle": "收藏",
|
||||
"favoritesBundleHint": "收藏資料、分類、標籤、提示詞資產、範例、版本鏈和收藏圖片資源"
|
||||
"favoritesBundleHint": "收藏、分類、標籤、提示詞資產、範例、版本鏈和收藏圖片"
|
||||
},
|
||||
"contexts": {
|
||||
"title": "情境集合管理",
|
||||
@@ -305,10 +309,156 @@ const messages = {
|
||||
"importModeRequired": "請選擇匯入模式"
|
||||
},
|
||||
"backupWorkspace": {
|
||||
"title": "資料備份",
|
||||
"description": "匯出或匯入整個應用資料,用於遷移、備份或還原目前應用狀態。",
|
||||
"exportTitle": "匯出目前應用資料",
|
||||
"importTitle": "匯入備份檔案"
|
||||
"title": "本機匯入/匯出",
|
||||
"description": "將目前應用資料匯出到本機檔案,或從本機檔案匯入資料。",
|
||||
"exportTitle": "匯出目前資料",
|
||||
"importTitle": "匯入本機資料"
|
||||
},
|
||||
"remote": {
|
||||
"title": "遠端備份",
|
||||
"webRecommendation": "Web 版推薦使用 Google Drive;S3/R2/MinIO 和 WebDAV 需要服務端允許瀏覽器跨域。",
|
||||
"desktopRecommendation": "桌面版暫不支援 Google Drive;推薦使用 Cloudflare R2、S3/MinIO 或 WebDAV。",
|
||||
"provider": "備份方式",
|
||||
"providers": {
|
||||
"googleDrive": "Google Drive(推薦)",
|
||||
"cloudflareR2": "Cloudflare R2",
|
||||
"s3Compatible": "S3 / R2 / MinIO",
|
||||
"webdav": "WebDAV"
|
||||
},
|
||||
"authorizeGoogleDrive": "授權 Google Drive",
|
||||
"authorizedGoogleDrive": "已授權 Google Drive",
|
||||
"authorizeSuccess": "Google Drive 授權成功",
|
||||
"authorizeFailed": "Google Drive 授權失敗:{message}",
|
||||
"authorizeUnavailable": "目前備份方式不需要單獨授權",
|
||||
"googleDrivePath": "Google Drive 備份路徑為:{path}",
|
||||
"configuration": "設定方式",
|
||||
"configured": "已設定",
|
||||
"setupRequired": "待設定",
|
||||
"showSetupGuide": "顯示設定方式",
|
||||
"hideSetupGuide": "收起設定方式",
|
||||
"showAllSetupSteps": "顯示全部步驟",
|
||||
"hideExtraSetupSteps": "只看目前步驟",
|
||||
"cloudflareR2SetupSummary": "按 3 步完成 R2:Account ID、Bucket/CORS、帳戶 API 權杖。",
|
||||
"cloudflareR2ConfiguredSummary": "Cloudflare R2 已設定。Bucket:{bucket},物件前綴:{prefix}",
|
||||
"cloudflareLinks": {
|
||||
"dashboard": "開啟 Cloudflare",
|
||||
"buckets": "R2 Buckets",
|
||||
"apiTokens": "R2 API Tokens",
|
||||
"docs": "R2 文件"
|
||||
},
|
||||
"cloudflareSteps": {
|
||||
"account": {
|
||||
"title": "填寫 Account ID",
|
||||
"description": "開啟控制台後,從網址 https://dash.cloudflare.com/[AccountID]/home/overview 複製 Account ID。",
|
||||
"action": "開啟 Cloudflare 控制台"
|
||||
},
|
||||
"bucket": {
|
||||
"title": "建立專用 Bucket 並設定 CORS",
|
||||
"description": "開啟 R2 Overview,建立下方名稱的 bucket;進入該 bucket 的 設定 - CORS 策略,貼上複製的設定。",
|
||||
"action": "開啟 R2 Overview",
|
||||
"corsAction": "複製 CORS 設定"
|
||||
},
|
||||
"credentials": {
|
||||
"title": "建立帳戶 API 權杖",
|
||||
"description": "建立帳戶 API 權杖,權限選物件讀取/寫入,並僅綁定上一步的 bucket。",
|
||||
"tokenAction": "開啟 R2 API Tokens",
|
||||
"docsAction": "查看 R2 文件"
|
||||
}
|
||||
},
|
||||
"accountId": "Account ID",
|
||||
"cloudflareAccountHint": "在 Cloudflare Dashboard 右側帳戶資訊中查看。",
|
||||
"cloudflareBucketHint": "建議使用專用 bucket,例如 prompt-optimizer-backups。",
|
||||
"cloudflareEndpoint": "R2 Endpoint 將自動使用:{endpoint}",
|
||||
"cloudflareBackupPath": "R2 備份路徑為:{path}",
|
||||
"cloudflareBucketPreview": "R2 Bucket:{bucket}",
|
||||
"cloudflareObjectPrefix": "物件前綴:{prefix}",
|
||||
"copyCors": "複製 CORS 設定",
|
||||
"copyCorsSuccess": "已複製推薦 CORS 設定",
|
||||
"copyCorsFailed": "複製 CORS 設定失敗",
|
||||
"cloudflareCorsHint": "瀏覽器直連 R2 需要在 bucket 中設定 CORS;複製設定後到 R2 bucket 設定裡貼上即可。",
|
||||
"endpoint": "服務位址",
|
||||
"bucket": "Bucket",
|
||||
"region": "Region",
|
||||
"prefix": "路徑前綴",
|
||||
"accessKey": "Access Key",
|
||||
"secretKey": "Secret Key",
|
||||
"forcePathStyle": "使用 path-style S3 位址",
|
||||
"username": "使用者名稱",
|
||||
"password": "密碼",
|
||||
"directory": "目錄",
|
||||
"connectionStatus": {
|
||||
"unconfigured": "未設定",
|
||||
"unverified": "未驗證",
|
||||
"checking": "連線中",
|
||||
"connected": "已連線",
|
||||
"failed": "連線失敗"
|
||||
},
|
||||
"connectionSetupTitle": "連線目前備份方式",
|
||||
"connectionReadyTitle": "目前備份方式已連線",
|
||||
"connectionMissingConfig": "請先補齊必要設定。",
|
||||
"connectionNeedsCheck": "設定已儲存,請點擊「儲存並連線」確認可用。",
|
||||
"connectionChecking": "正在檢測遠端儲存:列表、寫入、讀取和清理測試檔案...",
|
||||
"connectionFailedGeneric": "遠端儲存檢測未通過",
|
||||
"connectionSuccess": "遠端儲存連線成功",
|
||||
"connectionFailed": "遠端儲存連線失敗:{message}",
|
||||
"connectFirst": "請先儲存並連線目前備份方式。",
|
||||
"saveAndConnect": "儲存並連線",
|
||||
"editConfiguration": "修改設定",
|
||||
"hideConfiguration": "收起設定",
|
||||
"lastCheckedAt": "上次檢測:{time}",
|
||||
"connectionSummary": {
|
||||
"r2": "Bucket:{bucket},路徑:{path}",
|
||||
"s3": "Bucket:{bucket},路徑前綴:{path}",
|
||||
"webdav": "目錄:{path}"
|
||||
},
|
||||
"s3DesktopHelp": "桌面版透過主行程連線 S3,不需要瀏覽器 CORS。",
|
||||
"s3WebHelp": "Web 版直連 S3 需要服務端允許目前網頁跨域存取。",
|
||||
"webdavDesktopHelp": "桌面版透過主行程連線 WebDAV,不需要 WebDAV 服務提供 CORS。",
|
||||
"webdavWebHelp": "Web 版直連 WebDAV 需要服務端允許目前網頁跨域存取。",
|
||||
"backupNow": "建立備份",
|
||||
"backupSuccess": "遠端備份成功,新增 {uploaded} 個圖片資源,跳過 {skipped} 個已有資源",
|
||||
"backupFailed": "遠端備份失敗:{message}",
|
||||
"refreshList": "重新整理遠端備份列表",
|
||||
"listFailed": "取得遠端備份列表失敗:{message}",
|
||||
"restoreFrom": "從遠端備份還原",
|
||||
"noBackups": "暫無遠端備份",
|
||||
"restoreSelected": "還原選中備份",
|
||||
"restoreFailed": "還原遠端備份失敗:{message}",
|
||||
"cleanupAssets": "清理未引用圖片",
|
||||
"cleanupConfirm": "將刪除 {count} 個未被任何遠端備份引用的圖片資源,預計釋放 {size}。是否繼續?",
|
||||
"cleanupNothing": "沒有可清理的遠端圖片資源",
|
||||
"cleanupSuccess": "已清理 {count} 個遠端圖片資源,釋放約 {size}",
|
||||
"cleanupPartial": "遠端圖片清理完成:已刪除 {deleted} 個,失敗 {failed} 個",
|
||||
"cleanupFailed": "遠端圖片清理失敗:{message}",
|
||||
"progress": {
|
||||
"working": "正在處理遠端備份...",
|
||||
"list": {
|
||||
"start": "正在讀取遠端備份列表...",
|
||||
"list": "正在掃描遠端快照..."
|
||||
},
|
||||
"backup": {
|
||||
"start": "正在準備備份...",
|
||||
"prepare": "正在匯出應用資料和收藏資料...",
|
||||
"scan": "正在掃描圖片資源 {item} {count}",
|
||||
"asset-check": "正在檢查遠端圖片是否已存在:{item} {count}",
|
||||
"asset-upload": "正在上傳圖片資源:{item} {count}",
|
||||
"metadata-upload": "正在上傳備份資料:{item} {count}",
|
||||
"manifest-upload": "正在提交備份清單...",
|
||||
"done": "備份完成,新增 {uploaded} 個圖片資源,跳過 {skipped} 個已有資源"
|
||||
},
|
||||
"restore": {
|
||||
"start": "正在準備還原...",
|
||||
"restore-validate": "正在校驗遠端備份資源:{item} {count}",
|
||||
"restore-write": "正在寫入本地資料:{item} {count}",
|
||||
"done": "還原完成"
|
||||
},
|
||||
"cleanup": {
|
||||
"start": "正在分析可清理圖片...",
|
||||
"cleanup-analyze": "正在掃描快照引用和遠端圖片資源...",
|
||||
"cleanup-delete": "正在刪除未引用圖片:{item} {count}",
|
||||
"done": "清理完成"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"title": "儲存占用",
|
||||
@@ -326,7 +476,7 @@ const messages = {
|
||||
"refresh": "重新整理",
|
||||
"refreshFailed": "重新整理儲存占用失敗"
|
||||
},
|
||||
"warning": "匯入完整備份將覆蓋或更新現有歷史紀錄、模型配置、情境、收藏和使用者設定;請先確認已備份重要資料。"
|
||||
"warning": "匯入資料會覆蓋或更新現有歷史紀錄、模型配置、情境、收藏和使用者設定;請先確認重要資料已備份。"
|
||||
}
|
||||
} as const;
|
||||
|
||||
|
||||
Vendored
+3
@@ -13,6 +13,7 @@ import type {
|
||||
ImportResult,
|
||||
ContextMode
|
||||
} from '@prompt-optimizer/core'
|
||||
import type { RemoteStorageIpcApi } from '../utils/remote-backup'
|
||||
|
||||
// 基础响应类型
|
||||
interface ElectronErrorPayload {
|
||||
@@ -205,6 +206,7 @@ interface ElectronAPI {
|
||||
imageModel: ImageModelAPI
|
||||
context: ContextAPI
|
||||
data: DataAPI
|
||||
remoteStorage: RemoteStorageIpcApi
|
||||
on: EventAPI['on']
|
||||
off: EventAPI['off']
|
||||
once: EventAPI['once']
|
||||
@@ -269,6 +271,7 @@ export type {
|
||||
ContextAPI,
|
||||
DataAPI,
|
||||
DataStorageInfo,
|
||||
RemoteStorageIpcApi,
|
||||
ElectronAPI,
|
||||
DownloadProgress,
|
||||
UpdateInfo,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export const DATA_BACKUP_STATUS_KEY = 'prompt-optimizer:data-backup-status'
|
||||
export const DATA_BACKUP_STATUS_EVENT = 'prompt-optimizer:data-backup-status-changed'
|
||||
export const DATA_BACKUP_REMINDER_THRESHOLD_DAYS = 10
|
||||
|
||||
export type DataBackupStatus = {
|
||||
lastBackupAt?: string
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
export const normalizeDataBackupStatus = (input: unknown): DataBackupStatus => {
|
||||
if (!isRecord(input) || typeof input.lastBackupAt !== 'string') return {}
|
||||
return { lastBackupAt: input.lastBackupAt }
|
||||
}
|
||||
|
||||
export const loadDataBackupStatus = (): DataBackupStatus => {
|
||||
if (typeof window === 'undefined') return {}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(DATA_BACKUP_STATUS_KEY)
|
||||
return normalizeDataBackupStatus(raw ? JSON.parse(raw) : null)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export const isDataBackupReminderDue = (
|
||||
status: DataBackupStatus = loadDataBackupStatus(),
|
||||
now: Date = new Date(),
|
||||
): boolean => {
|
||||
if (!status.lastBackupAt) return false
|
||||
const lastBackup = new Date(status.lastBackupAt).getTime()
|
||||
if (!Number.isFinite(lastBackup)) return true
|
||||
return now.getTime() - lastBackup >= DATA_BACKUP_REMINDER_THRESHOLD_DAYS * DAY_MS
|
||||
}
|
||||
|
||||
export const recordDataBackupCompleted = (now: Date = new Date()): DataBackupStatus => {
|
||||
const status = { lastBackupAt: now.toISOString() }
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(DATA_BACKUP_STATUS_KEY, JSON.stringify(status))
|
||||
window.dispatchEvent(new CustomEvent(DATA_BACKUP_STATUS_EVENT, { detail: status }))
|
||||
}
|
||||
return status
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,845 @@
|
||||
import type {
|
||||
FullImageData,
|
||||
IDataManager,
|
||||
IFavoriteManager,
|
||||
IImageStorageService,
|
||||
ImageMetadata,
|
||||
} from '@prompt-optimizer/core'
|
||||
|
||||
import {
|
||||
DEFAULT_DATA_MANAGER_PACKAGE_SECTIONS,
|
||||
type DataManagerFavoritesMergeStrategy,
|
||||
type DataManagerImageStoreKey,
|
||||
type DataManagerPackageSection,
|
||||
type DataManagerPackageSectionSelection,
|
||||
} from './data-manager-resource-package'
|
||||
import { joinRemotePath, type RemoteObjectEntry, type RemoteObjectStore } from './remote-backup'
|
||||
|
||||
export const REMOTE_SNAPSHOT_SCHEMA_VERSION = 'prompt-optimizer/remote-snapshot/v1' as const
|
||||
export const REMOTE_SNAPSHOT_ROOT = 'v1'
|
||||
|
||||
const APP_DATA_FILE_NAME = 'app-data.json'
|
||||
const FAVORITES_FILE_NAME = 'favorites.json'
|
||||
const MANIFEST_FILE_NAME = 'manifest.json'
|
||||
|
||||
export type RemoteSnapshotAsset = {
|
||||
kind: 'image'
|
||||
store: DataManagerImageStoreKey
|
||||
id: string
|
||||
path: string
|
||||
mimeType: string
|
||||
sizeBytes: number
|
||||
createdAt: number
|
||||
accessedAt?: number
|
||||
source: ImageMetadata['source']
|
||||
metadata?: ImageMetadata['metadata']
|
||||
sha256?: string
|
||||
}
|
||||
|
||||
export type RemoteSnapshotManifest = {
|
||||
schemaVersion: typeof REMOTE_SNAPSHOT_SCHEMA_VERSION
|
||||
snapshotId: string
|
||||
createdAt: string
|
||||
appDataPath: string
|
||||
favoritesPath: string
|
||||
assets: RemoteSnapshotAsset[]
|
||||
missingAssets: Array<{ store: DataManagerImageStoreKey; id: string }>
|
||||
assetCounts: Record<DataManagerImageStoreKey, number>
|
||||
includedSections: DataManagerPackageSection[]
|
||||
}
|
||||
|
||||
export type RemoteSnapshotEntry = {
|
||||
id: string
|
||||
name: string
|
||||
manifestPath: string
|
||||
updatedAt?: string
|
||||
sizeBytes?: number
|
||||
manifest?: RemoteSnapshotManifest
|
||||
}
|
||||
|
||||
export type RemoteSnapshotBackupResult = {
|
||||
entry: RemoteSnapshotEntry
|
||||
manifest: RemoteSnapshotManifest
|
||||
uploadedAssets: number
|
||||
skippedAssets: number
|
||||
missingAssets: Array<{ store: DataManagerImageStoreKey; id: string }>
|
||||
}
|
||||
|
||||
export type RemoteSnapshotRestoreReport = {
|
||||
restored: number
|
||||
skipped: number
|
||||
missing: Array<{ store: DataManagerImageStoreKey; id: string }>
|
||||
corrupt: Array<{ store: DataManagerImageStoreKey; id: string }>
|
||||
errors: string[]
|
||||
imported: {
|
||||
appData: boolean
|
||||
favorites: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type RemoteSnapshotCleanupCandidate = {
|
||||
path: string
|
||||
sizeBytes?: number
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type RemoteSnapshotCleanupAnalysis = {
|
||||
candidates: RemoteSnapshotCleanupCandidate[]
|
||||
referencedAssetCount: number
|
||||
totalCandidateBytes: number
|
||||
}
|
||||
|
||||
export type RemoteSnapshotCleanupResult = RemoteSnapshotCleanupAnalysis & {
|
||||
deleted: number
|
||||
failed: Array<{ path: string; message: string }>
|
||||
}
|
||||
|
||||
export type RemoteSnapshotProgressPhase =
|
||||
| 'prepare'
|
||||
| 'scan'
|
||||
| 'asset-check'
|
||||
| 'asset-upload'
|
||||
| 'metadata-upload'
|
||||
| 'manifest-upload'
|
||||
| 'list'
|
||||
| 'restore-validate'
|
||||
| 'restore-write'
|
||||
| 'cleanup-analyze'
|
||||
| 'cleanup-delete'
|
||||
| 'done'
|
||||
|
||||
export type RemoteSnapshotProgressEvent = {
|
||||
phase: RemoteSnapshotProgressPhase
|
||||
current?: number
|
||||
total?: number
|
||||
item?: string
|
||||
uploaded?: number
|
||||
skipped?: number
|
||||
deleted?: number
|
||||
}
|
||||
|
||||
export type RemoteSnapshotProgressReporter = (event: RemoteSnapshotProgressEvent) => void
|
||||
|
||||
type ExportRemoteSnapshotOptions = {
|
||||
objectStore: RemoteObjectStore
|
||||
dataManager: Pick<IDataManager, 'exportAllData'>
|
||||
favoriteManager: Pick<IFavoriteManager, 'exportFavorites'> | null | undefined
|
||||
imageStorageService?: Pick<IImageStorageService, 'listAllMetadata' | 'getImage'> | null
|
||||
favoriteImageStorageService?: Pick<IImageStorageService, 'listAllMetadata' | 'getImage'> | null
|
||||
sections?: Partial<DataManagerPackageSectionSelection>
|
||||
onProgress?: RemoteSnapshotProgressReporter
|
||||
}
|
||||
|
||||
type RestoreRemoteSnapshotOptions = {
|
||||
objectStore: RemoteObjectStore
|
||||
snapshotId: string
|
||||
dataManager: Pick<IDataManager, 'importAllData'>
|
||||
favoriteManager: Pick<IFavoriteManager, 'importFavorites'> | null | undefined
|
||||
imageStorageService?: Pick<IImageStorageService, 'getMetadata' | 'saveImage'> | null
|
||||
favoriteImageStorageService?: Pick<IImageStorageService, 'getMetadata' | 'saveImage'> | null
|
||||
sections?: Partial<DataManagerPackageSectionSelection>
|
||||
favoriteMergeStrategy?: DataManagerFavoritesMergeStrategy
|
||||
onProgress?: RemoteSnapshotProgressReporter
|
||||
}
|
||||
|
||||
type ImageStoreExportConfig = {
|
||||
key: DataManagerImageStoreKey
|
||||
service: ExportRemoteSnapshotOptions['imageStorageService']
|
||||
}
|
||||
|
||||
type PreparedImageRestore = {
|
||||
store: DataManagerImageStoreKey
|
||||
image: FullImageData
|
||||
}
|
||||
|
||||
const EMPTY_APP_DATA_JSON = JSON.stringify({ version: 1, data: {} }, null, 2)
|
||||
const EMPTY_FAVORITES_JSON = JSON.stringify({ version: '1.0', favorites: [], categories: [], tags: [] }, null, 2)
|
||||
const ALL_DATA_MANAGER_PACKAGE_SECTIONS = Object.keys(
|
||||
DEFAULT_DATA_MANAGER_PACKAGE_SECTIONS,
|
||||
) as DataManagerPackageSection[]
|
||||
|
||||
const resolveSectionSelection = (
|
||||
sections?: Partial<DataManagerPackageSectionSelection>,
|
||||
): DataManagerPackageSectionSelection => ({
|
||||
...DEFAULT_DATA_MANAGER_PACKAGE_SECTIONS,
|
||||
...(sections ?? {}),
|
||||
})
|
||||
|
||||
const getManifestIncludedSectionSet = (
|
||||
manifest: RemoteSnapshotManifest,
|
||||
): Set<DataManagerPackageSection> => {
|
||||
const included = Array.isArray(manifest.includedSections)
|
||||
? manifest.includedSections.filter((section): section is DataManagerPackageSection =>
|
||||
ALL_DATA_MANAGER_PACKAGE_SECTIONS.includes(section as DataManagerPackageSection),
|
||||
)
|
||||
: []
|
||||
|
||||
if (included.length > 0) {
|
||||
return new Set(included)
|
||||
}
|
||||
|
||||
return new Set([
|
||||
'appData',
|
||||
'favorites',
|
||||
...manifest.assets.map((asset) => asset.store),
|
||||
])
|
||||
}
|
||||
|
||||
const resolveRestoreSectionSelection = (
|
||||
manifest: RemoteSnapshotManifest,
|
||||
requested?: Partial<DataManagerPackageSectionSelection>,
|
||||
): DataManagerPackageSectionSelection => {
|
||||
const requestedSections = resolveSectionSelection(requested)
|
||||
const includedSections = getManifestIncludedSectionSet(manifest)
|
||||
|
||||
return ALL_DATA_MANAGER_PACKAGE_SECTIONS.reduce((selection, section) => {
|
||||
selection[section] = requestedSections[section] && includedSections.has(section)
|
||||
return selection
|
||||
}, {} as DataManagerPackageSectionSelection)
|
||||
}
|
||||
|
||||
const snapshotDirectory = (snapshotId: string): string =>
|
||||
joinRemotePath(REMOTE_SNAPSHOT_ROOT, 'snapshots', snapshotId)
|
||||
|
||||
const snapshotManifestPath = (snapshotId: string): string =>
|
||||
joinRemotePath(snapshotDirectory(snapshotId), MANIFEST_FILE_NAME)
|
||||
|
||||
const snapshotAppDataPath = (snapshotId: string): string =>
|
||||
joinRemotePath(snapshotDirectory(snapshotId), APP_DATA_FILE_NAME)
|
||||
|
||||
const snapshotFavoritesPath = (snapshotId: string): string =>
|
||||
joinRemotePath(snapshotDirectory(snapshotId), FAVORITES_FILE_NAME)
|
||||
|
||||
export const createRemoteSnapshotId = (date = new Date()): string =>
|
||||
date.toISOString().replace(/[:.]/g, '-')
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
const textEncoder = new TextEncoder()
|
||||
|
||||
const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => {
|
||||
const view = new Uint8Array(bytes.byteLength)
|
||||
view.set(bytes)
|
||||
return view.buffer
|
||||
}
|
||||
|
||||
const bytesToBase64 = (bytes: Uint8Array): string => {
|
||||
let binary = ''
|
||||
const chunkSize = 0x8000
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize)
|
||||
binary += String.fromCharCode(...chunk)
|
||||
}
|
||||
return globalThis.btoa(binary)
|
||||
}
|
||||
|
||||
const base64ToBytes = (base64: string): Uint8Array => {
|
||||
const binary = globalThis.atob(base64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
const sha256Hex = async (bytes: Uint8Array): Promise<string | undefined> => {
|
||||
if (!globalThis.crypto?.subtle) return undefined
|
||||
try {
|
||||
const digest = await globalThis.crypto.subtle.digest('SHA-256', toArrayBuffer(bytes))
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const extensionFromMimeType = (mimeType: string): string => {
|
||||
const normalized = mimeType.toLowerCase().split(';')[0].trim()
|
||||
if (normalized === 'image/jpeg' || normalized === 'image/jpg') return 'jpg'
|
||||
if (normalized === 'image/png') return 'png'
|
||||
if (normalized === 'image/webp') return 'webp'
|
||||
if (normalized === 'image/gif') return 'gif'
|
||||
if (normalized === 'image/svg+xml') return 'svg'
|
||||
return 'bin'
|
||||
}
|
||||
|
||||
const inferMimeTypeFromBytes = (bytes: Uint8Array): string | null => {
|
||||
if (
|
||||
bytes.length >= 8 &&
|
||||
bytes[0] === 0x89 &&
|
||||
bytes[1] === 0x50 &&
|
||||
bytes[2] === 0x4e &&
|
||||
bytes[3] === 0x47 &&
|
||||
bytes[4] === 0x0d &&
|
||||
bytes[5] === 0x0a &&
|
||||
bytes[6] === 0x1a &&
|
||||
bytes[7] === 0x0a
|
||||
) {
|
||||
return 'image/png'
|
||||
}
|
||||
|
||||
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes[0] === 0x52 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 &&
|
||||
bytes[9] === 0x45 &&
|
||||
bytes[10] === 0x42 &&
|
||||
bytes[11] === 0x50
|
||||
) {
|
||||
return 'image/webp'
|
||||
}
|
||||
|
||||
if (
|
||||
bytes.length >= 6 &&
|
||||
bytes[0] === 0x47 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x38 &&
|
||||
(bytes[4] === 0x37 || bytes[4] === 0x39) &&
|
||||
bytes[5] === 0x61
|
||||
) {
|
||||
return 'image/gif'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const resolveResourceMimeType = (declaredMimeType: string | undefined, bytes: Uint8Array): string =>
|
||||
inferMimeTypeFromBytes(bytes) || declaredMimeType || 'application/octet-stream'
|
||||
|
||||
const safeAssetFileName = (id: string, mimeType: string): string =>
|
||||
`${encodeURIComponent(id)}.${extensionFromMimeType(mimeType)}`
|
||||
|
||||
const remoteAssetPath = (
|
||||
store: DataManagerImageStoreKey,
|
||||
id: string,
|
||||
mimeType: string,
|
||||
): string =>
|
||||
joinRemotePath(REMOTE_SNAPSHOT_ROOT, 'assets', store, safeAssetFileName(id, mimeType))
|
||||
|
||||
const parseManifest = (json: string): RemoteSnapshotManifest => {
|
||||
const parsed = JSON.parse(json) as unknown
|
||||
if (
|
||||
!isRecord(parsed) ||
|
||||
parsed.schemaVersion !== REMOTE_SNAPSHOT_SCHEMA_VERSION ||
|
||||
typeof parsed.snapshotId !== 'string' ||
|
||||
typeof parsed.appDataPath !== 'string' ||
|
||||
typeof parsed.favoritesPath !== 'string' ||
|
||||
!Array.isArray(parsed.assets) ||
|
||||
!Array.isArray(parsed.missingAssets)
|
||||
) {
|
||||
throw new Error('Invalid remote snapshot manifest')
|
||||
}
|
||||
return parsed as RemoteSnapshotManifest
|
||||
}
|
||||
|
||||
const validateJsonText = (text: string, label: string): void => {
|
||||
try {
|
||||
JSON.parse(text)
|
||||
} catch (error) {
|
||||
throw new Error(`${label} is not valid JSON: ${(error as Error).message}`, {
|
||||
cause: error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeImageMetadata = (
|
||||
asset: RemoteSnapshotAsset,
|
||||
sizeBytes: number,
|
||||
mimeType: string,
|
||||
): ImageMetadata => ({
|
||||
id: asset.id,
|
||||
mimeType,
|
||||
sizeBytes,
|
||||
createdAt: typeof asset.createdAt === 'number' ? asset.createdAt : Date.now(),
|
||||
accessedAt: Date.now(),
|
||||
source: asset.source === 'generated' ? 'generated' : 'uploaded',
|
||||
...(asset.metadata ? { metadata: asset.metadata } : {}),
|
||||
})
|
||||
|
||||
const getImportStorageService = (
|
||||
store: DataManagerImageStoreKey,
|
||||
options: RestoreRemoteSnapshotOptions,
|
||||
): Pick<IImageStorageService, 'getMetadata' | 'saveImage'> | null | undefined =>
|
||||
store === 'favoriteImages'
|
||||
? options.favoriteImageStorageService
|
||||
: options.imageStorageService
|
||||
|
||||
const getRemoteObjectEntry = async (
|
||||
objectStore: RemoteObjectStore,
|
||||
path: string,
|
||||
): Promise<Pick<RemoteObjectEntry, 'path' | 'sizeBytes'> | null> => {
|
||||
if (typeof objectStore.head === 'function') {
|
||||
return objectStore.head(path)
|
||||
}
|
||||
return await objectStore.exists(path) ? { path } : null
|
||||
}
|
||||
|
||||
const remoteObjectMatchesLocalBytes = (
|
||||
remoteEntry: Pick<RemoteObjectEntry, 'sizeBytes'> | null,
|
||||
localSizeBytes: number,
|
||||
): boolean => {
|
||||
if (!remoteEntry) return false
|
||||
if (typeof remoteEntry.sizeBytes !== 'number') return true
|
||||
return remoteEntry.sizeBytes === localSizeBytes
|
||||
}
|
||||
|
||||
const collectStoreAssets = async (
|
||||
objectStore: RemoteObjectStore,
|
||||
config: ImageStoreExportConfig,
|
||||
onProgress: RemoteSnapshotProgressReporter | undefined,
|
||||
): Promise<{
|
||||
assets: RemoteSnapshotAsset[]
|
||||
missing: Array<{ store: DataManagerImageStoreKey; id: string }>
|
||||
uploaded: number
|
||||
skipped: number
|
||||
}> => {
|
||||
if (!config.service) {
|
||||
return { assets: [], missing: [], uploaded: 0, skipped: 0 }
|
||||
}
|
||||
|
||||
const metadataList = await config.service.listAllMetadata()
|
||||
onProgress?.({
|
||||
phase: 'scan',
|
||||
current: 0,
|
||||
total: metadataList.length,
|
||||
item: config.key,
|
||||
})
|
||||
const assets: RemoteSnapshotAsset[] = []
|
||||
const missing: Array<{ store: DataManagerImageStoreKey; id: string }> = []
|
||||
let uploaded = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const [index, metadata] of metadataList.entries()) {
|
||||
const image: FullImageData | null = await config.service.getImage(metadata.id)
|
||||
if (!image?.data) {
|
||||
missing.push({ store: config.key, id: metadata.id })
|
||||
continue
|
||||
}
|
||||
|
||||
const bytes = base64ToBytes(image.data)
|
||||
const mimeType = resolveResourceMimeType(image.metadata.mimeType || metadata.mimeType, bytes)
|
||||
const path = remoteAssetPath(config.key, metadata.id, mimeType)
|
||||
const sha256 = await sha256Hex(bytes)
|
||||
|
||||
onProgress?.({
|
||||
phase: 'asset-check',
|
||||
current: index + 1,
|
||||
total: metadataList.length,
|
||||
item: metadata.id,
|
||||
uploaded,
|
||||
skipped,
|
||||
})
|
||||
const remoteEntry = await getRemoteObjectEntry(objectStore, path)
|
||||
if (remoteObjectMatchesLocalBytes(remoteEntry, bytes.byteLength)) {
|
||||
skipped += 1
|
||||
} else {
|
||||
onProgress?.({
|
||||
phase: 'asset-upload',
|
||||
current: index + 1,
|
||||
total: metadataList.length,
|
||||
item: metadata.id,
|
||||
uploaded,
|
||||
skipped,
|
||||
})
|
||||
await objectStore.put(path, bytes, { contentType: mimeType })
|
||||
uploaded += 1
|
||||
}
|
||||
|
||||
assets.push({
|
||||
kind: 'image',
|
||||
store: config.key,
|
||||
id: metadata.id,
|
||||
path,
|
||||
mimeType,
|
||||
sizeBytes: bytes.byteLength,
|
||||
createdAt: image.metadata.createdAt || metadata.createdAt || Date.now(),
|
||||
accessedAt: image.metadata.accessedAt || metadata.accessedAt,
|
||||
source: image.metadata.source || metadata.source || 'uploaded',
|
||||
metadata: image.metadata.metadata || metadata.metadata,
|
||||
sha256,
|
||||
})
|
||||
}
|
||||
|
||||
return { assets, missing, uploaded, skipped }
|
||||
}
|
||||
|
||||
export const createRemoteSnapshotBackup = async (
|
||||
options: ExportRemoteSnapshotOptions,
|
||||
): Promise<RemoteSnapshotBackupResult> => {
|
||||
const sections = resolveSectionSelection(options.sections)
|
||||
const snapshotId = createRemoteSnapshotId()
|
||||
options.onProgress?.({ phase: 'prepare' })
|
||||
const appDataJson = sections.appData
|
||||
? await options.dataManager.exportAllData()
|
||||
: EMPTY_APP_DATA_JSON
|
||||
const favoritesJson = sections.favorites && options.favoriteManager
|
||||
? await options.favoriteManager.exportFavorites()
|
||||
: EMPTY_FAVORITES_JSON
|
||||
|
||||
validateJsonText(appDataJson, 'app-data.json')
|
||||
validateJsonText(favoritesJson, 'favorites.json')
|
||||
|
||||
const storeConfigs: ImageStoreExportConfig[] = [
|
||||
{
|
||||
key: 'imageCache',
|
||||
service: sections.imageCache ? options.imageStorageService : null,
|
||||
},
|
||||
{
|
||||
key: 'favoriteImages',
|
||||
service: sections.favoriteImages ? options.favoriteImageStorageService : null,
|
||||
},
|
||||
]
|
||||
|
||||
const assets: RemoteSnapshotAsset[] = []
|
||||
const missingAssets: Array<{ store: DataManagerImageStoreKey; id: string }> = []
|
||||
let uploadedAssets = 0
|
||||
let skippedAssets = 0
|
||||
|
||||
for (const config of storeConfigs) {
|
||||
const result = await collectStoreAssets(options.objectStore, config, options.onProgress)
|
||||
assets.push(...result.assets)
|
||||
missingAssets.push(...result.missing)
|
||||
uploadedAssets += result.uploaded
|
||||
skippedAssets += result.skipped
|
||||
}
|
||||
|
||||
const manifest: RemoteSnapshotManifest = {
|
||||
schemaVersion: REMOTE_SNAPSHOT_SCHEMA_VERSION,
|
||||
snapshotId,
|
||||
createdAt: new Date().toISOString(),
|
||||
appDataPath: snapshotAppDataPath(snapshotId),
|
||||
favoritesPath: snapshotFavoritesPath(snapshotId),
|
||||
assets,
|
||||
missingAssets,
|
||||
assetCounts: {
|
||||
imageCache: assets.filter((asset) => asset.store === 'imageCache').length,
|
||||
favoriteImages: assets.filter((asset) => asset.store === 'favoriteImages').length,
|
||||
},
|
||||
includedSections: (Object.keys(sections) as DataManagerPackageSection[])
|
||||
.filter((section) => sections[section]),
|
||||
}
|
||||
|
||||
options.onProgress?.({ phase: 'metadata-upload', current: 1, total: 2, item: APP_DATA_FILE_NAME })
|
||||
await options.objectStore.put(manifest.appDataPath, appDataJson, { contentType: 'application/json' })
|
||||
options.onProgress?.({ phase: 'metadata-upload', current: 2, total: 2, item: FAVORITES_FILE_NAME })
|
||||
await options.objectStore.put(manifest.favoritesPath, favoritesJson, { contentType: 'application/json' })
|
||||
const manifestText = JSON.stringify(manifest, null, 2)
|
||||
options.onProgress?.({ phase: 'manifest-upload', item: MANIFEST_FILE_NAME })
|
||||
await options.objectStore.put(snapshotManifestPath(snapshotId), manifestText, { contentType: 'application/json' })
|
||||
options.onProgress?.({
|
||||
phase: 'done',
|
||||
uploaded: uploadedAssets,
|
||||
skipped: skippedAssets,
|
||||
})
|
||||
|
||||
return {
|
||||
entry: {
|
||||
id: snapshotId,
|
||||
name: snapshotId,
|
||||
manifestPath: snapshotManifestPath(snapshotId),
|
||||
updatedAt: manifest.createdAt,
|
||||
sizeBytes: textEncoder.encode(manifestText).byteLength,
|
||||
manifest,
|
||||
},
|
||||
manifest,
|
||||
uploadedAssets,
|
||||
skippedAssets,
|
||||
missingAssets,
|
||||
}
|
||||
}
|
||||
|
||||
export const listRemoteSnapshotBackups = async (
|
||||
objectStore: RemoteObjectStore,
|
||||
onProgress?: RemoteSnapshotProgressReporter,
|
||||
): Promise<RemoteSnapshotEntry[]> => {
|
||||
onProgress?.({ phase: 'list' })
|
||||
const entries = await objectStore.list(joinRemotePath(REMOTE_SNAPSHOT_ROOT, 'snapshots'))
|
||||
const manifests = entries.filter((entry) => entry.path.endsWith(`/${MANIFEST_FILE_NAME}`))
|
||||
const snapshots: RemoteSnapshotEntry[] = []
|
||||
|
||||
for (const entry of manifests) {
|
||||
try {
|
||||
const manifest = parseManifest(await objectStore.getText(entry.path))
|
||||
snapshots.push({
|
||||
id: manifest.snapshotId,
|
||||
name: manifest.snapshotId,
|
||||
manifestPath: entry.path,
|
||||
updatedAt: manifest.createdAt || entry.updatedAt,
|
||||
sizeBytes: entry.sizeBytes,
|
||||
manifest,
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[RemoteSnapshotBackup] Ignoring invalid remote snapshot manifest:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return snapshots.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
|
||||
}
|
||||
|
||||
const prepareRemoteSnapshotRestore = async (
|
||||
manifest: RemoteSnapshotManifest,
|
||||
options: RestoreRemoteSnapshotOptions,
|
||||
): Promise<{
|
||||
appDataJson: string | null
|
||||
favoritesJson: string | null
|
||||
images: PreparedImageRestore[]
|
||||
report: RemoteSnapshotRestoreReport
|
||||
}> => {
|
||||
const sections = resolveRestoreSectionSelection(manifest, options.sections)
|
||||
const report: RemoteSnapshotRestoreReport = {
|
||||
restored: 0,
|
||||
skipped: 0,
|
||||
missing: [],
|
||||
corrupt: [],
|
||||
errors: [],
|
||||
imported: {
|
||||
appData: sections.appData,
|
||||
favorites: Boolean(sections.favorites && options.favoriteManager),
|
||||
},
|
||||
}
|
||||
|
||||
const selectedStores = new Set<DataManagerImageStoreKey>([
|
||||
...(sections.imageCache ? ['imageCache' as const] : []),
|
||||
...(sections.favoriteImages ? ['favoriteImages' as const] : []),
|
||||
])
|
||||
|
||||
const appDataJson = sections.appData
|
||||
? await options.objectStore.getText(manifest.appDataPath)
|
||||
: null
|
||||
const favoritesJson = sections.favorites && options.favoriteManager
|
||||
? await options.objectStore.getText(manifest.favoritesPath)
|
||||
: null
|
||||
|
||||
if (appDataJson !== null) validateJsonText(appDataJson, 'app-data.json')
|
||||
if (favoritesJson !== null) validateJsonText(favoritesJson, 'favorites.json')
|
||||
|
||||
const images: PreparedImageRestore[] = []
|
||||
for (const [index, asset] of manifest.assets.entries()) {
|
||||
if (
|
||||
asset.kind !== 'image' ||
|
||||
!asset.id ||
|
||||
!asset.path ||
|
||||
!selectedStores.has(asset.store)
|
||||
) {
|
||||
report.skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const storageService = getImportStorageService(asset.store, options)
|
||||
if (!storageService) {
|
||||
report.errors.push(`${asset.store}:${asset.id}: image storage service is unavailable`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
options.onProgress?.({
|
||||
phase: 'restore-validate',
|
||||
current: index + 1,
|
||||
total: manifest.assets.length,
|
||||
item: asset.id,
|
||||
})
|
||||
const bytes = new Uint8Array(await options.objectStore.get(asset.path))
|
||||
if (bytes.byteLength === 0) {
|
||||
report.corrupt.push({ store: asset.store, id: asset.id })
|
||||
continue
|
||||
}
|
||||
if (asset.sha256) {
|
||||
const actualHash = await sha256Hex(bytes)
|
||||
if (actualHash && actualHash !== asset.sha256) {
|
||||
report.corrupt.push({ store: asset.store, id: asset.id })
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (
|
||||
!asset.sha256 &&
|
||||
typeof asset.sizeBytes === 'number' &&
|
||||
Number.isFinite(asset.sizeBytes) &&
|
||||
asset.sizeBytes > 0 &&
|
||||
Math.abs(asset.sizeBytes - bytes.byteLength) > 2
|
||||
) {
|
||||
report.corrupt.push({ store: asset.store, id: asset.id })
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = await storageService.getMetadata(asset.id)
|
||||
if (existing) {
|
||||
report.skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
images.push({
|
||||
store: asset.store,
|
||||
image: {
|
||||
metadata: normalizeImageMetadata(
|
||||
asset,
|
||||
bytes.byteLength,
|
||||
resolveResourceMimeType(asset.mimeType, bytes),
|
||||
),
|
||||
data: bytesToBase64(bytes),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (String((error as Error).message || error).includes('not found')) {
|
||||
report.missing.push({ store: asset.store, id: asset.id })
|
||||
} else {
|
||||
report.errors.push(`${asset.store}:${asset.id}: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (report.missing.length > 0 || report.corrupt.length > 0 || report.errors.length > 0) {
|
||||
const details = [
|
||||
report.missing.length ? `missing=${report.missing.length}` : '',
|
||||
report.corrupt.length ? `corrupt=${report.corrupt.length}` : '',
|
||||
report.errors.length ? `errors=${report.errors.length}` : '',
|
||||
].filter(Boolean).join(', ')
|
||||
throw new Error(`Remote snapshot restore validation failed: ${details}`)
|
||||
}
|
||||
|
||||
report.missing.push(...manifest.missingAssets.filter((asset) => selectedStores.has(asset.store)))
|
||||
|
||||
return {
|
||||
appDataJson,
|
||||
favoritesJson,
|
||||
images,
|
||||
report,
|
||||
}
|
||||
}
|
||||
|
||||
export const restoreRemoteSnapshotBackup = async (
|
||||
options: RestoreRemoteSnapshotOptions,
|
||||
): Promise<RemoteSnapshotRestoreReport> => {
|
||||
options.onProgress?.({ phase: 'restore-validate', current: 0, total: 1, item: MANIFEST_FILE_NAME })
|
||||
const manifest = parseManifest(await options.objectStore.getText(snapshotManifestPath(options.snapshotId)))
|
||||
const prepared = await prepareRemoteSnapshotRestore(manifest, options)
|
||||
|
||||
for (const [index, item] of prepared.images.entries()) {
|
||||
const storageService = getImportStorageService(item.store, options)
|
||||
if (!storageService) {
|
||||
throw new Error(`${item.store}:${item.image.metadata.id}: image storage service is unavailable`)
|
||||
}
|
||||
options.onProgress?.({
|
||||
phase: 'restore-write',
|
||||
current: index + 1,
|
||||
total: prepared.images.length,
|
||||
item: item.image.metadata.id,
|
||||
})
|
||||
await storageService.saveImage(item.image)
|
||||
prepared.report.restored += 1
|
||||
}
|
||||
|
||||
if (prepared.appDataJson !== null) {
|
||||
options.onProgress?.({ phase: 'restore-write', item: APP_DATA_FILE_NAME })
|
||||
await options.dataManager.importAllData(prepared.appDataJson)
|
||||
}
|
||||
|
||||
if (prepared.favoritesJson !== null && options.favoriteManager) {
|
||||
options.onProgress?.({ phase: 'restore-write', item: FAVORITES_FILE_NAME })
|
||||
await options.favoriteManager.importFavorites(prepared.favoritesJson, {
|
||||
mergeStrategy: options.favoriteMergeStrategy ?? 'overwrite',
|
||||
})
|
||||
}
|
||||
|
||||
options.onProgress?.({
|
||||
phase: 'done',
|
||||
current: prepared.report.restored,
|
||||
total: prepared.images.length,
|
||||
})
|
||||
return prepared.report
|
||||
}
|
||||
|
||||
const readCommittedSnapshotManifests = async (
|
||||
objectStore: RemoteObjectStore,
|
||||
): Promise<RemoteSnapshotManifest[]> => {
|
||||
const entries = await objectStore.list(joinRemotePath(REMOTE_SNAPSHOT_ROOT, 'snapshots'))
|
||||
const manifestEntries = entries.filter((entry) => entry.path.endsWith(`/${MANIFEST_FILE_NAME}`))
|
||||
const manifests: RemoteSnapshotManifest[] = []
|
||||
const failures: Array<{ path: string; message: string }> = []
|
||||
|
||||
for (const entry of manifestEntries) {
|
||||
try {
|
||||
manifests.push(parseManifest(await objectStore.getText(entry.path)))
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
path: entry.path,
|
||||
message: (error as Error).message || String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
const firstFailure = failures[0]
|
||||
throw new Error(
|
||||
`Unable to safely analyze remote snapshot assets because ${failures.length} snapshot manifest(s) could not be read. First failure: ${firstFailure.path}: ${firstFailure.message}`,
|
||||
)
|
||||
}
|
||||
|
||||
return manifests
|
||||
}
|
||||
|
||||
export const analyzeRemoteSnapshotAssetCleanup = async (
|
||||
objectStore: RemoteObjectStore,
|
||||
onProgress?: RemoteSnapshotProgressReporter,
|
||||
): Promise<RemoteSnapshotCleanupAnalysis> => {
|
||||
onProgress?.({ phase: 'cleanup-analyze' })
|
||||
const manifests = await readCommittedSnapshotManifests(objectStore)
|
||||
const referenced = new Set(manifests.flatMap((manifest) => manifest.assets.map((asset) => asset.path)))
|
||||
const remoteAssets = await objectStore.list(joinRemotePath(REMOTE_SNAPSHOT_ROOT, 'assets'))
|
||||
const candidates = remoteAssets
|
||||
.filter((entry) => !referenced.has(entry.path))
|
||||
.map((entry) => ({
|
||||
path: entry.path,
|
||||
sizeBytes: entry.sizeBytes,
|
||||
updatedAt: entry.updatedAt,
|
||||
}))
|
||||
|
||||
return {
|
||||
candidates,
|
||||
referencedAssetCount: referenced.size,
|
||||
totalCandidateBytes: candidates.reduce((sum, candidate) => sum + (candidate.sizeBytes ?? 0), 0),
|
||||
}
|
||||
}
|
||||
|
||||
export const cleanupRemoteSnapshotAssets = async (
|
||||
objectStore: RemoteObjectStore,
|
||||
onProgress?: RemoteSnapshotProgressReporter,
|
||||
): Promise<RemoteSnapshotCleanupResult> => {
|
||||
if (!objectStore.delete) {
|
||||
throw new Error('Remote asset cleanup is not supported by this provider')
|
||||
}
|
||||
const analysis = await analyzeRemoteSnapshotAssetCleanup(objectStore, onProgress)
|
||||
const failed: Array<{ path: string; message: string }> = []
|
||||
let deleted = 0
|
||||
|
||||
for (const [index, candidate] of analysis.candidates.entries()) {
|
||||
try {
|
||||
onProgress?.({
|
||||
phase: 'cleanup-delete',
|
||||
current: index + 1,
|
||||
total: analysis.candidates.length,
|
||||
item: candidate.path,
|
||||
deleted,
|
||||
})
|
||||
await objectStore.delete(candidate.path)
|
||||
deleted += 1
|
||||
} catch (error) {
|
||||
failed.push({ path: candidate.path, message: (error as Error).message })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...analysis,
|
||||
deleted,
|
||||
failed,
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,13 @@ vi.mock('naive-ui', async (importOriginal) => {
|
||||
return () => h('button', attrs, slots.default?.())
|
||||
},
|
||||
}),
|
||||
NBadge: defineComponent({
|
||||
name: 'NBadge',
|
||||
props: ['show'],
|
||||
setup(props, { slots }) {
|
||||
return () => h('span', { class: 'n-badge-stub', 'data-show': String(Boolean(props.show)) }, slots.default?.())
|
||||
},
|
||||
}),
|
||||
NPopover: defineComponent({
|
||||
name: 'NPopover',
|
||||
setup(_, { slots }) {
|
||||
@@ -154,4 +161,63 @@ describe('AppHeaderActions about menu layout hooks', () => {
|
||||
|
||||
expect(wrapper.emitted('open-favorites')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('marks the data manager action when an existing backup is stale', async () => {
|
||||
const wrapper = mount(AppHeaderActions, {
|
||||
props: {
|
||||
appVersion: 'v2.7.0',
|
||||
backupReminderDue: true,
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'nav.templates': 'Templates',
|
||||
'nav.history': 'History',
|
||||
'nav.modelManager': 'Model Manager',
|
||||
'nav.favorites': 'Favorite Library',
|
||||
'nav.dataManager': 'Data Manager',
|
||||
'nav.variableManager': 'Variable Manager',
|
||||
'nav.about': 'About',
|
||||
'updater.viewOnGitHub': 'View on GitHub',
|
||||
'dataManager.backupReminder.tooltip': 'No data export in over 10 days. Export a local copy.',
|
||||
}
|
||||
return map[key] ?? key
|
||||
},
|
||||
},
|
||||
stubs: {
|
||||
ThemeToggleUI: true,
|
||||
LanguageSwitchDropdown: true,
|
||||
UpdaterIcon: true,
|
||||
ActionButtonUI: defineComponent({
|
||||
name: 'ActionButtonUI',
|
||||
props: ['text', 'type'],
|
||||
emits: ['click'],
|
||||
setup(props, { emit, attrs }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
...attrs,
|
||||
'data-type': props.type,
|
||||
onClick: () => emit('click'),
|
||||
},
|
||||
props.text,
|
||||
)
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const dataManagerAction = wrapper.findAll('button').find((button) => button.text() === 'Data Manager')
|
||||
|
||||
expect(wrapper.find('.n-badge-stub[data-show="true"]').exists()).toBe(true)
|
||||
expect(dataManagerAction?.attributes('data-type')).toBe('warning')
|
||||
expect(dataManagerAction?.attributes('title')).toBe('No data export in over 10 days. Export a local copy.')
|
||||
|
||||
await dataManagerAction!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('open-data-manager')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,29 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
|
||||
const remoteSnapshotMocks = vi.hoisted(() => ({
|
||||
listRemoteSnapshotBackups: vi.fn(),
|
||||
restoreRemoteSnapshotBackup: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/utils/remote-snapshot-backup', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../src/utils/remote-snapshot-backup')>()
|
||||
return {
|
||||
...actual,
|
||||
listRemoteSnapshotBackups: remoteSnapshotMocks.listRemoteSnapshotBackups,
|
||||
restoreRemoteSnapshotBackup: remoteSnapshotMocks.restoreRemoteSnapshotBackup,
|
||||
}
|
||||
})
|
||||
|
||||
import DataManager from '../../../src/components/DataManager.vue'
|
||||
|
||||
const isRunningInElectronMock = vi.fn(() => false)
|
||||
const getEnvVarMock = vi.fn(() => '')
|
||||
|
||||
vi.mock('@prompt-optimizer/core', () => ({
|
||||
isRunningInElectron: () => isRunningInElectronMock(),
|
||||
getEnvVar: (key: string) => getEnvVarMock(key),
|
||||
}))
|
||||
|
||||
const naiveStubs = {
|
||||
@@ -33,12 +49,14 @@ const naiveStubs = {
|
||||
props: ['disabled', 'loading', 'type', 'block', 'size'],
|
||||
emits: ['click'],
|
||||
},
|
||||
NUpload: {
|
||||
NUpload: defineComponent({
|
||||
name: 'NUpload',
|
||||
template: '<div class="n-upload"><slot /></div>',
|
||||
props: ['fileList', 'accept', 'showFileList', 'customRequest', 'disabled'],
|
||||
emits: ['change'],
|
||||
},
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'n-upload' }, slots.default?.())
|
||||
},
|
||||
}),
|
||||
NUploadDragger: {
|
||||
name: 'NUploadDragger',
|
||||
template: '<div class="n-upload-dragger"><slot /></div>',
|
||||
@@ -63,6 +81,11 @@ const naiveStubs = {
|
||||
template: '<div class="n-statistic"><div>{{ label }}</div><div>{{ value }}</div><slot /></div>',
|
||||
props: ['label', 'value'],
|
||||
},
|
||||
NProgress: {
|
||||
name: 'NProgress',
|
||||
template: '<div class="n-progress" :data-percentage="percentage"></div>',
|
||||
props: ['type', 'percentage', 'height', 'borderRadius', 'fillBorderRadius', 'showIndicator'],
|
||||
},
|
||||
NGrid: {
|
||||
name: 'NGrid',
|
||||
template: '<div class="n-grid"><slot /></div>',
|
||||
@@ -73,6 +96,30 @@ const naiveStubs = {
|
||||
template: '<div class="n-grid-item"><slot /></div>',
|
||||
props: ['span'],
|
||||
},
|
||||
NSelect: {
|
||||
name: 'NSelect',
|
||||
template: '<select :value="value" @change="$emit(\'update:value\', $event.target.value)"><option v-for="option in options" :key="option.value" :value="option.value">{{ option.label }}</option></select>',
|
||||
props: ['value', 'options', 'placeholder', 'filterable', 'disabled'],
|
||||
emits: ['update:value'],
|
||||
},
|
||||
NInput: {
|
||||
name: 'NInput',
|
||||
template: '<input :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
|
||||
props: ['value', 'placeholder', 'type', 'clearable', 'showPasswordOn'],
|
||||
emits: ['update:value'],
|
||||
},
|
||||
NInputNumber: {
|
||||
name: 'NInputNumber',
|
||||
template: '<input type="number" :value="value" @input="$emit(\'update:value\', Number($event.target.value))" />',
|
||||
props: ['value', 'min', 'max', 'disabled'],
|
||||
emits: ['update:value'],
|
||||
},
|
||||
NSwitch: {
|
||||
name: 'NSwitch',
|
||||
template: '<input type="checkbox" :checked="value" @change="$emit(\'update:value\', $event.target.checked)" />',
|
||||
props: ['value'],
|
||||
emits: ['update:value'],
|
||||
},
|
||||
}
|
||||
|
||||
const createServices = () => ({
|
||||
@@ -108,6 +155,10 @@ const createServices = () => ({
|
||||
predefinedVariablesRemoved: 0,
|
||||
}),
|
||||
},
|
||||
favoriteManager: {
|
||||
exportFavorites: vi.fn().mockResolvedValue(JSON.stringify({ version: '1.0', favorites: [] })),
|
||||
importFavorites: vi.fn().mockResolvedValue({ imported: 0, skipped: 0, errors: [] }),
|
||||
},
|
||||
preferenceService: {
|
||||
exportData: vi.fn().mockResolvedValue({ theme: 'light' }),
|
||||
},
|
||||
@@ -146,11 +197,29 @@ describe('DataManager storage breakdown', () => {
|
||||
beforeEach(() => {
|
||||
isRunningInElectronMock.mockReset()
|
||||
isRunningInElectronMock.mockReturnValue(false)
|
||||
getEnvVarMock.mockReset()
|
||||
getEnvVarMock.mockReturnValue('')
|
||||
remoteSnapshotMocks.listRemoteSnapshotBackups.mockReset()
|
||||
remoteSnapshotMocks.listRemoteSnapshotBackups.mockResolvedValue([])
|
||||
remoteSnapshotMocks.restoreRemoteSnapshotBackup.mockReset()
|
||||
remoteSnapshotMocks.restoreRemoteSnapshotBackup.mockResolvedValue({
|
||||
restored: 0,
|
||||
skipped: 0,
|
||||
missing: [],
|
||||
corrupt: [],
|
||||
errors: [],
|
||||
imported: {
|
||||
appData: true,
|
||||
favorites: true,
|
||||
},
|
||||
})
|
||||
delete (window as any).electronAPI
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).electronAPI
|
||||
delete (window as any).google
|
||||
})
|
||||
|
||||
it('shows storage usage on web and keeps cache/favorite items separate', async () => {
|
||||
@@ -160,12 +229,18 @@ describe('DataManager storage breakdown', () => {
|
||||
|
||||
const modal = wrapper.find('.n-modal')
|
||||
const text = wrapper.text()
|
||||
expect(modal.attributes('style')).toContain('width: 90vw;')
|
||||
expect(modal.attributes('style')).toContain('width: calc(100vw - 32px);')
|
||||
expect(modal.attributes('style')).toContain('max-width: 1200px;')
|
||||
expect(modal.attributes('style')).toContain('max-height: 90vh;')
|
||||
expect(wrapper.findAll('.storage-stat-card')).toHaveLength(4)
|
||||
expect(text).toContain('Total (Includes Estimate)')
|
||||
expect(text).toContain('Data Backup')
|
||||
expect(text).toContain('Local Import/Export')
|
||||
expect(text).toContain('Remote Backup')
|
||||
expect(text).toContain('Google Drive is recommended for Web')
|
||||
expect(text).toContain('Authorize Google Drive')
|
||||
expect(text).toContain('Google Drive backup path: /prompt-optimizer-backups')
|
||||
expect(text).toContain('Create Backup')
|
||||
expect(text).not.toContain('Detect')
|
||||
expect(text).toContain('App Main Data (Estimated)')
|
||||
expect(text).toContain('Session/Result Image Cache')
|
||||
expect(text).toContain('Favorite Images')
|
||||
@@ -177,6 +252,8 @@ describe('DataManager storage breakdown', () => {
|
||||
expect(text).not.toContain('导入上下文集合')
|
||||
expect(text).not.toContain('Backup Data')
|
||||
expect(text).not.toContain('Data directory')
|
||||
expect(text).not.toContain('Backup folder')
|
||||
expect(text).not.toContain('Scheduled backup')
|
||||
})
|
||||
|
||||
it('shows desktop backup and storage directory actions in electron', async () => {
|
||||
@@ -202,6 +279,8 @@ describe('DataManager storage breakdown', () => {
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Backup Data')
|
||||
expect(text).toContain('Data directory')
|
||||
expect(text).toContain('Google Drive is not supported on Desktop yet')
|
||||
expect(text).not.toContain('Authorize Google Drive')
|
||||
expect(text).toContain('C:/PromptOptimizer/data')
|
||||
expect(text).toContain('Open directory')
|
||||
expect(text).toContain('Refresh')
|
||||
@@ -213,13 +292,276 @@ describe('DataManager storage breakdown', () => {
|
||||
await flushPromises()
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(wrapper.findAll('.backup-action-panel')).toHaveLength(2)
|
||||
expect(wrapper.find('.backup-action-panel--export').exists()).toBe(true)
|
||||
expect(wrapper.find('.backup-action-panel--import').exists()).toBe(true)
|
||||
expect(wrapper.find('.import-panel-body').exists()).toBe(true)
|
||||
expect(text).toContain('Export Current App Data')
|
||||
expect(text).toContain('Import Backup File')
|
||||
expect(wrapper.find('.local-transfer-panel').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.local-transfer-row')).toHaveLength(2)
|
||||
expect(wrapper.findAll('.local-scope-pill').length).toBeGreaterThanOrEqual(3)
|
||||
expect(wrapper.findAll('.remote-config-panel')).toHaveLength(2)
|
||||
expect(wrapper.find('.local-file-picker').exists()).toBe(true)
|
||||
expect(wrapper.find('.local-import-settings').exists()).toBe(false)
|
||||
expect(text).toContain('Export Current Data')
|
||||
expect(text).toContain('Import Local Data')
|
||||
expect(text).not.toContain('导出上下文集合')
|
||||
expect(text).not.toContain('导入上下文集合')
|
||||
})
|
||||
|
||||
it('shows Google Drive as authorized after the authorization flow returns a token', async () => {
|
||||
getEnvVarMock.mockImplementation((key: string) =>
|
||||
key === 'VITE_GOOGLE_DRIVE_CLIENT_ID'
|
||||
? 'configured-client-id.apps.googleusercontent.com'
|
||||
: ''
|
||||
)
|
||||
;(window as any).google = {
|
||||
accounts: {
|
||||
oauth2: {
|
||||
initTokenClient: vi.fn((config: { callback: (response: { access_token: string; expires_in: number }) => void }) => ({
|
||||
requestAccessToken: vi.fn(() => config.callback({ access_token: 'access-token', expires_in: 3600 })),
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
const authorizeButton = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('Authorize Google Drive')
|
||||
)
|
||||
expect(authorizeButton).toBeTruthy()
|
||||
await authorizeButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Google Drive Authorized')
|
||||
})
|
||||
|
||||
it('renders Cloudflare R2 as a simplified S3-compatible preset', async () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
window.localStorage.setItem('prompt-optimizer:remote-backup-settings', JSON.stringify({
|
||||
provider: {
|
||||
kind: 'cloudflare-r2',
|
||||
accountId: 'account-123',
|
||||
bucket: 'prompt-optimizer-backups',
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
},
|
||||
}))
|
||||
const wrapper = mountComponent()
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Setup Needed')
|
||||
expect(text).toContain('Set up R2 in 3 steps')
|
||||
expect(text).toContain('Enter Account ID')
|
||||
expect(text).toContain('Create a dedicated bucket and configure CORS')
|
||||
expect(text).toContain('Create an account API token')
|
||||
expect(text).not.toContain('Show All Steps')
|
||||
expect(text).not.toContain('Open R2 API Tokens')
|
||||
expect(text).not.toContain('Open Cloudflare Dashboard')
|
||||
expect(text).not.toContain('Open R2 Overview, create a bucket with the name below')
|
||||
expect(text).not.toContain('Region')
|
||||
expect(text).not.toContain('Use path-style S3 URLs')
|
||||
|
||||
const tokenStepButton = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('Create an account API token')
|
||||
)
|
||||
expect(tokenStepButton).toBeTruthy()
|
||||
await tokenStepButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Open R2 API Tokens')
|
||||
|
||||
const bucketStepButton = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('Create a dedicated bucket and configure CORS')
|
||||
)
|
||||
expect(bucketStepButton).toBeTruthy()
|
||||
await bucketStepButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Open R2 Overview')
|
||||
expect(wrapper.text()).toContain('Open R2 Overview, create a bucket with the name below')
|
||||
expect(wrapper.text()).toContain('R2 endpoint will be: https://account-123.r2.cloudflarestorage.com')
|
||||
expect(wrapper.text()).toContain('R2 bucket: prompt-optimizer-backups')
|
||||
expect(wrapper.text()).toContain('Object prefix: /prompt-optimizer-backups/')
|
||||
expect(wrapper.text()).toContain('Copy CORS Config')
|
||||
expect(wrapper.text()).toContain('Open R2 API Tokens')
|
||||
|
||||
const bucketsButton = wrapper.findAll('button').find((button) =>
|
||||
button.text().trim() === 'Open R2 Overview'
|
||||
)
|
||||
expect(bucketsButton).toBeTruthy()
|
||||
await bucketsButton!.trigger('click')
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://dash.cloudflare.com/account-123/r2/overview',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps configured Cloudflare R2 setup collapsed until requested', async () => {
|
||||
window.localStorage.setItem('prompt-optimizer:remote-backup-settings', JSON.stringify({
|
||||
provider: {
|
||||
kind: 'cloudflare-r2',
|
||||
accountId: 'account-123',
|
||||
bucket: 'prompt-optimizer-backups',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'sk',
|
||||
},
|
||||
}))
|
||||
const wrapper = mountComponent()
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Not Verified')
|
||||
expect(wrapper.text()).toContain('Configuration is saved. Click “Save and Connect” to verify it.')
|
||||
expect(wrapper.text()).toContain('Save and Connect')
|
||||
expect(wrapper.text()).toContain('Edit Configuration')
|
||||
expect(wrapper.text()).not.toContain('Cloudflare R2 is configured')
|
||||
expect(wrapper.text()).not.toContain('Enter Account ID')
|
||||
|
||||
const editConfigurationButton = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('Edit Configuration')
|
||||
)
|
||||
expect(editConfigurationButton).toBeTruthy()
|
||||
await editConfigurationButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Cloudflare R2 is configured')
|
||||
expect(wrapper.text()).not.toContain('Enter Account ID')
|
||||
|
||||
const showSetupButton = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('Show Setup')
|
||||
)
|
||||
expect(showSetupButton).toBeTruthy()
|
||||
await showSetupButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Enter Account ID')
|
||||
})
|
||||
|
||||
it('allows collapsing non-Google configuration after a connection failure', async () => {
|
||||
window.localStorage.setItem('prompt-optimizer:remote-backup-settings', JSON.stringify({
|
||||
provider: {
|
||||
kind: 'cloudflare-r2',
|
||||
accountId: 'account-123',
|
||||
bucket: 'prompt-optimizer-backups',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'sk',
|
||||
},
|
||||
}))
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
const setupState = (wrapper.vm as any).$?.setupState
|
||||
setupState.remoteConnectionStates = {
|
||||
...setupState.remoteConnectionStates,
|
||||
'cloudflare-r2': {
|
||||
...setupState.remoteConnectionStates['cloudflare-r2'],
|
||||
status: 'failed',
|
||||
message: 'boom',
|
||||
},
|
||||
}
|
||||
setupState.isRemoteConfigExpanded = true
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('boom')
|
||||
expect(wrapper.text()).toContain('Cloudflare R2 is configured')
|
||||
|
||||
const hideButton = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('Hide Configuration')
|
||||
)
|
||||
expect(hideButton).toBeTruthy()
|
||||
await hideButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('boom')
|
||||
expect(wrapper.text()).not.toContain('Cloudflare R2 is configured')
|
||||
})
|
||||
|
||||
it('opens Cloudflare R2 links in the system browser on desktop', async () => {
|
||||
isRunningInElectronMock.mockReturnValue(true)
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined)
|
||||
;(window as any).electronAPI = {
|
||||
shell: {
|
||||
openExternal,
|
||||
},
|
||||
}
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
window.localStorage.setItem('prompt-optimizer:remote-backup-settings', JSON.stringify({
|
||||
provider: {
|
||||
kind: 'cloudflare-r2',
|
||||
accountId: 'account-123',
|
||||
bucket: 'prompt-optimizer-backups',
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
},
|
||||
}))
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
const bucketStepButton = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('Create a dedicated bucket and configure CORS')
|
||||
)
|
||||
expect(bucketStepButton).toBeTruthy()
|
||||
await bucketStepButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const bucketsButton = wrapper.findAll('button').find((button) =>
|
||||
button.text().trim() === 'Open R2 Overview'
|
||||
)
|
||||
expect(bucketsButton).toBeTruthy()
|
||||
await bucketsButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(openExternal).toHaveBeenCalledWith('https://dash.cloudflare.com/account-123/r2/overview')
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('restores remote backups without reusing the selected local import file sections', async () => {
|
||||
remoteSnapshotMocks.listRemoteSnapshotBackups.mockResolvedValue([{
|
||||
id: 'snap-1',
|
||||
name: 'snap-1',
|
||||
manifestPath: 'v1/snapshots/snap-1/manifest.json',
|
||||
updatedAt: '2026-05-06T10:30:00.000Z',
|
||||
manifest: {
|
||||
includedSections: ['appData', 'favorites', 'imageCache', 'favoriteImages'],
|
||||
},
|
||||
}])
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
const setupState = (wrapper.vm as any).$?.setupState
|
||||
expect(setupState).toBeTruthy()
|
||||
setupState.availableImportSections = new Set(['appData'])
|
||||
setupState.importFavorites = false
|
||||
setupState.importAppDataImages = false
|
||||
await flushPromises()
|
||||
|
||||
const refreshButton = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('Refresh Remote Backup List')
|
||||
)
|
||||
expect(refreshButton).toBeTruthy()
|
||||
await refreshButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const restoreButton = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('Restore Selected Backup')
|
||||
)
|
||||
expect(restoreButton).toBeTruthy()
|
||||
await restoreButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(remoteSnapshotMocks.restoreRemoteSnapshotBackup).toHaveBeenCalledWith(expect.objectContaining({
|
||||
snapshotId: 'snap-1',
|
||||
sections: {
|
||||
appData: true,
|
||||
favorites: true,
|
||||
imageCache: true,
|
||||
favoriteImages: true,
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
DATA_BACKUP_STATUS_KEY,
|
||||
isDataBackupReminderDue,
|
||||
loadDataBackupStatus,
|
||||
recordDataBackupCompleted,
|
||||
} from '../../../src/utils/data-backup-reminder'
|
||||
|
||||
describe('data backup reminder', () => {
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem(DATA_BACKUP_STATUS_KEY)
|
||||
})
|
||||
|
||||
it('is not due when no backup has been recorded yet', () => {
|
||||
expect(isDataBackupReminderDue({}, new Date('2026-05-06T00:00:00.000Z'))).toBe(false)
|
||||
})
|
||||
|
||||
it('is due only after ten days since the last backup', () => {
|
||||
expect(isDataBackupReminderDue({
|
||||
lastBackupAt: '2026-04-26T00:00:00.000Z',
|
||||
}, new Date('2026-05-06T00:00:00.000Z'))).toBe(true)
|
||||
|
||||
expect(isDataBackupReminderDue({
|
||||
lastBackupAt: '2026-04-27T00:00:01.000Z',
|
||||
}, new Date('2026-05-06T00:00:00.000Z'))).toBe(false)
|
||||
})
|
||||
|
||||
it('records a completed backup and notifies the current tab', () => {
|
||||
const listener = vi.fn()
|
||||
window.addEventListener('prompt-optimizer:data-backup-status-changed', listener)
|
||||
|
||||
const status = recordDataBackupCompleted(new Date('2026-05-06T01:02:03.000Z'))
|
||||
|
||||
expect(status.lastBackupAt).toBe('2026-05-06T01:02:03.000Z')
|
||||
expect(loadDataBackupStatus()).toEqual(status)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
|
||||
window.removeEventListener('prompt-optimizer:data-backup-status-changed', listener)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,833 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const awsS3Mocks = vi.hoisted(() => {
|
||||
const send = vi.fn()
|
||||
const clients: Array<{ config: Record<string, unknown> }> = []
|
||||
|
||||
class S3Client {
|
||||
constructor(readonly config: Record<string, unknown>) {
|
||||
clients.push(this)
|
||||
}
|
||||
|
||||
send(command: unknown) {
|
||||
return send(command)
|
||||
}
|
||||
}
|
||||
|
||||
class HeadObjectCommand {
|
||||
constructor(readonly input: Record<string, unknown>) {}
|
||||
}
|
||||
|
||||
class PutObjectCommand {
|
||||
constructor(readonly input: Record<string, unknown>) {}
|
||||
}
|
||||
|
||||
class GetObjectCommand {
|
||||
constructor(readonly input: Record<string, unknown>) {}
|
||||
}
|
||||
|
||||
class ListObjectsV2Command {
|
||||
constructor(readonly input: Record<string, unknown>) {}
|
||||
}
|
||||
|
||||
class DeleteObjectCommand {
|
||||
constructor(readonly input: Record<string, unknown>) {}
|
||||
}
|
||||
|
||||
return {
|
||||
clients,
|
||||
send,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@aws-sdk/client-s3', () => ({
|
||||
DeleteObjectCommand: awsS3Mocks.DeleteObjectCommand,
|
||||
GetObjectCommand: awsS3Mocks.GetObjectCommand,
|
||||
HeadObjectCommand: awsS3Mocks.HeadObjectCommand,
|
||||
ListObjectsV2Command: awsS3Mocks.ListObjectsV2Command,
|
||||
PutObjectCommand: awsS3Mocks.PutObjectCommand,
|
||||
S3Client: awsS3Mocks.S3Client,
|
||||
}))
|
||||
|
||||
import {
|
||||
CLOUDFLARE_R2_DEFAULT_BACKUP_PREFIX,
|
||||
GOOGLE_DRIVE_DEFAULT_BACKUP_FOLDER_NAME,
|
||||
REMOTE_BACKUP_SETTINGS_KEY,
|
||||
createCloudflareR2CorsConfig,
|
||||
createRemoteObjectStore,
|
||||
createRemoteBackupFileName,
|
||||
createDefaultRemoteBackupProvider,
|
||||
createDefaultRemoteBackupSettings,
|
||||
getCloudflareR2DashboardLinks,
|
||||
getCloudflareR2Endpoint,
|
||||
getRecommendedRemoteBackupProvider,
|
||||
isGoogleDriveRemoteBackupAuthorized,
|
||||
loadRemoteBackupSettings,
|
||||
normalizeRemoteBackupSettings,
|
||||
resolveGoogleDriveClientId,
|
||||
saveRemoteBackupSettings,
|
||||
switchRemoteBackupProvider,
|
||||
} from '../../../src/utils/remote-backup'
|
||||
|
||||
describe('remote backup settings', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { runtime_config?: Record<string, unknown> }).runtime_config
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI
|
||||
delete (globalThis as unknown as { google?: unknown }).google
|
||||
globalThis.fetch = originalFetch
|
||||
window.localStorage.clear()
|
||||
awsS3Mocks.clients.length = 0
|
||||
awsS3Mocks.send.mockReset()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('recommends Google Drive for web and initializes a default provider', () => {
|
||||
const settings = createDefaultRemoteBackupSettings('web')
|
||||
|
||||
expect(getRecommendedRemoteBackupProvider('web')).toBe('google-drive')
|
||||
expect(settings.provider.kind).toBe('google-drive')
|
||||
})
|
||||
|
||||
it('disables Google Drive for desktop remote backup defaults', () => {
|
||||
const settings = createDefaultRemoteBackupSettings('desktop')
|
||||
|
||||
expect(getRecommendedRemoteBackupProvider('desktop')).toBe('cloudflare-r2')
|
||||
expect(settings.provider.kind).toBe('cloudflare-r2')
|
||||
expect(normalizeRemoteBackupSettings({
|
||||
provider: { kind: 'google-drive' },
|
||||
}, 'desktop').provider.kind).toBe('cloudflare-r2')
|
||||
})
|
||||
|
||||
it('creates timestamp-only ZIP backup file names', () => {
|
||||
expect(createRemoteBackupFileName()).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.po-backup\.zip$/,
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes S3-compatible settings', () => {
|
||||
const settings = normalizeRemoteBackupSettings({
|
||||
provider: {
|
||||
kind: 's3-compatible',
|
||||
endpoint: 'https://r2.example.test',
|
||||
region: '',
|
||||
bucket: 'po',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'sk',
|
||||
prefix: '',
|
||||
forcePathStyle: false,
|
||||
},
|
||||
}, 'web')
|
||||
|
||||
expect(settings.provider).toMatchObject({
|
||||
kind: 's3-compatible',
|
||||
endpoint: 'https://r2.example.test',
|
||||
region: 'auto',
|
||||
bucket: 'po',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'sk',
|
||||
prefix: 'prompt-optimizer-backups/',
|
||||
forcePathStyle: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('switches provider defaults without carrying incompatible credentials', () => {
|
||||
expect(createDefaultRemoteBackupProvider('google-drive')).toEqual({
|
||||
kind: 'google-drive',
|
||||
})
|
||||
|
||||
expect(createDefaultRemoteBackupProvider('webdav')).toEqual({
|
||||
kind: 'webdav',
|
||||
endpoint: '',
|
||||
username: '',
|
||||
password: '',
|
||||
directory: 'prompt-optimizer-backups',
|
||||
})
|
||||
|
||||
expect(createDefaultRemoteBackupProvider('cloudflare-r2')).toEqual({
|
||||
kind: 'cloudflare-r2',
|
||||
accountId: '',
|
||||
bucket: 'prompt-optimizer-backups',
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes Cloudflare R2 settings and derives its S3-compatible endpoint', () => {
|
||||
const settings = normalizeRemoteBackupSettings({
|
||||
provider: {
|
||||
kind: 'cloudflare-r2',
|
||||
accountId: 'account-123',
|
||||
bucket: '',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'sk',
|
||||
endpoint: 'ignored',
|
||||
},
|
||||
}, 'web')
|
||||
|
||||
expect(settings.provider).toEqual({
|
||||
kind: 'cloudflare-r2',
|
||||
accountId: 'account-123',
|
||||
bucket: 'prompt-optimizer-backups',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'sk',
|
||||
})
|
||||
expect(getCloudflareR2Endpoint('account-123')).toBe('https://account-123.r2.cloudflarestorage.com')
|
||||
expect(CLOUDFLARE_R2_DEFAULT_BACKUP_PREFIX).toBe('prompt-optimizer-backups/')
|
||||
expect(createRemoteObjectStore(settings.provider).provider).toBe('cloudflare-r2')
|
||||
})
|
||||
|
||||
it('remembers provider-specific settings when switching backup providers', () => {
|
||||
const r2Settings = normalizeRemoteBackupSettings({
|
||||
provider: {
|
||||
kind: 'cloudflare-r2',
|
||||
accountId: 'account-123',
|
||||
bucket: 'po-r2',
|
||||
accessKeyId: 'r2-ak',
|
||||
secretAccessKey: 'r2-sk',
|
||||
},
|
||||
}, 'web')
|
||||
|
||||
const s3Settings = switchRemoteBackupProvider(r2Settings, 's3-compatible')
|
||||
s3Settings.provider = {
|
||||
kind: 's3-compatible',
|
||||
endpoint: 'https://s3.example.test',
|
||||
region: 'auto',
|
||||
bucket: 'po-s3',
|
||||
accessKeyId: 's3-ak',
|
||||
secretAccessKey: 's3-sk',
|
||||
prefix: 'root/',
|
||||
forcePathStyle: true,
|
||||
}
|
||||
|
||||
const switchedBackToR2 = switchRemoteBackupProvider(s3Settings, 'cloudflare-r2')
|
||||
|
||||
expect(switchedBackToR2.provider).toMatchObject({
|
||||
kind: 'cloudflare-r2',
|
||||
accountId: 'account-123',
|
||||
bucket: 'po-r2',
|
||||
accessKeyId: 'r2-ak',
|
||||
secretAccessKey: 'r2-sk',
|
||||
})
|
||||
expect(switchedBackToR2.providers?.['s3-compatible']).toMatchObject({
|
||||
kind: 's3-compatible',
|
||||
endpoint: 'https://s3.example.test',
|
||||
secretAccessKey: 's3-sk',
|
||||
})
|
||||
})
|
||||
|
||||
it('builds Cloudflare R2 dashboard links and browser CORS config', () => {
|
||||
expect(getCloudflareR2DashboardLinks('account-123')).toMatchObject({
|
||||
dashboard: 'https://dash.cloudflare.com/account-123/home/overview',
|
||||
buckets: 'https://dash.cloudflare.com/account-123/r2/overview',
|
||||
apiTokens: 'https://dash.cloudflare.com/account-123/r2/api-tokens',
|
||||
})
|
||||
expect(getCloudflareR2DashboardLinks('').buckets).toContain('/:account/r2/overview')
|
||||
|
||||
const cors = JSON.parse(createCloudflareR2CorsConfig('https://app.example.com'))
|
||||
expect(cors[0].AllowedOrigins).toEqual([
|
||||
'http://localhost:18181',
|
||||
'http://127.0.0.1:18181',
|
||||
'https://prompt.always200.com',
|
||||
'https://app.example.com',
|
||||
])
|
||||
expect(cors[0].AllowedMethods).toEqual(['GET', 'PUT', 'HEAD', 'DELETE'])
|
||||
expect(cors[0].ExposeHeaders).toEqual(['ETag'])
|
||||
})
|
||||
|
||||
it('resolves Google Drive OAuth client id from app config only', () => {
|
||||
;(window as unknown as { runtime_config: Record<string, unknown> }).runtime_config = {
|
||||
GOOGLE_DRIVE_CLIENT_ID: 'configured-client-id.apps.googleusercontent.com',
|
||||
}
|
||||
|
||||
expect(resolveGoogleDriveClientId()).toBe('configured-client-id.apps.googleusercontent.com')
|
||||
})
|
||||
|
||||
it('falls back to the bundled Google Drive Web OAuth client id', () => {
|
||||
expect(resolveGoogleDriveClientId()).toBe(
|
||||
'1056948847608-0gshmh967ei478h0ood6c8q2korb1ku8.apps.googleusercontent.com',
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes legacy Google Drive folder settings to the fixed provider config', () => {
|
||||
const settings = normalizeRemoteBackupSettings({
|
||||
provider: {
|
||||
kind: 'google-drive',
|
||||
folderName: 'Prompt Optimizer Backups',
|
||||
},
|
||||
}, 'web')
|
||||
|
||||
expect(settings.provider).toEqual({ kind: 'google-drive' })
|
||||
})
|
||||
|
||||
it('persists direct-provider credentials so backups keep working after refresh', () => {
|
||||
saveRemoteBackupSettings({
|
||||
provider: {
|
||||
kind: 'cloudflare-r2',
|
||||
accountId: 'account-id',
|
||||
bucket: 'po',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'r2-secret',
|
||||
},
|
||||
})
|
||||
expect(JSON.parse(window.localStorage.getItem(REMOTE_BACKUP_SETTINGS_KEY) || '{}')).toMatchObject({
|
||||
provider: {
|
||||
kind: 'cloudflare-r2',
|
||||
accountId: 'account-id',
|
||||
bucket: 'po',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'r2-secret',
|
||||
},
|
||||
providers: {
|
||||
'cloudflare-r2': {
|
||||
kind: 'cloudflare-r2',
|
||||
secretAccessKey: 'r2-secret',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
saveRemoteBackupSettings({
|
||||
provider: {
|
||||
kind: 's3-compatible',
|
||||
endpoint: 'https://s3.example.test',
|
||||
region: 'auto',
|
||||
bucket: 'po',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 's3-secret',
|
||||
prefix: 'root/',
|
||||
forcePathStyle: true,
|
||||
},
|
||||
})
|
||||
expect(JSON.parse(window.localStorage.getItem(REMOTE_BACKUP_SETTINGS_KEY) || '{}').provider)
|
||||
.toMatchObject({
|
||||
kind: 's3-compatible',
|
||||
secretAccessKey: 's3-secret',
|
||||
})
|
||||
|
||||
saveRemoteBackupSettings({
|
||||
provider: {
|
||||
kind: 'webdav',
|
||||
endpoint: 'https://dav.example.test',
|
||||
username: 'user',
|
||||
password: 'dav-password',
|
||||
directory: 'prompt-optimizer-backups',
|
||||
},
|
||||
})
|
||||
expect(JSON.parse(window.localStorage.getItem(REMOTE_BACKUP_SETTINGS_KEY) || '{}').provider)
|
||||
.toMatchObject({
|
||||
kind: 'webdav',
|
||||
password: 'dav-password',
|
||||
})
|
||||
})
|
||||
|
||||
it('loads legacy persisted provider secrets without clearing them', () => {
|
||||
window.localStorage.setItem(REMOTE_BACKUP_SETTINGS_KEY, JSON.stringify({
|
||||
provider: {
|
||||
kind: 's3-compatible',
|
||||
endpoint: 'https://s3.example.test',
|
||||
region: 'auto',
|
||||
bucket: 'po',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'legacy-secret',
|
||||
prefix: 'root/',
|
||||
forcePathStyle: true,
|
||||
},
|
||||
}))
|
||||
|
||||
const settings = loadRemoteBackupSettings('web')
|
||||
|
||||
expect(settings.provider).toMatchObject({
|
||||
kind: 's3-compatible',
|
||||
secretAccessKey: 'legacy-secret',
|
||||
})
|
||||
expect(JSON.parse(window.localStorage.getItem(REMOTE_BACKUP_SETTINGS_KEY) || '{}').provider)
|
||||
.toMatchObject({
|
||||
kind: 's3-compatible',
|
||||
secretAccessKey: 'legacy-secret',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the AWS SDK S3 client for S3-compatible object operations', async () => {
|
||||
awsS3Mocks.send.mockImplementation(async (command: unknown) => {
|
||||
if (command instanceof awsS3Mocks.HeadObjectCommand) return {}
|
||||
if (command instanceof awsS3Mocks.PutObjectCommand) return {}
|
||||
if (command instanceof awsS3Mocks.GetObjectCommand) {
|
||||
return {
|
||||
Body: {
|
||||
transformToByteArray: async () => new Uint8Array([104, 105]),
|
||||
},
|
||||
}
|
||||
}
|
||||
if (command instanceof awsS3Mocks.ListObjectsV2Command) {
|
||||
return {
|
||||
Contents: [
|
||||
{
|
||||
Key: 'root/v1/manifest.json',
|
||||
LastModified: new Date('2026-05-07T00:00:00.000Z'),
|
||||
Size: 12,
|
||||
},
|
||||
],
|
||||
IsTruncated: false,
|
||||
}
|
||||
}
|
||||
if (command instanceof awsS3Mocks.DeleteObjectCommand) return {}
|
||||
throw new Error('unexpected S3 command')
|
||||
})
|
||||
|
||||
const objectStore = createRemoteObjectStore({
|
||||
kind: 's3-compatible',
|
||||
endpoint: 'https://r2.example.test',
|
||||
region: 'auto',
|
||||
bucket: 'po',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'sk',
|
||||
prefix: 'root/',
|
||||
forcePathStyle: true,
|
||||
})
|
||||
|
||||
await expect(objectStore.exists('v1/manifest.json')).resolves.toBe(true)
|
||||
await expect(objectStore.put('v1/manifest.json', '{"ok":true}', {
|
||||
contentType: 'application/json',
|
||||
})).resolves.toMatchObject({
|
||||
path: 'v1/manifest.json',
|
||||
sizeBytes: 11,
|
||||
contentType: 'application/json',
|
||||
})
|
||||
await expect(objectStore.getText('v1/manifest.json')).resolves.toBe('hi')
|
||||
await expect(objectStore.list('v1')).resolves.toEqual([
|
||||
{
|
||||
path: 'v1/manifest.json',
|
||||
sizeBytes: 12,
|
||||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||||
},
|
||||
])
|
||||
await expect(objectStore.delete?.('v1/manifest.json')).resolves.toBeUndefined()
|
||||
|
||||
expect(awsS3Mocks.clients[0].config).toMatchObject({
|
||||
endpoint: 'https://r2.example.test',
|
||||
region: 'auto',
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'sk',
|
||||
},
|
||||
})
|
||||
expect(awsS3Mocks.send.mock.calls.map(([command]) => command.constructor.name)).toEqual([
|
||||
'HeadObjectCommand',
|
||||
'PutObjectCommand',
|
||||
'GetObjectCommand',
|
||||
'ListObjectsV2Command',
|
||||
'DeleteObjectCommand',
|
||||
])
|
||||
|
||||
const [headCommand] = awsS3Mocks.send.mock.calls[0]
|
||||
expect((headCommand as InstanceType<typeof awsS3Mocks.HeadObjectCommand>).input).toMatchObject({
|
||||
Bucket: 'po',
|
||||
Key: 'root/v1/manifest.json',
|
||||
})
|
||||
|
||||
const [putCommand] = awsS3Mocks.send.mock.calls[1]
|
||||
const putInput = (putCommand as InstanceType<typeof awsS3Mocks.PutObjectCommand>).input
|
||||
expect(putInput.Body).toBeInstanceOf(Uint8Array)
|
||||
expect(new TextDecoder().decode(putInput.Body as Uint8Array)).toBe('{"ok":true}')
|
||||
expect(putInput.ContentType).toBe('application/json')
|
||||
|
||||
const [listCommand] = awsS3Mocks.send.mock.calls[3]
|
||||
expect((listCommand as InstanceType<typeof awsS3Mocks.ListObjectsV2Command>).input).toMatchObject({
|
||||
Bucket: 'po',
|
||||
Prefix: 'root/v1/',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses desktop remote storage IPC for Desktop S3-compatible object operations', async () => {
|
||||
const requests: unknown[] = []
|
||||
const binaryBody = new Uint8Array([0, 1, 127, 128, 255])
|
||||
const invoke = vi.fn(async (request: {
|
||||
operation: string
|
||||
path?: string
|
||||
body?: unknown
|
||||
contentType?: string
|
||||
provider?: { kind?: string }
|
||||
}) => {
|
||||
requests.push(request)
|
||||
if (request.operation === 'put') {
|
||||
expect(ArrayBuffer.isView(request.body)).toBe(true)
|
||||
const body = request.body as Uint8Array
|
||||
expect(Array.from(body)).toEqual(Array.from(binaryBody))
|
||||
return {
|
||||
path: request.path,
|
||||
sizeBytes: body.byteLength,
|
||||
contentType: request.contentType,
|
||||
}
|
||||
}
|
||||
if (request.operation === 'get') {
|
||||
return new Uint8Array([111, 107]).buffer
|
||||
}
|
||||
if (request.operation === 'getText') return 'ok'
|
||||
if (request.operation === 'head') return { path: request.path, sizeBytes: 2 }
|
||||
if (request.operation === 'list') return [{ path: 'v1/manifest.json', sizeBytes: 2 }]
|
||||
if (request.operation === 'delete') return null
|
||||
return true
|
||||
})
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
remoteStorage: { invoke },
|
||||
},
|
||||
})
|
||||
|
||||
const objectStore = createRemoteObjectStore({
|
||||
kind: 's3-compatible',
|
||||
endpoint: 'https://s3.example.test',
|
||||
region: 'auto',
|
||||
bucket: 'po',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'sk',
|
||||
prefix: 'root/',
|
||||
forcePathStyle: true,
|
||||
}, 'desktop')
|
||||
|
||||
await expect(objectStore.put('v1/assets/image.bin', binaryBody, {
|
||||
contentType: 'application/octet-stream',
|
||||
})).resolves.toMatchObject({
|
||||
path: 'v1/assets/image.bin',
|
||||
sizeBytes: binaryBody.byteLength,
|
||||
contentType: 'application/octet-stream',
|
||||
})
|
||||
await expect(objectStore.get('v1/manifest.json').then((buffer) => new TextDecoder().decode(buffer))).resolves.toBe('ok')
|
||||
await expect(objectStore.getText('v1/manifest.json')).resolves.toBe('ok')
|
||||
await expect(objectStore.head?.('v1/manifest.json')).resolves.toEqual({
|
||||
path: 'v1/manifest.json',
|
||||
sizeBytes: 2,
|
||||
})
|
||||
await expect(objectStore.list('v1')).resolves.toEqual([{ path: 'v1/manifest.json', sizeBytes: 2 }])
|
||||
await expect(objectStore.delete?.('v1/manifest.json')).resolves.toBeUndefined()
|
||||
|
||||
expect(awsS3Mocks.clients).toHaveLength(0)
|
||||
expect(requests).toMatchObject([
|
||||
{ operation: 'put', path: 'v1/assets/image.bin', provider: { kind: 's3-compatible' } },
|
||||
{ operation: 'get', path: 'v1/manifest.json', provider: { kind: 's3-compatible' } },
|
||||
{ operation: 'getText', path: 'v1/manifest.json', provider: { kind: 's3-compatible' } },
|
||||
{ operation: 'head', path: 'v1/manifest.json', provider: { kind: 's3-compatible' } },
|
||||
{ operation: 'list', path: 'v1', provider: { kind: 's3-compatible' } },
|
||||
{ operation: 'delete', path: 'v1/manifest.json', provider: { kind: 's3-compatible' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses desktop remote storage IPC for Desktop WebDAV and rejects Desktop Google Drive', async () => {
|
||||
const invoke = vi.fn(async (request: { operation: string }) => {
|
||||
if (request.operation === 'list') return []
|
||||
return null
|
||||
})
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
remoteStorage: { invoke },
|
||||
},
|
||||
})
|
||||
|
||||
const webDavStore = createRemoteObjectStore({
|
||||
kind: 'webdav',
|
||||
endpoint: 'https://dav.example.test',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
directory: 'prompt-optimizer-backups',
|
||||
}, 'desktop')
|
||||
|
||||
await expect(webDavStore.list('v1')).resolves.toEqual([])
|
||||
expect(invoke).toHaveBeenCalledWith(expect.objectContaining({
|
||||
operation: 'list',
|
||||
path: 'v1',
|
||||
provider: expect.objectContaining({ kind: 'webdav' }),
|
||||
}))
|
||||
|
||||
expect(() => createRemoteObjectStore({ kind: 'google-drive' }, 'desktop')).toThrow(
|
||||
'Google Drive remote backup is only supported in the Web version',
|
||||
)
|
||||
})
|
||||
|
||||
it('sends plain provider config objects over desktop IPC when Vue state is reactive', async () => {
|
||||
const invoke = vi.fn(async (request: unknown) => {
|
||||
expect(() => structuredClone(request)).not.toThrow()
|
||||
return []
|
||||
})
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
remoteStorage: { invoke },
|
||||
},
|
||||
})
|
||||
|
||||
const provider = reactive({
|
||||
kind: 'webdav' as const,
|
||||
endpoint: 'https://dav.example.test',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
directory: 'prompt-optimizer-backups',
|
||||
})
|
||||
const objectStore = createRemoteObjectStore(provider, 'desktop')
|
||||
await expect(objectStore.list('v1')).resolves.toEqual([])
|
||||
|
||||
const request = invoke.mock.calls[0]?.[0] as { provider: unknown }
|
||||
expect(request.provider).not.toBe(provider)
|
||||
expect(request.provider).toEqual({
|
||||
kind: 'webdav',
|
||||
endpoint: 'https://dav.example.test',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
directory: 'prompt-optimizer-backups',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps Cloudflare R2 settings onto the AWS SDK S3 client', async () => {
|
||||
awsS3Mocks.send.mockResolvedValue({})
|
||||
|
||||
const objectStore = createRemoteObjectStore({
|
||||
kind: 'cloudflare-r2',
|
||||
accountId: 'account-id',
|
||||
bucket: 'po',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'sk',
|
||||
})
|
||||
|
||||
await expect(objectStore.exists('v1/manifest.json')).resolves.toBe(true)
|
||||
|
||||
expect(objectStore.provider).toBe('cloudflare-r2')
|
||||
expect(awsS3Mocks.clients[0].config).toMatchObject({
|
||||
endpoint: 'https://account-id.r2.cloudflarestorage.com',
|
||||
region: 'auto',
|
||||
forcePathStyle: true,
|
||||
})
|
||||
const [headCommand] = awsS3Mocks.send.mock.calls[0]
|
||||
expect((headCommand as InstanceType<typeof awsS3Mocks.HeadObjectCommand>).input).toMatchObject({
|
||||
Bucket: 'po',
|
||||
Key: 'prompt-optimizer-backups/v1/manifest.json',
|
||||
})
|
||||
})
|
||||
|
||||
it('retries transient S3-compatible download body failures', async () => {
|
||||
let attempts = 0
|
||||
awsS3Mocks.send.mockImplementation(async (command: unknown) => {
|
||||
if (command instanceof awsS3Mocks.GetObjectCommand) {
|
||||
attempts += 1
|
||||
if (attempts === 1) {
|
||||
throw new TypeError('net::ERR_CONTENT_LENGTH_MISMATCH 200 (OK)')
|
||||
}
|
||||
return {
|
||||
Body: {
|
||||
transformToByteArray: async () => new Uint8Array([111, 107]),
|
||||
},
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const objectStore = createRemoteObjectStore({
|
||||
kind: 'cloudflare-r2',
|
||||
accountId: 'account-id',
|
||||
bucket: 'po',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'sk',
|
||||
})
|
||||
|
||||
await expect(objectStore.getText('v1/assets/image.png')).resolves.toBe('ok')
|
||||
expect(attempts).toBe(2)
|
||||
})
|
||||
|
||||
it('returns Google Drive object metadata through the common head contract', async () => {
|
||||
const folderMimeType = 'application/vnd.google-apps.folder'
|
||||
;(window as unknown as { runtime_config: Record<string, unknown> }).runtime_config = {
|
||||
GOOGLE_DRIVE_CLIENT_ID: 'metadata-client-id.apps.googleusercontent.com',
|
||||
}
|
||||
;(globalThis as unknown as { google: unknown }).google = {
|
||||
accounts: {
|
||||
oauth2: {
|
||||
initTokenClient: ({ callback }: { callback: (response: { access_token: string }) => void }) => ({
|
||||
requestAccessToken: () => callback({ access_token: 'access-token' }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const lookupResponses = [
|
||||
[{ id: 'root-folder', name: GOOGLE_DRIVE_DEFAULT_BACKUP_FOLDER_NAME }],
|
||||
[{ id: 'v1-folder', mimeType: folderMimeType }],
|
||||
[{ id: 'assets-folder', mimeType: folderMimeType }],
|
||||
[{ id: 'image-cache-folder', mimeType: folderMimeType }],
|
||||
[{
|
||||
id: 'image-file',
|
||||
mimeType: 'image/png',
|
||||
size: '1234',
|
||||
modifiedTime: '2026-05-07T00:00:00.000Z',
|
||||
}],
|
||||
]
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ files: lookupResponses.shift() ?? [] }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
globalThis.fetch = fetchMock as typeof fetch
|
||||
|
||||
const objectStore = createRemoteObjectStore({ kind: 'google-drive' })
|
||||
|
||||
await expect(objectStore.head?.('v1/assets/imageCache/test.png')).resolves.toEqual({
|
||||
path: 'v1/assets/imageCache/test.png',
|
||||
sizeBytes: 1234,
|
||||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||||
contentType: 'image/png',
|
||||
})
|
||||
})
|
||||
|
||||
it('retries transient Google Drive media download failures', async () => {
|
||||
const folderMimeType = 'application/vnd.google-apps.folder'
|
||||
;(window as unknown as { runtime_config: Record<string, unknown> }).runtime_config = {
|
||||
GOOGLE_DRIVE_CLIENT_ID: 'retry-client-id.apps.googleusercontent.com',
|
||||
}
|
||||
;(globalThis as unknown as { google: unknown }).google = {
|
||||
accounts: {
|
||||
oauth2: {
|
||||
initTokenClient: ({ callback }: { callback: (response: { access_token: string }) => void }) => ({
|
||||
requestAccessToken: () => callback({ access_token: 'access-token' }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const lookupResponses = [
|
||||
[{ id: 'root-folder', name: GOOGLE_DRIVE_DEFAULT_BACKUP_FOLDER_NAME }],
|
||||
[{ id: 'v1-folder', mimeType: folderMimeType }],
|
||||
[{ id: 'assets-folder', mimeType: folderMimeType }],
|
||||
[{ id: 'image-cache-folder', mimeType: folderMimeType }],
|
||||
[{ id: 'image-file', mimeType: 'image/png' }],
|
||||
]
|
||||
let mediaAttempts = 0
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.includes('alt=media')) {
|
||||
mediaAttempts += 1
|
||||
if (mediaAttempts === 1) {
|
||||
throw new TypeError('Failed to fetch')
|
||||
}
|
||||
return new Response(new Uint8Array([1, 2, 3]))
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ files: lookupResponses.shift() ?? [] }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
globalThis.fetch = fetchMock as typeof fetch
|
||||
|
||||
const objectStore = createRemoteObjectStore({ kind: 'google-drive' })
|
||||
const bytes = new Uint8Array(await objectStore.get('v1/assets/imageCache/test.png'))
|
||||
|
||||
expect(Array.from(bytes)).toEqual([1, 2, 3])
|
||||
expect(mediaAttempts).toBe(2)
|
||||
})
|
||||
|
||||
it('refreshes expired Google Drive access tokens before reuse', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-05-07T00:00:00.000Z'))
|
||||
|
||||
;(window as unknown as { runtime_config: Record<string, unknown> }).runtime_config = {
|
||||
GOOGLE_DRIVE_CLIENT_ID: 'expiry-client-id.apps.googleusercontent.com',
|
||||
}
|
||||
const requestAccessToken = vi.fn()
|
||||
const tokenResponses = ['first-token', 'second-token']
|
||||
;(globalThis as unknown as { google: unknown }).google = {
|
||||
accounts: {
|
||||
oauth2: {
|
||||
initTokenClient: ({ callback }: {
|
||||
callback: (response: { access_token: string; expires_in: number }) => void
|
||||
}) => ({
|
||||
requestAccessToken: () => {
|
||||
requestAccessToken()
|
||||
callback({
|
||||
access_token: tokenResponses.shift() || 'fallback-token',
|
||||
expires_in: 120,
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const objectStore = createRemoteObjectStore({ kind: 'google-drive' })
|
||||
|
||||
await objectStore.authorize?.()
|
||||
expect(isGoogleDriveRemoteBackupAuthorized()).toBe(true)
|
||||
|
||||
vi.setSystemTime(new Date('2026-05-07T00:01:01.000Z'))
|
||||
expect(isGoogleDriveRemoteBackupAuthorized()).toBe(false)
|
||||
|
||||
await objectStore.authorize?.()
|
||||
|
||||
expect(requestAccessToken).toHaveBeenCalledTimes(2)
|
||||
expect(isGoogleDriveRemoteBackupAuthorized()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears stale Google Drive tokens and retries authorization failures once', async () => {
|
||||
const folderMimeType = 'application/vnd.google-apps.folder'
|
||||
;(window as unknown as { runtime_config: Record<string, unknown> }).runtime_config = {
|
||||
GOOGLE_DRIVE_CLIENT_ID: 'auth-retry-client-id.apps.googleusercontent.com',
|
||||
}
|
||||
const requestAccessToken = vi.fn()
|
||||
const tokenResponses = ['first-token', 'second-token']
|
||||
;(globalThis as unknown as { google: unknown }).google = {
|
||||
accounts: {
|
||||
oauth2: {
|
||||
initTokenClient: ({ callback }: {
|
||||
callback: (response: { access_token: string; expires_in: number }) => void
|
||||
}) => ({
|
||||
requestAccessToken: () => {
|
||||
requestAccessToken()
|
||||
callback({
|
||||
access_token: tokenResponses.shift() || 'fallback-token',
|
||||
expires_in: 3600,
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const lookupResponses = [
|
||||
[{ id: 'root-folder', name: GOOGLE_DRIVE_DEFAULT_BACKUP_FOLDER_NAME }],
|
||||
[{ id: 'v1-folder', mimeType: folderMimeType }],
|
||||
[{ id: 'assets-folder', mimeType: folderMimeType }],
|
||||
[{ id: 'image-cache-folder', mimeType: folderMimeType }],
|
||||
[{ id: 'image-file', mimeType: 'image/png' }],
|
||||
]
|
||||
const mediaAuthorizations: string[] = []
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url.includes('alt=media')) {
|
||||
const authorization = new Headers(init?.headers).get('Authorization') || ''
|
||||
mediaAuthorizations.push(authorization)
|
||||
if (authorization === 'Bearer first-token') {
|
||||
return new Response('expired', { status: 401, statusText: 'Unauthorized' })
|
||||
}
|
||||
return new Response(new Uint8Array([4, 5, 6]))
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ files: lookupResponses.shift() ?? [] }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
globalThis.fetch = fetchMock as typeof fetch
|
||||
|
||||
const objectStore = createRemoteObjectStore({ kind: 'google-drive' })
|
||||
const bytes = new Uint8Array(await objectStore.get('v1/assets/imageCache/test.png'))
|
||||
|
||||
expect(Array.from(bytes)).toEqual([4, 5, 6])
|
||||
expect(requestAccessToken).toHaveBeenCalledTimes(2)
|
||||
expect(mediaAuthorizations).toEqual(['Bearer first-token', 'Bearer second-token'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,395 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { FullImageData } from '@prompt-optimizer/core'
|
||||
|
||||
import {
|
||||
REMOTE_SNAPSHOT_SCHEMA_VERSION,
|
||||
analyzeRemoteSnapshotAssetCleanup,
|
||||
cleanupRemoteSnapshotAssets,
|
||||
createRemoteSnapshotBackup,
|
||||
listRemoteSnapshotBackups,
|
||||
restoreRemoteSnapshotBackup,
|
||||
type RemoteSnapshotManifest,
|
||||
} from '../../../src/utils/remote-snapshot-backup'
|
||||
import type { RemoteObjectStore } from '../../../src/utils/remote-backup'
|
||||
|
||||
const encode = (value: string): string => globalThis.btoa(value)
|
||||
|
||||
const createImage = (id: string, data: string): FullImageData => ({
|
||||
metadata: {
|
||||
id,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: data.length,
|
||||
createdAt: 1700000000000,
|
||||
accessedAt: 1700000000001,
|
||||
source: 'uploaded',
|
||||
},
|
||||
data: encode(data),
|
||||
})
|
||||
|
||||
const createStorage = (images: FullImageData[]) => {
|
||||
const store = new Map(images.map((image) => [image.metadata.id, image]))
|
||||
return {
|
||||
store,
|
||||
listAllMetadata: vi.fn(async () => Array.from(store.values()).map((image) => image.metadata)),
|
||||
getImage: vi.fn(async (id: string) => store.get(id) ?? null),
|
||||
getMetadata: vi.fn(async (id: string) => store.get(id)?.metadata ?? null),
|
||||
saveImage: vi.fn(async (image: FullImageData) => {
|
||||
store.set(image.metadata.id, image)
|
||||
return image.metadata.id
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const blobToArrayBuffer = async (body: Blob | ArrayBuffer | Uint8Array | string): Promise<ArrayBuffer> => {
|
||||
if (body instanceof Blob) return body.arrayBuffer()
|
||||
if (body instanceof ArrayBuffer) return body
|
||||
if (body instanceof Uint8Array) {
|
||||
const copy = new Uint8Array(body.byteLength)
|
||||
copy.set(body)
|
||||
return copy.buffer
|
||||
}
|
||||
return new TextEncoder().encode(body).buffer
|
||||
}
|
||||
|
||||
const createObjectStore = (initial?: Record<string, string | Uint8Array>): RemoteObjectStore & {
|
||||
objects: Map<string, { bytes: Uint8Array; contentType?: string; updatedAt: string }>
|
||||
putCalls: string[]
|
||||
deleteCalls: string[]
|
||||
} => {
|
||||
const objects = new Map<string, { bytes: Uint8Array; contentType?: string; updatedAt: string }>()
|
||||
Object.entries(initial || {}).forEach(([path, value]) => {
|
||||
objects.set(path, {
|
||||
bytes: typeof value === 'string' ? new TextEncoder().encode(value) : value,
|
||||
contentType: typeof value === 'string' ? 'application/json' : 'image/png',
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
})
|
||||
})
|
||||
const putCalls: string[] = []
|
||||
const deleteCalls: string[] = []
|
||||
return {
|
||||
provider: 'google-drive',
|
||||
objects,
|
||||
putCalls,
|
||||
deleteCalls,
|
||||
head: vi.fn(async (path: string) => {
|
||||
const object = objects.get(path)
|
||||
return object
|
||||
? {
|
||||
path,
|
||||
sizeBytes: object.bytes.byteLength,
|
||||
updatedAt: object.updatedAt,
|
||||
contentType: object.contentType,
|
||||
}
|
||||
: null
|
||||
}),
|
||||
exists: vi.fn(async (path: string) => objects.has(path)),
|
||||
put: vi.fn(async (path, body, options) => {
|
||||
putCalls.push(path)
|
||||
const bytes = new Uint8Array(await blobToArrayBuffer(body))
|
||||
objects.set(path, {
|
||||
bytes,
|
||||
contentType: options?.contentType,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
return {
|
||||
path,
|
||||
sizeBytes: bytes.byteLength,
|
||||
updatedAt: objects.get(path)?.updatedAt,
|
||||
contentType: options?.contentType,
|
||||
}
|
||||
}),
|
||||
get: vi.fn(async (path: string) => {
|
||||
const object = objects.get(path)
|
||||
if (!object) throw new Error(`object not found: ${path}`)
|
||||
const copy = new Uint8Array(object.bytes.byteLength)
|
||||
copy.set(object.bytes)
|
||||
return copy.buffer
|
||||
}),
|
||||
getText: vi.fn(async (path: string) => {
|
||||
const object = objects.get(path)
|
||||
if (!object) throw new Error(`object not found: ${path}`)
|
||||
return new TextDecoder().decode(object.bytes)
|
||||
}),
|
||||
list: vi.fn(async (prefix: string) =>
|
||||
Array.from(objects.entries())
|
||||
.filter(([path]) => path.startsWith(prefix))
|
||||
.map(([path, object]) => ({
|
||||
path,
|
||||
sizeBytes: object.bytes.byteLength,
|
||||
updatedAt: object.updatedAt,
|
||||
contentType: object.contentType,
|
||||
})),
|
||||
),
|
||||
delete: vi.fn(async (path: string) => {
|
||||
deleteCalls.push(path)
|
||||
objects.delete(path)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('remote snapshot backup', () => {
|
||||
it('creates a manifest snapshot and skips image assets that already exist remotely', async () => {
|
||||
const objectStore = createObjectStore({
|
||||
'v1/assets/imageCache/session-1.png': new TextEncoder().encode('session-image'),
|
||||
})
|
||||
const progressPhases: string[] = []
|
||||
|
||||
const result = await createRemoteSnapshotBackup({
|
||||
objectStore,
|
||||
dataManager: {
|
||||
exportAllData: vi.fn(async () => JSON.stringify({ version: 1, data: { models: [] } })),
|
||||
},
|
||||
favoriteManager: {
|
||||
exportFavorites: vi.fn(async () => JSON.stringify({ version: '1.0', favorites: [] })),
|
||||
},
|
||||
imageStorageService: createStorage([createImage('session-1', 'session-image')]),
|
||||
favoriteImageStorageService: createStorage([]),
|
||||
onProgress: (event) => {
|
||||
progressPhases.push(event.phase)
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.uploadedAssets).toBe(0)
|
||||
expect(result.skippedAssets).toBe(1)
|
||||
expect(result.manifest.schemaVersion).toBe(REMOTE_SNAPSHOT_SCHEMA_VERSION)
|
||||
expect(result.manifest.assets[0]).toMatchObject({
|
||||
store: 'imageCache',
|
||||
id: 'session-1',
|
||||
path: 'v1/assets/imageCache/session-1.png',
|
||||
mimeType: 'image/png',
|
||||
})
|
||||
expect(objectStore.putCalls).toEqual([
|
||||
result.manifest.appDataPath,
|
||||
result.manifest.favoritesPath,
|
||||
result.entry.manifestPath,
|
||||
])
|
||||
expect(progressPhases).toContain('prepare')
|
||||
expect(progressPhases).toContain('scan')
|
||||
expect(progressPhases).toContain('asset-check')
|
||||
expect(progressPhases).toContain('metadata-upload')
|
||||
expect(progressPhases).toContain('manifest-upload')
|
||||
expect(progressPhases.at(-1)).toBe('done')
|
||||
})
|
||||
|
||||
it('reuploads existing image assets when remote size does not match', async () => {
|
||||
const objectStore = createObjectStore({
|
||||
'v1/assets/imageCache/session-1.png': new Uint8Array([1, 2, 3]),
|
||||
})
|
||||
|
||||
const result = await createRemoteSnapshotBackup({
|
||||
objectStore,
|
||||
dataManager: {
|
||||
exportAllData: vi.fn(async () => JSON.stringify({ version: 1, data: { models: [] } })),
|
||||
},
|
||||
favoriteManager: {
|
||||
exportFavorites: vi.fn(async () => JSON.stringify({ version: '1.0', favorites: [] })),
|
||||
},
|
||||
imageStorageService: createStorage([createImage('session-1', 'session-image')]),
|
||||
favoriteImageStorageService: createStorage([]),
|
||||
})
|
||||
|
||||
expect(result.uploadedAssets).toBe(1)
|
||||
expect(result.skippedAssets).toBe(0)
|
||||
expect(objectStore.putCalls).toContain('v1/assets/imageCache/session-1.png')
|
||||
expect(Array.from(objectStore.objects.get('v1/assets/imageCache/session-1.png')?.bytes ?? [])).toEqual(
|
||||
Array.from(new TextEncoder().encode('session-image')),
|
||||
)
|
||||
})
|
||||
|
||||
it('lists committed snapshots by manifest and restores only after resources validate', async () => {
|
||||
const objectStore = createObjectStore()
|
||||
const exported = await createRemoteSnapshotBackup({
|
||||
objectStore,
|
||||
dataManager: {
|
||||
exportAllData: vi.fn(async () => JSON.stringify({ version: 1, data: { history: [] } })),
|
||||
},
|
||||
favoriteManager: {
|
||||
exportFavorites: vi.fn(async () => JSON.stringify({ version: '1.0', favorites: [{ id: 'fav-1' }] })),
|
||||
},
|
||||
imageStorageService: createStorage([createImage('session-1', 'session-image')]),
|
||||
favoriteImageStorageService: createStorage([createImage('favorite-1', 'favorite-image')]),
|
||||
})
|
||||
|
||||
const snapshots = await listRemoteSnapshotBackups(objectStore)
|
||||
expect(snapshots.map((snapshot) => snapshot.id)).toEqual([exported.entry.id])
|
||||
|
||||
const order: string[] = []
|
||||
const sessionTarget = createStorage([])
|
||||
const favoriteTarget = createStorage([])
|
||||
sessionTarget.saveImage.mockImplementation(async (image: FullImageData) => {
|
||||
order.push(`save:session:${image.metadata.id}`)
|
||||
sessionTarget.store.set(image.metadata.id, image)
|
||||
return image.metadata.id
|
||||
})
|
||||
favoriteTarget.saveImage.mockImplementation(async (image: FullImageData) => {
|
||||
order.push(`save:favorite:${image.metadata.id}`)
|
||||
favoriteTarget.store.set(image.metadata.id, image)
|
||||
return image.metadata.id
|
||||
})
|
||||
|
||||
const result = await restoreRemoteSnapshotBackup({
|
||||
objectStore,
|
||||
snapshotId: exported.entry.id,
|
||||
dataManager: {
|
||||
importAllData: vi.fn(async () => {
|
||||
order.push('import:app')
|
||||
}),
|
||||
},
|
||||
favoriteManager: {
|
||||
importFavorites: vi.fn(async () => {
|
||||
order.push('import:favorites')
|
||||
return { imported: 1, skipped: 0, errors: [] }
|
||||
}),
|
||||
},
|
||||
imageStorageService: sessionTarget,
|
||||
favoriteImageStorageService: favoriteTarget,
|
||||
})
|
||||
|
||||
expect(result.restored).toBe(2)
|
||||
expect(order).toEqual([
|
||||
'save:session:session-1',
|
||||
'save:favorite:favorite-1',
|
||||
'import:app',
|
||||
'import:favorites',
|
||||
])
|
||||
})
|
||||
|
||||
it('does not restore sections that were not included in the remote snapshot', async () => {
|
||||
const objectStore = createObjectStore()
|
||||
const exportAllData = vi.fn(async () => JSON.stringify({ version: 1, data: { history: ['local'] } }))
|
||||
const exported = await createRemoteSnapshotBackup({
|
||||
objectStore,
|
||||
dataManager: {
|
||||
exportAllData,
|
||||
},
|
||||
favoriteManager: {
|
||||
exportFavorites: vi.fn(async () => JSON.stringify({ version: '1.0', favorites: [{ id: 'fav-1' }] })),
|
||||
},
|
||||
imageStorageService: createStorage([]),
|
||||
favoriteImageStorageService: createStorage([]),
|
||||
sections: {
|
||||
appData: false,
|
||||
imageCache: false,
|
||||
favorites: true,
|
||||
favoriteImages: false,
|
||||
},
|
||||
})
|
||||
const importAllData = vi.fn(async () => {})
|
||||
const importFavorites = vi.fn(async () => ({ imported: 1, skipped: 0, errors: [] }))
|
||||
|
||||
const result = await restoreRemoteSnapshotBackup({
|
||||
objectStore,
|
||||
snapshotId: exported.entry.id,
|
||||
dataManager: {
|
||||
importAllData,
|
||||
},
|
||||
favoriteManager: {
|
||||
importFavorites,
|
||||
},
|
||||
imageStorageService: createStorage([]),
|
||||
favoriteImageStorageService: createStorage([]),
|
||||
})
|
||||
|
||||
expect(exportAllData).not.toHaveBeenCalled()
|
||||
expect(importAllData).not.toHaveBeenCalled()
|
||||
expect(importFavorites).toHaveBeenCalledTimes(1)
|
||||
expect(result.imported).toEqual({
|
||||
appData: false,
|
||||
favorites: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not write local data when strict restore validation fails', async () => {
|
||||
const manifest: RemoteSnapshotManifest = {
|
||||
schemaVersion: REMOTE_SNAPSHOT_SCHEMA_VERSION,
|
||||
snapshotId: '2026-05-06T10-30-00-000Z',
|
||||
createdAt: new Date(0).toISOString(),
|
||||
appDataPath: 'v1/snapshots/2026-05-06T10-30-00-000Z/app-data.json',
|
||||
favoritesPath: 'v1/snapshots/2026-05-06T10-30-00-000Z/favorites.json',
|
||||
assets: [{
|
||||
kind: 'image',
|
||||
store: 'imageCache',
|
||||
id: 'missing-image',
|
||||
path: 'v1/assets/imageCache/missing-image.png',
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 100,
|
||||
createdAt: 1,
|
||||
source: 'uploaded',
|
||||
}],
|
||||
missingAssets: [],
|
||||
assetCounts: { imageCache: 1, favoriteImages: 0 },
|
||||
includedSections: ['appData', 'imageCache'],
|
||||
}
|
||||
const objectStore = createObjectStore({
|
||||
[manifest.appDataPath]: JSON.stringify({ version: 1, data: {} }),
|
||||
[manifest.favoritesPath]: JSON.stringify({ version: '1.0', favorites: [] }),
|
||||
'v1/snapshots/2026-05-06T10-30-00-000Z/manifest.json': JSON.stringify(manifest),
|
||||
})
|
||||
const importAllData = vi.fn(async () => {})
|
||||
const target = createStorage([])
|
||||
|
||||
await expect(restoreRemoteSnapshotBackup({
|
||||
objectStore,
|
||||
snapshotId: manifest.snapshotId,
|
||||
dataManager: { importAllData },
|
||||
favoriteManager: null,
|
||||
imageStorageService: target,
|
||||
favoriteImageStorageService: createStorage([]),
|
||||
})).rejects.toThrow('validation failed')
|
||||
|
||||
expect(importAllData).not.toHaveBeenCalled()
|
||||
expect(target.saveImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans only remote image assets not referenced by committed snapshots', async () => {
|
||||
const manifest: RemoteSnapshotManifest = {
|
||||
schemaVersion: REMOTE_SNAPSHOT_SCHEMA_VERSION,
|
||||
snapshotId: '2026-05-06T10-30-00-000Z',
|
||||
createdAt: new Date(0).toISOString(),
|
||||
appDataPath: 'v1/snapshots/2026-05-06T10-30-00-000Z/app-data.json',
|
||||
favoritesPath: 'v1/snapshots/2026-05-06T10-30-00-000Z/favorites.json',
|
||||
assets: [{
|
||||
kind: 'image',
|
||||
store: 'imageCache',
|
||||
id: 'kept',
|
||||
path: 'v1/assets/imageCache/kept.png',
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 3,
|
||||
createdAt: 1,
|
||||
source: 'uploaded',
|
||||
}],
|
||||
missingAssets: [],
|
||||
assetCounts: { imageCache: 1, favoriteImages: 0 },
|
||||
includedSections: ['appData', 'imageCache'],
|
||||
}
|
||||
const objectStore = createObjectStore({
|
||||
'v1/snapshots/2026-05-06T10-30-00-000Z/manifest.json': JSON.stringify(manifest),
|
||||
'v1/assets/imageCache/kept.png': new Uint8Array([1, 2, 3]),
|
||||
'v1/assets/imageCache/orphan.png': new Uint8Array([4, 5]),
|
||||
})
|
||||
|
||||
const analysis = await analyzeRemoteSnapshotAssetCleanup(objectStore)
|
||||
expect(analysis.candidates.map((candidate) => candidate.path)).toEqual([
|
||||
'v1/assets/imageCache/orphan.png',
|
||||
])
|
||||
|
||||
const result = await cleanupRemoteSnapshotAssets(objectStore)
|
||||
expect(result.deleted).toBe(1)
|
||||
expect(objectStore.objects.has('v1/assets/imageCache/kept.png')).toBe(true)
|
||||
expect(objectStore.objects.has('v1/assets/imageCache/orphan.png')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not clean remote image assets when any snapshot manifest cannot be read', async () => {
|
||||
const objectStore = createObjectStore({
|
||||
'v1/snapshots/2026-05-06T10-30-00-000Z/manifest.json': '{not-json',
|
||||
'v1/assets/imageCache/orphan.png': new Uint8Array([4, 5]),
|
||||
})
|
||||
|
||||
await expect(cleanupRemoteSnapshotAssets(objectStore)).rejects.toThrow(
|
||||
'Unable to safely analyze remote snapshot assets',
|
||||
)
|
||||
|
||||
expect(objectStore.deleteCalls).toEqual([])
|
||||
expect(objectStore.objects.has('v1/assets/imageCache/orphan.png')).toBe(true)
|
||||
})
|
||||
})
|
||||
Generated
+1305
File diff suppressed because it is too large.
Load diff
@@ -19,7 +19,10 @@ const collectMatches = (text, patterns) => {
|
||||
|
||||
test('desktop preload IPC channels have main-process handlers', () => {
|
||||
const preload = readText('packages/desktop/preload.js')
|
||||
const main = readText('packages/desktop/main.js')
|
||||
const main = [
|
||||
readText('packages/desktop/main.js'),
|
||||
readText('packages/desktop/remote-storage.js'),
|
||||
].join('\n')
|
||||
|
||||
const preloadChannels = collectMatches(preload, [
|
||||
/ipcRenderer\.invoke\(\s*['"]([^'"]+)['"]/g,
|
||||
@@ -47,6 +50,147 @@ test('desktop preload IPC channels have main-process handlers', () => {
|
||||
assert.deepEqual(missingHandlers, [])
|
||||
})
|
||||
|
||||
test('desktop remote storage handler routes S3-compatible operations through AWS SDK commands', async () => {
|
||||
const { handleRemoteStorageOperation } = await import('../packages/desktop/remote-storage.js')
|
||||
const sentCommands = []
|
||||
|
||||
class S3Client {
|
||||
constructor(config) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
async send(command) {
|
||||
sentCommands.push(command)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
class PutObjectCommand {
|
||||
constructor(input) {
|
||||
this.input = input
|
||||
}
|
||||
}
|
||||
|
||||
const result = await handleRemoteStorageOperation({
|
||||
operation: 'put',
|
||||
path: 'v1/manifest.json',
|
||||
body: new Uint8Array([104, 105]),
|
||||
contentType: 'application/json',
|
||||
provider: {
|
||||
kind: 'cloudflare-r2',
|
||||
accountId: 'account-id',
|
||||
bucket: 'po',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'sk',
|
||||
},
|
||||
}, {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
})
|
||||
|
||||
assert.equal(result.path, 'v1/manifest.json')
|
||||
assert.equal(result.sizeBytes, 2)
|
||||
assert.equal(sentCommands.length, 1)
|
||||
assert.deepEqual(sentCommands[0].input, {
|
||||
Bucket: 'po',
|
||||
Key: 'prompt-optimizer-backups/v1/manifest.json',
|
||||
Body: new Uint8Array([104, 105]),
|
||||
ContentType: 'application/json',
|
||||
})
|
||||
})
|
||||
|
||||
test('desktop remote storage handler routes WebDAV operations through the WebDAV client library adapter', async () => {
|
||||
const { handleRemoteStorageOperation } = await import('../packages/desktop/remote-storage.js')
|
||||
const calls = []
|
||||
const client = {
|
||||
async createDirectory(path, options) {
|
||||
calls.push(['createDirectory', path, options])
|
||||
},
|
||||
async putFileContents(path, body, options) {
|
||||
calls.push(['putFileContents', path, body, options])
|
||||
assert.equal(Buffer.isBuffer(body), true)
|
||||
assert.deepEqual([...body], [0, 1, 127, 128, 255])
|
||||
},
|
||||
async getDirectoryContents(path, options) {
|
||||
calls.push(['getDirectoryContents', path, options])
|
||||
return [
|
||||
{
|
||||
filename: '/prompt-optimizer-backups/v1/manifest.json',
|
||||
type: 'file',
|
||||
size: 12,
|
||||
lastmod: '2026-05-07T00:00:00.000Z',
|
||||
mime: 'application/json',
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
const dependencies = {
|
||||
createWebDavClient: async (endpoint, options) => {
|
||||
calls.push(['createWebDavClient', endpoint, options])
|
||||
return client
|
||||
},
|
||||
}
|
||||
|
||||
await handleRemoteStorageOperation({
|
||||
operation: 'put',
|
||||
path: 'v1/assets/image.bin',
|
||||
body: new Uint8Array([0, 1, 127, 128, 255]),
|
||||
contentType: 'application/octet-stream',
|
||||
provider: {
|
||||
kind: 'webdav',
|
||||
endpoint: 'https://dav.example.test',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
directory: 'prompt-optimizer-backups',
|
||||
},
|
||||
}, dependencies)
|
||||
|
||||
const entries = await handleRemoteStorageOperation({
|
||||
operation: 'list',
|
||||
path: 'v1',
|
||||
provider: {
|
||||
kind: 'webdav',
|
||||
endpoint: 'https://dav.example.test',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
directory: 'prompt-optimizer-backups',
|
||||
},
|
||||
}, dependencies)
|
||||
|
||||
assert.equal(calls.some(([name]) => name === 'putFileContents'), true)
|
||||
assert.equal(calls.some(([name]) => name === 'getDirectoryContents'), true)
|
||||
assert.deepEqual(entries, [
|
||||
{
|
||||
path: 'v1/manifest.json',
|
||||
sizeBytes: 12,
|
||||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('desktop remote storage rejects Google Drive provider', async () => {
|
||||
const { handleRemoteStorageOperation } = await import('../packages/desktop/remote-storage.js')
|
||||
|
||||
await assert.rejects(
|
||||
() => handleRemoteStorageOperation({
|
||||
operation: 'authorize',
|
||||
provider: { kind: 'google-drive' },
|
||||
}),
|
||||
/Google Drive remote backup is only supported in the Web version/,
|
||||
)
|
||||
})
|
||||
|
||||
test('desktop remote storage implementation avoids renderer fetch/WebDAV XML paths', () => {
|
||||
const remoteStorage = readText('packages/desktop/remote-storage.js')
|
||||
|
||||
assert.match(remoteStorage, /require\('@aws-sdk\/client-s3'\)/)
|
||||
assert.match(remoteStorage, /require\('webdav'\)/)
|
||||
assert.doesNotMatch(remoteStorage, /\bfetch\s*\(/)
|
||||
assert.doesNotMatch(remoteStorage, /\bPROPFIND\b|\bMKCOL\b/)
|
||||
})
|
||||
|
||||
test('desktop preference bridge exposes only registered preference handlers', () => {
|
||||
const main = readText('packages/desktop/main.js')
|
||||
const mainHandlers = collectMatches(main, [
|
||||
|
||||
Reference in new issue
Block a user