From bb65ad5ca69736a1dee9d5d865745a6e11df4124 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Sun, 31 May 2026 01:37:36 +0800 Subject: [PATCH] fix: add startup sync and dark chat text fix --- .../src/components/chat/MarkdownRenderer.tsx | 46 +- .../screens/settings/SyncSettingsScreen.tsx | 507 ++++++++++-------- .../src/components/settings/SyncSettings.tsx | 91 +++- packages/core/src/hooks/use-auto-sync.ts | 62 ++- .../core/src/i18n/locales/en/settings.json | 6 +- .../core/src/i18n/locales/es/settings.json | 2 + .../core/src/i18n/locales/fr/settings.json | 2 + .../core/src/i18n/locales/ja/settings.json | 2 + .../core/src/i18n/locales/ko/settings.json | 2 + .../core/src/i18n/locales/zh-TW/settings.json | 2 + .../core/src/i18n/locales/zh/settings.json | 2 + packages/core/src/stores/sync-store.test.ts | 17 + packages/core/src/stores/sync-store.ts | 46 +- packages/core/src/sync/sync-backend.ts | 8 +- 14 files changed, 521 insertions(+), 274 deletions(-) diff --git a/packages/app-expo/src/components/chat/MarkdownRenderer.tsx b/packages/app-expo/src/components/chat/MarkdownRenderer.tsx index 9ed9ebde..0a51567e 100644 --- a/packages/app-expo/src/components/chat/MarkdownRenderer.tsx +++ b/packages/app-expo/src/components/chat/MarkdownRenderer.tsx @@ -5,7 +5,7 @@ import type { CitationPart } from "@readany/core/types/message"; import * as Clipboard from "expo-clipboard"; import { Fragment, type ReactNode, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Text, TouchableOpacity, View } from "react-native"; +import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; import Markdown, { type RenderRules, type ASTNode } from "react-native-markdown-display"; interface MarkdownRendererProps { @@ -128,8 +128,11 @@ function renderTextWithCitations( const parts = text.split(/(\[\d+\])/g); const result: React.ReactNode[] = []; + let offset = 0; - parts.forEach((part, i) => { + for (const part of parts) { + const keyOffset = offset; + offset += part.length; const match = part.match(/\[(\d+)\]/); if (match) { const num = Number.parseInt(match[1]); @@ -137,27 +140,26 @@ function renderTextWithCitations( if (citation) { result.push( , ); - return; + continue; } } if (part) { - result.push({part}); + result.push({part}); } - }); + } return result; } export function MarkdownRenderer({ content, - isStreaming, styleOverrides, citations, onCitationClick, @@ -170,7 +172,7 @@ export function MarkdownRenderer({ const rules = useMemo( () => ({ - fence: (node: ASTNode, children: ReactNode[], parentNodes: ASTNode[], style: any) => { + fence: (node: ASTNode, _children: ReactNode[], _parentNodes: ASTNode[], style: any) => { const code = node.content || ""; const lang = getCodeLanguage(node); @@ -180,7 +182,7 @@ export function MarkdownRenderer({ return ; }, - code_block: (node: ASTNode, children: ReactNode[], parentNodes: ASTNode[], style: any) => { + code_block: (node: ASTNode, _children: ReactNode[], _parentNodes: ASTNode[], style: any) => { const code = node.content || ""; const lang = getCodeLanguage(node); @@ -192,17 +194,29 @@ export function MarkdownRenderer({ ); }, - text: (node: ASTNode, children: ReactNode[], parentNodes: ASTNode[], style: any) => { + text: ( + node: ASTNode, + _children: ReactNode[], + _parentNodes: ASTNode[], + style: any, + inheritedStyles: any = {}, + ) => { const text = node.content || ""; + const textStyle = [inheritedStyles, style.text]; + const resolvedTextStyle = StyleSheet.flatten(textStyle); + const themedTextStyle = resolvedTextStyle?.color + ? textStyle + : [...textStyle, { color: colors.foreground }]; + if (citations && citations.length > 0 && /\[\d+\]/.test(text)) { return ( - + {renderTextWithCitations(text, citations, onCitationClick, colors)} ); } return ( - + {text} ); @@ -227,6 +241,14 @@ const makeMarkdownStyles = (colors: ThemeColors) => fontSize: fs.sm, lineHeight: 20, }, + text: { + color: colors.foreground, + fontSize: fs.sm, + lineHeight: 20, + }, + textgroup: { + color: colors.foreground, + }, heading1: { color: colors.foreground, fontSize: fs.lg, diff --git a/packages/app-expo/src/screens/settings/SyncSettingsScreen.tsx b/packages/app-expo/src/screens/settings/SyncSettingsScreen.tsx index 7a56469b..46b1a9e7 100644 --- a/packages/app-expo/src/screens/settings/SyncSettingsScreen.tsx +++ b/packages/app-expo/src/screens/settings/SyncSettingsScreen.tsx @@ -1,16 +1,14 @@ -import { ConfigTransfer } from "../../components/settings/ConfigTransfer"; +import { useResponsiveLayout } from "@/hooks/use-responsive-layout"; import { getPlatformService } from "@readany/core/services"; import { useSyncStore } from "@readany/core/stores"; -import { useResponsiveLayout } from "@/hooks/use-responsive-layout"; -import type { S3Config, WebDavConfig } from "@readany/core/sync/sync-backend"; import { SYNC_SECRET_KEYS } from "@readany/core/sync/sync-backend"; +import Constants from "expo-constants"; /** * SyncSettingsScreen — Multi-backend sync configuration and status panel (mobile). * Supports WebDAV, S3, and LAN sync. */ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import Constants from "expo-constants"; import { ActivityIndicator, Alert, @@ -24,26 +22,19 @@ import { View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +import { ConfigTransfer } from "../../components/settings/ConfigTransfer"; import { spacing, useColors } from "../../styles/theme"; import { SettingsHeader } from "./SettingsHeader"; -import { makeStyles } from "./sync/sync-styles"; -import { WebDavForm } from "./sync/WebDavForm"; -import { S3Form } from "./sync/S3Form"; import { LanSection } from "./sync/LanSection"; +import { S3Form } from "./sync/S3Form"; +import { WebDavForm } from "./sync/WebDavForm"; +import { makeStyles } from "./sync/sync-styles"; type BackendType = "webdav" | "s3" | "lan"; -function isWebDavConfig(config: unknown): config is WebDavConfig { - return ( - typeof config === "object" && config !== null && (config as WebDavConfig).type === "webdav" - ); -} - -function isS3Config(config: unknown): config is S3Config { - return typeof config === "object" && config !== null && (config as S3Config).type === "s3"; -} - -function hasAutoSync(config: unknown): config is { autoSync: boolean; syncIntervalMins?: number } { +function hasAutoSync( + config: unknown, +): config is { autoSync: boolean; syncOnStartup?: boolean; syncIntervalMins?: number } { return typeof config === "object" && config !== null && "autoSync" in config; } @@ -72,6 +63,7 @@ export default function SyncSettingsScreen() { syncWithBackend, forceFullSync, setAutoSync, + setSyncOnStartup, setSyncIntervalMins, resetSync, } = useSyncStore(); @@ -180,9 +172,20 @@ export default function SyncSettingsScreen() { setTesting(false); } }, [ - selectedBackend, url, username, password, allowInsecure, remoteRoot, - s3Endpoint, s3Region, s3Bucket, s3AccessKeyId, s3SecretAccessKey, - testWebDavConnection, testS3Connection, t, + selectedBackend, + url, + username, + password, + allowInsecure, + remoteRoot, + s3Endpoint, + s3Region, + s3Bucket, + s3AccessKeyId, + s3SecretAccessKey, + testWebDavConnection, + testS3Connection, + t, ]); const handleSave = useCallback(async () => { @@ -200,9 +203,19 @@ export default function SyncSettingsScreen() { setSaving(false); } }, [ - selectedBackend, url, username, password, allowInsecure, remoteRoot, - s3Endpoint, s3Region, s3Bucket, s3AccessKeyId, s3SecretAccessKey, - saveWebDavConfig, saveS3Config, + selectedBackend, + url, + username, + password, + allowInsecure, + remoteRoot, + s3Endpoint, + s3Region, + s3Bucket, + s3AccessKeyId, + s3SecretAccessKey, + saveWebDavConfig, + saveS3Config, ]); const handleSync = useCallback(async () => { @@ -219,7 +232,9 @@ export default function SyncSettingsScreen() { }, [syncNow, t]); const handleConflict = useCallback( - (direction: "upload" | "download") => { syncNow(direction); }, + (direction: "upload" | "download") => { + syncNow(direction); + }, [syncNow], ); @@ -258,7 +273,9 @@ export default function SyncSettingsScreen() { { text: t("common.confirm"), style: "destructive", - onPress: () => { void forceFullSync(direction); }, + onPress: () => { + void forceFullSync(direction); + }, }, ], ); @@ -274,24 +291,36 @@ export default function SyncSettingsScreen() { const statusLabel = () => { if (isLanContext) { switch (status) { - case "checking": return t("settings.syncLANPreparingImport"); - case "downloading": return t("settings.syncLANImporting"); - case "syncing-files": return t("settings.syncLANImportingFiles"); - case "error": return t("settings.syncError"); - default: return null; + case "checking": + return t("settings.syncLANPreparingImport"); + case "downloading": + return t("settings.syncLANImporting"); + case "syncing-files": + return t("settings.syncLANImportingFiles"); + case "error": + return t("settings.syncError"); + default: + return null; } } switch (status) { - case "checking": return t("settings.syncChecking"); - case "uploading": return t("settings.syncUploading"); - case "downloading": return t("settings.syncDownloading"); - case "syncing-files": return t("settings.syncSyncingFiles"); - case "error": return t("settings.syncError"); - default: return null; + case "checking": + return t("settings.syncChecking"); + case "uploading": + return t("settings.syncUploading"); + case "downloading": + return t("settings.syncDownloading"); + case "syncing-files": + return t("settings.syncSyncingFiles"); + case "error": + return t("settings.syncError"); + default: + return null; } }; const autoSyncEnabled = hasAutoSync(config) ? config.autoSync : false; + const syncOnStartupEnabled = hasAutoSync(config) ? config.syncOnStartup === true : false; const showScheduledSyncSettings = hasAutoSync(config); const progressLabel = () => { @@ -306,14 +335,16 @@ export default function SyncSettingsScreen() { } return progress.phase === "database" ? t("settings.syncProgressDatabase", { - operation: progress.operation === "upload" - ? t("settings.syncUploading") - : t("settings.syncDownloading"), + operation: + progress.operation === "upload" + ? t("settings.syncUploading") + : t("settings.syncDownloading"), }) : t("settings.syncProgressFiles", { - operation: progress.operation === "upload" - ? t("settings.syncUploading") - : t("settings.syncDownloading"), + operation: + progress.operation === "upload" + ? t("settings.syncUploading") + : t("settings.syncDownloading"), completed: progress.completedFiles, total: progress.totalFiles, }); @@ -343,7 +374,9 @@ export default function SyncSettingsScreen() { keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" > - + {t("settings.syncLayoutMigrationNotice")} {/* Backend Type Selector */} @@ -353,7 +386,10 @@ export default function SyncSettingsScreen() { {(["webdav", "s3", "lan"] as const).map((backend) => ( setSelectedBackend(backend)} > - - {t("settings.syncConflictTitle")} - {t("settings.syncConflictDesc")} - - handleConflict("upload")} - activeOpacity={0.7} - > - {t("settings.syncConflictUpload")} - - handleConflict("download")} - activeOpacity={0.7} - > - {t("settings.syncConflictDownload")} - + + + {t("settings.syncConflictTitle")} + {t("settings.syncConflictDesc")} + + handleConflict("upload")} + activeOpacity={0.7} + > + {t("settings.syncConflictUpload")} + + handleConflict("download")} + activeOpacity={0.7} + > + + {t("settings.syncConflictDownload")} + + + - )} {/* Sync Status */} {selectedBackend !== "lan" && (isConfigured || isBusy || lastSyncAt) && ( - - - {isLanContext ? t("settings.syncLANImportStatus") : t("settings.syncStatus")} - - - - - - {isLanContext ? t("settings.syncLANLastImport") : t("settings.syncLastSync")} - - {formatLastSync(lastSyncAt)} - {statusLabel() && {statusLabel()}} - {isBusy && progress && ( - - - {progress.phase === "database" ? ( - - ) : ( - 0 ? Math.round((progress.completedFiles / progress.totalFiles) * 100) : 0}%`, - }, - ]} - /> - )} + + + {isLanContext ? t("settings.syncLANImportStatus") : t("settings.syncStatus")} + + + + + + {isLanContext + ? t("settings.syncLANLastImport") + : t("settings.syncLastSync")} + + {formatLastSync(lastSyncAt)} + {statusLabel() && {statusLabel()}} + {isBusy && progress && ( + + + {progress.phase === "database" ? ( + + ) : ( + 0 ? Math.round((progress.completedFiles / progress.totalFiles) * 100) : 0}%`, + }, + ]} + /> + )} + + {progressLabel()} - {progressLabel()} - + )} + + {!isLanContext && ( + + {isBusy && ( + + )} + + {isBusy ? t("settings.syncSyncing") : t("settings.syncNow")} + + )} - {!isLanContext && ( - - {isBusy && } - - {isBusy ? t("settings.syncSyncing") : t("settings.syncNow")} - - - )} - - {lastResult && ( - - {lastResult.success ? ( - <> - - {isLanContext - ? t("settings.syncLANImportComplete") - : t("settings.syncDirection", { direction: lastResult.direction })} - - {lastResult.filesUploaded > 0 && ( - - {t("settings.syncFilesUp", { count: lastResult.filesUploaded })} - - )} - {lastResult.filesDownloaded > 0 && ( + {lastResult && ( + + {lastResult.success ? ( + <> {isLanContext - ? t("settings.syncLANImportedFiles", { count: lastResult.filesDownloaded }) - : t("settings.syncFilesDown", { count: lastResult.filesDownloaded })} + ? t("settings.syncLANImportComplete") + : t("settings.syncDirection", { direction: lastResult.direction })} - )} - {(lastResult.filesUploadFailed > 0 || lastResult.filesDownloadFailed > 0) && ( - - {t("settings.syncFilesPartialFailed", { - uploadFailed: lastResult.filesUploadFailed, - downloadFailed: lastResult.filesDownloadFailed, - })} - - )} - > - ) : ( - - {t("settings.syncFailed", { error: lastResult.error })} - - )} - - )} + {lastResult.filesUploaded > 0 && ( + + {t("settings.syncFilesUp", { count: lastResult.filesUploaded })} + + )} + {lastResult.filesDownloaded > 0 && ( + + {isLanContext + ? t("settings.syncLANImportedFiles", { + count: lastResult.filesDownloaded, + }) + : t("settings.syncFilesDown", { + count: lastResult.filesDownloaded, + })} + + )} + {(lastResult.filesUploadFailed > 0 || + lastResult.filesDownloadFailed > 0) && ( + + {t("settings.syncFilesPartialFailed", { + uploadFailed: lastResult.filesUploadFailed, + downloadFailed: lastResult.filesDownloadFailed, + })} + + )} + > + ) : ( + + {t("settings.syncFailed", { error: lastResult.error })} + + )} + + )} - {error && !lastResult && {error}} + {error && !lastResult && {error}} - {showScheduledSyncSettings && ( - <> - - - {t("settings.syncAutoSync")} - {t("settings.syncAutoSyncDesc")} + {showScheduledSyncSettings && ( + <> + + + {t("settings.syncAutoSync")} + {t("settings.syncAutoSyncDesc")} + + setAutoSync(!autoSyncEnabled)} + > + + - setAutoSync(!autoSyncEnabled)} - > - - - - - - {t("settings.syncInterval")} - {t("settings.syncIntervalDesc")} + + + {t("settings.syncOnStartup")} + {t("settings.syncOnStartupDesc")} + + setSyncOnStartup(!syncOnStartupEnabled)} + > + + - - void handleSyncIntervalBlur()} - keyboardType="number-pad" - returnKeyType="done" - /> - - {t("settings.syncIntervalMinutes", { - count: Number.parseInt(syncIntervalInput || "30", 10) || 30, - })} - + + + {t("settings.syncInterval")} + {t("settings.syncIntervalDesc")} + + + void handleSyncIntervalBlur()} + keyboardType="number-pad" + returnKeyType="done" + /> + + {t("settings.syncIntervalMinutes", { + count: Number.parseInt(syncIntervalInput || "30", 10) || 30, + })} + + - - > - )} + > + )} + - )} {/* Advanced */} {isConfigured && selectedBackend !== "lan" && ( - - setShowAdvanced(!showAdvanced)} - > - {t("settings.syncAdvanced")} - {showAdvanced ? "▲" : "▼"} - - {showAdvanced && ( - - - handleForceFullSync("upload")} - disabled={isBusy} - activeOpacity={0.7} - > - {t("settings.syncForceUpload")} - + + setShowAdvanced(!showAdvanced)} + > + {t("settings.syncAdvanced")} + {showAdvanced ? "▲" : "▼"} + + {showAdvanced && ( + + + handleForceFullSync("upload")} + disabled={isBusy} + activeOpacity={0.7} + > + {t("settings.syncForceUpload")} + + handleForceFullSync("download")} + disabled={isBusy} + activeOpacity={0.7} + > + + {t("settings.syncForceDownload")} + + + + {t("settings.syncForceUploadDesc")} + {t("settings.syncForceDownloadDesc")} handleForceFullSync("download")} - disabled={isBusy} + style={styles.resetBtn} + onPress={handleReset} activeOpacity={0.7} > - - {t("settings.syncForceDownload")} - + {t("settings.syncReset")} + {t("settings.syncResetDesc")} - {t("settings.syncForceUploadDesc")} - {t("settings.syncForceDownloadDesc")} - - {t("settings.syncReset")} - - {t("settings.syncResetDesc")} - - )} - + )} + )} {/* Transfer sync config */} diff --git a/packages/app/src/components/settings/SyncSettings.tsx b/packages/app/src/components/settings/SyncSettings.tsx index bf323bd9..211de35a 100644 --- a/packages/app/src/components/settings/SyncSettings.tsx +++ b/packages/app/src/components/settings/SyncSettings.tsx @@ -35,6 +35,7 @@ export function SyncSettings() { syncNow, forceFullSync, setAutoSync, + setSyncOnStartup, setSyncIntervalMins, resetSync, } = useSyncStore(); @@ -339,6 +340,7 @@ export function SyncSettings() { {t("settings.syncBackendType")} setSelectedBackend("webdav")} className={`rounded-lg border p-3 text-left transition-colors ${ selectedBackend === "webdav" @@ -351,6 +353,7 @@ export function SyncSettings() { {t("settings.syncWebdavDesc")} setSelectedBackend("s3")} className={`rounded-lg border p-3 text-left transition-colors ${ selectedBackend === "s3" @@ -363,6 +366,7 @@ export function SyncSettings() { {t("settings.syncS3Desc")} setSelectedBackend("lan")} className={`rounded-lg border p-3 text-left transition-colors ${ selectedBackend === "lan" @@ -383,8 +387,11 @@ export function SyncSettings() { {t("settings.syncWebDavConfig")} - {t("settings.syncUrl")} + + {t("settings.syncUrl")} + setWebdavUrl(e.target.value)} @@ -394,10 +401,11 @@ export function SyncSettings() { - + {t("settings.syncUsername")} setWebdavUsername(e.target.value)} @@ -405,10 +413,11 @@ export function SyncSettings() { /> - + {t("settings.syncPassword")} setWebdavPassword(e.target.value)} className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground outline-none focus:border-primary" @@ -416,8 +425,11 @@ export function SyncSettings() { - {t("settings.syncRemoteRoot")} + + {t("settings.syncRemoteRoot")} + setWebdavRemoteRoot(e.target.value)} @@ -429,7 +441,9 @@ export function SyncSettings() { {t("settings.syncAllowInsecure")} - {t("settings.syncAllowInsecureDesc")} + + {t("settings.syncAllowInsecureDesc")} + - + {t("settings.syncS3Endpoint")} setS3Endpoint(e.target.value)} @@ -484,10 +501,11 @@ export function SyncSettings() { /> - + {t("settings.syncS3Region")} setS3Region(e.target.value)} @@ -497,8 +515,11 @@ export function SyncSettings() { - {t("settings.syncS3Bucket")} + + {t("settings.syncS3Bucket")} + setS3Bucket(e.target.value)} @@ -508,10 +529,11 @@ export function SyncSettings() { - + {t("settings.syncS3AccessKeyId")} setS3AccessKeyId(e.target.value)} @@ -519,10 +541,11 @@ export function SyncSettings() { /> - + {t("settings.syncS3SecretAccessKey")} setS3SecretAccessKey(e.target.value)} className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground outline-none focus:border-primary" @@ -543,6 +566,7 @@ export function SyncSettings() { {t("settings.syncLANDescFull")} { setLanDialogMode("server"); @@ -589,6 +615,7 @@ export function SyncSettings() { { setLanDialogMode("client"); @@ -639,6 +666,7 @@ export function SyncSettings() { {!isLanContext && ( {t("settings.syncAutoSync")} - {t("settings.syncAutoSyncDesc")} + + {t("settings.syncAutoSyncDesc")} + setAutoSync(checked)} /> + + + {t("settings.syncOnStartup")} + + {t("settings.syncOnStartupDesc")} + + + setSyncOnStartup(checked)} + /> + {t("settings.syncInterval")} - {t("settings.syncIntervalDesc")} + + {t("settings.syncIntervalDesc")} + - {t("settings.syncIntervalMinutes", { count: Number.parseInt(syncIntervalInput || "30", 10) || 30 })} + + {t("settings.syncIntervalMinutes", { + count: Number.parseInt(syncIntervalInput || "30", 10) || 30, + })} + > @@ -757,12 +810,14 @@ export function SyncSettings() { {t("settings.syncConflictDesc")} handleConflict("upload")} className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground transition-colors hover:bg-primary/90" > {t("settings.syncConflictUpload")} handleConflict("download")} className="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" > @@ -778,6 +833,7 @@ export function SyncSettings() { {isConfigured && selectedBackend !== "lan" && ( setShowAdvanced(!showAdvanced)} className="mb-2 text-sm font-medium text-foreground" > @@ -788,6 +844,7 @@ export function SyncSettings() { @@ -860,9 +919,7 @@ export function SyncSettings() { saveS3Config(d.config as never, (d.secretAccessKey as string) || ""); } }} - validate={(d) => - typeof d === "object" && d !== null && "backendType" in d - } + validate={(d) => typeof d === "object" && d !== null && "backendType" in d} /> diff --git a/packages/core/src/hooks/use-auto-sync.ts b/packages/core/src/hooks/use-auto-sync.ts index 64fdd3ed..7b5805c1 100644 --- a/packages/core/src/hooks/use-auto-sync.ts +++ b/packages/core/src/hooks/use-auto-sync.ts @@ -7,7 +7,9 @@ import { useEffect, useRef } from "react"; import { useSyncStore } from "../stores/sync-store"; -function hasAutoSync(config: unknown): config is { autoSync: boolean; syncIntervalMins?: number } { +function hasAutoSync( + config: unknown, +): config is { autoSync: boolean; syncOnStartup?: boolean; syncIntervalMins?: number } { return typeof config === "object" && config !== null && "autoSync" in config; } @@ -27,6 +29,8 @@ export function useAutoSync(onSyncComplete?: () => void) { const statusRef = useRef(status); statusRef.current = status; const timerRef = useRef | null>(null); + const startupTimerRef = useRef | null>(null); + const startupSyncAttemptedRef = useRef(false); const lastErrorRef = useRef(null); lastErrorRef.current = error; @@ -45,7 +49,49 @@ export function useAutoSync(onSyncComplete?: () => void) { } }, [lastResult, onSyncComplete]); - // Delayed startup sync + periodic sync + // Optional one-shot startup sync + useEffect(() => { + const startupSyncEnabled = hasAutoSync(config) && config.syncOnStartup === true; + + if (!isConfigured || !startupSyncEnabled || startupSyncAttemptedRef.current) { + if (startupTimerRef.current) { + clearTimeout(startupTimerRef.current); + startupTimerRef.current = null; + } + return; + } + + if ( + lastErrorRef.current?.includes("connect") || + lastErrorRef.current?.includes("Unauthorized") + ) { + console.log("[AutoSync] Skipping startup sync due to connection/auth error"); + return; + } + + let cancelled = false; + startupTimerRef.current = setTimeout( + async () => { + if (cancelled) return; + startupSyncAttemptedRef.current = true; + + if (statusRef.current === "idle" && !lastErrorRef.current) { + await syncNow(); + } + }, + withJitter(10_000, 10_000), + ); + + return () => { + cancelled = true; + if (startupTimerRef.current) { + clearTimeout(startupTimerRef.current); + startupTimerRef.current = null; + } + }; + }, [isConfigured, config, syncNow]); + + // Periodic sync useEffect(() => { const autoSyncEnabled = hasAutoSync(config) && config.autoSync; @@ -58,7 +104,10 @@ export function useAutoSync(onSyncComplete?: () => void) { } // Don't auto-sync if last error was auth-related - if (lastErrorRef.current?.includes("connect") || lastErrorRef.current?.includes("Unauthorized")) { + if ( + lastErrorRef.current?.includes("connect") || + lastErrorRef.current?.includes("Unauthorized") + ) { console.log("[AutoSync] Skipping auto-sync due to connection/auth error"); return; } @@ -80,7 +129,12 @@ export function useAutoSync(onSyncComplete?: () => void) { }, delayMs); }; - scheduleNext(withJitter(10_000, 10_000)); + const startupSyncEnabled = hasAutoSync(config) && config.syncOnStartup === true; + scheduleNext( + startupSyncEnabled + ? withJitter(intervalMs, Math.min(60_000, Math.floor(intervalMs * 0.1))) + : withJitter(10_000, 10_000), + ); return () => { cancelled = true; diff --git a/packages/core/src/i18n/locales/en/settings.json b/packages/core/src/i18n/locales/en/settings.json index 6f40affa..88f689cb 100644 --- a/packages/core/src/i18n/locales/en/settings.json +++ b/packages/core/src/i18n/locales/en/settings.json @@ -260,8 +260,10 @@ "syncFilesUp": "Uploaded {{count}} files", "syncFilesDown": "Downloaded {{count}} files", "syncFilesPartialFailed": "⚠️ Some files didn't sync ({{uploadFailed}} upload failures, {{downloadFailed}} download failures). Likely causes: remote refused to create a new folder, network blip, or permission denied. The next sync will retry automatically.", - "syncAutoSync": "自动同步", - "syncAutoSyncDesc": "在后台自动同步数据", + "syncAutoSync": "Auto Sync", + "syncAutoSyncDesc": "Automatically sync data in the background", + "syncOnStartup": "Sync on Startup", + "syncOnStartupDesc": "Automatically sync once after opening the app", "syncInterval": "Sync Interval", "syncIntervalDesc": "When auto sync is enabled, check for updates every N minutes", "syncIntervalMinutes": "{{count}} min", diff --git a/packages/core/src/i18n/locales/es/settings.json b/packages/core/src/i18n/locales/es/settings.json index d9a75224..67b3cf5b 100644 --- a/packages/core/src/i18n/locales/es/settings.json +++ b/packages/core/src/i18n/locales/es/settings.json @@ -255,6 +255,8 @@ "syncFilesPartialFailed": "⚠️ Algunos archivos no se sincronizaron ({{uploadFailed}} errores de subida, {{downloadFailed}} errores de descarga). Causas probables: el servidor rechazó crear una carpeta, problema de red o permiso denegado. La próxima sincronización reintentará automáticamente.", "syncAutoSync": "Sincronización automática", "syncAutoSyncDesc": "Sincronizar datos automáticamente en segundo plano", + "syncOnStartup": "Sincronizar al iniciar", + "syncOnStartupDesc": "Sincronizar automáticamente una vez al abrir la app", "syncInterval": "Intervalo de sincronización", "syncIntervalDesc": "Cuando la sincronización automática está habilitada, buscar actualizaciones cada N minutos", "syncIntervalMinutes": "{{count}} min", diff --git a/packages/core/src/i18n/locales/fr/settings.json b/packages/core/src/i18n/locales/fr/settings.json index aedfc5c0..1a576ae4 100644 --- a/packages/core/src/i18n/locales/fr/settings.json +++ b/packages/core/src/i18n/locales/fr/settings.json @@ -255,6 +255,8 @@ "syncFilesPartialFailed": "⚠️ Certains fichiers n'ont pas été synchronisés ({{uploadFailed}} échecs d'envoi, {{downloadFailed}} échecs de téléchargement). Causes probables : le serveur distant a refusé de créer un nouveau dossier, coupure réseau ou accès refusé. La prochaine synchronisation réessaiera automatiquement.", "syncAutoSync": "Synchronisation automatique", "syncAutoSyncDesc": "Synchroniser automatiquement les données en arrière-plan", + "syncOnStartup": "Synchroniser au démarrage", + "syncOnStartupDesc": "Synchroniser automatiquement une fois à l'ouverture de l'app", "syncInterval": "Intervalle de synchronisation", "syncIntervalDesc": "Lorsque la synchronisation auto est activée, vérifier les mises à jour toutes les N minutes", "syncIntervalMinutes": "{{count}} min", diff --git a/packages/core/src/i18n/locales/ja/settings.json b/packages/core/src/i18n/locales/ja/settings.json index 41fc0605..c07f68cb 100644 --- a/packages/core/src/i18n/locales/ja/settings.json +++ b/packages/core/src/i18n/locales/ja/settings.json @@ -255,6 +255,8 @@ "syncFilesPartialFailed": "⚠️ 一部のファイルの同期に失敗しました({{uploadFailed}}件のアップロード失敗、{{downloadFailed}}件のダウンロード失敗)。原因: リモートがフォルダ作成を拒否した、ネットワークの一時的な問題、またはアクセス拒否。次回の同期で自動的に再試行されます。", "syncAutoSync": "自動同期", "syncAutoSyncDesc": "バックグラウンドで自動的にデータを同期", + "syncOnStartup": "起動時に同期", + "syncOnStartupDesc": "アプリを開いた後に自動で1回同期します", "syncInterval": "同期間隔", "syncIntervalDesc": "自動同期が有効な場合、N分ごとに更新を確認します", "syncIntervalMinutes": "{{count}}分", diff --git a/packages/core/src/i18n/locales/ko/settings.json b/packages/core/src/i18n/locales/ko/settings.json index fcb2fe44..49060962 100644 --- a/packages/core/src/i18n/locales/ko/settings.json +++ b/packages/core/src/i18n/locales/ko/settings.json @@ -255,6 +255,8 @@ "syncFilesPartialFailed": "⚠️ 일부 파일이 동기화되지 않았어요 (업로드 {{uploadFailed}}건, 다운로드 {{downloadFailed}}건 실패). 원격에서 새 폴더 생성을 거부했거나, 네트워크 문제 또는 권한 거부가 원인일 수 있어요. 다음 동기화 시 자동으로 재시도해요.", "syncAutoSync": "자동 동기화", "syncAutoSyncDesc": "백그라운드에서 자동으로 데이터를 동기화해요", + "syncOnStartup": "시작 시 동기화", + "syncOnStartupDesc": "앱을 열 때 자동으로 한 번 동기화해요", "syncInterval": "동기화 간격", "syncIntervalDesc": "자동 동기화 활성화 시, N분마다 업데이트를 확인해요", "syncIntervalMinutes": "{{count}}분", diff --git a/packages/core/src/i18n/locales/zh-TW/settings.json b/packages/core/src/i18n/locales/zh-TW/settings.json index 22fde942..8ba90128 100644 --- a/packages/core/src/i18n/locales/zh-TW/settings.json +++ b/packages/core/src/i18n/locales/zh-TW/settings.json @@ -258,6 +258,8 @@ "syncFilesPartialFailed": "⚠️ 部分檔案未同步成功(上傳失敗 {{uploadFailed}},下載失敗 {{downloadFailed}})。常見原因:WebDAV 伺服器拒絕建立新目錄、網路中斷或權限不足。下次同步會自動重試。", "syncAutoSync": "自動同步", "syncAutoSyncDesc": "在背景自動同步資料", + "syncOnStartup": "啟動時同步", + "syncOnStartupDesc": "每次開啟應用程式後自動同步一次", "syncInterval": "同步間隔", "syncIntervalDesc": "自動同步開啟後,每隔多少分鐘檢查一次", "syncIntervalMinutes": "{{count}} 分鐘", diff --git a/packages/core/src/i18n/locales/zh/settings.json b/packages/core/src/i18n/locales/zh/settings.json index f30be47c..6a98e3f4 100644 --- a/packages/core/src/i18n/locales/zh/settings.json +++ b/packages/core/src/i18n/locales/zh/settings.json @@ -258,6 +258,8 @@ "syncFilesPartialFailed": "⚠️ 部分文件未同步成功(上传失败 {{uploadFailed}},下载失败 {{downloadFailed}})。常见原因:WebDAV 服务器拒绝建新目录、网络中断或权限不足。下次同步会自动重试。", "syncAutoSync": "自动同步", "syncAutoSyncDesc": "在后台自动同步数据", + "syncOnStartup": "启动时同步", + "syncOnStartupDesc": "每次打开应用后自动同步一次", "syncInterval": "同步间隔", "syncIntervalDesc": "自动同步开启后,每隔多少分钟检查一次", "syncIntervalMinutes": "{{count}} 分钟", diff --git a/packages/core/src/stores/sync-store.test.ts b/packages/core/src/stores/sync-store.test.ts index 4a0d915c..7fcf6228 100644 --- a/packages/core/src/stores/sync-store.test.ts +++ b/packages/core/src/stores/sync-store.test.ts @@ -68,6 +68,7 @@ const baseConfig: SyncConfig = { username: "alice", remoteRoot: "readany", autoSync: false, + syncOnStartup: false, syncIntervalMins: 30, wifiOnly: false, notifyOnComplete: true, @@ -281,6 +282,22 @@ describe("useSyncStore", () => { expect(getWebDavConfigFromState().syncIntervalMins).toBe(720); }); + it("updates startup sync preference", async () => { + useSyncStore.setState({ + config: baseConfig, + isConfigured: true, + backendType: "webdav", + }); + + await useSyncStore.getState().setSyncOnStartup(true); + + expect(getWebDavConfigFromState().syncOnStartup).toBe(true); + expect(mockPlatformService.kvSetItem).toHaveBeenCalledWith( + "sync_config", + expect.stringContaining('"syncOnStartup":true'), + ); + }); + it("syncSimple success updates runtime state and emits completion", async () => { const emitSpy = vi.spyOn(eventBus, "emit"); diff --git a/packages/core/src/stores/sync-store.ts b/packages/core/src/stores/sync-store.ts index 27996a4c..7927627e 100644 --- a/packages/core/src/stores/sync-store.ts +++ b/packages/core/src/stores/sync-store.ts @@ -16,13 +16,8 @@ import { } from "../sync/sync-backend"; import type { ISyncBackend } from "../sync/sync-backend"; import { createSyncBackend, getSecretKeyForBackend } from "../sync/sync-backend-factory"; +import type { SyncDirection, SyncProgress, SyncResult, SyncStatusType } from "../sync/sync-types"; import { sanitizeWebDavRemoteRoot, sanitizeWebDavUrl } from "../sync/webdav-client"; -import type { - SyncDirection, - SyncProgress, - SyncResult, - SyncStatusType, -} from "../sync/sync-types"; import { eventBus } from "../utils/event-bus"; let activeSyncPromise: Promise | null = null; @@ -137,14 +132,14 @@ export interface SyncState { saveS3Config: ( config: Omit< S3Config, - "type" | "autoSync" | "syncIntervalMins" | "wifiOnly" | "notifyOnComplete" + "type" | "autoSync" | "syncOnStartup" | "syncIntervalMins" | "wifiOnly" | "notifyOnComplete" >, secretAccessKey: string, ) => Promise; testS3Connection: ( config: Omit< S3Config, - "type" | "autoSync" | "syncIntervalMins" | "wifiOnly" | "notifyOnComplete" + "type" | "autoSync" | "syncOnStartup" | "syncIntervalMins" | "wifiOnly" | "notifyOnComplete" >, secretAccessKey: string, ) => Promise; @@ -164,6 +159,7 @@ export interface SyncState { syncSimple: (backend: ISyncBackend) => Promise; forceFullSync: (direction: "upload" | "download") => Promise; setAutoSync: (enabled: boolean) => Promise; + setSyncOnStartup: (enabled: boolean) => Promise; setSyncIntervalMins: (minutes: number) => Promise; setWifiOnly: (enabled: boolean) => Promise; setNotifyOnComplete: (enabled: boolean) => Promise; @@ -177,8 +173,8 @@ function normalizeSyncConfig(config: SyncConfig): SyncConfig { url: sanitizeWebDavUrl(config.url), username: config.username.trim(), remoteRoot: - sanitizeWebDavRemoteRoot(config.remoteRoot ?? DEFAULT_WEBDAV_REMOTE_ROOT) - || DEFAULT_WEBDAV_REMOTE_ROOT, + sanitizeWebDavRemoteRoot(config.remoteRoot ?? DEFAULT_WEBDAV_REMOTE_ROOT) || + DEFAULT_WEBDAV_REMOTE_ROOT, }; } return config; @@ -212,9 +208,7 @@ export const useSyncStore = create((set, get) => ({ : normalizedConfig; const secretKey = config.type !== "lan" ? getSecretKeyForBackend(config.type) : null; const secret = secretKey ? await platform.kvGetItem(secretKey) : null; - console.log( - `[SyncStore] loadConfig: secret = ${secret ? "found" : "not found"}`, - ); + console.log(`[SyncStore] loadConfig: secret = ${secret ? "found" : "not found"}`); const isConfigured = config.type === "lan" @@ -261,13 +255,14 @@ export const useSyncStore = create((set, get) => ({ ) || DEFAULT_WEBDAV_REMOTE_ROOT, allowInsecure: allowInsecure ?? (existing as WebDavConfig)?.allowInsecure ?? false, autoSync: (existing as WebDavConfig)?.autoSync ?? DEFAULT_SYNC_CONFIG.autoSync, + syncOnStartup: (existing as WebDavConfig)?.syncOnStartup ?? DEFAULT_SYNC_CONFIG.syncOnStartup, syncIntervalMins: (existing as WebDavConfig)?.syncIntervalMins ?? DEFAULT_SYNC_CONFIG.syncIntervalMins, wifiOnly: (existing as WebDavConfig)?.wifiOnly ?? DEFAULT_SYNC_CONFIG.wifiOnly, notifyOnComplete: (existing as WebDavConfig)?.notifyOnComplete ?? DEFAULT_SYNC_CONFIG.notifyOnComplete, }; - console.log(`[SyncStore] saveWebDavConfig: saving config...`); + console.log("[SyncStore] saveWebDavConfig: saving config..."); await platform.kvSetItem(SYNC_CONFIG_KEY, JSON.stringify(config)); await platform.kvSetItem(SYNC_SECRET_KEYS.webdav, password); @@ -287,10 +282,11 @@ export const useSyncStore = create((set, get) => ({ url: sanitizeWebDavUrl(url), username: username.trim(), remoteRoot: - sanitizeWebDavRemoteRoot(remoteRoot ?? DEFAULT_WEBDAV_REMOTE_ROOT) - || DEFAULT_WEBDAV_REMOTE_ROOT, + sanitizeWebDavRemoteRoot(remoteRoot ?? DEFAULT_WEBDAV_REMOTE_ROOT) || + DEFAULT_WEBDAV_REMOTE_ROOT, allowInsecure: allowInsecure ?? false, autoSync: false, + syncOnStartup: DEFAULT_SYNC_CONFIG.syncOnStartup, syncIntervalMins: DEFAULT_SYNC_CONFIG.syncIntervalMins, wifiOnly: DEFAULT_SYNC_CONFIG.wifiOnly, notifyOnComplete: DEFAULT_SYNC_CONFIG.notifyOnComplete, @@ -307,6 +303,7 @@ export const useSyncStore = create((set, get) => ({ ...s3Config, type: "s3", autoSync: (existing as S3Config)?.autoSync ?? DEFAULT_SYNC_CONFIG.autoSync, + syncOnStartup: (existing as S3Config)?.syncOnStartup ?? DEFAULT_SYNC_CONFIG.syncOnStartup, syncIntervalMins: (existing as S3Config)?.syncIntervalMins ?? DEFAULT_SYNC_CONFIG.syncIntervalMins, wifiOnly: (existing as S3Config)?.wifiOnly ?? DEFAULT_SYNC_CONFIG.wifiOnly, @@ -324,6 +321,7 @@ export const useSyncStore = create((set, get) => ({ ...s3Config, type: "s3", autoSync: false, + syncOnStartup: DEFAULT_SYNC_CONFIG.syncOnStartup, syncIntervalMins: 30, wifiOnly: false, notifyOnComplete: true, @@ -794,18 +792,30 @@ export const useSyncStore = create((set, get) => ({ setAutoSync: async (enabled) => { const state = get(); - if (!state.config) return; + if (!state.config || state.config.type === "lan") return; const config = { ...state.config, autoSync: enabled }; const platform = getPlatformService(); await platform.kvSetItem(SYNC_CONFIG_KEY, JSON.stringify(config)); set({ config }); }, + setSyncOnStartup: async (enabled) => { + const state = get(); + if (!state.config || state.config.type === "lan") return; + const config = { ...state.config, syncOnStartup: enabled }; + const platform = getPlatformService(); + await platform.kvSetItem(SYNC_CONFIG_KEY, JSON.stringify(config)); + set({ config }); + }, + setSyncIntervalMins: async (minutes) => { const state = get(); if (!state.config || state.config.type === "lan") return; - const clampedMinutes = Math.max(5, Math.min(720, Math.round(minutes || DEFAULT_SYNC_CONFIG.syncIntervalMins))); + const clampedMinutes = Math.max( + 5, + Math.min(720, Math.round(minutes || DEFAULT_SYNC_CONFIG.syncIntervalMins)), + ); const config = { ...state.config, syncIntervalMins: clampedMinutes }; const platform = getPlatformService(); await platform.kvSetItem(SYNC_CONFIG_KEY, JSON.stringify(config)); diff --git a/packages/core/src/sync/sync-backend.ts b/packages/core/src/sync/sync-backend.ts index 5763c73e..39c500a8 100644 --- a/packages/core/src/sync/sync-backend.ts +++ b/packages/core/src/sync/sync-backend.ts @@ -33,7 +33,10 @@ export interface ISyncBackend { get(path: string): Promise; /** Download data with progress reporting (optional — falls back to get() if not implemented) */ - getWithProgress?(path: string, onProgress?: (loaded: number, total: number) => void): Promise; + getWithProgress?( + path: string, + onProgress?: (loaded: number, total: number) => void, + ): Promise; /** Get JSON data from a path, returns null if not found */ getJSON(path: string): Promise; @@ -75,6 +78,7 @@ export interface WebDavConfig { remoteRoot?: string; allowInsecure?: boolean; autoSync: boolean; + syncOnStartup: boolean; syncIntervalMins: number; wifiOnly: boolean; notifyOnComplete: boolean; @@ -89,6 +93,7 @@ export interface S3Config { accessKeyId: string; pathStyle?: boolean; autoSync: boolean; + syncOnStartup: boolean; syncIntervalMins: number; wifiOnly: boolean; notifyOnComplete: boolean; @@ -105,6 +110,7 @@ export type SyncConfig = WebDavConfig | S3Config | LANConfig; /** Default configuration values */ export const DEFAULT_SYNC_CONFIG = { autoSync: false, + syncOnStartup: false, syncIntervalMins: 30, wifiOnly: false, notifyOnComplete: true,
{t("settings.syncAllowInsecureDesc")}
+ {t("settings.syncAllowInsecureDesc")} +
{t("settings.syncAutoSyncDesc")}
+ {t("settings.syncAutoSyncDesc")} +
+ {t("settings.syncOnStartupDesc")} +
{t("settings.syncIntervalDesc")}
+ {t("settings.syncIntervalDesc")} +
{t("settings.syncConflictDesc")}