From 95ce1945fd95e4b060dee432487d0e74d273e1c1 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Fri, 23 Oct 2026 10:39:52 -0400 Subject: [PATCH 1/3] fix: harden daily local backup retention, naming, and failure reporting - keep the snapshot just written and rank future-dated backups last - millisecond backup names published by hard link so none is overwritten; legacy names still recognized - report localBackupFailed from Initialize and warn in settings - show a failed-load state with Retry in the local backup card --- docs/architecture/data-and-migrations.md | 18 +-- docs/reference/api.md | 2 +- .../src/app/(dashboard)/settings/page.tsx | 8 +- .../components/settings/LocalBackupCard.tsx | 37 +++++- frontend/src/lib/i18n/messages/ar.json | 2 + frontend/src/lib/i18n/messages/bn.json | 2 + frontend/src/lib/i18n/messages/de.json | 2 + frontend/src/lib/i18n/messages/en.json | 2 + frontend/src/lib/i18n/messages/es.json | 2 + frontend/src/lib/i18n/messages/fa.json | 2 + frontend/src/lib/i18n/messages/fil.json | 2 + frontend/src/lib/i18n/messages/fr.json | 2 + frontend/src/lib/i18n/messages/hi.json | 2 + frontend/src/lib/i18n/messages/id.json | 2 + frontend/src/lib/i18n/messages/it.json | 2 + frontend/src/lib/i18n/messages/ja.json | 2 + frontend/src/lib/i18n/messages/ko.json | 2 + frontend/src/lib/i18n/messages/ne.json | 2 + frontend/src/lib/i18n/messages/nl.json | 2 + frontend/src/lib/i18n/messages/pt.json | 2 + frontend/src/lib/i18n/messages/ru.json | 2 + frontend/src/lib/i18n/messages/sq.json | 2 + frontend/src/lib/i18n/messages/th.json | 2 + frontend/src/lib/i18n/messages/tr.json | 2 + frontend/src/lib/i18n/messages/ur.json | 2 + frontend/src/lib/i18n/messages/vi.json | 2 + frontend/src/lib/i18n/messages/zh-tw.json | 2 + frontend/src/lib/i18n/messages/zh.json | 2 + main/routes/database-tools.ts | 10 +- main/services/local-backup.ts | 111 +++++++++++++----- tests/local-backup.test.ts | 91 ++++++++++++-- 31 files changed, 275 insertions(+), 50 deletions(-) diff --git a/docs/architecture/data-and-migrations.md b/docs/architecture/data-and-migrations.md index 9b6db1e1d..eb7f1c274 100644 --- a/docs/architecture/data-and-migrations.md +++ b/docs/architecture/data-and-migrations.md @@ -160,13 +160,17 @@ loss. Retention failure is logged and never fails the committed restore. `main/services/local-backup.ts` writes one backup a day to `Documents/FloCafe/Backups`, outside `userData`, so it survives a data wipe, a reinstall, and an uninstall. It is on unless `local_backup_enabled` is `'false'`. A check runs two minutes after startup and then hourly; it calls -`createBackup(targetPath)` when the newest file is at least 24 hours old, and skips a database with +`createBackup()` when the newest file is at least 24 hours old, and skips a database with no orders, products, or customers so an empty install cannot push real backups out of retention. -Files are named `flo-backup-Z.db`. Only names matching that pattern are listed or -pruned, and the newest `LOCAL_BACKUP_RETENTION` (5) are kept. Failures are logged and retried on the -next check. These files are plain SQLite and unencrypted. They are not part of `listBackups()`; restore -one through `POST /api/db/restore` with the file picker. `POST /api/db-tools/initialize` also copies its -pre-reset backup here (`retainBackupLocally`) when the setting is on. +Files are named `flo-backup-Z.db`; names from older releases +without milliseconds are still recognized. Each file is staged and then hard-linked in under the +first free millisecond name, so a snapshot never replaces an existing backup. Only names matching that pattern are listed or pruned. Files dated +ahead of the clock rank below every other file because their time cannot be trusted; the file just +written is always kept, and the newest `LOCAL_BACKUP_RETENTION` (5) are kept in total. Failures are +logged and retried on the next check. These files are plain SQLite and unencrypted. They are not part +of `listBackups()`; restore one through `POST /api/db/restore` with the file picker. +`POST /api/db-tools/initialize` also copies its pre-reset backup here (`retainBackupLocally`) when the +setting is on. ### Durability @@ -191,7 +195,7 @@ Directory syncing is skipped on Windows, which does not support it. | `npm run test:schema-health` | Schema shape, index health, and recovery reference caching. | | `npm run test:schema-reference-cache` | Recovery schema reference caching and migration registry invalidation. | | `npm run test:backup` | Backup and restore behaviour. | -| `npm run test:local-backup` | The daily local backup: naming, due logic, retention, opt-out, and empty-database skip. | +| `npm run test:local-backup` | The daily local backup: naming, due logic, retention (including future-dated files), opt-out, empty-database skip, and the Initialize copy warning. | | `npm run test:sequence` | Order and bill sequence generation, repair performance, and no-reuse invariants. | | `npm run test:issue-278-fail-closed-db` | The missing-database guard. | diff --git a/docs/reference/api.md b/docs/reference/api.md index 3262b8c8f..9757f4ba1 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -826,7 +826,7 @@ Router: `main/routes/database-tools.ts`. Full path: `/api/db-tools`. | `POST` | `/master-pin/reset` | `ROLE_ACCESS.owner` (`database.manage`) + current Master PIN when one is configured | body: `pin`, `confirm_pin`, and `master_pin` when a PIN is already configured | First-time setup requires no `master_pin`. | | `GET` | `/currency-reset-impact` | `ROLE_ACCESS.owner` | none | Active currency plus the invoice, order, refund, customer, product, and add-on counts the destructive warning uses. | | `POST` | `/currency-reset` | `ROLE_ACCESS.owner` + Master PIN | body: `currency` | Also needs `current_currency` and `confirmation_phrase` of the form `CHANGE TO `. Creates a recovery backup, recreates the local database, preserves the sanitized menu catalog with monetary fields zeroed, and returns the backup path. The active session becomes invalid. | -| `POST` | `/initialize` | `ROLE_ACCESS.owner` + Master PIN | body: `confirmation_phrase` | A stale Drive restore boundary classified as ambiguous by recovery may be discarded only by this owner-authorized reset with the exact `INITIALIZE` phrase (stored Drive credentials are dropped); an active in-process reset still conflicts. | +| `POST` | `/initialize` | `ROLE_ACCESS.owner` + Master PIN | body: `confirmation_phrase` | A stale Drive restore boundary classified as ambiguous by recovery may be discarded only by this owner-authorized reset with the exact `INITIALIZE` phrase (stored Drive credentials are dropped); an active in-process reset still conflicts. Returns `{ success, backupPath, cleanupPending, localBackupFailed }`; `localBackupFailed` is `true` when the daily local backup is on but the pre-reset copy could not be kept there, which never fails the committed reset. | ### Reports diff --git a/frontend/src/app/(dashboard)/settings/page.tsx b/frontend/src/app/(dashboard)/settings/page.tsx index 9bf357811..e6b3f39a1 100644 --- a/frontend/src/app/(dashboard)/settings/page.tsx +++ b/frontend/src/app/(dashboard)/settings/page.tsx @@ -993,12 +993,14 @@ export default function SettingsPage() { setPinGate({ mode: 'delete-backup', payload: { fileName: backup.fileName } }); }; + const initializeLocalBackupFailedRef = useRef(false); const handleInitializeDatabase = async (pin: string) => { try { const { data } = await api.post('/db-tools/initialize', { master_pin: pin, confirmation_phrase: 'INITIALIZE', }); + initializeLocalBackupFailedRef.current = data.localBackupFailed === true; return { success: true, backupPath: data.backupPath }; } catch { return { success: false, error: t('initializeFailedGeneric') }; @@ -6599,7 +6601,11 @@ export default function SettingsPage() { onConfirm={handleInitializeDatabase} onSuccess={() => { toast.success(t('dbInitializedRedirecting')); - setTimeout(() => window.location.replace('/setup'), 1200); + const localBackupFailed = initializeLocalBackupFailedRef.current; + if (localBackupFailed) { + toast.error(t('initializeLocalBackupFailed'), { duration: 6000 }); + } + setTimeout(() => window.location.replace('/setup'), localBackupFailed ? 6000 : 1200); }} /> (null); const [busy, setBusy] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + const [loadAttempt, setLoadAttempt] = useState(0); useEffect(() => { let cancelled = false; @@ -28,13 +31,15 @@ export function LocalBackupCard() { .then(({ data }) => { if (!cancelled) setStatus(data); }) - .catch(() => {}); + .catch(() => { + if (!cancelled) setLoadFailed(true); + }); return () => { cancelled = true; }; - }, []); + }, [loadAttempt]); - if (!status) return null; + if (!status && !loadFailed) return null; const handleChange = async (enabled: boolean) => { setBusy(true); @@ -48,6 +53,32 @@ export function LocalBackupCard() { } }; + const retryLoad = () => { + setLoadFailed(false); + setLoadAttempt((attempt) => attempt + 1); + }; + + if (!status) { + return ( +
+
+
+
+ +

