feat: add deployment-aware remote backups

This commit is contained in:
linshen committed 2026-05-08 21:17:23 +08:00
1 parent b8de75b46c
commit b0680cda7e
24 files changed
+8561 -336

No files matched your search

+9
View File
@@ -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
+5
View File
@@ -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) => {
+4 -1
View File
@@ -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/**/*"
+12
View File
@@ -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 () => {
+515
View File
@@ -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,
};
+1
View File
@@ -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'
+173 -23
View File
@@ -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;
+174 -24
View File
@@ -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;
+174 -24
View File
@@ -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;
+3
View File
@@ -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)
})
})
+1305
View File
File diff suppressed because it is too large. Load diff
+145 -1
View File
@@ -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, [