diff --git a/apps/cli/src/http/routes/web/import.ts b/apps/cli/src/http/routes/web/import.ts index 10c7180f..63e13cbb 100644 --- a/apps/cli/src/http/routes/web/import.ts +++ b/apps/cli/src/http/routes/web/import.ts @@ -407,8 +407,9 @@ export function registerImportRoutes( // ==================== Demo Import ==================== - server.post<{ Body: { locale?: string } }>('/_web/demo/import', async (request, reply) => { - const locale = (request.body as any)?.locale === 'cn' ? 'cn' : 'en' + server.post<{ Body: { locale?: string; timeZone?: string } }>('/_web/demo/import', async (request, reply) => { + const locale = request.body?.locale === 'cn' ? 'cn' : 'en' + const targetTimeZone = typeof request.body?.timeZone === 'string' ? request.body.timeZone : undefined const nativeBinding = resolveNativeBinding() reply.raw.writeHead(200, { @@ -424,7 +425,11 @@ export function registerImportRoutes( const result = await importDemoSessions({ locale, tempPrefix: 'cli-demo-', + targetTimeZone, importFile: (filePath) => streamImport(dbManager, filePath, { nativeBinding }), + deleteSession: (sessionId) => { + dbManager.deleteSessionDatabaseFiles(sessionId) + }, onProgress: (progress) => sendEvent('progress', progress), }) sendEvent('result', result) diff --git a/apps/desktop/main/ipc/demo.ts b/apps/desktop/main/ipc/demo.ts index b97f98dc..a25a7303 100644 --- a/apps/desktop/main/ipc/demo.ts +++ b/apps/desktop/main/ipc/demo.ts @@ -33,6 +33,7 @@ export function registerDemoHandlers(ctx: IpcContext): void { locale: locale === 'cn' ? 'cn' : 'en', tempPrefix: 'desktop-demo-', importFile: (filePath) => worker.streamImport(filePath), + deleteSession: (sessionId) => worker.deleteImportedSession(sessionId), onProgress: sendProgress, }) } diff --git a/apps/desktop/main/worker/workerManager.ts b/apps/desktop/main/worker/workerManager.ts index a16b8269..3b8010d8 100644 --- a/apps/desktop/main/worker/workerManager.ts +++ b/apps/desktop/main/worker/workerManager.ts @@ -17,6 +17,10 @@ import { getPathProvider } from '../paths/provider' import { IMPORT_IN_PROGRESS_ERROR_KEY, ImportInProgressError, + contactsService, + deleteSessionCache, + globalInsightService, + peopleRelationshipsService, raiseChatDbCompatibilityGate, withDataDirImportLock, } from '@openchatlab/node-runtime' @@ -546,7 +550,7 @@ async function streamImportUnlocked( kind: 'desktop', }) } catch (error) { - deleteImportedSessionFiles(result.sessionId) + deleteImportedSession(result.sessionId) return { success: false, error: error instanceof Error ? error.message : String(error), @@ -598,7 +602,7 @@ export async function autoImport( kind: 'desktop', }) } catch (error) { - deleteImportedSessionFiles(result.sessionId) + deleteImportedSession(result.sessionId) return { success: false, error: error instanceof Error ? error.message : String(error), @@ -633,7 +637,7 @@ export async function pushImport(sessionId: string, payload: PushImportPayload): kind: 'desktop', }) } catch (error) { - if (outcome.result.created) deleteImportedSessionFiles(sessionId) + if (outcome.result.created) deleteImportedSession(sessionId) throw error } return outcome @@ -661,7 +665,7 @@ export async function analyzePushImport( return sendToWorker('analyzePushImport', { sessionId, payload }) } -function deleteImportedSessionFiles(sessionId: string): void { +export function deleteImportedSession(sessionId: string): void { const dbPath = path.join(getDbDir(), `${sessionId}.db`) for (const suffix of ['', '-wal', '-shm']) { try { @@ -671,6 +675,15 @@ function deleteImportedSessionFiles(sessionId: string): void { /* ignore */ } } + + const cacheDir = getCacheDir() + const userDataDir = getPathProvider().getUserDataDir() + deleteSessionCache(sessionId, cacheDir) + deleteSessionCache(sessionId, path.join(cacheDir, 'query')) + deleteSessionCache(sessionId, contactsService.getContactsFactsCacheDir(userDataDir)) + deleteSessionCache(sessionId, globalInsightService.getGlobalInsightFactsCacheDir(userDataDir)) + globalInsightService.deleteAnnualSummarySnapshots(globalInsightService.getGlobalInsightDir(userDataDir)) + deleteSessionCache(sessionId, peopleRelationshipsService.getPeopleRelationshipsFactsCacheDir(userDataDir)) } /** diff --git a/packages/node-runtime/src/services/demo-import.test.ts b/packages/node-runtime/src/services/demo-import.test.ts index fe9f6bc3..8693da7d 100644 --- a/packages/node-runtime/src/services/demo-import.test.ts +++ b/packages/node-runtime/src/services/demo-import.test.ts @@ -52,6 +52,7 @@ describe('importDemoSessions', () => { const result = await importDemoSessions({ locale: 'cn', tempPrefix: 'demo-service-test-', + targetTimeZone: 'America/Los_Angeles', fetchImpl: async function (this: unknown) { assert.equal(this, globalThis) return new Response(documents[downloadIndex++], { status: 200 }) @@ -63,12 +64,10 @@ describe('importDemoSessions', () => { imported.push({ name: filePath, document }) return { success: true, sessionId: `session-${imported.length}` } }, + deleteSession: async () => {}, }) - const expectedLatest = new Date(now) - expectedLatest.setDate(expectedLatest.getDate() - 1) - expectedLatest.setHours(22, 30, 0, 0) - const latestTimestamp = Math.floor(expectedLatest.getTime() / 1000) + const latestTimestamp = Math.floor(new Date('2026-07-24T05:30:00.000Z').getTime() / 1000) const offset = latestTimestamp - sourceLatest assert.deepEqual(result, { @@ -117,6 +116,7 @@ describe('importDemoSessions', () => { importCount += 1 return { success: true, sessionId: 'unexpected' } }, + deleteSession: async () => {}, }) assert.equal(result.success, false) @@ -124,4 +124,34 @@ describe('importDemoSessions', () => { assert.equal(importCount, 0) assert.equal(progress.at(-1)?.stage, 'error') }) + + it('rolls back sessions imported before a later session fails', async () => { + let downloadIndex = 0 + let importCount = 0 + const rolledBackSessionIds: string[] = [] + const documents = [ + createDemoDocument('group', [sourceTimestamp('2000-12-10', '22:30:00')]), + createDemoDocument('private-a', [sourceTimestamp('2000-12-09', '21:00:00')]), + createDemoDocument('private-b', [sourceTimestamp('2000-12-08', '21:00:00')]), + createDemoDocument('private-c', [sourceTimestamp('2000-12-07', '21:00:00')]), + ] + + const result = await importDemoSessions({ + locale: 'cn', + tempPrefix: 'demo-service-rollback-test-', + fetchImpl: async () => new Response(documents[downloadIndex++], { status: 200 }), + importFile: async () => { + importCount += 1 + if (importCount === 3) return { success: false, error: 'third import failed' } + return { success: true, sessionId: `session-${importCount}` } + }, + deleteSession: async (sessionId) => { + rolledBackSessionIds.push(sessionId) + }, + }) + + assert.equal(result.success, false) + assert.match(result.error ?? '', /third import failed/) + assert.deepEqual(rolledBackSessionIds, ['session-2', 'session-1']) + }) }) diff --git a/packages/node-runtime/src/services/demo-import.ts b/packages/node-runtime/src/services/demo-import.ts index 07561514..a52b596c 100644 --- a/packages/node-runtime/src/services/demo-import.ts +++ b/packages/node-runtime/src/services/demo-import.ts @@ -36,9 +36,15 @@ export interface ImportDemoSessionsOptions { locale: 'cn' | 'en' tempPrefix: string importFile: (filePath: string) => Promise + /** + * Rolls back a completed session. Platform cleanup intentionally keeps its established + * best-effort semantics for rare OS-level unlink failures and only surfaces errors its API exposes. + */ + deleteSession: (sessionId: string) => Promise | void onProgress?: (progress: DemoImportProgress) => void fetchImpl?: typeof fetch now?: () => Date + targetTimeZone?: string } /** @@ -48,12 +54,17 @@ export interface ImportDemoSessionsOptions { export async function importDemoSessions(options: ImportDemoSessionsOptions): Promise { const total = DEMO_FILES.length const fetchImpl = options.fetchImpl ?? fetch + const sessionIds: string[] = [] let tempDir: string | undefined try { const createdTempDir = createChatLabTempDir('imports', options.tempPrefix) tempDir = createdTempDir - appLogger.info('demo-import', 'Demo import started', { locale: options.locale, sessionCount: total }) + appLogger.info('demo-import', 'Demo import started', { + locale: options.locale, + sessionCount: total, + targetTimeZone: options.targetTimeZone ?? 'runtime-local', + }) const downloaded: string[] = [] for (let index = 0; index < total; index++) { @@ -71,7 +82,7 @@ export async function importDemoSessions(options: ImportDemoSessionsOptions): Pr downloaded.push(content.toString('utf8')) } - const rebased = rebaseChatLabDemoDocuments(downloaded, options.now?.() ?? new Date()) + const rebased = rebaseChatLabDemoDocuments(downloaded, options.now?.() ?? new Date(), options.targetTimeZone) const localPaths = rebased.documents.map((document, index) => { const localPath = path.join(createdTempDir, DEMO_FILES[index]) fs.writeFileSync(localPath, document, 'utf8') @@ -84,7 +95,6 @@ export async function importDemoSessions(options: ImportDemoSessionsOptions): Pr latestTimestamp: rebased.latestTimestamp, }) - const sessionIds: string[] = [] for (let index = 0; index < localPaths.length; index++) { options.onProgress?.({ stage: 'importing', current: index + 1, total }) const result = await options.importFile(localPaths[index]) @@ -99,7 +109,27 @@ export async function importDemoSessions(options: ImportDemoSessionsOptions): Pr appLogger.info('demo-import', 'Demo import completed', { sessionCount: sessionIds.length }) return { success: true, groupSessionId, privateSessionIds } } catch (error) { - const message = error instanceof Error ? error.message : String(error) + const rollbackFailures: unknown[] = [] + for (const sessionId of sessionIds.toReversed()) { + try { + await options.deleteSession(sessionId) + } catch (rollbackError) { + rollbackFailures.push(rollbackError) + appLogger.error('demo-import', `Failed to roll back Demo session ${sessionId}`, rollbackError) + } + } + + if (sessionIds.length > 0 && rollbackFailures.length === 0) { + appLogger.info('demo-import', 'Rolled back partially imported Demo sessions', { + sessionCount: sessionIds.length, + }) + } + + const originalMessage = error instanceof Error ? error.message : String(error) + const message = + rollbackFailures.length === 0 + ? originalMessage + : `${originalMessage} (${rollbackFailures.length} Demo session(s) could not be rolled back.)` options.onProgress?.({ stage: 'error', current: 0, total, message }) appLogger.error('demo-import', 'Demo import failed', error) return { success: false, error: message } diff --git a/packages/parser/src/chatlab-demo-timeline.test.ts b/packages/parser/src/chatlab-demo-timeline.test.ts index be24e53b..cecc1a59 100644 --- a/packages/parser/src/chatlab-demo-timeline.test.ts +++ b/packages/parser/src/chatlab-demo-timeline.test.ts @@ -78,6 +78,15 @@ describe('rebaseChatLabDemoDocuments', () => { assert.ok(result.latestTimestamp < Math.floor(now.getTime() / 1000)) }) + it('uses an explicit importing-user timezone instead of the runtime timezone', () => { + const source = sourceTimestamp('2000-12-10', '22:30:00') + const now = new Date('2026-07-25T04:00:00.000Z') + + const result = rebaseChatLabDemoDocuments([createDocument([source])], now, 'America/Los_Angeles') + + assert.equal(result.latestTimestamp, Math.floor(new Date('2026-07-24T05:30:00.000Z').getTime() / 1000)) + }) + it('rejects inconsistent or malformed Demo timelines before import', () => { const timestamp = sourceTimestamp('2000-12-10', '22:30:00') diff --git a/packages/parser/src/chatlab-demo-timeline.ts b/packages/parser/src/chatlab-demo-timeline.ts index db18c2ec..3a969a0a 100644 --- a/packages/parser/src/chatlab-demo-timeline.ts +++ b/packages/parser/src/chatlab-demo-timeline.ts @@ -36,7 +36,8 @@ export interface RebasedChatLabDemoDocuments { */ export function rebaseChatLabDemoDocuments( jsonDocuments: readonly string[], - now: Date = new Date() + now: Date = new Date(), + targetTimeZone?: string ): RebasedChatLabDemoDocuments { if (jsonDocuments.length === 0) throw new Error('Demo timeline requires at least one document') if (!Number.isFinite(now.getTime())) throw new Error('Demo timeline requires a valid import time') @@ -47,18 +48,9 @@ export function rebaseChatLabDemoDocuments( ...documents.flatMap((document) => document.messages.map((message) => message.timestamp)) ) - // 源时间戳使用固定时区生成;先还原其中的墙上时间,再映射到用户本地的昨天。 + // Restore the source wall-clock time before mapping it to yesterday in the user's local calendar. const sourceLatestWallTime = new Date((sourceLatestTimestamp + timeZoneOffsetMinutes * SECONDS_PER_MINUTE) * 1000) - const targetLatestTime = new Date(now) - targetLatestTime.setDate(targetLatestTime.getDate() - 1) - targetLatestTime.setHours( - sourceLatestWallTime.getUTCHours(), - sourceLatestWallTime.getUTCMinutes(), - sourceLatestWallTime.getUTCSeconds(), - 0 - ) - - const latestTimestamp = Math.floor(targetLatestTime.getTime() / 1000) + const latestTimestamp = resolveTargetLatestTimestamp(now, sourceLatestWallTime, targetTimeZone) const offsetSeconds = latestTimestamp - sourceLatestTimestamp const exportedAt = Math.floor(now.getTime() / 1000) @@ -77,6 +69,98 @@ export function rebaseChatLabDemoDocuments( } } +function resolveTargetLatestTimestamp(now: Date, sourceWallTime: Date, targetTimeZone?: string): number { + if (!targetTimeZone) { + const targetLatestTime = new Date(now) + targetLatestTime.setDate(targetLatestTime.getDate() - 1) + targetLatestTime.setHours( + sourceWallTime.getUTCHours(), + sourceWallTime.getUTCMinutes(), + sourceWallTime.getUTCSeconds(), + 0 + ) + return Math.floor(targetLatestTime.getTime() / 1000) + } + + let formatter: Intl.DateTimeFormat + try { + formatter = new Intl.DateTimeFormat('en-US-u-ca-gregory-nu-latn', { + timeZone: targetTimeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }) + } catch { + throw new Error('Demo timeline has an invalid target time zone') + } + + const currentDate = getZonedDateTimeParts(now, formatter) + const yesterday = new Date(Date.UTC(currentDate.year, currentDate.month - 1, currentDate.day - 1)) + const targetParts: ZonedDateTimeParts = { + year: yesterday.getUTCFullYear(), + month: yesterday.getUTCMonth() + 1, + day: yesterday.getUTCDate(), + hour: sourceWallTime.getUTCHours(), + minute: sourceWallTime.getUTCMinutes(), + second: sourceWallTime.getUTCSeconds(), + } + + return Math.floor(zonedWallTimeToUnixMillis(targetParts, formatter) / 1000) +} + +interface ZonedDateTimeParts { + year: number + month: number + day: number + hour: number + minute: number + second: number +} + +function getZonedDateTimeParts(date: Date, formatter: Intl.DateTimeFormat): ZonedDateTimeParts { + const values = new Map(formatter.formatToParts(date).map((part) => [part.type, part.value])) + const read = (type: keyof ZonedDateTimeParts) => Number(values.get(type)) + const parts: ZonedDateTimeParts = { + year: read('year'), + month: read('month'), + day: read('day'), + hour: read('hour'), + minute: read('minute'), + second: read('second'), + } + if (Object.values(parts).some((value) => !Number.isInteger(value))) { + throw new Error('Demo timeline could not resolve the target time zone') + } + return parts +} + +function zonedWallTimeToUnixMillis(target: ZonedDateTimeParts, formatter: Intl.DateTimeFormat): number { + const targetAsUtc = dateTimePartsToUtcMillis(target) + let candidate = targetAsUtc + + // Intl maps timestamps to zoned wall-clock time; iterative corrections resolve the inverse mapping. + for (let attempt = 0; attempt < 3; attempt++) { + const actual = getZonedDateTimeParts(new Date(candidate), formatter) + const correction = targetAsUtc - dateTimePartsToUtcMillis(actual) + candidate += correction + if (correction === 0) return candidate + } + + const actual = getZonedDateTimeParts(new Date(candidate), formatter) + if (dateTimePartsToUtcMillis(actual) !== targetAsUtc) { + throw new Error('Demo timeline target wall time does not exist in the selected time zone') + } + return candidate +} + +function dateTimePartsToUtcMillis(parts: ZonedDateTimeParts): number { + return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) +} + function parseDemoDocument(json: string, index: number): ChatLabDemoDocument { let value: unknown try { diff --git a/src/components/home/DemoImportButton.vue b/src/components/home/DemoImportButton.vue index b6ecc559..f2f2c20d 100644 --- a/src/components/home/DemoImportButton.vue +++ b/src/components/home/DemoImportButton.vue @@ -29,7 +29,7 @@ async function navigateToSession(sessionId: string) { } async function handleImportViaService() { - // 当前示例数据只提供中、英文;其他界面语言使用英文示例。 + // Demo assets are available in Chinese and English; other interface locales use the English version. const demoLocale = getChatlabSiteLocalePath(settingsStore.locale) === 'cn' ? 'cn' : 'en' return useImportService().importDemo(demoLocale, (progress) => { stage.value = progress.stage diff --git a/src/services/import/browser.test.ts b/src/services/import/browser.test.ts index 384f3e32..6305c8a4 100644 --- a/src/services/import/browser.test.ts +++ b/src/services/import/browser.test.ts @@ -225,6 +225,107 @@ describe('BrowserImportAdapter', () => { assert.equal(importRequests, 0) }) + it('times out a stalled demo response body and releases the active import', async () => { + let importRequests = 0 + const rpc = { + async request(): Promise> { + importRequests += 1 + throw new Error('Import should not start') + }, + dispose: () => undefined, + } + const fetchDemo: typeof fetch = async (_input, init) => { + const signal = init?.signal + return new Response( + new ReadableStream({ + start(controller) { + signal?.addEventListener('abort', () => controller.error(signal.reason), { once: true }) + }, + }) + ) + } + const adapter = new BrowserImportAdapter(rpc, fetchDemo, () => new Date(), 10) + const pending = adapter.importDemo('en') + let watchdog: ReturnType | undefined + + try { + const result = await Promise.race([ + pending, + new Promise<'still-pending'>((resolve) => { + watchdog = setTimeout(() => resolve('still-pending'), 100) + }), + ]) + if (result === 'still-pending') assert.fail('Demo download did not time out') + + assert.equal(result.success, false) + assert.match(result.error ?? '', /timed out/i) + assert.equal(importRequests, 0) + + const retry = await adapter.importDemo('en') + assert.match(retry.error ?? '', /timed out/i) + } finally { + if (watchdog) clearTimeout(watchdog) + adapter.cancelActiveImport() + await pending + } + }) + + it('deletes sessions created earlier in the batch when a later demo import fails', async () => { + const deletedSessionIds: string[] = [] + const reportedErrors: unknown[] = [] + const importError = new Error('third import failed') + let importCount = 0 + const documents = [ + createDemoDocument('group', [demoTimestamp('2000-12-10', '22:30:00')]), + createDemoDocument('private-a', [demoTimestamp('2000-12-09', '21:00:00')]), + createDemoDocument('private-b', [demoTimestamp('2000-12-08', '20:00:00')]), + createDemoDocument('private-c', [demoTimestamp('2000-12-07', '19:00:00')]), + ] + let downloadIndex = 0 + const rpc = { + async request( + type: T, + payload: WebRuntimeTaskPayload + ): Promise> { + if (type === 'import.start') { + importCount += 1 + if (importCount === 3) throw importError + return { + sessionId: `session-${importCount}`, + formatId: 'chatlab', + messageCount: 1, + memberCount: 1, + skippedCount: 0, + } as WebRuntimeTaskResult + } + if (type === 'session.delete') { + deletedSessionIds.push((payload as WebRuntimeTaskPayload<'session.delete'>).sessionId) + return { deleted: true } as WebRuntimeTaskResult + } + throw new Error(`Unexpected task: ${type}`) + }, + dispose: () => undefined, + } + const adapter = new BrowserImportAdapter( + rpc, + async () => new Response(documents[downloadIndex++], { status: 200 }), + () => new Date('2026-07-25T04:00:00.000Z'), + 60_000, + (event) => { + if (event.level === 'error' && event.message === 'Demo import failed') { + reportedErrors.push(event.data?.error) + } + } + ) + + const result = await adapter.importDemo('en') + + assert.equal(result.success, false) + assert.match(result.error ?? '', /third import failed/) + assert.deepEqual(deletedSessionIds, ['session-2', 'session-1']) + assert.deepEqual(reportedErrors, [importError]) + }) + it('forwards browser-safe format identifiers to the worker runtime', async () => { const requestedFormats: string[] = [] const rpc = { diff --git a/src/services/import/browser.ts b/src/services/import/browser.ts index 046f3f8f..4bc539df 100644 --- a/src/services/import/browser.ts +++ b/src/services/import/browser.ts @@ -17,6 +17,7 @@ import type { } from './types' const DEMO_BASE_URL = '/api/demo' +const DEMO_DOWNLOAD_TIMEOUT_MS = 60_000 const DEMO_FILES = [ 'demo-group.json', 'demo-private-A-cuilan.json', @@ -30,7 +31,9 @@ export class BrowserImportAdapter implements ImportAdapter { constructor( private readonly rpc: BrowserRuntimeRpcPort, private readonly fetchDemo: typeof fetch = fetch, - private readonly now: () => Date = () => new Date() + private readonly now: () => Date = () => new Date(), + private readonly demoDownloadTimeoutMs = DEMO_DOWNLOAD_TIMEOUT_MS, + private readonly reportLog: typeof reportRuntimeLog = reportRuntimeLog ) {} async importFile( @@ -98,22 +101,17 @@ export class BrowserImportAdapter implements ImportAdapter { const controller = new AbortController() this.activeImport = controller - reportRuntimeLog({ level: 'info', scope: 'web-wasm-demo', message: 'Demo import started' }) + this.reportLog({ level: 'info', scope: 'web-wasm-demo', message: 'Demo import started' }) + const sessionIds: string[] = [] try { const downloaded: Array<{ filename: string; contentType: string; json: string }> = [] for (const filename of DEMO_FILES) { onProgress?.({ stage: 'downloading' }) - const response = await this.fetchDemo.call(globalThis, `${DEMO_BASE_URL}/${locale}/${filename}`, { - signal: controller.signal, - }) - if (!response.ok) throw new Error(`Download demo failed (${filename}): HTTP ${response.status}`) - - const content = await response.arrayBuffer() - if (content.byteLength < 100) throw new Error(`Downloaded demo file is too small (${filename})`) + const { content, contentType } = await this.downloadDemoFile(locale, filename, controller) downloaded.push({ filename, - contentType: response.headers.get('content-type') || 'application/json', + contentType, json: new TextDecoder().decode(content), }) } @@ -126,7 +124,6 @@ export class BrowserImportAdapter implements ImportAdapter { (json, index) => new File([json], downloaded[index].filename, { type: downloaded[index].contentType }) ) - const sessionIds: string[] = [] for (const file of files) { onProgress?.({ stage: 'importing' }) const result = await this.importBrowserFile(file, 'chatlab', undefined, controller.signal) @@ -137,7 +134,7 @@ export class BrowserImportAdapter implements ImportAdapter { } const [groupSessionId, ...privateSessionIds] = sessionIds - reportRuntimeLog({ + this.reportLog({ level: 'info', scope: 'web-wasm-demo', message: 'Demo import completed', @@ -150,13 +147,52 @@ export class BrowserImportAdapter implements ImportAdapter { return { success: true, groupSessionId, privateSessionIds } } catch (error) { const message = error instanceof Error ? error.message : String(error) - reportRuntimeLog({ level: 'error', scope: 'web-wasm-demo', message: 'Demo import failed', data: { message } }) - return { success: false, error: message } + const rollbackFailureCount = await this.rollbackDemoSessions(sessionIds) + this.reportLog({ + level: 'error', + scope: 'web-wasm-demo', + message: 'Demo import failed', + data: { message, error }, + }) + return { + success: false, + error: + rollbackFailureCount > 0 + ? `${message} (${rollbackFailureCount} imported Demo sessions could not be removed)` + : message, + } } finally { if (this.activeImport === controller) this.activeImport = undefined } } + private async downloadDemoFile( + locale: string, + filename: (typeof DEMO_FILES)[number], + controller: AbortController + ): Promise<{ content: ArrayBuffer; contentType: string }> { + // Use one controller for user cancellation and per-file timeout so response-body reads are also abortable. + const timeout = setTimeout(() => { + controller.abort(new Error(`Download demo timed out (${filename})`)) + }, this.demoDownloadTimeoutMs) + + try { + const response = await this.fetchDemo.call(globalThis, `${DEMO_BASE_URL}/${locale}/${filename}`, { + signal: controller.signal, + }) + if (!response.ok) throw new Error(`Download demo failed (${filename}): HTTP ${response.status}`) + + const content = await response.arrayBuffer() + if (content.byteLength < 100) throw new Error(`Downloaded demo file is too small (${filename})`) + return { + content, + contentType: response.headers.get('content-type') || 'application/json', + } + } finally { + clearTimeout(timeout) + } + } + analyzeIncrementalImport(_sessionId: string, _file: File | string): Promise { return unsupported('Incremental import') } @@ -201,6 +237,28 @@ export class BrowserImportAdapter implements ImportAdapter { memberCount: result.memberCount, } } + + private async rollbackDemoSessions(sessionIds: readonly string[]): Promise { + let failureCount = 0 + for (const sessionId of [...sessionIds].reverse()) { + try { + const result = await this.rpc.request('session.delete', { sessionId }) + if (!result.deleted) failureCount += 1 + } catch { + failureCount += 1 + } + } + + if (sessionIds.length > 0) { + this.reportLog({ + level: failureCount > 0 ? 'error' : 'info', + scope: 'web-wasm-demo', + message: failureCount > 0 ? 'Demo import rollback incomplete' : 'Demo import rolled back', + data: { sessionCount: sessionIds.length, failureCount }, + }) + } + return failureCount + } } function mapImportProgress(progress: RpcProgressPayload): ImportProgress { diff --git a/src/services/import/fetch.ts b/src/services/import/fetch.ts index 2e278a0f..d5ef44d3 100644 --- a/src/services/import/fetch.ts +++ b/src/services/import/fetch.ts @@ -151,7 +151,10 @@ export class FetchImportAdapter implements ImportAdapter { fetchWithAuth(`${getBaseUrl()}/demo/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ locale }), + body: JSON.stringify({ + locale, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }), }) .then(async (resp) => { if (!resp.ok || !resp.body) { diff --git a/src/services/import/importSourceAdapters.test.ts b/src/services/import/importSourceAdapters.test.ts index a4d8dc5c..98f792be 100644 --- a/src/services/import/importSourceAdapters.test.ts +++ b/src/services/import/importSourceAdapters.test.ts @@ -104,4 +104,23 @@ describe('archive import source adapters', () => { assert.equal(requests[1].init?.body, JSON.stringify({ chatId: 'Groups/DM sample' })) assert.equal(requests[2].init?.method, 'DELETE') }) + + it('sends the browser timezone when importing Demo sessions through CLI Web', async () => { + let requestBody: string | undefined + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + requestBody = init?.body as string | undefined + return new Response('event: result\ndata: {"success":true,"groupSessionId":"group"}\n\n', { + headers: { 'Content-Type': 'text/event-stream' }, + }) + }) as typeof fetch + + const adapter = new FetchImportAdapter() + const result = await adapter.importDemo('cn') + + assert.equal(result.success, true) + assert.deepEqual(JSON.parse(requestBody ?? ''), { + locale: 'cn', + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }) + }) })