{t('localBackupTitle')}

+
+

+ {t('localBackupLoadFailed')} +

+
+ +
+
+ ); + } + return (
diff --git a/frontend/src/lib/i18n/messages/ar.json b/frontend/src/lib/i18n/messages/ar.json index bf5a12658..b07324bab 100644 --- a/frontend/src/lib/i18n/messages/ar.json +++ b/frontend/src/lib/i18n/messages/ar.json @@ -1716,6 +1716,7 @@ "localBackupLast": "آخر نسخة احتياطية: {time}", "localBackupNone": "لا توجد نسخة احتياطية بعد", "localBackupUpdateFailed": "تعذر تحديث إعداد النسخ الاحتياطي", + "localBackupLoadFailed": "تعذّر تحميل إعداد النسخ الاحتياطي", "backupKindAuto": "تلقائي", "backupSchemaVersion": "المخطط v{version}", "billContent": "مضمون مشروع القانون", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "يتم إنشاء نسخة احتياطية تلقائيًا قبل حذف أي شيء.", "initializeDialogBody": "سيؤدي هذا إلى الحذف النهائي لجميع المنتجات والطلبات والعملاء والإعدادات، وإعادة ضبط قاعدة البيانات إلى حالة التثبيت الفارغة. وستضطر بعد ذلك إلى إجراء عملية الإعداد الأولي مرة أخرى.", "initializeFailedGeneric": "فشل التهيئة", + "initializeLocalBackupFailed": "تمت تهيئة قاعدة البيانات، لكن تعذّر حفظ نسخة احتياطية في مجلد النسخ الاحتياطي التلقائي. لا تزال النسخة الاحتياطية السابقة لإعادة التعيين في مجلد بيانات التطبيق.", "initializeMasterPinPrompt": "أدخل رقم التعريف الشخصي الرئيسي (PIN) الخاص بجهازك لتهيئة قاعدة البيانات بشكل نهائي.", "initializeTypeConfirm": "اكتب {phrase} للمتابعة", "instagramHandle": "اسم المستخدم على إنستغرام", diff --git a/frontend/src/lib/i18n/messages/bn.json b/frontend/src/lib/i18n/messages/bn.json index 9b6741f77..d901cc0ec 100644 --- a/frontend/src/lib/i18n/messages/bn.json +++ b/frontend/src/lib/i18n/messages/bn.json @@ -1716,6 +1716,7 @@ "localBackupLast": "সর্বশেষ ব্যাকআপ: {time}", "localBackupNone": "এখনও কোনো ব্যাকআপ নেই", "localBackupUpdateFailed": "ব্যাকআপ সেটিং আপডেট করা যায়নি", + "localBackupLoadFailed": "ব্যাকআপ সেটিং লোড করা যায়নি", "backupKindAuto": "অটো", "backupSchemaVersion": "স্কিমা v{version}", "billContent": "বিল বিষয়বস্তু", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "কিছু মুছে ফেলার আগে একটি ব্যাকআপ স্বয়ংক্রিয়ভাবে তৈরি হয়।", "initializeDialogBody": "এটি স্থায়ীভাবে প্রতিটি পণ্য, অর্ডার, গ্রাহক এবং সেটিং মুছে দেয় এবং ডাটাবেসটিকে একটি ফাঁকা ইনস্টলে পুনরায় সেট করে। আপনি পরে আবার প্রথম-রান সেটআপের মধ্য দিয়ে যাবেন।", "initializeFailedGeneric": "সূচনা ব্যর্থ হয়েছে।", + "initializeLocalBackupFailed": "ডেটাবেস ইনিশিয়ালাইজ হয়েছে, কিন্তু স্বয়ংক্রিয় ব্যাকআপ ফোল্ডারে ব্যাকআপ কপি সংরক্ষণ করা যায়নি। রিসেটের আগের ব্যাকআপটি এখনও অ্যাপের ডেটা ফোল্ডারে আছে।", "initializeMasterPinPrompt": "স্থায়ীভাবে ডাটাবেস আরম্ভ করতে আপনার ডিভাইস মাস্টার পিন লিখুন.", "initializeTypeConfirm": "টাইপ {phrase} চালিয়ে যেতে", "instagramHandle": "ইনস্টাগ্রাম হ্যান্ডেল", diff --git a/frontend/src/lib/i18n/messages/de.json b/frontend/src/lib/i18n/messages/de.json index 897ec4a9d..ced662db4 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -1716,6 +1716,7 @@ "localBackupLast": "Letzte Sicherung: {time}", "localBackupNone": "Noch keine Sicherung", "localBackupUpdateFailed": "Die Sicherungseinstellung konnte nicht aktualisiert werden", + "localBackupLoadFailed": "Die Sicherungseinstellung konnte nicht geladen werden", "backupKindAuto": "Automatisch", "backupSchemaVersion": "Schema v{version}", "billContent": "Rechnungsinhalt", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "Vor dem Löschen von Daten wird automatisch eine Sicherung erstellt.", "initializeDialogBody": "Dadurch werden jedes Produkt, jede Bestellung, jeder Kunde und jede Einstellung dauerhaft gelöscht und die Datenbank zurückgesetzt. Anschließend durchlaufen Sie die Ersteinrichtung erneut.", "initializeFailedGeneric": "Initialisierung fehlgeschlagen", + "initializeLocalBackupFailed": "Die Datenbank wurde initialisiert, aber die Sicherungskopie konnte nicht im Ordner für automatische Sicherungen gespeichert werden. Die Sicherung vor dem Zurücksetzen liegt weiterhin im Datenordner der App.", "initializeMasterPinPrompt": "Geben Sie die Master-PIN Ihres Geräts ein, um die Datenbank dauerhaft zu initialisieren.", "initializeTypeConfirm": "Geben Sie {phrase} ein, um fortzufahren", "instagramHandle": "Instagram-Benutzername", diff --git a/frontend/src/lib/i18n/messages/en.json b/frontend/src/lib/i18n/messages/en.json index d85735190..7c742fffa 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -1716,6 +1716,7 @@ "localBackupLast": "Last backup: {time}", "localBackupNone": "No backup yet", "localBackupUpdateFailed": "Could not update the backup setting", + "localBackupLoadFailed": "Could not load the backup setting", "backupKindAuto": "Auto", "backupSchemaVersion": "schema v{version}", "billContent": "Bill Content", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "A backup is created automatically before anything is deleted.", "initializeDialogBody": "This permanently deletes every product, order, customer, and setting, and resets the database to a blank install. You'll go through first-run setup again afterward.", "initializeFailedGeneric": "Initialize failed", + "initializeLocalBackupFailed": "Database initialized, but the backup copy could not be saved to the automatic backup folder. The pre-reset backup is still in the app's data folder.", "initializeMasterPinPrompt": "Enter your device Master PIN to permanently initialize the database.", "initializeTypeConfirm": "Type {phrase} to continue", "instagramHandle": "Instagram Handle", diff --git a/frontend/src/lib/i18n/messages/es.json b/frontend/src/lib/i18n/messages/es.json index 82e161e10..fea0d9389 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -1716,6 +1716,7 @@ "localBackupLast": "Última copia de seguridad: {time}", "localBackupNone": "Aún no hay copias de seguridad", "localBackupUpdateFailed": "No se pudo actualizar la configuración de copias de seguridad", + "localBackupLoadFailed": "No se pudo cargar la configuración de copia de seguridad", "backupKindAuto": "Automático", "backupSchemaVersion": "esquema v{version}", "billContent": "Contenido del comprobante", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "Se crea una copia de seguridad automáticamente antes de borrar nada.", "initializeDialogBody": "Esto borra de forma permanente cada producto, pedido, cliente y configuración, y reinicia la base de datos a una instalación limpia. Vas a tener que pasar por la configuración inicial de nuevo.", "initializeFailedGeneric": "Inicialización falló", + "initializeLocalBackupFailed": "La base de datos se inicializó, pero no se pudo guardar la copia de seguridad en la carpeta de copias automáticas. La copia previa al restablecimiento sigue en la carpeta de datos de la aplicación.", "initializeMasterPinPrompt": "Ingresá el PIN Maestro del dispositivo para inicializar la base de datos de forma permanente.", "initializeTypeConfirm": "Escribí {phrase} para continuar", "instagramHandle": "Usuario de Instagram", diff --git a/frontend/src/lib/i18n/messages/fa.json b/frontend/src/lib/i18n/messages/fa.json index a550dfc53..aa9e84da9 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -1716,6 +1716,7 @@ "localBackupLast": "آخرین پشتیبان: {time}", "localBackupNone": "هنوز پشتیبانی وجود ندارد", "localBackupUpdateFailed": "به‌روزرسانی تنظیم پشتیبان‌گیری ممکن نشد", + "localBackupLoadFailed": "بارگیری تنظیم پشتیبان‌گیری ممکن نشد", "backupKindAuto": "خودکار", "backupSchemaVersion": "ساختار v{version}", "billContent": "محتوای صورتحساب", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "پیش از حذف هر چیزی، به‌طور خودکار یک پشتیبان ساخته می‌شود.", "initializeDialogBody": "این کار هر کالا، سفارش، مشتری و تنظیمی را برای همیشه پاک می‌کند و پایگاه داده را به یک نصب خالی بازنشانی می‌کند. پس از آن دوباره راه‌اندازی نخست را پشت سر می‌گذارید.", "initializeFailedGeneric": "بازنشانی انجام نشد", + "initializeLocalBackupFailed": "پایگاه داده راه‌اندازی اولیه شد، اما نسخه پشتیبان در پوشه پشتیبان‌گیری خودکار ذخیره نشد. نسخه پشتیبان پیش از بازنشانی همچنان در پوشه داده‌های برنامه است.", "initializeMasterPinPrompt": "برای بازنشانی دائمی پایگاه داده، رمز اصلی دستگاه را وارد کنید.", "initializeTypeConfirm": "برای ادامه، {phrase} را بنویسید", "instagramHandle": "نام کاربری اینستاگرام", diff --git a/frontend/src/lib/i18n/messages/fil.json b/frontend/src/lib/i18n/messages/fil.json index 82680d61d..cf59d1cff 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -1716,6 +1716,7 @@ "localBackupLast": "Huling backup: {time}", "localBackupNone": "Wala pang backup", "localBackupUpdateFailed": "Hindi ma-update ang setting ng backup", + "localBackupLoadFailed": "Hindi ma-load ang setting ng backup", "backupKindAuto": "Auto", "backupSchemaVersion": "schema v{version}", "billContent": "Nilalaman ng Bill", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "Kusang gumagawa ng backup bago burahin ang anuman.", "initializeDialogBody": "Permanenteng buburahin nito ang bawat produkto, order, customer, at setting. Pupunta ka muli sa unang setup pagkatapos.", "initializeFailedGeneric": "Nabigo ang pag-initialize", + "initializeLocalBackupFailed": "Na-initialize ang database, pero hindi na-save ang kopya ng backup sa folder ng awtomatikong backup. Nasa data folder pa rin ng app ang backup bago ang reset.", "initializeMasterPinPrompt": "Ilagay ang iyong Master PIN upang permanenteng i-initialize ang database.", "initializeTypeConfirm": "I-type ang {phrase} upang magpatuloy", "instagramHandle": "Instagram Handle", diff --git a/frontend/src/lib/i18n/messages/fr.json b/frontend/src/lib/i18n/messages/fr.json index f135c9b66..d1d1ec794 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -1716,6 +1716,7 @@ "localBackupLast": "Dernière sauvegarde : {time}", "localBackupNone": "Aucune sauvegarde pour le moment", "localBackupUpdateFailed": "Impossible de mettre à jour le paramètre de sauvegarde", + "localBackupLoadFailed": "Impossible de charger le paramètre de sauvegarde", "backupKindAuto": "Automatique", "backupSchemaVersion": "schéma v{version}", "billContent": "Contenu de la facture", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "Une sauvegarde est automatiquement créée avant toute suppression.", "initializeDialogBody": "Cette action supprime définitivement tous les produits, commandes, clients et paramètres, puis rétablit une installation vierge. Vous devrez ensuite reprendre la configuration initiale.", "initializeFailedGeneric": "Échec de l’initialisation", + "initializeLocalBackupFailed": "La base de données a été initialisée, mais la copie de sauvegarde n'a pas pu être enregistrée dans le dossier des sauvegardes automatiques. La sauvegarde d'avant la réinitialisation se trouve toujours dans le dossier de données de l'application.", "initializeMasterPinPrompt": "Saisissez le code PIN principal de votre appareil pour initialiser définitivement la base de données.", "initializeTypeConfirm": "Saisissez {phrase} pour continuer", "instagramHandle": "Identifiant Instagram", diff --git a/frontend/src/lib/i18n/messages/hi.json b/frontend/src/lib/i18n/messages/hi.json index c66f4418e..0a25462ea 100644 --- a/frontend/src/lib/i18n/messages/hi.json +++ b/frontend/src/lib/i18n/messages/hi.json @@ -1716,6 +1716,7 @@ "localBackupLast": "आख़िरी बैकअप: {time}", "localBackupNone": "अभी तक कोई बैकअप नहीं", "localBackupUpdateFailed": "बैकअप सेटिंग अपडेट नहीं हो सकी", + "localBackupLoadFailed": "बैकअप सेटिंग लोड नहीं हो सकी", "backupKindAuto": "ऑटो", "backupSchemaVersion": "schema v{version}", "billContent": "बिल सामग्री", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "कुछ भी डिलीट होने से पहले एक बैकअप स्वचालित रूप से बनाया जाता है।", "initializeDialogBody": "यह प्रत्येक उत्पाद, ऑर्डर, ग्राहक और सेटिंग को स्थायी रूप से हटा देता है, और डेटाबेस को रिक्त इंस्टॉल पर रीसेट कर देता है। आप बाद में फिर से फर्स्ट-रन सेटअप से गुजरेंगे।", "initializeFailedGeneric": "प्रारंभ विफल रहा", + "initializeLocalBackupFailed": "डेटाबेस इनिशियलाइज़ हो गया, लेकिन बैकअप कॉपी स्वचालित बैकअप फ़ोल्डर में सहेजी नहीं जा सकी। रीसेट से पहले का बैकअप अभी भी ऐप के डेटा फ़ोल्डर में है।", "initializeMasterPinPrompt": "डेटाबेस को स्थायी रूप से प्रारंभ करने के लिए अपना डिवाइस मास्टर पिन दर्ज करें।", "initializeTypeConfirm": "जारी रखने के लिए {phrase} टाइप करें", "instagramHandle": "इंस्टाग्राम हैंडल", diff --git a/frontend/src/lib/i18n/messages/id.json b/frontend/src/lib/i18n/messages/id.json index 9c88a71c9..e9ca237e4 100644 --- a/frontend/src/lib/i18n/messages/id.json +++ b/frontend/src/lib/i18n/messages/id.json @@ -1716,6 +1716,7 @@ "localBackupLast": "Cadangan terakhir: {time}", "localBackupNone": "Belum ada cadangan", "localBackupUpdateFailed": "Tidak dapat memperbarui pengaturan pencadangan", + "localBackupLoadFailed": "Tidak dapat memuat pengaturan cadangan", "backupKindAuto": "Otomatis", "backupSchemaVersion": "skema v{version}", "billContent": "Isi Tagihan", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "Cadangan dibuat secara otomatis sebelum ada yang dihapus.", "initializeDialogBody": "Ini menghapus permanen semua produk, pesanan, pelanggan, dan pengaturan, serta mengembalikan database ke instalasi kosong. Anda akan menjalani penyiapan awal lagi setelahnya.", "initializeFailedGeneric": "Inisialisasi gagal", + "initializeLocalBackupFailed": "Database telah diinisialisasi, tetapi salinan cadangan tidak dapat disimpan ke folder cadangan otomatis. Cadangan sebelum reset masih ada di folder data aplikasi.", "initializeMasterPinPrompt": "Masukkan PIN Master perangkat Anda untuk menginisialisasi database secara permanen.", "initializeTypeConfirm": "Ketik {phrase} untuk melanjutkan", "instagramHandle": "Akun Instagram", diff --git a/frontend/src/lib/i18n/messages/it.json b/frontend/src/lib/i18n/messages/it.json index 004eb112a..59ecd79a2 100644 --- a/frontend/src/lib/i18n/messages/it.json +++ b/frontend/src/lib/i18n/messages/it.json @@ -1716,6 +1716,7 @@ "localBackupLast": "Ultimo backup: {time}", "localBackupNone": "Nessun backup finora", "localBackupUpdateFailed": "Impossibile aggiornare l’impostazione del backup", + "localBackupLoadFailed": "Impossibile caricare l'impostazione di backup", "backupKindAuto": "Auto", "backupSchemaVersion": "schema v{version}", "billContent": "Contenuto conto", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "Un backup viene creato automaticamente prima che qualcosa venga eliminato.", "initializeDialogBody": "Questo elimina definitivamente ogni prodotto, ordine, cliente e impostazione e ripristina il database a un'installazione vuota. Dopodiché ripeterai la configurazione iniziale.", "initializeFailedGeneric": "Inizializzazione non riuscita", + "initializeLocalBackupFailed": "Il database è stato inizializzato, ma non è stato possibile salvare la copia di backup nella cartella dei backup automatici. Il backup precedente al ripristino è ancora nella cartella dati dell'app.", "initializeMasterPinPrompt": "Inserisci il Master PIN del dispositivo per inizializzare definitivamente il database.", "initializeTypeConfirm": "Digita {phrase} per continuare", "instagramHandle": "Profilo Instagram", diff --git a/frontend/src/lib/i18n/messages/ja.json b/frontend/src/lib/i18n/messages/ja.json index 2afb6516d..878b5fa10 100644 --- a/frontend/src/lib/i18n/messages/ja.json +++ b/frontend/src/lib/i18n/messages/ja.json @@ -1716,6 +1716,7 @@ "localBackupLast": "最終バックアップ: {time}", "localBackupNone": "バックアップはまだありません", "localBackupUpdateFailed": "バックアップ設定を更新できませんでした", + "localBackupLoadFailed": "バックアップ設定を読み込めませんでした", "backupKindAuto": "自動", "backupSchemaVersion": "スキーマ v{version}", "billContent": "請求書の内容", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "削除する前にバックアップが自動作成されます。", "initializeDialogBody": "すべての商品、注文、顧客、設定を完全に削除し、空のインストール状態に戻します。その後、初回セットアップをやり直します。", "initializeFailedGeneric": "初期化に失敗しました", + "initializeLocalBackupFailed": "データベースは初期化されましたが、バックアップのコピーを自動バックアップフォルダーに保存できませんでした。リセット前のバックアップはアプリのデータフォルダーに残っています。", "initializeMasterPinPrompt": "データベースを完全に初期化するには、端末のマスターPINを入力してください。", "initializeTypeConfirm": "続行するには{phrase}と入力してください", "instagramHandle": "Instagramハンドル", diff --git a/frontend/src/lib/i18n/messages/ko.json b/frontend/src/lib/i18n/messages/ko.json index b0f2a1a92..b0b3b0e50 100644 --- a/frontend/src/lib/i18n/messages/ko.json +++ b/frontend/src/lib/i18n/messages/ko.json @@ -1716,6 +1716,7 @@ "localBackupLast": "마지막 백업: {time}", "localBackupNone": "아직 백업이 없습니다", "localBackupUpdateFailed": "백업 설정을 업데이트하지 못했습니다", + "localBackupLoadFailed": "백업 설정을 불러올 수 없습니다", "backupKindAuto": "자동", "backupSchemaVersion": "스키마 v{version}", "billContent": "청구서 내용", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "삭제하기 전에 자동으로 백업이 생성됩니다.", "initializeDialogBody": "이 작업은 모든 상품, 주문, 고객, 설정을 영구적으로 삭제하고 데이터베이스를 빈 설치로 재설정합니다. 이후 처음 설정 과정을 다시 거치게 됩니다.", "initializeFailedGeneric": "초기화 실패", + "initializeLocalBackupFailed": "데이터베이스가 초기화되었지만 백업 사본을 자동 백업 폴더에 저장하지 못했습니다. 초기화 전 백업은 앱 데이터 폴더에 그대로 있습니다.", "initializeMasterPinPrompt": "데이터베이스를 영구적으로 초기화하려면 기기 마스터 PIN을 입력하세요.", "initializeTypeConfirm": "계속하려면 {phrase} 입력", "instagramHandle": "Instagram 계정", diff --git a/frontend/src/lib/i18n/messages/ne.json b/frontend/src/lib/i18n/messages/ne.json index 8c1d55352..a60a62d07 100644 --- a/frontend/src/lib/i18n/messages/ne.json +++ b/frontend/src/lib/i18n/messages/ne.json @@ -1716,6 +1716,7 @@ "localBackupLast": "पछिल्लो ब्याकअप: {time}", "localBackupNone": "अहिलेसम्म कुनै ब्याकअप छैन", "localBackupUpdateFailed": "ब्याकअप सेटिङ अपडेट गर्न सकिएन", + "localBackupLoadFailed": "ब्याकअप सेटिङ लोड गर्न सकिएन", "backupKindAuto": "अटो", "backupSchemaVersion": "योजना v{version}", "billContent": "बिल सामग्री", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "कुनै पनि चीज मेटाउनु अघि ब्याकअप स्वचालित रूपमा सिर्जना हुन्छ।", "initializeDialogBody": "यसले स्थायी रूपमा प्रत्येक उत्पादन, अर्डर, ग्राहक, र सेटिङहरू मेटाउँछ, र डाटाबेसलाई खाली स्थापनामा रिसेट गर्छ। तपाईं पछि फेरि पहिलो-रन सेटअप मार्फत जानुहुनेछ।", "initializeFailedGeneric": "प्रारम्भ असफल भयो", + "initializeLocalBackupFailed": "डाटाबेस इनिसियलाइज भयो, तर ब्याकअप प्रतिलिपि स्वचालित ब्याकअप फोल्डरमा सुरक्षित गर्न सकिएन। रिसेट अघिको ब्याकअप अझै पनि एपको डाटा फोल्डरमा छ।", "initializeMasterPinPrompt": "स्थायी रूपमा डाटाबेस प्रारम्भ गर्न आफ्नो उपकरण मास्टर PIN प्रविष्ट गर्नुहोस्।", "initializeTypeConfirm": "टाइप गर्नुहोस् {phrase} जारी राख्न", "instagramHandle": "इन्स्टाग्राम ह्यान्डल", diff --git a/frontend/src/lib/i18n/messages/nl.json b/frontend/src/lib/i18n/messages/nl.json index cf60b6473..3ea1e625a 100644 --- a/frontend/src/lib/i18n/messages/nl.json +++ b/frontend/src/lib/i18n/messages/nl.json @@ -1716,6 +1716,7 @@ "localBackupLast": "Laatste back-up: {time}", "localBackupNone": "Nog geen back-up", "localBackupUpdateFailed": "De back-upinstelling kon niet worden bijgewerkt", + "localBackupLoadFailed": "De back-upinstelling kon niet worden geladen", "backupKindAuto": "Auto", "backupSchemaVersion": "schema v{version}", "billContent": "Boninhoud", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "Er wordt automatisch een back-up gemaakt voordat er iets wordt verwijderd.", "initializeDialogBody": "Hiermee worden elk product, bestelling, klant en instelling permanent verwijderd en wordt de database opnieuw ingesteld op een lege installatie. Daarna doorloopt u de eerste configuratie opnieuw.", "initializeFailedGeneric": "Initialiseren is mislukt", + "initializeLocalBackupFailed": "De database is geïnitialiseerd, maar de back-upkopie kon niet worden opgeslagen in de map voor automatische back-ups. De back-up van vóór het resetten staat nog in de gegevensmap van de app.", "initializeMasterPinPrompt": "Voer uw apparaatmaster-PIN in om de database permanent te initialiseren.", "initializeTypeConfirm": "Typ {phrase} om door te gaan", "instagramHandle": "Instagram-handvat", diff --git a/frontend/src/lib/i18n/messages/pt.json b/frontend/src/lib/i18n/messages/pt.json index d8b717a05..43b9691df 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -1716,6 +1716,7 @@ "localBackupLast": "Último backup: {time}", "localBackupNone": "Ainda não há backups", "localBackupUpdateFailed": "Não foi possível atualizar a configuração de backup", + "localBackupLoadFailed": "Não foi possível carregar a configuração de backup", "backupKindAuto": "Automático", "backupSchemaVersion": "schema v{version}", "billContent": "Conteúdo da nota", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "Um backup é criado automaticamente antes de qualquer exclusão.", "initializeDialogBody": "Isso exclui permanentemente todos os produtos, pedidos, clientes e configurações e redefine o banco de dados para uma instalação em branco. Você passará pela configuração inicial novamente depois.", "initializeFailedGeneric": "Falha na inicialização", + "initializeLocalBackupFailed": "O banco de dados foi inicializado, mas não foi possível salvar a cópia de backup na pasta de backups automáticos. O backup anterior à redefinição continua na pasta de dados do aplicativo.", "initializeMasterPinPrompt": "Digite o PIN Mestre do seu dispositivo para inicializar permanentemente o banco de dados.", "initializeTypeConfirm": "Digite {phrase} para continuar", "instagramHandle": "Usuário do Instagram", diff --git a/frontend/src/lib/i18n/messages/ru.json b/frontend/src/lib/i18n/messages/ru.json index 944b4751d..9b9a31c6d 100644 --- a/frontend/src/lib/i18n/messages/ru.json +++ b/frontend/src/lib/i18n/messages/ru.json @@ -1716,6 +1716,7 @@ "localBackupLast": "Последняя копия: {time}", "localBackupNone": "Резервных копий пока нет", "localBackupUpdateFailed": "Не удалось обновить настройку резервного копирования", + "localBackupLoadFailed": "Не удалось загрузить настройку резервного копирования", "backupKindAuto": "Авто", "backupSchemaVersion": "схема v{version}", "billContent": "Содержимое чека", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "Резервная копия создается автоматически перед удалением чего-либо.", "initializeDialogBody": "При этом безвозвратно удаляются все товары, заказы, клиенты и настройки, а база данных сбрасывается до пустой установки. После этого вам снова придется выполнить первоначальную настройку.", "initializeFailedGeneric": "Инициализация не удалась", + "initializeLocalBackupFailed": "База данных инициализирована, но не удалось сохранить резервную копию в папку автоматических резервных копий. Резервная копия, созданная перед сбросом, по-прежнему находится в папке данных приложения.", "initializeMasterPinPrompt": "Введите главный PIN-код вашего устройства, чтобы окончательно инициализировать базу данных.", "initializeTypeConfirm": "Введите {phrase}, чтобы продолжить.", "instagramHandle": "Дескриптор Instagram", diff --git a/frontend/src/lib/i18n/messages/sq.json b/frontend/src/lib/i18n/messages/sq.json index 4517f39ab..c39e2028e 100644 --- a/frontend/src/lib/i18n/messages/sq.json +++ b/frontend/src/lib/i18n/messages/sq.json @@ -1716,6 +1716,7 @@ "localBackupLast": "Kopja e fundit rezervë: {time}", "localBackupNone": "Ende nuk ka kopje rezervë", "localBackupUpdateFailed": "Cilësimi i kopjes rezervë nuk u përditësua", + "localBackupLoadFailed": "Cilësimi i kopjes rezervë nuk mund të ngarkohej", "backupKindAuto": "Automatik", "backupSchemaVersion": "skema v {version}", "billContent": "Përmbajtja e dëftesës", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "Një kopje rezervë krijohet automatikisht përpara se çdo gjë të fshihet.", "initializeDialogBody": "Kjo fshin përgjithmonë çdo produkt, porosi, klient dhe cilësim dhe rivendos bazën e të dhënave në një instalim bosh. Do të kaloni përsëri konfigurimin e ekzekutimit të parë më pas.", "initializeFailedGeneric": "Inicializimi dështoi", + "initializeLocalBackupFailed": "Baza e të dhënave u inicializua, por kopja rezervë nuk mund të ruhej në dosjen e kopjeve rezervë automatike. Kopja rezervë para rivendosjes ndodhet ende në dosjen e të dhënave të aplikacionit.", "initializeMasterPinPrompt": "Futni kodin kryesor të pajisjes suaj për të inicializuar përgjithmonë bazën e të dhënave.", "initializeTypeConfirm": "Lloji {phrase} për të vazhduar", "instagramHandle": "Emri i Instagram-it", diff --git a/frontend/src/lib/i18n/messages/th.json b/frontend/src/lib/i18n/messages/th.json index c386ee754..31d5cad67 100644 --- a/frontend/src/lib/i18n/messages/th.json +++ b/frontend/src/lib/i18n/messages/th.json @@ -1716,6 +1716,7 @@ "localBackupLast": "สำรองล่าสุด: {time}", "localBackupNone": "ยังไม่มีข้อมูลสำรอง", "localBackupUpdateFailed": "ไม่สามารถอัปเดตการตั้งค่าการสำรองข้อมูลได้", + "localBackupLoadFailed": "ไม่สามารถโหลดการตั้งค่าการสำรองข้อมูลได้", "backupKindAuto": "อัตโนมัติ", "backupSchemaVersion": "สคีมา v{version}", "billContent": "เนื้อหาในบิล", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "ระบบจะสร้างข้อมูลสำรองโดยอัตโนมัติก่อนลบข้อมูลใด", "initializeDialogBody": "การดำเนินการนี้จะลบสินค้า คำสั่งซื้อ ลูกค้า และการตั้งค่าทั้งหมดอย่างถาวร แล้วรีเซ็ตฐานข้อมูลเป็นการติดตั้งใหม่ที่ว่างเปล่า จากนั้นคุณจะต้องตั้งค่าครั้งแรกอีกครั้ง", "initializeFailedGeneric": "เริ่มต้นไม่สำเร็จ", + "initializeLocalBackupFailed": "เริ่มต้นฐานข้อมูลแล้ว แต่ไม่สามารถบันทึกสำเนาสำรองลงในโฟลเดอร์สำรองข้อมูลอัตโนมัติได้ ข้อมูลสำรองก่อนการรีเซ็ตยังอยู่ในโฟลเดอร์ข้อมูลของแอป", "initializeMasterPinPrompt": "ป้อน PIN หลักของอุปกรณ์เพื่อเริ่มต้นฐานข้อมูลอย่างถาวร", "initializeTypeConfirm": "พิมพ์ {phrase} เพื่อดำเนินการต่อ", "instagramHandle": "ชื่อผู้ใช้ Instagram", diff --git a/frontend/src/lib/i18n/messages/tr.json b/frontend/src/lib/i18n/messages/tr.json index ccb870c85..efceccc16 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -1716,6 +1716,7 @@ "localBackupLast": "Son yedek: {time}", "localBackupNone": "Henüz yedek yok", "localBackupUpdateFailed": "Yedekleme ayarı güncellenemedi", + "localBackupLoadFailed": "Yedekleme ayarı yüklenemedi", "backupKindAuto": "Otomatik", "backupSchemaVersion": "şema v{version}", "billContent": "Fiş İçeriği", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "Herhangi bir veri silinmeden önce otomatik olarak bir yedek oluşturulur.", "initializeDialogBody": "Bu işlem tüm ürün, sipariş, müşteri ve ayarları kalıcı olarak siler. Sonrasında ilk kurulum sihirbazına yönlendirileceksiniz.", "initializeFailedGeneric": "Sıfırlama başarısız oldu", + "initializeLocalBackupFailed": "Veritabanı başlatıldı ancak yedek kopya otomatik yedekleme klasörüne kaydedilemedi. Sıfırlama öncesi yedek hâlâ uygulamanın veri klasöründe.", "initializeMasterPinPrompt": "Veritabanını kalıcı olarak sıfırlamak için cihaz Ana PIN kodunuzu girin.", "initializeTypeConfirm": "Devam etmek için {phrase} yazın", "instagramHandle": "Instagram Kullanıcı Adı", diff --git a/frontend/src/lib/i18n/messages/ur.json b/frontend/src/lib/i18n/messages/ur.json index df59037c3..d81400705 100644 --- a/frontend/src/lib/i18n/messages/ur.json +++ b/frontend/src/lib/i18n/messages/ur.json @@ -1716,6 +1716,7 @@ "localBackupLast": "آخری بیک اپ: {time}", "localBackupNone": "ابھی تک کوئی بیک اپ نہیں", "localBackupUpdateFailed": "بیک اپ کی سیٹنگ اپ ڈیٹ نہیں ہو سکی", + "localBackupLoadFailed": "بیک اپ کی ترتیب لوڈ نہیں ہو سکی", "backupKindAuto": "خودکار", "backupSchemaVersion": "schema v{version}", "billContent": "بل کا مواد", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "کچھ بھی حذف ہونے سے پہلے خودکار بیک اپ بنایا جاتا ہے۔", "initializeDialogBody": "یہ تمام پروڈکٹ، آرڈر، گاہک اور ترتیبات مستقل طور پر حذف کر کے ڈیٹابیس کو خالی انسٹال پر ری سیٹ کرتا ہے۔ اس کے بعد دوبارہ پہلی بار کا سیٹ اپ کرنا ہوگا۔", "initializeFailedGeneric": "شروع کرنا ناکام", + "initializeLocalBackupFailed": "ڈیٹابیس انیشیلائز ہو گیا، لیکن بیک اپ کاپی خودکار بیک اپ فولڈر میں محفوظ نہیں ہو سکی۔ ری سیٹ سے پہلے کا بیک اپ اب بھی ایپ کے ڈیٹا فولڈر میں موجود ہے۔", "initializeMasterPinPrompt": "ڈیٹابیس کو مستقل طور پر شروع کرنے کے لیے اپنے ڈیوائس کا ماسٹر PIN درج کریں۔", "initializeTypeConfirm": "جاری رکھنے کے لیے {phrase} لکھیں", "instagramHandle": "Instagram ہینڈل", diff --git a/frontend/src/lib/i18n/messages/vi.json b/frontend/src/lib/i18n/messages/vi.json index 7a72f42cd..1ee85781b 100644 --- a/frontend/src/lib/i18n/messages/vi.json +++ b/frontend/src/lib/i18n/messages/vi.json @@ -1716,6 +1716,7 @@ "localBackupLast": "Lần sao lưu gần nhất: {time}", "localBackupNone": "Chưa có bản sao lưu", "localBackupUpdateFailed": "Không thể cập nhật cài đặt sao lưu", + "localBackupLoadFailed": "Không thể tải cài đặt sao lưu", "backupKindAuto": "Tự động", "backupSchemaVersion": "lược đồ v{version}", "billContent": "Nội dung hóa đơn", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "Bản sao lưu được tạo tự động trước khi xóa bất kỳ dữ liệu nào.", "initializeDialogBody": "Thao tác này xóa vĩnh viễn mọi sản phẩm, đơn hàng, khách hàng và cài đặt, rồi đặt lại cơ sở dữ liệu thành bản cài đặt trống. Sau đó bạn sẽ thiết lập lần đầu từ đầu.", "initializeFailedGeneric": "Khởi tạo thất bại", + "initializeLocalBackupFailed": "Cơ sở dữ liệu đã được khởi tạo, nhưng không thể lưu bản sao lưu vào thư mục sao lưu tự động. Bản sao lưu trước khi đặt lại vẫn nằm trong thư mục dữ liệu của ứng dụng.", "initializeMasterPinPrompt": "Nhập mã PIN Master của thiết bị để khởi tạo vĩnh viễn cơ sở dữ liệu.", "initializeTypeConfirm": "Nhập {phrase} để tiếp tục", "instagramHandle": "Tài khoản Instagram", diff --git a/frontend/src/lib/i18n/messages/zh-tw.json b/frontend/src/lib/i18n/messages/zh-tw.json index 009c941c9..1e71bbfe2 100644 --- a/frontend/src/lib/i18n/messages/zh-tw.json +++ b/frontend/src/lib/i18n/messages/zh-tw.json @@ -1716,6 +1716,7 @@ "localBackupLast": "上次備份:{time}", "localBackupNone": "尚無備份", "localBackupUpdateFailed": "無法更新備份設定", + "localBackupLoadFailed": "無法載入備份設定", "backupKindAuto": "自動", "backupSchemaVersion": "架構 v{version}", "billContent": "帳單內容", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "在刪除任何內容前會自動建立備份。", "initializeDialogBody": "這將永久刪除全部商品、訂單、顧客和設定,並將資料庫重置為空白安裝。之後您將重新進行首次執行設定。", "initializeFailedGeneric": "初始化失敗", + "initializeLocalBackupFailed": "資料庫已初始化,但無法將備份副本儲存到自動備份資料夾。重設前的備份仍在應用程式的資料夾中。", "initializeMasterPinPrompt": "輸入您的裝置主 PIN 以永久初始化資料庫。", "initializeTypeConfirm": "輸入 {phrase} 以繼續", "instagramHandle": "Instagram 帳號", diff --git a/frontend/src/lib/i18n/messages/zh.json b/frontend/src/lib/i18n/messages/zh.json index 85804b1ea..e82a6db50 100644 --- a/frontend/src/lib/i18n/messages/zh.json +++ b/frontend/src/lib/i18n/messages/zh.json @@ -1716,6 +1716,7 @@ "localBackupLast": "上次备份:{time}", "localBackupNone": "尚无备份", "localBackupUpdateFailed": "无法更新备份设置", + "localBackupLoadFailed": "无法加载备份设置", "backupKindAuto": "自动", "backupSchemaVersion": "架构 v{version}", "billContent": "账单内容", @@ -2020,6 +2021,7 @@ "initializeDialogBackup": "在删除任何内容前会自动创建备份。", "initializeDialogBody": "这将永久删除全部商品、订单、顾客和设置,并将数据库重置为空白安装。之后您将重新进行首次运行设置。", "initializeFailedGeneric": "初始化失败", + "initializeLocalBackupFailed": "数据库已初始化,但无法将备份副本保存到自动备份文件夹。重置前的备份仍在应用的数据文件夹中。", "initializeMasterPinPrompt": "输入您的设备主 PIN 以永久初始化数据库。", "initializeTypeConfirm": "输入 {phrase} 以继续", "instagramHandle": "Instagram 账号", diff --git a/main/routes/database-tools.ts b/main/routes/database-tools.ts index 52b19e140..55971d13d 100644 --- a/main/routes/database-tools.ts +++ b/main/routes/database-tools.ts @@ -278,8 +278,9 @@ router.post( restorePrepared = true; const keepLocalBackup = isLocalBackupEnabled(); const { backupPath } = await resetDatabaseWithBackup(getHttpRequestSignal(req)); + let localBackupFailed = false; if (keepLocalBackup) { - retainBackupLocally(backupPath); + localBackupFailed = retainBackupLocally(backupPath) === null; } else { setLocalBackupEnabled(false); } @@ -287,7 +288,12 @@ router.post( clearUserAuthCache(); clearInMemoryRevokedTokens(); clearJWTSecretCache(); - res.json({ success: true, backupPath, cleanupPending: cleanup.cleanupPending }); + res.json({ + success: true, + backupPath, + cleanupPending: cleanup.cleanupPending, + localBackupFailed, + }); } catch (error: any) { console.error('[DB Tools] initialize error:', error); res.status(500).json({ error: 'Initialize failed' }); diff --git a/main/services/local-backup.ts b/main/services/local-backup.ts index 2d5b0e0e3..f91a4d686 100644 --- a/main/services/local-backup.ts +++ b/main/services/local-backup.ts @@ -1,6 +1,7 @@ /** Daily automatic database backups kept outside the app's data folder. */ import { app } from 'electron'; +import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { createBackup, getDatabase, getSettingValue, upsertSettings } from '../db'; @@ -11,7 +12,8 @@ export const LOCAL_BACKUP_RETENTION = 5; const DAY_MS = 24 * 60 * 60_000; const CHECK_INTERVAL_MS = 60 * 60_000; const STARTUP_DELAY_MS = 2 * 60_000; -const BACKUP_FILE_PATTERN = /^flo-backup-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z\.db$/; +const BACKUP_FILE_PATTERN = + /^flo-backup-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})(?:-(\d{3}))?Z\.db$/; export function getLocalBackupDir(): string { try { @@ -30,28 +32,42 @@ export function setLocalBackupEnabled(enabled: boolean): void { } export function localBackupFileName(at: Date): string { - return `flo-backup-${at - .toISOString() - .replace(/\.\d{3}Z$/, 'Z') - .replace(/:/g, '-')}.db`; + return `flo-backup-${at.toISOString().replace(/[:.]/g, '-')}.db`; } export function parseLocalBackupTime(fileName: string): number | null { const match = BACKUP_FILE_PATTERN.exec(fileName); if (!match) return null; - const [, y, mo, d, h, mi, s] = match; - const time = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)); + const [, y, mo, d, h, mi, s, ms = '0'] = match; + const time = Date.UTC( + Number(y), + Number(mo) - 1, + Number(d), + Number(h), + Number(mi), + Number(s), + Number(ms), + ); return Number.isNaN(time) ? null : time; } -/** Newest first. Only files this service wrote are ever listed. */ -export function listLocalBackups(dir: string = getLocalBackupDir()): string[] { +/** + * Newest first. Files dated ahead of the clock sort last: their time cannot be trusted. + * Only files this service wrote are ever listed. + */ +export function listLocalBackups(dir: string = getLocalBackupDir(), nowMs = Date.now()): string[] { try { return fs .readdirSync(dir) - .filter((name) => parseLocalBackupTime(name) !== null) - .sort() - .reverse(); + .map((name) => ({ name, time: parseLocalBackupTime(name) })) + .filter((entry): entry is { name: string; time: number } => entry.time !== null) + .sort( + (a, b) => + Number(a.time > nowMs) - Number(b.time > nowMs) || + b.time - a.time || + b.name.localeCompare(a.name), + ) + .map((entry) => entry.name); } catch { return []; } @@ -68,9 +84,19 @@ export function isLocalBackupDue(newestFileName: string | undefined, nowMs = Dat return last === null || last > nowMs || nowMs - last >= DAY_MS; } -export function pruneLocalBackups(dir: string, keep: number = LOCAL_BACKUP_RETENTION): string[] { +/** Keeps the newest backups by listing order; `justWritten` is always kept. */ +export function pruneLocalBackups( + dir: string, + keep: number = LOCAL_BACKUP_RETENTION, + justWritten?: string, +): string[] { + const listed = listLocalBackups(dir); + const ranked = + justWritten && listed.includes(justWritten) + ? [justWritten, ...listed.filter((name) => name !== justWritten)] + : listed; const removed: string[] = []; - for (const name of listLocalBackups(dir).slice(keep)) { + for (const name of ranked.slice(keep)) { try { fs.unlinkSync(path.join(dir, name)); removed.push(name); @@ -81,6 +107,33 @@ export function pruneLocalBackups(dir: string, keep: number = LOCAL_BACKUP_RETEN return removed; } +function removeQuietly(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch { + // Ignore cleanup failure + } +} + +function stagingPath(dir: string): string { + return path.join(dir, `.flo-backup-${randomUUID()}.tmp`); +} + +/** Links a staged file in under the first free backup name; never replaces an existing file. */ +function publishBackup(stagedPath: string, dir: string): string { + for (let stamp = Date.now(); ; stamp++) { + const name = localBackupFileName(new Date(stamp)); + try { + fs.linkSync(stagedPath, path.join(dir, name)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') continue; + throw error; + } + removeQuietly(stagedPath); + return name; + } +} + /** Copies a finished backup into the local backup folder and applies retention. Best effort. */ export function retainBackupLocally(sourcePath: string): string | null { if (!isLocalBackupEnabled()) return null; @@ -88,21 +141,14 @@ export function retainBackupLocally(sourcePath: string): string | null { try { const dir = getLocalBackupDir(); fs.mkdirSync(dir, { recursive: true }); - const target = path.join(dir, localBackupFileName(new Date())); - tempTarget = `${target}.tmp-${process.pid}-${Date.now()}`; + tempTarget = stagingPath(dir); fs.copyFileSync(sourcePath, tempTarget); - fs.renameSync(tempTarget, target); + const name = publishBackup(tempTarget, dir); tempTarget = null; - pruneLocalBackups(dir); - return target; + pruneLocalBackups(dir, LOCAL_BACKUP_RETENTION, name); + return path.join(dir, name); } catch (error) { - if (tempTarget) { - try { - fs.unlinkSync(tempTarget); - } catch { - // Ignore cleanup failure - } - } + if (tempTarget) removeQuietly(tempTarget); console.warn('[LocalBackup] Could not keep a copy of the backup:', error); return null; } @@ -161,9 +207,16 @@ class LocalBackupService { fs.mkdirSync(dir, { recursive: true }); this.abortController = new AbortController(); - const target = path.join(dir, localBackupFileName(new Date())); - await createBackup(target, this.abortController.signal); - pruneLocalBackups(dir); + const staged = stagingPath(dir); + let name: string; + try { + await createBackup(staged, this.abortController.signal); + name = publishBackup(staged, dir); + } catch (error) { + removeQuietly(staged); + throw error; + } + pruneLocalBackups(dir, LOCAL_BACKUP_RETENTION, name); } } diff --git a/tests/local-backup.test.ts b/tests/local-backup.test.ts index 0fff8873c..f77629351 100644 --- a/tests/local-backup.test.ts +++ b/tests/local-backup.test.ts @@ -78,8 +78,16 @@ async function main(): Promise { } const stamp = new Date('2026-10-22T13:05:09.456Z'); - assert.equal(localBackupFileName(stamp), 'flo-backup-2026-10-22T13-05-09Z.db'); - assert.equal(parseLocalBackupTime(localBackupFileName(stamp)), Date.UTC(2026, 9, 22, 13, 5, 9)); + assert.equal(localBackupFileName(stamp), 'flo-backup-2026-10-22T13-05-09-456Z.db'); + assert.equal( + parseLocalBackupTime(localBackupFileName(stamp)), + Date.UTC(2026, 9, 22, 13, 5, 9, 456), + ); + assert.equal( + parseLocalBackupTime('flo-backup-2026-10-22T13-05-09Z.db'), + Date.UTC(2026, 9, 22, 13, 5, 9), + 'backups named before millisecond precision are still recognized', + ); assert.equal(parseLocalBackupTime('flo-backup-2026-10-22T13-05-09Z.db.tmp'), null); assert.equal(parseLocalBackupTime('notes.db'), null); @@ -183,17 +191,65 @@ async function main(): Promise { LOCAL_BACKUP_RETENTION, 'retention still applies', ); + + assert.notEqual( + localBackupFileName(new Date('2026-10-22T13:05:09.100Z')), + localBackupFileName(new Date('2026-10-22T13:05:09.900Z')), + 'snapshots in the same second get distinct names', + ); + const firstCopy = retainBackupLocally(source); + const secondCopy = retainBackupLocally(source); + assert.ok(firstCopy && secondCopy && firstCopy !== secondCopy, 'back-to-back copies never share a name'); + assert.ok(fs.existsSync(firstCopy as string) && fs.existsSync(secondCopy as string)); + + for (const name of listLocalBackups(backupDir)) fs.unlinkSync(path.join(backupDir, name)); + const legacyName = 'flo-backup-2026-01-02T03-00-00Z.db'; + const precise = localBackupFileName(new Date('2026-01-02T03:00:00.500Z')); + fs.writeFileSync(path.join(backupDir, legacyName), 'legacy'); + fs.writeFileSync(path.join(backupDir, precise), 'precise'); + assert.deepEqual( + listLocalBackups(backupDir), + [precise, legacyName], + 'legacy and current names are listed together in time order', + ); + + for (const name of listLocalBackups(backupDir)) fs.unlinkSync(path.join(backupDir, name)); + const futureNames = Array.from({ length: LOCAL_BACKUP_RETENTION }, (_, idx) => + localBackupFileName(new Date(Date.now() + (idx + 1) * 24 * HOUR)), + ); + for (const name of futureNames) fs.writeFileSync(path.join(backupDir, name), 'future'); + await localBackup.runIfDue(); + const afterClockFix = listLocalBackups(backupDir); + assert.equal(afterClockFix.length, LOCAL_BACKUP_RETENTION, 'retention still applies after a clock fix'); + const written = afterClockFix.filter((name) => !futureNames.includes(name)); + assert.equal(written.length, 1, 'the snapshot written after a clock fix is kept'); + assert.ok((parseLocalBackupTime(written[0]) as number) <= Date.now()); + assert.equal(afterClockFix[0], written[0], 'future-dated backups rank below the real one'); + assert.equal( + getLastLocalBackupAt(backupDir), + new Date(parseLocalBackupTime(written[0]) as number).toISOString(), + 'last backup time ignores future-dated files', + ); + await localBackup.runIfDue(); + assert.deepEqual( + listLocalBackups(backupDir), + afterClockFix, + 'future-dated backups do not make a fresh real backup due again', + ); + setLocalBackupEnabled(false); assert.equal(retainBackupLocally(source), null, 'respects the off switch'); for (const name of listLocalBackups(backupDir)) { fs.unlinkSync(path.join(backupDir, name)); } - getDatabase() - .prepare( - "INSERT OR IGNORE INTO users (id, name, password, role, is_active) VALUES ('owner-1', 'Owner', 'hash', 'owner', 1)", - ) - .run(); + const ensureOwner = () => + getDatabase() + .prepare( + "INSERT OR IGNORE INTO users (id, name, password, role, is_active) VALUES ('owner-1', 'Owner', 'hash', 'owner', 1)", + ) + .run(); + ensureOwner(); resetMasterPin('1234'); setLocalBackupEnabled(false); @@ -219,6 +275,27 @@ async function main(): Promise { false, 'local_backup_enabled remains false after database initialize', ); + assert.equal(initRes.body.localBackupFailed, false, 'no warning when local backup is off'); + + ensureOwner(); + setLocalBackupEnabled(true); + const keptRes = await request(api) + .post('/api/db-tools/initialize') + .send({ master_pin: '1234', confirmation_phrase: 'INITIALIZE' }); + assert.equal(keptRes.status, 200); + assert.equal(keptRes.body.localBackupFailed, false, 'no warning when the copy is kept'); + assert.equal(listLocalBackups(backupDir).length, 1, 'initialize keeps a local copy when enabled'); + + fs.rmSync(backupDir, { recursive: true, force: true }); + fs.writeFileSync(backupDir, 'not a folder'); + ensureOwner(); + const failedRes = await request(api) + .post('/api/db-tools/initialize') + .send({ master_pin: '1234', confirmation_phrase: 'INITIALIZE' }); + assert.equal(failedRes.status, 200, 'the committed reset is still reported as done'); + assert.equal(failedRes.body.success, true); + assert.equal(failedRes.body.localBackupFailed, true, 'a failed local copy is reported'); + fs.rmSync(backupDir, { force: true }); await localBackup.stop(); closeDatabase(); From 64e49e9990b2298c5e1db4cdff3293952dde0a51 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Fri, 23 Oct 2026 10:58:23 -0400 Subject: [PATCH 2/3] no-mistakes(review): fix: preserve collision-bumped backups and retry card state --- .../components/settings/LocalBackupCard.tsx | 14 ++++-- main/services/local-backup.ts | 23 ++++++++-- tests/local-backup.test.ts | 43 +++++++++++++++++++ 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/settings/LocalBackupCard.tsx b/frontend/src/components/settings/LocalBackupCard.tsx index a17490954..202264c8c 100644 --- a/frontend/src/components/settings/LocalBackupCard.tsx +++ b/frontend/src/components/settings/LocalBackupCard.tsx @@ -22,6 +22,7 @@ export function LocalBackupCard() { const [status, setStatus] = useState(null); const [busy, setBusy] = useState(false); const [loadFailed, setLoadFailed] = useState(false); + const [retrying, setRetrying] = useState(false); const [loadAttempt, setLoadAttempt] = useState(0); useEffect(() => { @@ -29,10 +30,16 @@ export function LocalBackupCard() { api .get('/db-tools/local-backup') .then(({ data }) => { - if (!cancelled) setStatus(data); + if (!cancelled) { + setStatus(data); + setLoadFailed(false); + } }) .catch(() => { if (!cancelled) setLoadFailed(true); + }) + .finally(() => { + if (!cancelled) setRetrying(false); }); return () => { cancelled = true; @@ -54,7 +61,8 @@ export function LocalBackupCard() { }; const retryLoad = () => { - setLoadFailed(false); + if (retrying) return; + setRetrying(true); setLoadAttempt((attempt) => attempt + 1); }; @@ -71,7 +79,7 @@ export function LocalBackupCard() { {t('localBackupLoadFailed')}

-
diff --git a/main/services/local-backup.ts b/main/services/local-backup.ts index f91a4d686..e336e1e9d 100644 --- a/main/services/local-backup.ts +++ b/main/services/local-backup.ts @@ -51,11 +51,21 @@ export function parseLocalBackupTime(fileName: string): number | null { return Number.isNaN(time) ? null : time; } +let lastPublish = { wallMs: 0, stampMs: 0 }; + +function currentNowMs(): number { + const now = Date.now(); + return now >= lastPublish.wallMs ? Math.max(now, lastPublish.stampMs) : now; +} + /** * Newest first. Files dated ahead of the clock sort last: their time cannot be trusted. * Only files this service wrote are ever listed. */ -export function listLocalBackups(dir: string = getLocalBackupDir(), nowMs = Date.now()): string[] { +export function listLocalBackups( + dir: string = getLocalBackupDir(), + nowMs = currentNowMs(), +): string[] { try { return fs .readdirSync(dir) @@ -78,7 +88,10 @@ export function getLastLocalBackupAt(dir?: string): string | null { return newest ? new Date(parseLocalBackupTime(newest) as number).toISOString() : null; } -export function isLocalBackupDue(newestFileName: string | undefined, nowMs = Date.now()): boolean { +export function isLocalBackupDue( + newestFileName: string | undefined, + nowMs = currentNowMs(), +): boolean { if (!newestFileName) return true; const last = parseLocalBackupTime(newestFileName); return last === null || last > nowMs || nowMs - last >= DAY_MS; @@ -121,7 +134,10 @@ function stagingPath(dir: string): string { /** Links a staged file in under the first free backup name; never replaces an existing file. */ function publishBackup(stagedPath: string, dir: string): string { - for (let stamp = Date.now(); ; stamp++) { + const wallMs = Date.now(); + const startStamp = + wallMs >= lastPublish.wallMs ? Math.max(wallMs, lastPublish.stampMs) : wallMs; + for (let stamp = startStamp; ; stamp++) { const name = localBackupFileName(new Date(stamp)); try { fs.linkSync(stagedPath, path.join(dir, name)); @@ -129,6 +145,7 @@ function publishBackup(stagedPath: string, dir: string): string { if ((error as NodeJS.ErrnoException).code === 'EEXIST') continue; throw error; } + lastPublish = { wallMs, stampMs: stamp }; removeQuietly(stagedPath); return name; } diff --git a/tests/local-backup.test.ts b/tests/local-backup.test.ts index f77629351..fa66678dd 100644 --- a/tests/local-backup.test.ts +++ b/tests/local-backup.test.ts @@ -202,6 +202,49 @@ async function main(): Promise { assert.ok(firstCopy && secondCopy && firstCopy !== secondCopy, 'back-to-back copies never share a name'); assert.ok(fs.existsSync(firstCopy as string) && fs.existsSync(secondCopy as string)); + for (const name of listLocalBackups(backupDir)) fs.unlinkSync(path.join(backupDir, name)); + for (let day = 1; day <= LOCAL_BACKUP_RETENTION; day++) { + const name = localBackupFileName(new Date(Date.UTC(2026, 0, day, 3, 0, 0))); + fs.writeFileSync(path.join(backupDir, name), 'old'); + } + const realDateNow = Date.now; + const pinnedNow = realDateNow() - 10_000; + Date.now = () => pinnedNow; + try { + const burst: string[] = []; + for (let idx = 0; idx < LOCAL_BACKUP_RETENTION + 2; idx++) { + const copyPath = retainBackupLocally(source); + assert.ok(copyPath, 'same-millisecond copy succeeds'); + burst.push(path.basename(copyPath)); + if (idx === 1) { + assert.deepEqual( + listLocalBackups(backupDir).slice(0, 2), + [burst[1], burst[0]], + 'same-millisecond copies sort newest-first ahead of older backups', + ); + } + } + assert.deepEqual( + burst, + Array.from({ length: LOCAL_BACKUP_RETENTION + 2 }, (_, idx) => + localBackupFileName(new Date(pinnedNow + idx)), + ), + 'same-millisecond copies advance monotonically even after earlier slots are pruned', + ); + assert.deepEqual( + listLocalBackups(backupDir), + burst.slice(-LOCAL_BACKUP_RETENTION).reverse(), + 'retention keeps the newest same-millisecond copies', + ); + assert.equal( + getLastLocalBackupAt(backupDir), + new Date(pinnedNow + LOCAL_BACKUP_RETENTION + 1).toISOString(), + ); + assert.equal(isLocalBackupDue(listLocalBackups(backupDir)[0]), false); + } finally { + Date.now = realDateNow; + } + for (const name of listLocalBackups(backupDir)) fs.unlinkSync(path.join(backupDir, name)); const legacyName = 'flo-backup-2026-01-02T03-00-00Z.db'; const precise = localBackupFileName(new Date('2026-01-02T03:00:00.500Z')); From b6fbbc9624893cd91720635a7214714821cf988f Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Fri, 23 Oct 2026 11:20:18 -0400 Subject: [PATCH 3/3] no-mistakes(document): Format local backup service and test files --- main/services/local-backup.ts | 3 +-- tests/local-backup.test.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/main/services/local-backup.ts b/main/services/local-backup.ts index e336e1e9d..78e2478e0 100644 --- a/main/services/local-backup.ts +++ b/main/services/local-backup.ts @@ -135,8 +135,7 @@ function stagingPath(dir: string): string { /** Links a staged file in under the first free backup name; never replaces an existing file. */ function publishBackup(stagedPath: string, dir: string): string { const wallMs = Date.now(); - const startStamp = - wallMs >= lastPublish.wallMs ? Math.max(wallMs, lastPublish.stampMs) : wallMs; + const startStamp = wallMs >= lastPublish.wallMs ? Math.max(wallMs, lastPublish.stampMs) : wallMs; for (let stamp = startStamp; ; stamp++) { const name = localBackupFileName(new Date(stamp)); try { diff --git a/tests/local-backup.test.ts b/tests/local-backup.test.ts index fa66678dd..35be01796 100644 --- a/tests/local-backup.test.ts +++ b/tests/local-backup.test.ts @@ -199,7 +199,10 @@ async function main(): Promise { ); const firstCopy = retainBackupLocally(source); const secondCopy = retainBackupLocally(source); - assert.ok(firstCopy && secondCopy && firstCopy !== secondCopy, 'back-to-back copies never share a name'); + assert.ok( + firstCopy && secondCopy && firstCopy !== secondCopy, + 'back-to-back copies never share a name', + ); assert.ok(fs.existsSync(firstCopy as string) && fs.existsSync(secondCopy as string)); for (const name of listLocalBackups(backupDir)) fs.unlinkSync(path.join(backupDir, name)); @@ -263,7 +266,11 @@ async function main(): Promise { for (const name of futureNames) fs.writeFileSync(path.join(backupDir, name), 'future'); await localBackup.runIfDue(); const afterClockFix = listLocalBackups(backupDir); - assert.equal(afterClockFix.length, LOCAL_BACKUP_RETENTION, 'retention still applies after a clock fix'); + assert.equal( + afterClockFix.length, + LOCAL_BACKUP_RETENTION, + 'retention still applies after a clock fix', + ); const written = afterClockFix.filter((name) => !futureNames.includes(name)); assert.equal(written.length, 1, 'the snapshot written after a clock fix is kept'); assert.ok((parseLocalBackupTime(written[0]) as number) <= Date.now());