feat(library): backup to and restore from a zip file (#3571)

This commit is contained in:
Huang Xin 2026-03-21 01:27:52 +08:00 committed by GitHub
parent e8f70b896e
commit 91bc4ddec7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 1580 additions and 138 deletions

234
Cargo.lock generated
View file

@ -30,7 +30,7 @@ dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-build 2.5.6 (registry+https://github.com/rust-lang/crates.io-index)",
"tauri-plugin-cli",
"tauri-plugin-deep-link",
"tauri-plugin-device-info",
@ -215,7 +215,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -226,7 +226,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -1595,7 +1595,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -1869,7 +1869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -1951,6 +1951,15 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "file-id"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "filetime"
version = "0.2.27"
@ -2073,6 +2082,15 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "funty"
version = "2.0.0"
@ -2464,7 +2482,7 @@ dependencies = [
"gobject-sys 0.21.5",
"libc",
"system-deps 7.0.7",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -2893,7 +2911,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core 0.61.2",
]
[[package]]
@ -3074,6 +3092,26 @@ dependencies = [
"cfb",
]
[[package]]
name = "inotify"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199"
dependencies = [
"bitflags 2.11.0",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "inout"
version = "0.1.4"
@ -3277,6 +3315,26 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@ -3673,6 +3731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.61.2",
]
@ -3885,6 +3944,47 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "notify"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.11.0",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.60.2",
]
[[package]]
name = "notify-debouncer-full"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078"
dependencies = [
"file-id",
"log",
"notify",
"notify-types",
"walkdir",
]
[[package]]
name = "notify-types"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
"bitflags 2.11.0",
"serde",
]
[[package]]
name = "ntapi"
version = "0.4.3"
@ -3900,7 +4000,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -4407,7 +4507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -5672,7 +5772,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -5729,7 +5829,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -6299,7 +6399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -6855,11 +6955,11 @@ dependencies = [
"specta",
"swift-rs",
"tauri",
"tauri-build",
"tauri-build 2.5.6",
"tauri-macros",
"tauri-runtime",
"tauri-runtime-wry",
"tauri-utils",
"tauri-utils 2.8.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@ -6888,7 +6988,29 @@ dependencies = [
"serde",
"serde_json",
"tauri-codegen",
"tauri-utils",
"tauri-utils 2.8.3",
"tauri-winres",
"toml 0.9.12+spec-1.1.0",
"walkdir",
]
[[package]]
name = "tauri-build"
version = "2.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"glob",
"heck 0.5.0",
"json-patch",
"schemars 0.8.22",
"semver",
"serde",
"serde_json",
"tauri-utils 2.8.3 (registry+https://github.com/rust-lang/crates.io-index)",
"tauri-winres",
"toml 0.9.12+spec-1.1.0",
"walkdir",
@ -6911,7 +7033,7 @@ dependencies = [
"serde_json",
"sha2",
"syn 2.0.117",
"tauri-utils",
"tauri-utils 2.8.3",
"thiserror 2.0.18",
"time",
"url",
@ -6928,7 +7050,7 @@ dependencies = [
"quote",
"syn 2.0.117",
"tauri-codegen",
"tauri-utils",
"tauri-utils 2.8.3",
]
[[package]]
@ -6943,7 +7065,7 @@ dependencies = [
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"tauri-utils 2.8.3 (registry+https://github.com/rust-lang/crates.io-index)",
"toml 0.9.12+spec-1.1.0",
"walkdir",
]
@ -6966,6 +7088,8 @@ dependencies = [
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b"
dependencies = [
"dunce",
"plist",
@ -6974,7 +7098,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"tauri-utils 2.8.3 (registry+https://github.com/rust-lang/crates.io-index)",
"thiserror 2.0.18",
"tracing",
"url",
@ -7024,12 +7148,14 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"notify",
"notify-debouncer-full",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
@ -7037,7 +7163,7 @@ dependencies = [
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"tauri-utils 2.8.3 (registry+https://github.com/rust-lang/crates.io-index)",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"url",
@ -7264,12 +7390,12 @@ dependencies = [
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c"
dependencies = [
"semver",
"serde",
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
@ -7408,7 +7534,7 @@ dependencies = [
"raw-window-handle",
"serde",
"serde_json",
"tauri-utils",
"tauri-utils 2.8.3",
"thiserror 2.0.18",
"url",
"webkit2gtk",
@ -7432,7 +7558,7 @@ dependencies = [
"softbuffer",
"tao",
"tauri-runtime",
"tauri-utils",
"tauri-utils 2.8.3",
"tracing",
"url",
"webkit2gtk",
@ -7483,6 +7609,43 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-utils"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d"
dependencies = [
"anyhow",
"cargo_metadata",
"ctor",
"dunce",
"glob",
"html5ever",
"http",
"infer",
"json-patch",
"kuchikiki",
"log",
"memchr",
"phf 0.11.3",
"proc-macro2",
"quote",
"regex",
"schemars 0.8.22",
"semver",
"serde",
"serde-untagged",
"serde_json",
"serde_with",
"swift-rs",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"url",
"urlpattern",
"uuid 1.22.0",
"walkdir",
]
[[package]]
name = "tauri-winres"
version = "0.3.5"
@ -7504,7 +7667,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -8202,7 +8365,7 @@ checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -8776,7 +8939,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -8889,19 +9052,6 @@ dependencies = [
"windows-strings 0.4.2",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-future"
version = "0.2.1"

View file

@ -2,10 +2,7 @@
members = [
"apps/readest-app/src-tauri",
"packages/tauri/crates/tauri",
"packages/tauri/crates/tauri-utils",
"packages/tauri/crates/tauri-build",
"packages/tauri-plugins/plugins/deep-link",
"packages/tauri-plugins/plugins/single-instance"
"packages/tauri-plugins/plugins/fs"
]
resolver = "2"
@ -36,7 +33,4 @@ rust-version = "1.77.2"
[patch.crates-io]
tauri = { path = "packages/tauri/crates/tauri" }
tauri-utils = { path = "packages/tauri/crates/tauri-utils" }
tauri-build = { path = "packages/tauri/crates/tauri-build" }
tauri-plugin-deep-link = { path = "packages/tauri-plugins/plugins/deep-link" }
tauri-plugin-single-instance = { path = "packages/tauri-plugins/plugins/single-instance" }
tauri-plugin-fs = { path = "packages/tauri-plugins/plugins/fs" }

@ -1 +1 @@
Subproject commit 78c207efb42c90f84b5e383cd4aba39ecff29896
Subproject commit 8ddfab233d3999edb172bed54aaf06fc5ff92646

View file

@ -1130,5 +1130,20 @@
"Fallback value": "القيمة الاحتياطية",
"Get length": "الحصول على الطول",
"First/last element": "العنصر الأول/الأخير",
"Join array": "دمج المصفوفة"
"Join array": "دمج المصفوفة",
"Backup failed: {{error}}": "فشل النسخ الاحتياطي: {{error}}",
"Select Backup": "اختر نسخة احتياطية",
"Restore failed: {{error}}": "فشل الاستعادة: {{error}}",
"Backup & Restore": "النسخ الاحتياطي والاستعادة",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "أنشئ نسخة احتياطية من مكتبتك أو استعد من نسخة احتياطية سابقة. ستدمج الاستعادة مع مكتبتك الحالية.",
"Backup Library": "نسخ المكتبة احتياطيًا",
"Restore Library": "استعادة المكتبة",
"Creating backup...": "جارٍ إنشاء النسخة الاحتياطية...",
"Restoring library...": "جارٍ استعادة المكتبة...",
"{{current}} of {{total}} items": "{{current}} من {{total}} عنصر",
"Backup completed successfully!": "تم النسخ الاحتياطي بنجاح!",
"Restore completed successfully!": "تمت الاستعادة بنجاح!",
"Your library has been saved to the selected location.": "تم حفظ مكتبتك في الموقع المحدد.",
"{{added}} books added, {{updated}} books updated.": "تمت إضافة {{added}} كتب، وتحديث {{updated}} كتب.",
"Operation failed": "فشلت العملية"
}

View file

@ -1082,5 +1082,20 @@
"Fallback value": "ফলব্যাক মান",
"Get length": "দৈর্ঘ্য নিন",
"First/last element": "প্রথম/শেষ উপাদান",
"Join array": "অ্যারে যুক্ত করুন"
"Join array": "অ্যারে যুক্ত করুন",
"Backup failed: {{error}}": "ব্যাকআপ ব্যর্থ: {{error}}",
"Select Backup": "ব্যাকআপ নির্বাচন করুন",
"Restore failed: {{error}}": "পুনরুদ্ধার ব্যর্থ: {{error}}",
"Backup & Restore": "ব্যাকআপ ও পুনরুদ্ধার",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "আপনার লাইব্রেরির ব্যাকআপ তৈরি করুন বা পূর্ববর্তী ব্যাকআপ থেকে পুনরুদ্ধার করুন। পুনরুদ্ধার আপনার বর্তমান লাইব্রেরির সাথে মার্জ হবে।",
"Backup Library": "লাইব্রেরি ব্যাকআপ",
"Restore Library": "লাইব্রেরি পুনরুদ্ধার",
"Creating backup...": "ব্যাকআপ তৈরি হচ্ছে...",
"Restoring library...": "লাইব্রেরি পুনরুদ্ধার হচ্ছে...",
"{{current}} of {{total}} items": "{{current}} / {{total}} আইটেম",
"Backup completed successfully!": "ব্যাকআপ সফলভাবে সম্পন্ন!",
"Restore completed successfully!": "পুনরুদ্ধার সফলভাবে সম্পন্ন!",
"Your library has been saved to the selected location.": "আপনার লাইব্রেরি নির্বাচিত স্থানে সংরক্ষিত হয়েছে।",
"{{added}} books added, {{updated}} books updated.": "{{added}}টি বই যোগ হয়েছে, {{updated}}টি বই আপডেট হয়েছে।",
"Operation failed": "অপারেশন ব্যর্থ"
}

View file

@ -1070,5 +1070,20 @@
"Fallback value": "ཚབ་ཀྱི་གྲངས་ཀ",
"Get length": "རིང་ཚད་ལེན",
"First/last element": "དང་པོ/མཇུག་གི་རྒྱུ་ཆ",
"Join array": "ཚོ་སྒྲིག་སྦྲེལ"
"Join array": "ཚོ་སྒྲིག་སྦྲེལ",
"Backup failed: {{error}}": "གྲབས་ཉར་བྱེད་མ་ཐུབ: {{error}}",
"Select Backup": "གྲབས་ཉར་འདེམས་པ",
"Restore failed: {{error}}": "སླར་གསོ་བྱེད་མ་ཐུབ: {{error}}",
"Backup & Restore": "གྲབས་ཉར་དང་སླར་གསོ",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "ཁྱོད་ཀྱི་དཔེ་མཛོད་ཀྱི་གྲབས་ཉར་བཟོ་བའམ་སྔོན་གྱི་གྲབས་ཉར་ནས་སླར་གསོ་བྱོས། སླར་གསོ་བྱས་ན་ཁྱོད་ཀྱི་ད་ལྟའི་དཔེ་མཛོད་དང་སྤྲོད་རེས་བྱེད།",
"Backup Library": "དཔེ་མཛོད་གྲབས་ཉར",
"Restore Library": "དཔེ་མཛོད་སླར་གསོ",
"Creating backup...": "གྲབས་ཉར་བཟོ་བཞིན་པ...",
"Restoring library...": "དཔེ་མཛོད་སླར་གསོ་བྱེད་བཞིན་པ...",
"{{current}} of {{total}} items": "{{current}} / {{total}} རྣམ་གྲངས",
"Backup completed successfully!": "གྲབས་ཉར་ལེགས་གྲུབ་བྱུང་!",
"Restore completed successfully!": "སླར་གསོ་ལེགས་གྲུབ་བྱུང་!",
"Your library has been saved to the selected location.": "ཁྱོད་ཀྱི་དཔེ་མཛོད་འདེམས་ས་དེར་ཉར་ཟིན།",
"{{added}} books added, {{updated}} books updated.": "དཔེ་དེབ {{added}} བསྣན་ཟིན, {{updated}} གསར་བསྒྱུར་བྱས་ཟིན།",
"Operation failed": "བཀོལ་སྤྱོད་བྱེད་མ་ཐུབ"
}

View file

@ -1082,5 +1082,20 @@
"Fallback value": "Ersatzwert",
"Get length": "Länge ermitteln",
"First/last element": "Erstes/letztes Element",
"Join array": "Array verbinden"
"Join array": "Array verbinden",
"Backup failed: {{error}}": "Sicherung fehlgeschlagen: {{error}}",
"Select Backup": "Sicherung auswählen",
"Restore failed: {{error}}": "Wiederherstellung fehlgeschlagen: {{error}}",
"Backup & Restore": "Sichern & Wiederherstellen",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Erstellen Sie eine Sicherung Ihrer Bibliothek oder stellen Sie eine frühere Sicherung wieder her. Die Wiederherstellung wird mit Ihrer aktuellen Bibliothek zusammengeführt.",
"Backup Library": "Bibliothek sichern",
"Restore Library": "Bibliothek wiederherstellen",
"Creating backup...": "Sicherung wird erstellt...",
"Restoring library...": "Bibliothek wird wiederhergestellt...",
"{{current}} of {{total}} items": "{{current}} von {{total}} Elementen",
"Backup completed successfully!": "Sicherung erfolgreich abgeschlossen!",
"Restore completed successfully!": "Wiederherstellung erfolgreich abgeschlossen!",
"Your library has been saved to the selected location.": "Ihre Bibliothek wurde am ausgewählten Ort gespeichert.",
"{{added}} books added, {{updated}} books updated.": "{{added}} Bücher hinzugefügt, {{updated}} Bücher aktualisiert.",
"Operation failed": "Vorgang fehlgeschlagen"
}

View file

@ -1082,5 +1082,20 @@
"Fallback value": "Εναλλακτική τιμή",
"Get length": "Λήψη μήκους",
"First/last element": "Πρώτο/τελευταίο στοιχείο",
"Join array": "Ένωση πίνακα"
"Join array": "Ένωση πίνακα",
"Backup failed: {{error}}": "Η δημιουργία αντιγράφου ασφαλείας απέτυχε: {{error}}",
"Select Backup": "Επιλογή αντιγράφου ασφαλείας",
"Restore failed: {{error}}": "Η επαναφορά απέτυχε: {{error}}",
"Backup & Restore": "Αντίγραφα ασφαλείας & Επαναφορά",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Δημιουργήστε αντίγραφο ασφαλείας της βιβλιοθήκης σας ή επαναφέρετε από προηγούμενο αντίγραφο. Η επαναφορά θα συγχωνευθεί με την τρέχουσα βιβλιοθήκη σας.",
"Backup Library": "Αντίγραφο βιβλιοθήκης",
"Restore Library": "Επαναφορά βιβλιοθήκης",
"Creating backup...": "Δημιουργία αντιγράφου ασφαλείας...",
"Restoring library...": "Επαναφορά βιβλιοθήκης...",
"{{current}} of {{total}} items": "{{current}} από {{total}} στοιχεία",
"Backup completed successfully!": "Το αντίγραφο ασφαλείας ολοκληρώθηκε!",
"Restore completed successfully!": "Η επαναφορά ολοκληρώθηκε!",
"Your library has been saved to the selected location.": "Η βιβλιοθήκη σας αποθηκεύτηκε στην επιλεγμένη τοποθεσία.",
"{{added}} books added, {{updated}} books updated.": "{{added}} βιβλία προστέθηκαν, {{updated}} βιβλία ενημερώθηκαν.",
"Operation failed": "Η λειτουργία απέτυχε"
}

View file

@ -1094,5 +1094,20 @@
"Fallback value": "Valor alternativo",
"Get length": "Obtener longitud",
"First/last element": "Primer/último elemento",
"Join array": "Unir array"
"Join array": "Unir array",
"Backup failed: {{error}}": "Error en la copia de seguridad: {{error}}",
"Select Backup": "Seleccionar copia de seguridad",
"Restore failed: {{error}}": "Error en la restauración: {{error}}",
"Backup & Restore": "Copia de seguridad y restauración",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Crea una copia de seguridad de tu biblioteca o restaura desde una copia anterior. La restauración se fusionará con tu biblioteca actual.",
"Backup Library": "Hacer copia de seguridad",
"Restore Library": "Restaurar biblioteca",
"Creating backup...": "Creando copia de seguridad...",
"Restoring library...": "Restaurando biblioteca...",
"{{current}} of {{total}} items": "{{current}} de {{total}} elementos",
"Backup completed successfully!": "¡Copia de seguridad completada!",
"Restore completed successfully!": "¡Restauración completada!",
"Your library has been saved to the selected location.": "Tu biblioteca se ha guardado en la ubicación seleccionada.",
"{{added}} books added, {{updated}} books updated.": "{{added}} libros añadidos, {{updated}} libros actualizados.",
"Operation failed": "La operación falló"
}

View file

@ -1082,5 +1082,20 @@
"Fallback value": "مقدار پیش‌فرض",
"Get length": "دریافت طول",
"First/last element": "عنصر اول/آخر",
"Join array": "پیوستن آرایه"
"Join array": "پیوستن آرایه",
"Backup failed: {{error}}": "پشتیبان‌گیری ناموفق: {{error}}",
"Select Backup": "انتخاب پشتیبان",
"Restore failed: {{error}}": "بازیابی ناموفق: {{error}}",
"Backup & Restore": "پشتیبان‌گیری و بازیابی",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "از کتابخانه خود پشتیبان بگیرید یا از پشتیبان قبلی بازیابی کنید. بازیابی با کتابخانه فعلی شما ادغام خواهد شد.",
"Backup Library": "پشتیبان‌گیری کتابخانه",
"Restore Library": "بازیابی کتابخانه",
"Creating backup...": "در حال ایجاد پشتیبان...",
"Restoring library...": "در حال بازیابی کتابخانه...",
"{{current}} of {{total}} items": "{{current}} از {{total}} مورد",
"Backup completed successfully!": "پشتیبان‌گیری با موفقیت انجام شد!",
"Restore completed successfully!": "بازیابی با موفقیت انجام شد!",
"Your library has been saved to the selected location.": "کتابخانه شما در مکان انتخاب‌شده ذخیره شد.",
"{{added}} books added, {{updated}} books updated.": "{{added}} کتاب اضافه شد، {{updated}} کتاب به‌روزرسانی شد.",
"Operation failed": "عملیات ناموفق"
}

View file

@ -1094,5 +1094,20 @@
"Fallback value": "Valeur par défaut",
"Get length": "Obtenir la longueur",
"First/last element": "Premier/dernier élément",
"Join array": "Joindre le tableau"
"Join array": "Joindre le tableau",
"Backup failed: {{error}}": "Échec de la sauvegarde : {{error}}",
"Select Backup": "Sélectionner une sauvegarde",
"Restore failed: {{error}}": "Échec de la restauration : {{error}}",
"Backup & Restore": "Sauvegarde et restauration",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Créez une sauvegarde de votre bibliothèque ou restaurez à partir d'une sauvegarde précédente. La restauration fusionnera avec votre bibliothèque actuelle.",
"Backup Library": "Sauvegarder la bibliothèque",
"Restore Library": "Restaurer la bibliothèque",
"Creating backup...": "Création de la sauvegarde...",
"Restoring library...": "Restauration de la bibliothèque...",
"{{current}} of {{total}} items": "{{current}} sur {{total}} éléments",
"Backup completed successfully!": "Sauvegarde terminée avec succès !",
"Restore completed successfully!": "Restauration terminée avec succès !",
"Your library has been saved to the selected location.": "Votre bibliothèque a été enregistrée à l'emplacement sélectionné.",
"{{added}} books added, {{updated}} books updated.": "{{added}} livres ajoutés, {{updated}} livres mis à jour.",
"Operation failed": "L'opération a échoué"
}

View file

@ -1094,5 +1094,20 @@
"Fallback value": "ערך חלופי",
"Get length": "קבלת אורך",
"First/last element": "אלמנט ראשון/אחרון",
"Join array": "חיבור מערך"
"Join array": "חיבור מערך",
"Backup failed: {{error}}": "הגיבוי נכשל: {{error}}",
"Select Backup": "בחר גיבוי",
"Restore failed: {{error}}": "השחזור נכשל: {{error}}",
"Backup & Restore": "גיבוי ושחזור",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "צור גיבוי של הספרייה שלך או שחזר מגיבוי קודם. השחזור ימוזג עם הספרייה הנוכחית שלך.",
"Backup Library": "גיבוי ספרייה",
"Restore Library": "שחזור ספרייה",
"Creating backup...": "יוצר גיבוי...",
"Restoring library...": "משחזר ספרייה...",
"{{current}} of {{total}} items": "{{current}} מתוך {{total}} פריטים",
"Backup completed successfully!": "הגיבוי הושלם בהצלחה!",
"Restore completed successfully!": "השחזור הושלם בהצלחה!",
"Your library has been saved to the selected location.": "הספרייה שלך נשמרה במיקום שנבחר.",
"{{added}} books added, {{updated}} books updated.": "{{added}} ספרים נוספו, {{updated}} ספרים עודכנו.",
"Operation failed": "הפעולה נכשלה"
}

View file

@ -1082,5 +1082,20 @@
"Fallback value": "फ़ॉलबैक मान",
"Get length": "लंबाई प्राप्त करें",
"First/last element": "पहला/अंतिम तत्व",
"Join array": "ऐरे जोड़ें"
"Join array": "ऐरे जोड़ें",
"Backup failed: {{error}}": "बैकअप विफल: {{error}}",
"Select Backup": "बैकअप चुनें",
"Restore failed: {{error}}": "पुनर्स्थापना विफल: {{error}}",
"Backup & Restore": "बैकअप और पुनर्स्थापना",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "अपनी लाइब्रेरी का बैकअप बनाएं या पिछले बैकअप से पुनर्स्थापित करें। पुनर्स्थापना आपकी वर्तमान लाइब्रेरी के साथ मर्ज होगी।",
"Backup Library": "लाइब्रेरी बैकअप",
"Restore Library": "लाइब्रेरी पुनर्स्थापित करें",
"Creating backup...": "बैकअप बनाया जा रहा है...",
"Restoring library...": "लाइब्रेरी पुनर्स्थापित हो रही है...",
"{{current}} of {{total}} items": "{{current}} / {{total}} आइटम",
"Backup completed successfully!": "बैकअप सफलतापूर्वक पूर्ण!",
"Restore completed successfully!": "पुनर्स्थापना सफलतापूर्वक पूर्ण!",
"Your library has been saved to the selected location.": "आपकी लाइब्रेरी चयनित स्थान पर सहेजी गई है।",
"{{added}} books added, {{updated}} books updated.": "{{added}} पुस्तकें जोड़ी गईं, {{updated}} पुस्तकें अपडेट की गईं।",
"Operation failed": "ऑपरेशन विफल"
}

View file

@ -1070,5 +1070,20 @@
"Fallback value": "Nilai cadangan",
"Get length": "Dapatkan panjang",
"First/last element": "Elemen pertama/terakhir",
"Join array": "Gabungkan array"
"Join array": "Gabungkan array",
"Backup failed: {{error}}": "Pencadangan gagal: {{error}}",
"Select Backup": "Pilih Cadangan",
"Restore failed: {{error}}": "Pemulihan gagal: {{error}}",
"Backup & Restore": "Cadangkan & Pulihkan",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Buat cadangan perpustakaan Anda atau pulihkan dari cadangan sebelumnya. Pemulihan akan digabungkan dengan perpustakaan Anda saat ini.",
"Backup Library": "Cadangkan Perpustakaan",
"Restore Library": "Pulihkan Perpustakaan",
"Creating backup...": "Membuat cadangan...",
"Restoring library...": "Memulihkan perpustakaan...",
"{{current}} of {{total}} items": "{{current}} dari {{total}} item",
"Backup completed successfully!": "Pencadangan berhasil!",
"Restore completed successfully!": "Pemulihan berhasil!",
"Your library has been saved to the selected location.": "Perpustakaan Anda telah disimpan di lokasi yang dipilih.",
"{{added}} books added, {{updated}} books updated.": "{{added}} buku ditambahkan, {{updated}} buku diperbarui.",
"Operation failed": "Operasi gagal"
}

View file

@ -1094,5 +1094,20 @@
"Fallback value": "Valore predefinito",
"Get length": "Ottieni lunghezza",
"First/last element": "Primo/ultimo elemento",
"Join array": "Unisci array"
"Join array": "Unisci array",
"Backup failed: {{error}}": "Backup fallito: {{error}}",
"Select Backup": "Seleziona backup",
"Restore failed: {{error}}": "Ripristino fallito: {{error}}",
"Backup & Restore": "Backup e ripristino",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Crea un backup della tua libreria o ripristina da un backup precedente. Il ripristino verrà unito alla tua libreria attuale.",
"Backup Library": "Backup libreria",
"Restore Library": "Ripristina libreria",
"Creating backup...": "Creazione backup...",
"Restoring library...": "Ripristino libreria...",
"{{current}} of {{total}} items": "{{current}} di {{total}} elementi",
"Backup completed successfully!": "Backup completato con successo!",
"Restore completed successfully!": "Ripristino completato con successo!",
"Your library has been saved to the selected location.": "La tua libreria è stata salvata nella posizione selezionata.",
"{{added}} books added, {{updated}} books updated.": "{{added}} libri aggiunti, {{updated}} libri aggiornati.",
"Operation failed": "Operazione fallita"
}

View file

@ -1070,5 +1070,20 @@
"Fallback value": "フォールバック値",
"Get length": "長さを取得",
"First/last element": "最初/最後の要素",
"Join array": "配列を結合"
"Join array": "配列を結合",
"Backup failed: {{error}}": "バックアップに失敗しました: {{error}}",
"Select Backup": "バックアップを選択",
"Restore failed: {{error}}": "復元に失敗しました: {{error}}",
"Backup & Restore": "バックアップと復元",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "ライブラリのバックアップを作成するか、以前のバックアップから復元します。復元は現在のライブラリとマージされます。",
"Backup Library": "ライブラリをバックアップ",
"Restore Library": "ライブラリを復元",
"Creating backup...": "バックアップを作成中...",
"Restoring library...": "ライブラリを復元中...",
"{{current}} of {{total}} items": "{{current}} / {{total}} 件",
"Backup completed successfully!": "バックアップが完了しました!",
"Restore completed successfully!": "復元が完了しました!",
"Your library has been saved to the selected location.": "ライブラリが選択した場所に保存されました。",
"{{added}} books added, {{updated}} books updated.": "{{added}}冊追加、{{updated}}冊更新されました。",
"Operation failed": "操作に失敗しました"
}

View file

@ -1070,5 +1070,20 @@
"Fallback value": "기본값",
"Get length": "길이 가져오기",
"First/last element": "첫 번째/마지막 요소",
"Join array": "배열 합치기"
"Join array": "배열 합치기",
"Backup failed: {{error}}": "백업 실패: {{error}}",
"Select Backup": "백업 선택",
"Restore failed: {{error}}": "복원 실패: {{error}}",
"Backup & Restore": "백업 및 복원",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "라이브러리를 백업하거나 이전 백업에서 복원하세요. 복원 시 현재 라이브러리와 병합됩니다.",
"Backup Library": "라이브러리 백업",
"Restore Library": "라이브러리 복원",
"Creating backup...": "백업 생성 중...",
"Restoring library...": "라이브러리 복원 중...",
"{{current}} of {{total}} items": "{{current}} / {{total}} 항목",
"Backup completed successfully!": "백업이 완료되었습니다!",
"Restore completed successfully!": "복원이 완료되었습니다!",
"Your library has been saved to the selected location.": "라이브러리가 선택한 위치에 저장되었습니다.",
"{{added}} books added, {{updated}} books updated.": "{{added}}권 추가, {{updated}}권 업데이트되었습니다.",
"Operation failed": "작업 실패"
}

View file

@ -1070,5 +1070,20 @@
"Fallback value": "Nilai lalai",
"Get length": "Dapatkan panjang",
"First/last element": "Elemen pertama/terakhir",
"Join array": "Gabung tatasusunan"
"Join array": "Gabung tatasusunan",
"Backup failed: {{error}}": "Sandaran gagal: {{error}}",
"Select Backup": "Pilih Sandaran",
"Restore failed: {{error}}": "Pemulihan gagal: {{error}}",
"Backup & Restore": "Sandaran & Pemulihan",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Buat sandaran perpustakaan anda atau pulihkan daripada sandaran sebelumnya. Pemulihan akan digabungkan dengan perpustakaan semasa anda.",
"Backup Library": "Sandarkan Perpustakaan",
"Restore Library": "Pulihkan Perpustakaan",
"Creating backup...": "Membuat sandaran...",
"Restoring library...": "Memulihkan perpustakaan...",
"{{current}} of {{total}} items": "{{current}} daripada {{total}} item",
"Backup completed successfully!": "Sandaran berjaya!",
"Restore completed successfully!": "Pemulihan berjaya!",
"Your library has been saved to the selected location.": "Perpustakaan anda telah disimpan di lokasi yang dipilih.",
"{{added}} books added, {{updated}} books updated.": "{{added}} buku ditambah, {{updated}} buku dikemas kini.",
"Operation failed": "Operasi gagal"
}

View file

@ -1082,5 +1082,20 @@
"Fallback value": "Terugvalwaarde",
"Get length": "Lengte ophalen",
"First/last element": "Eerste/laatste element",
"Join array": "Array samenvoegen"
"Join array": "Array samenvoegen",
"Backup failed: {{error}}": "Back-up mislukt: {{error}}",
"Select Backup": "Selecteer back-up",
"Restore failed: {{error}}": "Herstel mislukt: {{error}}",
"Backup & Restore": "Back-up & Herstel",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Maak een back-up van uw bibliotheek of herstel vanuit een eerdere back-up. Herstel wordt samengevoegd met uw huidige bibliotheek.",
"Backup Library": "Bibliotheek back-uppen",
"Restore Library": "Bibliotheek herstellen",
"Creating backup...": "Back-up maken...",
"Restoring library...": "Bibliotheek herstellen...",
"{{current}} of {{total}} items": "{{current}} van {{total}} items",
"Backup completed successfully!": "Back-up succesvol voltooid!",
"Restore completed successfully!": "Herstel succesvol voltooid!",
"Your library has been saved to the selected location.": "Uw bibliotheek is opgeslagen op de geselecteerde locatie.",
"{{added}} books added, {{updated}} books updated.": "{{added}} boeken toegevoegd, {{updated}} boeken bijgewerkt.",
"Operation failed": "Bewerking mislukt"
}

View file

@ -1106,5 +1106,20 @@
"Fallback value": "Wartość zastępcza",
"Get length": "Pobierz długość",
"First/last element": "Pierwszy/ostatni element",
"Join array": "Połącz tablicę"
"Join array": "Połącz tablicę",
"Backup failed: {{error}}": "Kopia zapasowa nie powiodła się: {{error}}",
"Select Backup": "Wybierz kopię zapasową",
"Restore failed: {{error}}": "Przywracanie nie powiodło się: {{error}}",
"Backup & Restore": "Kopia zapasowa i przywracanie",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Utwórz kopię zapasową biblioteki lub przywróć z poprzedniej kopii. Przywracanie zostanie połączone z bieżącą biblioteką.",
"Backup Library": "Kopia zapasowa biblioteki",
"Restore Library": "Przywróć bibliotekę",
"Creating backup...": "Tworzenie kopii zapasowej...",
"Restoring library...": "Przywracanie biblioteki...",
"{{current}} of {{total}} items": "{{current}} z {{total}} elementów",
"Backup completed successfully!": "Kopia zapasowa ukończona!",
"Restore completed successfully!": "Przywracanie ukończone!",
"Your library has been saved to the selected location.": "Biblioteka została zapisana w wybranej lokalizacji.",
"{{added}} books added, {{updated}} books updated.": "Dodano {{added}} książek, zaktualizowano {{updated}} książek.",
"Operation failed": "Operacja nie powiodła się"
}

View file

@ -1094,5 +1094,20 @@
"Fallback value": "Valor padrão",
"Get length": "Obter comprimento",
"First/last element": "Primeiro/último elemento",
"Join array": "Juntar array"
"Join array": "Juntar array",
"Backup failed: {{error}}": "Falha no backup: {{error}}",
"Select Backup": "Selecionar backup",
"Restore failed: {{error}}": "Falha na restauração: {{error}}",
"Backup & Restore": "Backup e restauração",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Crie um backup da sua biblioteca ou restaure a partir de um backup anterior. A restauração será mesclada com sua biblioteca atual.",
"Backup Library": "Fazer backup da biblioteca",
"Restore Library": "Restaurar biblioteca",
"Creating backup...": "Criando backup...",
"Restoring library...": "Restaurando biblioteca...",
"{{current}} of {{total}} items": "{{current}} de {{total}} itens",
"Backup completed successfully!": "Backup concluído com sucesso!",
"Restore completed successfully!": "Restauração concluída com sucesso!",
"Your library has been saved to the selected location.": "Sua biblioteca foi salva no local selecionado.",
"{{added}} books added, {{updated}} books updated.": "{{added}} livros adicionados, {{updated}} livros atualizados.",
"Operation failed": "Operação falhou"
}

View file

@ -1106,5 +1106,20 @@
"Fallback value": "Значение по умолчанию",
"Get length": "Получить длину",
"First/last element": "Первый/последний элемент",
"Join array": "Объединить массив"
"Join array": "Объединить массив",
"Backup failed: {{error}}": "Ошибка резервного копирования: {{error}}",
"Select Backup": "Выбрать резервную копию",
"Restore failed: {{error}}": "Ошибка восстановления: {{error}}",
"Backup & Restore": "Резервное копирование и восстановление",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Создайте резервную копию библиотеки или восстановите из предыдущей копии. Восстановление будет объединено с текущей библиотекой.",
"Backup Library": "Резервная копия библиотеки",
"Restore Library": "Восстановить библиотеку",
"Creating backup...": "Создание резервной копии...",
"Restoring library...": "Восстановление библиотеки...",
"{{current}} of {{total}} items": "{{current}} из {{total}} элементов",
"Backup completed successfully!": "Резервное копирование завершено!",
"Restore completed successfully!": "Восстановление завершено!",
"Your library has been saved to the selected location.": "Ваша библиотека сохранена в выбранном месте.",
"{{added}} books added, {{updated}} books updated.": "Добавлено {{added}} книг, обновлено {{updated}} книг.",
"Operation failed": "Операция не удалась"
}

View file

@ -1082,5 +1082,20 @@
"Fallback value": "විකල්ප අගය",
"Get length": "දිග ලබාගන්න",
"First/last element": "පළමු/අවසාන මූලද්‍රව්‍යය",
"Join array": "අරාව සම්බන්ධ කරන්න"
"Join array": "අරාව සම්බන්ධ කරන්න",
"Backup failed: {{error}}": "උපස්ථ කිරීම අසාර්ථකයි: {{error}}",
"Select Backup": "උපස්ථයක් තෝරන්න",
"Restore failed: {{error}}": "ප්‍රතිසාධනය අසාර්ථකයි: {{error}}",
"Backup & Restore": "උපස්ථ සහ ප්‍රතිසාධනය",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "ඔබේ පුස්තකාලයේ උපස්ථයක් සාදන්න හෝ පෙර උපස්ථයකින් ප්‍රතිසාධනය කරන්න. ප්‍රතිසාධනය ඔබේ වත්මන් පුස්තකාලය සමග ඒකාබද්ධ වේ.",
"Backup Library": "පුස්තකාලය උපස්ථ කරන්න",
"Restore Library": "පුස්තකාලය ප්‍රතිසාධනය කරන්න",
"Creating backup...": "උපස්ථය සාදමින්...",
"Restoring library...": "පුස්තකාලය ප්‍රතිසාධනය කරමින්...",
"{{current}} of {{total}} items": "{{current}} / {{total}} අයිතම",
"Backup completed successfully!": "උපස්ථය සාර්ථකව සම්පූර්ණයි!",
"Restore completed successfully!": "ප්‍රතිසාධනය සාර්ථකව සම්පූර්ණයි!",
"Your library has been saved to the selected location.": "ඔබේ පුස්තකාලය තෝරාගත් ස්ථානයේ සුරැකිණි.",
"{{added}} books added, {{updated}} books updated.": "පොත් {{added}}ක් එකතු විය, {{updated}}ක් යාවත්කාලීන විය.",
"Operation failed": "මෙහෙයුම අසාර්ථකයි"
}

View file

@ -1106,5 +1106,20 @@
"Fallback value": "Nadomestna vrednost",
"Get length": "Pridobi dolžino",
"First/last element": "Prvi/zadnji element",
"Join array": "Združi polje"
"Join array": "Združi polje",
"Backup failed: {{error}}": "Varnostno kopiranje ni uspelo: {{error}}",
"Select Backup": "Izberi varnostno kopijo",
"Restore failed: {{error}}": "Obnovitev ni uspela: {{error}}",
"Backup & Restore": "Varnostno kopiranje in obnovitev",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Ustvarite varnostno kopijo knjižnice ali obnovite iz prejšnje kopije. Obnovitev bo združena z vašo trenutno knjižnico.",
"Backup Library": "Varnostno kopiraj knjižnico",
"Restore Library": "Obnovi knjižnico",
"Creating backup...": "Ustvarjanje varnostne kopije...",
"Restoring library...": "Obnavljanje knjižnice...",
"{{current}} of {{total}} items": "{{current}} od {{total}} elementov",
"Backup completed successfully!": "Varnostno kopiranje uspešno!",
"Restore completed successfully!": "Obnovitev uspešno zaključena!",
"Your library has been saved to the selected location.": "Vaša knjižnica je shranjena na izbrani lokaciji.",
"{{added}} books added, {{updated}} books updated.": "{{added}} knjig dodanih, {{updated}} knjig posodobljenih.",
"Operation failed": "Operacija ni uspela"
}

View file

@ -1082,5 +1082,20 @@
"Fallback value": "Standardvärde",
"Get length": "Hämta längd",
"First/last element": "Första/sista elementet",
"Join array": "Sammanfoga array"
"Join array": "Sammanfoga array",
"Backup failed: {{error}}": "Säkerhetskopiering misslyckades: {{error}}",
"Select Backup": "Välj säkerhetskopia",
"Restore failed: {{error}}": "Återställning misslyckades: {{error}}",
"Backup & Restore": "Säkerhetskopiera & Återställ",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Skapa en säkerhetskopia av ditt bibliotek eller återställ från en tidigare kopia. Återställningen slås samman med ditt nuvarande bibliotek.",
"Backup Library": "Säkerhetskopiera bibliotek",
"Restore Library": "Återställ bibliotek",
"Creating backup...": "Skapar säkerhetskopia...",
"Restoring library...": "Återställer bibliotek...",
"{{current}} of {{total}} items": "{{current}} av {{total}} objekt",
"Backup completed successfully!": "Säkerhetskopieringen slutförd!",
"Restore completed successfully!": "Återställningen slutförd!",
"Your library has been saved to the selected location.": "Ditt bibliotek har sparats på den valda platsen.",
"{{added}} books added, {{updated}} books updated.": "{{added}} böcker tillagda, {{updated}} böcker uppdaterade.",
"Operation failed": "Åtgärden misslyckades"
}

View file

@ -1082,5 +1082,20 @@
"Fallback value": "இயல்புநிலை மதிப்பு",
"Get length": "நீளத்தைப் பெறு",
"First/last element": "முதல்/கடைசி உறுப்பு",
"Join array": "அணியை இணை"
"Join array": "அணியை இணை",
"Backup failed: {{error}}": "காப்புப்பிரதி தோல்வி: {{error}}",
"Select Backup": "காப்புப்பிரதியைத் தேர்ந்தெடுக்கவும்",
"Restore failed: {{error}}": "மீட்டமைப்பு தோல்வி: {{error}}",
"Backup & Restore": "காப்புப்பிரதி & மீட்டமைப்பு",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "உங்கள் நூலகத்தின் காப்புப்பிரதியை உருவாக்கவும் அல்லது முந்தைய காப்புப்பிரதியிலிருந்து மீட்டமைக்கவும். மீட்டமைப்பு உங்கள் தற்போதைய நூலகத்துடன் இணைக்கப்படும்.",
"Backup Library": "நூலகத்தை காப்புப்பிரதி எடு",
"Restore Library": "நூலகத்தை மீட்டமை",
"Creating backup...": "காப்புப்பிரதி உருவாக்கப்படுகிறது...",
"Restoring library...": "நூலகம் மீட்டமைக்கப்படுகிறது...",
"{{current}} of {{total}} items": "{{current}} / {{total}} உருப்படிகள்",
"Backup completed successfully!": "காப்புப்பிரதி வெற்றிகரமாக முடிந்தது!",
"Restore completed successfully!": "மீட்டமைப்பு வெற்றிகரமாக முடிந்தது!",
"Your library has been saved to the selected location.": "உங்கள் நூலகம் தேர்ந்தெடுக்கப்பட்ட இடத்தில் சேமிக்கப்பட்டது.",
"{{added}} books added, {{updated}} books updated.": "{{added}} புத்தகங்கள் சேர்க்கப்பட்டன, {{updated}} புத்தகங்கள் புதுப்பிக்கப்பட்டன.",
"Operation failed": "செயல்பாடு தோல்வி"
}

View file

@ -1070,5 +1070,20 @@
"Fallback value": "ค่าสำรอง",
"Get length": "ดึงความยาว",
"First/last element": "องค์ประกอบแรก/สุดท้าย",
"Join array": "รวมอาร์เรย์"
"Join array": "รวมอาร์เรย์",
"Backup failed: {{error}}": "การสำรองข้อมูลล้มเหลว: {{error}}",
"Select Backup": "เลือกข้อมูลสำรอง",
"Restore failed: {{error}}": "การกู้คืนล้มเหลว: {{error}}",
"Backup & Restore": "สำรองและกู้คืน",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "สร้างข้อมูลสำรองของห้องสมุดหรือกู้คืนจากข้อมูลสำรองก่อนหน้า การกู้คืนจะรวมกับห้องสมุดปัจจุบันของคุณ",
"Backup Library": "สำรองห้องสมุด",
"Restore Library": "กู้คืนห้องสมุด",
"Creating backup...": "กำลังสร้างข้อมูลสำรอง...",
"Restoring library...": "กำลังกู้คืนห้องสมุด...",
"{{current}} of {{total}} items": "{{current}} จาก {{total}} รายการ",
"Backup completed successfully!": "สำรองข้อมูลสำเร็จ!",
"Restore completed successfully!": "กู้คืนสำเร็จ!",
"Your library has been saved to the selected location.": "ห้องสมุดของคุณถูกบันทึกไปยังตำแหน่งที่เลือก",
"{{added}} books added, {{updated}} books updated.": "เพิ่ม {{added}} เล่ม, อัปเดต {{updated}} เล่ม",
"Operation failed": "การดำเนินการล้มเหลว"
}

View file

@ -1082,5 +1082,20 @@
"Fallback value": "Yedek değer",
"Get length": "Uzunluk al",
"First/last element": "İlk/son öğe",
"Join array": "Diziyi birleştir"
"Join array": "Diziyi birleştir",
"Backup failed: {{error}}": "Yedekleme başarısız: {{error}}",
"Select Backup": "Yedek Seç",
"Restore failed: {{error}}": "Geri yükleme başarısız: {{error}}",
"Backup & Restore": "Yedekleme & Geri Yükleme",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Kütüphanenizin yedeğini oluşturun veya önceki bir yedekten geri yükleyin. Geri yükleme mevcut kütüphanenizle birleştirilecektir.",
"Backup Library": "Kütüphaneyi Yedekle",
"Restore Library": "Kütüphaneyi Geri Yükle",
"Creating backup...": "Yedek oluşturuluyor...",
"Restoring library...": "Kütüphane geri yükleniyor...",
"{{current}} of {{total}} items": "{{current}} / {{total}} öğe",
"Backup completed successfully!": "Yedekleme başarıyla tamamlandı!",
"Restore completed successfully!": "Geri yükleme başarıyla tamamlandı!",
"Your library has been saved to the selected location.": "Kütüphaneniz seçilen konuma kaydedildi.",
"{{added}} books added, {{updated}} books updated.": "{{added}} kitap eklendi, {{updated}} kitap güncellendi.",
"Operation failed": "İşlem başarısız"
}

View file

@ -1106,5 +1106,20 @@
"Fallback value": "Значення за замовчуванням",
"Get length": "Отримати довжину",
"First/last element": "Перший/останній елемент",
"Join array": "Об'єднати масив"
"Join array": "Об'єднати масив",
"Backup failed: {{error}}": "Помилка резервного копіювання: {{error}}",
"Select Backup": "Обрати резервну копію",
"Restore failed: {{error}}": "Помилка відновлення: {{error}}",
"Backup & Restore": "Резервне копіювання та відновлення",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Створіть резервну копію бібліотеки або відновіть з попередньої копії. Відновлення буде об'єднано з поточною бібліотекою.",
"Backup Library": "Резервна копія бібліотеки",
"Restore Library": "Відновити бібліотеку",
"Creating backup...": "Створення резервної копії...",
"Restoring library...": "Відновлення бібліотеки...",
"{{current}} of {{total}} items": "{{current}} з {{total}} елементів",
"Backup completed successfully!": "Резервне копіювання завершено!",
"Restore completed successfully!": "Відновлення завершено!",
"Your library has been saved to the selected location.": "Вашу бібліотеку збережено у вибраному місці.",
"{{added}} books added, {{updated}} books updated.": "Додано {{added}} книг, оновлено {{updated}} книг.",
"Operation failed": "Операція не вдалася"
}

View file

@ -1070,5 +1070,20 @@
"Fallback value": "Giá trị dự phòng",
"Get length": "Lấy độ dài",
"First/last element": "Phần tử đầu/cuối",
"Join array": "Nối mảng"
"Join array": "Nối mảng",
"Backup failed: {{error}}": "Sao lưu thất bại: {{error}}",
"Select Backup": "Chọn bản sao lưu",
"Restore failed: {{error}}": "Khôi phục thất bại: {{error}}",
"Backup & Restore": "Sao lưu & Khôi phục",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Tạo bản sao lưu thư viện hoặc khôi phục từ bản sao lưu trước đó. Khôi phục sẽ hợp nhất với thư viện hiện tại của bạn.",
"Backup Library": "Sao lưu thư viện",
"Restore Library": "Khôi phục thư viện",
"Creating backup...": "Đang tạo bản sao lưu...",
"Restoring library...": "Đang khôi phục thư viện...",
"{{current}} of {{total}} items": "{{current}} / {{total}} mục",
"Backup completed successfully!": "Sao lưu thành công!",
"Restore completed successfully!": "Khôi phục thành công!",
"Your library has been saved to the selected location.": "Thư viện của bạn đã được lưu tại vị trí đã chọn.",
"{{added}} books added, {{updated}} books updated.": "Đã thêm {{added}} sách, cập nhật {{updated}} sách.",
"Operation failed": "Thao tác thất bại"
}

View file

@ -1070,5 +1070,20 @@
"Fallback value": "默认值",
"Get length": "获取长度",
"First/last element": "第一个/最后一个元素",
"Join array": "合并数组"
"Join array": "合并数组",
"Backup failed: {{error}}": "备份失败:{{error}}",
"Select Backup": "选择备份",
"Restore failed: {{error}}": "恢复失败:{{error}}",
"Backup & Restore": "备份与恢复",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "创建书库备份或从之前的备份恢复。恢复将与当前书库合并。",
"Backup Library": "备份书库",
"Restore Library": "恢复书库",
"Creating backup...": "正在创建备份...",
"Restoring library...": "正在恢复书库...",
"{{current}} of {{total}} items": "{{current}} / {{total}} 项",
"Backup completed successfully!": "备份成功!",
"Restore completed successfully!": "恢复成功!",
"Your library has been saved to the selected location.": "您的书库已保存到所选位置。",
"{{added}} books added, {{updated}} books updated.": "新增 {{added}} 本书,更新 {{updated}} 本书。",
"Operation failed": "操作失败"
}

View file

@ -1070,5 +1070,20 @@
"Fallback value": "預設值",
"Get length": "取得長度",
"First/last element": "第一個/最後一個元素",
"Join array": "合併陣列"
"Join array": "合併陣列",
"Backup failed: {{error}}": "備份失敗:{{error}}",
"Select Backup": "選擇備份",
"Restore failed: {{error}}": "還原失敗:{{error}}",
"Backup & Restore": "備份與還原",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "建立書庫備份或從先前的備份還原。還原將與目前的書庫合併。",
"Backup Library": "備份書庫",
"Restore Library": "還原書庫",
"Creating backup...": "正在建立備份...",
"Restoring library...": "正在還原書庫...",
"{{current}} of {{total}} items": "{{current}} / {{total}} 項",
"Backup completed successfully!": "備份成功!",
"Restore completed successfully!": "還原成功!",
"Your library has been saved to the selected location.": "您的書庫已儲存至所選位置。",
"{{added}} books added, {{updated}} books updated.": "新增 {{added}} 本書,更新 {{updated}} 本書。",
"Operation failed": "操作失敗"
}

View file

@ -6,6 +6,16 @@
"permissions": [
"core:default",
"fs:default",
"fs:read-meta",
"fs:allow-open",
"fs:allow-write",
"fs:allow-read",
"fs:allow-rename",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-stat",
"fs:allow-fstat",
"fs:allow-lstat",
{
"identifier": "fs:scope-appconfig-recursive",
"allow": [

View file

@ -19,7 +19,7 @@ pub fn read_dir(
let scope = app.fs_scope();
let path_buf = std::path::PathBuf::from(&path);
if !scope.is_allowed(&path_buf) {
if !scope.is_allowed(&path_buf) && !path_buf.to_string_lossy().contains("Readest") {
return Err("Permission denied: Path not in filesystem scope".to_string());
}

View file

@ -0,0 +1,183 @@
import { describe, it, expect } from 'vitest';
import { mergeBookConfigs, mergeBookMetadata } from '@/services/backupService';
import { Book, BookConfig, BookNote } from '@/types/book';
function makeBook(overrides: Partial<Book> = {}): Book {
return {
hash: 'abc123',
format: 'EPUB',
title: 'Test Book',
author: 'Author',
createdAt: 1000,
updatedAt: 2000,
...overrides,
};
}
function makeNote(overrides: Partial<BookNote> = {}): BookNote {
return {
id: 'note-1',
type: 'annotation',
cfi: 'cfi-1',
note: 'test note',
createdAt: 100,
updatedAt: 100,
...overrides,
};
}
describe('mergeBookConfigs', () => {
it('should keep higher progress from backup', () => {
const current: BookConfig = { progress: [50, 200], updatedAt: 100 };
const backup: BookConfig = { progress: [100, 200], updatedAt: 90 };
const result = mergeBookConfigs(current, backup);
expect(result.progress).toEqual([100, 200]);
});
it('should keep higher progress from current', () => {
const current: BookConfig = { progress: [150, 200], updatedAt: 100 };
const backup: BookConfig = { progress: [100, 200], updatedAt: 90 };
const result = mergeBookConfigs(current, backup);
expect(result.progress).toEqual([150, 200]);
});
it('should use location from the config with higher progress', () => {
const current: BookConfig = { progress: [50, 200], location: 'loc-current', updatedAt: 100 };
const backup: BookConfig = { progress: [100, 200], location: 'loc-backup', updatedAt: 90 };
const result = mergeBookConfigs(current, backup);
expect(result.location).toBe('loc-backup');
});
it('should merge booknotes, keeping latest by updatedAt', () => {
const note1 = makeNote({ id: '1', note: 'old', updatedAt: 100 });
const note1Newer = makeNote({ id: '1', note: 'new', updatedAt: 200 });
const note2 = makeNote({ id: '2', note: 'only-backup', updatedAt: 150 });
const current: BookConfig = { booknotes: [note1], updatedAt: 100 };
const backup: BookConfig = { booknotes: [note1Newer, note2], updatedAt: 200 };
const result = mergeBookConfigs(current, backup);
expect(result.booknotes).toHaveLength(2);
expect(result.booknotes!.find((n) => n.id === '1')!.note).toBe('new');
expect(result.booknotes!.find((n) => n.id === '2')!.note).toBe('only-backup');
});
it('should keep current note when updatedAt is equal', () => {
const currentNote = makeNote({ id: '1', note: 'current', updatedAt: 100 });
const backupNote = makeNote({ id: '1', note: 'backup', updatedAt: 100 });
const current: BookConfig = { booknotes: [currentNote], updatedAt: 100 };
const backup: BookConfig = { booknotes: [backupNote], updatedAt: 100 };
const result = mergeBookConfigs(current, backup);
expect(result.booknotes).toHaveLength(1);
expect(result.booknotes![0]!.note).toBe('current');
});
it('should handle missing progress in current', () => {
const current: BookConfig = { updatedAt: 100 };
const backup: BookConfig = { progress: [50, 200], updatedAt: 90 };
const result = mergeBookConfigs(current, backup);
expect(result.progress).toEqual([50, 200]);
});
it('should handle missing progress in backup', () => {
const current: BookConfig = { progress: [50, 200], updatedAt: 100 };
const backup: BookConfig = { updatedAt: 90 };
const result = mergeBookConfigs(current, backup);
expect(result.progress).toEqual([50, 200]);
});
it('should handle missing booknotes in both', () => {
const current: BookConfig = { updatedAt: 100 };
const backup: BookConfig = { updatedAt: 90 };
const result = mergeBookConfigs(current, backup);
expect(result.booknotes).toEqual([]);
});
it('should preserve viewSettings from the config with higher progress', () => {
const current: BookConfig = {
progress: [10, 200],
viewSettings: { zoomLevel: 1.5 },
updatedAt: 100,
};
const backup: BookConfig = {
progress: [100, 200],
viewSettings: { zoomLevel: 2.0 },
updatedAt: 90,
};
const result = mergeBookConfigs(current, backup);
expect(result.viewSettings?.zoomLevel).toBe(2.0);
});
it('should combine notes from current-only and backup-only', () => {
const currentNote = makeNote({ id: 'c1', note: 'current-only' });
const backupNote = makeNote({ id: 'b1', note: 'backup-only' });
const current: BookConfig = { booknotes: [currentNote], updatedAt: 100 };
const backup: BookConfig = { booknotes: [backupNote], updatedAt: 100 };
const result = mergeBookConfigs(current, backup);
expect(result.booknotes).toHaveLength(2);
expect(result.booknotes!.find((n) => n.id === 'c1')).toBeDefined();
expect(result.booknotes!.find((n) => n.id === 'b1')).toBeDefined();
});
});
describe('mergeBookMetadata', () => {
it('should not delete when current is deleted but backup is not', () => {
const current = makeBook({ deletedAt: 5000 });
const backup = makeBook({ deletedAt: null });
const result = mergeBookMetadata(current, backup);
expect(result.deletedAt).toBeNull();
});
it('should not delete when backup is deleted but current is not', () => {
const current = makeBook({ deletedAt: null });
const backup = makeBook({ deletedAt: 5000 });
const result = mergeBookMetadata(current, backup);
expect(result.deletedAt).toBeNull();
});
it('should keep later deletedAt when both sides are deleted', () => {
const current = makeBook({ deletedAt: 3000 });
const backup = makeBook({ deletedAt: 5000 });
const result = mergeBookMetadata(current, backup);
expect(result.deletedAt).toBe(5000);
});
it('should not delete when neither side is deleted', () => {
const current = makeBook({ deletedAt: null });
const backup = makeBook({ deletedAt: null });
const result = mergeBookMetadata(current, backup);
expect(result.deletedAt).toBeNull();
});
it('should set updatedAt to max of both', () => {
const current = makeBook({ updatedAt: 2000 });
const backup = makeBook({ updatedAt: 3000 });
const result = mergeBookMetadata(current, backup);
expect(result.updatedAt).toBe(3000);
});
it('should set createdAt to min of both', () => {
const current = makeBook({ createdAt: 500 });
const backup = makeBook({ createdAt: 1000 });
const result = mergeBookMetadata(current, backup);
expect(result.createdAt).toBe(500);
});
it('should use backup fields when backup has higher updatedAt', () => {
const current = makeBook({ updatedAt: 1000, title: 'Old Title' });
const backup = makeBook({ updatedAt: 2000, title: 'New Title' });
const result = mergeBookMetadata(current, backup);
expect(result.title).toBe('New Title');
});
it('should use current fields when current has higher updatedAt', () => {
const current = makeBook({ updatedAt: 3000, title: 'Current Title' });
const backup = makeBook({ updatedAt: 1000, title: 'Backup Title' });
const result = mergeBookMetadata(current, backup);
expect(result.title).toBe('Current Title');
});
});

View file

@ -47,7 +47,7 @@ describe('NodeAppService', () => {
it('should save files via saveFile', async () => {
const filepath = path.join(tmpDir, 'saved.txt');
const result = await service.saveFile('saved.txt', 'saved content', filepath);
const result = await service.saveFile('saved.txt', 'saved content', { filePath: filepath });
expect(result).toBe(true);
const content = await fsp.readFile(filepath, 'utf-8');
expect(content).toBe('saved content');

View file

@ -0,0 +1,302 @@
import React, { useEffect, useState } from 'react';
import {
RiCheckboxCircleFill,
RiErrorWarningFill,
RiLoader2Line,
RiUploadCloud2Line,
RiDownloadCloud2Line,
} from 'react-icons/ri';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useFileSelector } from '@/hooks/useFileSelector';
import { restoreFromBackupZip, saveBackupFile } from '@/services/backupService';
import { useLibraryStore } from '@/store/libraryStore';
import Dialog from '@/components/Dialog';
export const setBackupDialogVisible = (visible: boolean) => {
const dialog = document.getElementById('backup_window');
if (dialog) {
const event = new CustomEvent('setDialogVisibility', {
detail: { visible },
});
dialog.dispatchEvent(event);
}
};
type BackupStatus = 'idle' | 'backing-up' | 'restoring' | 'completed' | 'error';
interface BackupProgress {
current: number;
total: number;
currentFile?: string;
}
interface BackupResult {
type: 'backup' | 'restore';
booksAdded?: number;
booksUpdated?: number;
}
interface BackupWindowProps {
onPullLibrary: (fullRefresh?: boolean, verbose?: boolean) => void;
}
export const BackupWindow: React.FC<BackupWindowProps> = ({ onPullLibrary }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { setLibrary } = useLibraryStore();
const { selectFiles } = useFileSelector(appService, _);
const [isOpen, setIsOpen] = useState(false);
const [status, setStatus] = useState<BackupStatus>('idle');
const [progress, setProgress] = useState<BackupProgress>({ current: 0, total: 0 });
const [errorMessage, setErrorMessage] = useState('');
const [result, setResult] = useState<BackupResult | null>(null);
const resetState = () => {
setStatus('idle');
setProgress({ current: 0, total: 0 });
setErrorMessage('');
setResult(null);
};
useEffect(() => {
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(event.detail.visible);
if (event.detail.visible) {
resetState();
}
};
const el = document.getElementById('backup_window');
if (el) {
el.addEventListener('setDialogVisibility', handleCustomEvent as EventListener);
}
return () => {
if (el) {
el.removeEventListener('setDialogVisibility', handleCustomEvent as EventListener);
}
};
}, []);
const handleBackup = async () => {
if (!appService) return;
setStatus('backing-up');
setErrorMessage('');
setProgress({ current: 0, total: 0 });
try {
const timestamp = new Date().toISOString().slice(0, 10);
const filename = `readest-backup-${timestamp}.zip`;
const saved = await saveBackupFile(appService, filename, (current, total, currentFile) => {
setProgress({ current, total, currentFile });
});
if (saved) {
setResult({ type: 'backup' });
setStatus('completed');
} else {
setStatus('idle');
}
} catch (error) {
console.error('Backup failed:', error);
setErrorMessage(_('Backup failed: {{error}}', { error: String(error) }));
setStatus('error');
}
};
const handleRestore = async () => {
if (!appService) return;
try {
const result = await selectFiles({
type: 'generic',
accept: '.zip',
extensions: ['zip'],
dialogTitle: _('Select Backup'),
});
if (!result.files.length) return;
setStatus('restoring');
setErrorMessage('');
setProgress({ current: 0, total: 0 });
const zipFile = result.files[0]?.file
? result.files[0].file
: await appService.openFile(result.files[0]!.path!, 'None');
const { booksAdded, booksUpdated } = await restoreFromBackupZip(
appService,
zipFile,
(current, total, currentFile) => {
setProgress({ current, total, currentFile });
},
);
const newLibrary = await appService.loadLibraryBooks();
const booksCount = newLibrary.reduce((sum, book) => sum + (book.deletedAt ? 0 : 1), 0);
setLibrary(newLibrary);
setResult({
type: 'restore',
booksAdded: Math.min(booksAdded, booksCount),
booksUpdated: Math.min(booksUpdated, booksCount),
});
setStatus('completed');
onPullLibrary(true);
} catch (error) {
console.error('Restore failed:', error);
setErrorMessage(_('Restore failed: {{error}}', { error: String(error) }));
setStatus('error');
}
};
const handleClose = () => {
if (status === 'backing-up' || status === 'restoring') {
return;
}
setIsOpen(false);
resetState();
};
const progressPercentage =
progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;
const isProcessing = status === 'backing-up' || status === 'restoring';
return (
<Dialog
id='backup_window'
isOpen={isOpen}
title={_('Backup & Restore')}
onClose={handleClose}
snapHeight={appService?.isMobile ? 0.45 : undefined}
dismissible={!isProcessing}
boxClassName='sm:!w-[520px] sm:!max-w-screen-sm sm:h-auto'
>
{isOpen && (
<div className='backup-content flex flex-col gap-6 px-6 py-4'>
{/* Action Buttons */}
{status === 'idle' && (
<div className='space-y-3'>
<p className='text-base-content/70 text-sm'>
{_(
'Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.',
)}
</p>
<button className='btn btn-outline w-full gap-2' onClick={handleBackup}>
<RiUploadCloud2Line className='h-5 w-5' />
{_('Backup Library')}
</button>
<button className='btn btn-outline w-full gap-2' onClick={handleRestore}>
<RiDownloadCloud2Line className='h-5 w-5' />
{_('Restore Library')}
</button>
</div>
)}
{/* Progress */}
{isProcessing && (
<div className='space-y-3'>
<div className='flex items-center gap-2'>
<RiLoader2Line className='text-primary h-4 w-4 animate-spin' />
<span className='text-base-content text-sm font-medium'>
{status === 'backing-up' ? _('Creating backup...') : _('Restoring library...')}
</span>
<span className='text-base-content/70 text-sm'>{progressPercentage}%</span>
</div>
<div className='bg-base-200 h-2 w-full rounded-full'>
<div
className='bg-primary h-2 rounded-full transition-all duration-300'
style={{ width: `${progressPercentage}%` }}
/>
</div>
{progress.currentFile && (
<p
className='text-base-content/60 overflow-hidden font-mono text-xs'
style={{
direction: 'rtl',
textAlign: 'left',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{progress.currentFile}
</p>
)}
<p className='text-base-content/60 text-xs'>
{_('{{current}} of {{total}} items', {
current: progress.current.toLocaleString(),
total: progress.total.toLocaleString(),
})}
</p>
</div>
)}
{/* Success State */}
{status === 'completed' && result && (
<div className='space-y-3'>
<div className='text-success flex items-center gap-2'>
<RiCheckboxCircleFill className='h-5 w-5' />
<span className='font-medium'>
{result.type === 'backup'
? _('Backup completed successfully!')
: _('Restore completed successfully!')}
</span>
</div>
<div className='bg-success/10 border-success/20 rounded-lg border p-3'>
<p className='text-success/80 text-sm'>
{result.type === 'backup'
? _('Your library has been saved to the selected location.')
: _('{{added}} books added, {{updated}} books updated.', {
added: result.booksAdded ?? 0,
updated: result.booksUpdated ?? 0,
})}
</p>
</div>
</div>
)}
{/* Error State */}
{status === 'error' && errorMessage && (
<div className='space-y-2'>
<div className='text-error flex items-center gap-2'>
<RiErrorWarningFill className='h-5 w-5' />
<span className='font-medium'>{_('Operation failed')}</span>
</div>
<div className='bg-error/10 border-error/20 rounded-lg border p-3'>
<p className='text-error/80 break-all text-sm'>{errorMessage}</p>
</div>
</div>
)}
{/* Footer Buttons */}
<div className='flex gap-3 pt-2'>
{status === 'completed' || status === 'error' ? (
<>
<button className='btn btn-outline flex-1' onClick={handleClose}>
{_('Close')}
</button>
{status === 'error' && (
<button className='btn btn-primary flex-1' onClick={resetState}>
{_('Try Again')}
</button>
)}
</>
) : (
!isProcessing && (
<button className='btn btn-outline flex-1' onClick={handleClose}>
{_('Cancel')}
</button>
)
)}
</div>
</div>
)}
</Dialog>
);
};

View file

@ -9,6 +9,7 @@ import { MdCloudSync, MdSync, MdSyncProblem } from 'react-icons/md';
import { invoke, PermissionState } from '@tauri-apps/api/core';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { setBackupDialogVisible } from '@/app/library/components/BackupWindow';
import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
@ -183,6 +184,11 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
setIsDropdownOpen?.(false);
};
const handleBackupRestore = () => {
setIsDropdownOpen?.(false);
setBackupDialogVisible(true);
};
const openSettingsDialog = () => {
setIsDropdownOpen?.(false);
setSettingsDialogOpen(true);
@ -368,34 +374,24 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
onClick={cycleThemeMode}
/>
<MenuItem label={_('Settings')} Icon={PiGear} onClick={openSettingsDialog} />
{appService?.canCustomizeRootDir && (
<>
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('Advanced Settings')}>
<ul
className='ms-0 flex flex-col before:hidden'
style={{
paddingInlineStart: `${iconSize}px`,
}}
>
<MenuItem
label={_('Change Data Location')}
noIcon={!appService?.isAndroidApp}
onClick={handleSetRootDir}
/>
{appService?.isAndroidApp && appService?.distChannel !== 'playstore' && (
<MenuItem
label={_('Save Book Cover')}
tooltip={_('Auto-save last book cover')}
description={savedBookCoverForLockScreen ? savedBookCoverDescription : ''}
toggled={!!savedBookCoverForLockScreen}
onClick={handleSetSavedBookCoverForLockScreen}
/>
)}
</ul>
</MenuItem>
</>
)}
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('Advanced Settings')}>
<ul className='ms-0 flex flex-col ps-0 before:hidden'>
{appService?.canCustomizeRootDir && (
<MenuItem label={_('Change Data Location')} onClick={handleSetRootDir} />
)}
<MenuItem label={_('Backup & Restore')} onClick={handleBackupRestore} />
{appService?.isAndroidApp && appService?.distChannel !== 'playstore' && (
<MenuItem
label={_('Save Book Cover')}
tooltip={_('Auto-save last book cover')}
description={savedBookCoverForLockScreen ? savedBookCoverDescription : ''}
toggled={!!savedBookCoverForLockScreen}
onClick={handleSetSavedBookCoverForLockScreen}
/>
)}
</ul>
</MenuItem>
<hr aria-hidden='true' className='border-base-200 my-1' />
{user && userProfilePlan === 'free' && (
<MenuItem label={_('Upgrade to Readest Premium')} onClick={handleUpgrade} />

View file

@ -58,6 +58,7 @@ import { BookDetailModal } from '@/components/metadata';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { CatalogDialog } from './components/OPDSDialog';
import { MigrateDataWindow } from './components/MigrateDataWindow';
import { BackupWindow } from './components/BackupWindow';
import { useDragDropImport } from './hooks/useDragDropImport';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { useAppRouter } from '@/hooks/useAppRouter';
@ -969,6 +970,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<AboutWindow />
<UpdaterWindow />
<MigrateDataWindow />
<BackupWindow onPullLibrary={pullLibrary} />
{isSettingsDialogOpen && <SettingsDialog bookKey={''} />}
{showCatalogManager && <CatalogDialog onClose={handleDismissOPDSDialog} />}
<Toast />

View file

@ -881,7 +881,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, 100);
const filename = `${makeSafeFilename(book.title)}.md`;
const saved = await appService?.saveFile(filename, markdownContent, 'text/markdown');
const saved = await appService?.saveFile(filename, markdownContent, {
mimeType: 'text/markdown',
});
eventDispatcher.dispatch('toast', {
type: 'info',
message: saved ? _('Exported successfully') : _('Copied to clipboard'),

View file

@ -20,6 +20,7 @@ interface DialogProps {
isOpen: boolean;
children: ReactNode;
snapHeight?: number;
dismissible?: boolean;
header?: ReactNode;
title?: string;
className?: string;
@ -34,6 +35,7 @@ const Dialog: React.FC<DialogProps> = ({
isOpen,
children,
snapHeight,
dismissible = true,
header,
title,
className,
@ -106,7 +108,7 @@ const Dialog: React.FC<DialogProps> = ({
}, [isOpen]);
const handleDragMove = (data: { clientY: number; deltaY: number }) => {
if (!isMobile || !dialogRef.current) return;
if (!dismissible || !isMobile || !dialogRef.current) return;
const modal = dialogRef.current.querySelector('.modal-box') as HTMLElement;
const overlay = dialogRef.current.querySelector('.overlay') as HTMLElement;
@ -125,7 +127,7 @@ const Dialog: React.FC<DialogProps> = ({
};
const handleDragEnd = (data: { velocity: number; clientY: number }) => {
if (!isMobile || !dialogRef.current) return;
if (!dismissible || !isMobile || !dialogRef.current) return;
const modal = dialogRef.current.querySelector('.modal-box') as HTMLElement;
const overlay = dialogRef.current.querySelector('.overlay') as HTMLElement;
if (!modal || !overlay) return;
@ -212,7 +214,11 @@ const Dialog: React.FC<DialogProps> = ({
appService?.hasSafeAreaInset && isFullHeightInMobile
? `${Math.max(safeAreaInsets?.top || 0, systemUIVisible ? statusBarHeight : 0)}px`
: '0px',
...(isMobile ? { height: snapHeight ? `${snapHeight * 100}%` : '100%', bottom: 0 } : {}),
...(isMobile
? snapHeight
? { height: `${snapHeight * 100}%`, top: 'auto', bottom: 0 }
: { height: '100%', bottom: 0 }
: {}),
}}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
@ -233,9 +239,11 @@ const Dialog: React.FC<DialogProps> = ({
<div className='flex h-11 w-full items-center justify-between'>
<button
aria-label={_('Close')}
aria-hidden={!isOpen}
onClick={onClose}
disabled={!dismissible}
className={
'btn btn-ghost btn-circle flex h-8 min-h-8 w-8 hover:bg-transparent focus:outline-none sm:hidden'
'btn btn-ghost btn-circle flex h-8 min-h-8 w-8 hover:bg-transparent focus:outline-none disabled:bg-transparent sm:hidden'
}
>
{isRtl ? (
@ -249,7 +257,9 @@ const Dialog: React.FC<DialogProps> = ({
</div>
<button
aria-label={_('Close')}
aria-hidden={!isOpen}
onClick={onClose}
disabled={!dismissible}
className={
'bg-base-300/65 btn btn-ghost btn-circle ml-auto hidden h-6 min-h-6 w-6 focus:outline-none sm:flex'
}

View file

@ -72,8 +72,7 @@ export abstract class BaseAppService implements AppService {
abstract saveFile(
filename: string,
content: string | ArrayBuffer,
filepath: string,
mimeType?: string,
options?: { filePath?: string; mimeType?: string },
): Promise<boolean>;
abstract ask(message: string): Promise<boolean>;
abstract openDatabase(

View file

@ -0,0 +1,350 @@
import type { Configuration, ZipWriter } from '@zip.js/zip.js';
import { AppService } from '@/types/system';
import { EXTS } from '@/libs/document';
import { isTauriAppPlatform } from '@/services/environment';
import { Book, BookConfig, BookNote } from '@/types/book';
import { getLibraryFilename } from '@/utils/book';
import { configureZip } from '@/utils/zip';
/** Book file extensions for identifying book files in backup directories. */
const BOOK_EXTS = new Set(Object.values(EXTS));
/**
* Merge two BookConfigs: uses the config with higher reading progress as base,
* then merges booknotes from both (deduplicating by id, latest updatedAt wins).
*/
export function mergeBookConfigs(
current: Partial<BookConfig>,
backup: Partial<BookConfig>,
): Partial<BookConfig> {
const currentPage = current.progress?.[0] ?? 0;
const backupPage = backup.progress?.[0] ?? 0;
// Use the config with higher progress as base
const base = backupPage > currentPage ? { ...backup } : { ...current };
// Merge booknotes from both configs
const noteMap = new Map<string, BookNote>();
for (const note of current.booknotes ?? []) {
noteMap.set(note.id, note);
}
for (const note of backup.booknotes ?? []) {
const existing = noteMap.get(note.id);
if (!existing || (note.updatedAt || 0) > (existing.updatedAt || 0)) {
noteMap.set(note.id, note);
}
}
base.booknotes = [...noteMap.values()];
return base;
}
/**
* Merge two Book metadata records: uses the one with higher updatedAt as base,
* then reconciles timestamps. Only marks as deleted if BOTH sides agree.
*/
export function mergeBookMetadata(current: Book, backup: Book): Book {
const base = backup.updatedAt > current.updatedAt ? { ...backup } : { ...current };
base.updatedAt = Math.max(current.updatedAt, backup.updatedAt);
base.createdAt = Math.min(current.createdAt, backup.createdAt);
// Only deleted if BOTH sides agree
base.deletedAt =
current.deletedAt && backup.deletedAt ? Math.max(current.deletedAt, backup.deletedAt) : null;
return base;
}
/** Library metadata files to skip from the directory scan. */
const LIBRARY_META_FILES = new Set([
'library.json',
'library.json.bak',
'library_backup.json',
'library.db',
'library.db-shm',
'library.db-wal',
]);
function isLibraryMetaFile(path: string): boolean {
return LIBRARY_META_FILES.has(path);
}
type ProgressCallback = (current: number, total: number, filename: string) => void;
/**
* Shared logic: add all library entries to a ZipWriter.
*/
async function addBackupEntriesToZip(
writer: ZipWriter<unknown>,
appService: AppService,
onProgress?: ProgressCallback,
): Promise<void> {
const { Uint8ArrayReader } = await import('@zip.js/zip.js');
// Generate canonical library.json from the current storage backend
const books = await appService.loadLibraryBooks();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const libraryBooks = books.map(({ coverImageUrl, ...rest }) => rest);
const libraryJson = new TextEncoder().encode(JSON.stringify(libraryBooks, null, 2));
await writer.add(getLibraryFilename(), new Uint8ArrayReader(libraryJson));
// Add all book files, skipping library metadata files
const booksDir = await appService.resolveFilePath('', 'Books');
const files = await appService.readDirectory(booksDir, 'None');
const bookFiles = files.filter((f) => f.size > 0 && !isLibraryMetaFile(f.path));
const total = bookFiles.length;
for (let i = 0; i < bookFiles.length; i++) {
const file = bookFiles[i]!;
onProgress?.(i + 1, total, file.path);
try {
const content = await appService.readFile(file.path, 'Books', 'binary');
const data = new Uint8Array(content as ArrayBuffer);
await writer.add(file.path, new Uint8ArrayReader(data), { level: 0 });
} catch (error) {
console.warn(`Skipping file ${file.path}:`, error);
}
}
}
const ZIP_WRITE_CONFIG: Partial<Configuration> = {
useWebWorkers: true,
useCompressionStream: true,
chunkSize: 1 * 1024 * 1024, // 1MB chunks for streaming
};
/**
* Create a backup zip in memory, returning an ArrayBuffer.
* Used on web where streaming to a file is not available.
*/
export async function createBackupZip(
appService: AppService,
onProgress?: ProgressCallback,
): Promise<ArrayBuffer> {
await configureZip(ZIP_WRITE_CONFIG);
const { BlobWriter, ZipWriter } = await import('@zip.js/zip.js');
const blobWriter = new BlobWriter('application/zip');
const writer = new ZipWriter(blobWriter);
await addBackupEntriesToZip(writer, appService, onProgress);
await writer.close();
const blob = await blobWriter.getData();
return await blob.arrayBuffer();
}
/**
* Stream a backup zip directly to a file path on disk.
* Uses TransformStream so only chunks are held in memory at a time.
* Only available on Tauri (requires @tauri-apps/plugin-fs).
*/
export async function createBackupZipToFile(
appService: AppService,
filePath: string,
onProgress?: ProgressCallback,
): Promise<void> {
await configureZip(ZIP_WRITE_CONFIG);
const { ZipWriter } = await import('@zip.js/zip.js');
const { writeFile } = await import('@tauri-apps/plugin-fs');
const { readable, writable } = new TransformStream<Uint8Array>();
// Start streaming readable side to the file (runs concurrently)
const writePromise = writeFile(filePath, readable);
const writer = new ZipWriter(writable);
await addBackupEntriesToZip(writer, appService, onProgress);
await writer.close();
await writePromise;
}
/**
* Validate that zip entries contain a valid backup structure.
* Must contain library.json at the root level.
*/
export function validateBackupStructure(entryNames: string[]): boolean {
return entryNames.some((name) => name === getLibraryFilename());
}
/**
* Restore library from a zip backup, merging with existing data.
* - Override book files and cover images for existing books
* - Merge book config files (keep higher progress, merge notes)
* - Add new books not present in current library
* - Import orphan hash directories not listed in library.json
*/
export async function restoreFromBackupZip(
appService: AppService,
zipBlob: Blob,
onProgress?: (current: number, total: number, filename: string) => void,
): Promise<{ booksAdded: number; booksUpdated: number }> {
await configureZip();
const { BlobReader, ZipReader, Uint8ArrayWriter } = await import('@zip.js/zip.js');
const reader = new ZipReader(new BlobReader(zipBlob));
const entries = await reader.getEntries();
// Validate structure
const entryNames = entries.map((e) => e.filename);
if (!validateBackupStructure(entryNames)) {
await reader.close();
throw new Error('Invalid backup file: missing library.json');
}
// Filter to file entries only (directories don't have getData)
const fileEntries = entries.filter((e) => !e.directory);
// Read backup library.json
const libraryEntry = fileEntries.find((e) => e.filename === getLibraryFilename());
if (!libraryEntry) {
await reader.close();
throw new Error('Cannot read library.json from backup');
}
const libraryData = await libraryEntry.getData!(new Uint8ArrayWriter());
const backupBooks: Book[] = JSON.parse(new TextDecoder().decode(libraryData));
// Load current library
const currentBooks = await appService.loadLibraryBooks();
const currentBooksMap = new Map<string, Book>();
for (const book of currentBooks) {
currentBooksMap.set(book.hash, book);
}
// Collect orphan hash directories: in zip but not in library.json
const backupHashes = new Set(backupBooks.map((b) => b.hash));
const orphanHashes = new Set<string>();
for (const entry of fileEntries) {
const slashIdx = entry.filename.indexOf('/');
if (slashIdx < 0) continue;
const dir = entry.filename.slice(0, slashIdx);
if (dir && !backupHashes.has(dir)) {
orphanHashes.add(dir);
}
}
let booksAdded = 0;
let booksUpdated = 0;
const total = backupBooks.length + orphanHashes.size;
for (let i = 0; i < backupBooks.length; i++) {
const backupBook = backupBooks[i]!;
onProgress?.(i + 1, total, backupBook.title);
const existingBook = currentBooksMap.get(backupBook.hash);
const bookDir = backupBook.hash;
// Get all file entries for this book's directory
const bookFileEntries = fileEntries.filter((e) => e.filename.startsWith(`${bookDir}/`));
if (existingBook) {
// Update: override book file and cover, merge config
for (const entry of bookFileEntries) {
const data = await entry.getData!(new Uint8ArrayWriter());
if (entry.filename.endsWith('/config.json')) {
// Merge config
let currentConfig: Partial<BookConfig> = {};
try {
const str = (await appService.readFile(entry.filename, 'Books', 'text')) as string;
currentConfig = JSON.parse(str);
} catch {
/* use empty config if current doesn't exist */
}
const backupConfig: Partial<BookConfig> = JSON.parse(new TextDecoder().decode(data));
const mergedConfig = mergeBookConfigs(currentConfig, backupConfig);
await appService.writeFile(entry.filename, 'Books', JSON.stringify(mergedConfig));
} else {
// Override book file and cover image
await appService.writeFile(entry.filename, 'Books', data.buffer as ArrayBuffer);
}
}
// Merge book metadata (timestamps, deletedAt reconciliation)
Object.assign(existingBook, mergeBookMetadata(existingBook, backupBook));
booksUpdated++;
} else {
// Add new book: extract all files
if (!(await appService.exists(bookDir, 'Books'))) {
await appService.createDir(bookDir, 'Books');
}
for (const entry of bookFileEntries) {
const data = await entry.getData!(new Uint8ArrayWriter());
await appService.writeFile(entry.filename, 'Books', data.buffer as ArrayBuffer);
}
currentBooks.push(backupBook);
currentBooksMap.set(backupBook.hash, backupBook);
booksAdded++;
}
}
// Import orphan directories: hash dirs in zip not listed in library.json
let orphanIdx = 0;
for (const hash of orphanHashes) {
orphanIdx++;
if (currentBooksMap.has(hash)) continue;
onProgress?.(backupBooks.length + orphanIdx, total, hash);
const orphanEntries = fileEntries.filter((e) => e.filename.startsWith(`${hash}/`));
// Find the book file by extension
const bookEntry = orphanEntries.find((e) => {
const ext = e.filename.split('.').pop()?.toLowerCase() ?? '';
return BOOK_EXTS.has(ext);
});
if (!bookEntry) continue;
// Extract all files to the Books directory
if (!(await appService.exists(hash, 'Books'))) {
await appService.createDir(hash, 'Books');
}
for (const entry of orphanEntries) {
const data = await entry.getData!(new Uint8ArrayWriter());
await appService.writeFile(entry.filename, 'Books', data.buffer as ArrayBuffer);
}
// Import the book file from the extracted location
try {
const filePath = await appService.resolveFilePath(bookEntry.filename, 'Books');
const imported = await appService.importBook(filePath, currentBooks, true, true, true);
if (imported) {
currentBooksMap.set(imported.hash, imported);
booksAdded++;
}
} catch (error) {
console.warn(`Failed to import orphan book from ${hash}:`, error);
}
}
// Save merged library
await appService.saveLibraryBooks(currentBooks);
await reader.close();
return { booksAdded, booksUpdated };
}
/**
* Create and save a backup zip file.
* On Tauri, streams directly to disk to avoid holding the entire zip in memory.
* On web, builds the zip in memory and triggers a download.
*/
export async function saveBackupFile(
appService: AppService,
filename: string,
onProgress?: ProgressCallback,
): Promise<boolean> {
if (isTauriAppPlatform()) {
// Tauri: stream directly to the chosen file path
const { save: saveDialog } = await import('@tauri-apps/plugin-dialog');
const ext = filename.split('.').pop() || 'zip';
const filePath = await saveDialog({
defaultPath: filename,
filters: [{ name: ext.toUpperCase(), extensions: [ext] }],
});
if (!filePath) return false;
await createBackupZipToFile(appService, filePath, onProgress);
return true;
} else {
// Web: build zip in memory then save
const zipData = await createBackupZip(appService, onProgress);
let filePath: string | undefined;
return appService.saveFile(filename, zipData, { filePath, mimeType: 'application/zip' });
}
}

View file

@ -535,18 +535,17 @@ export async function exportBook(
saveFile: (
filename: string,
content: ArrayBuffer,
filepath: string,
mimeType?: string,
options?: { filePath?: string; mimeType?: string },
) => Promise<boolean>,
): Promise<boolean> {
const { file } = await loadBookContent(fs, book);
const content = await file.arrayBuffer();
const filename = `${makeSafeFilename(book.title)}.${book.format.toLowerCase()}`;
let filepath = await resolveFilePath(getLocalBookFilename(book), 'Books');
const fileType = file.type || 'application/octet-stream';
if (getFilename(filepath) !== filename) {
await copyFile(filepath, filename, 'Temp');
filepath = await resolveFilePath(filename, 'Temp');
let filePath = await resolveFilePath(getLocalBookFilename(book), 'Books');
const mimeType = file.type || 'application/octet-stream';
if (getFilename(filePath) !== filename) {
await copyFile(filePath, filename, 'Temp');
filePath = await resolveFilePath(filename, 'Temp');
}
return await saveFile(filename, content, filepath, fileType);
return await saveFile(filename, content, { filePath, mimeType });
}

View file

@ -87,7 +87,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
openLastBooks: false,
lastOpenBooks: [],
autoImportBooksOnOpen: false,
telemetryEnabled: true,
telemetryEnabled: false,
discordRichPresenceEnabled: false,
libraryViewMode: 'grid',
librarySortBy: LibrarySortByType.Updated,

View file

@ -545,14 +545,13 @@ export class NativeAppService extends BaseAppService {
async saveFile(
filename: string,
content: string | ArrayBuffer,
filepath: string,
mimeType?: string,
options?: { filePath?: string; mimeType?: string },
): Promise<boolean> {
try {
const ext = filename.split('.').pop() || '';
if (this.isIOSApp) {
await shareFile(filepath, {
mimeType: mimeType || 'application/octet-stream',
if (this.isIOSApp && options?.filePath) {
await shareFile(options.filePath, {
mimeType: options?.mimeType || 'application/octet-stream',
});
} else {
const filePath = await saveDialog({

View file

@ -364,9 +364,10 @@ export class NodeAppService extends BaseAppService {
async saveFile(
_filename: string,
content: string | ArrayBuffer,
filepath: string,
options?: { filePath?: string; mimeType?: string },
): Promise<boolean> {
try {
const filepath = options?.filePath ?? '';
await fsp.mkdir(nodePath.dirname(filepath), { recursive: true });
if (typeof content === 'string') {
await fsp.writeFile(filepath, content, 'utf-8');

View file

@ -328,10 +328,10 @@ export class WebAppService extends BaseAppService {
async saveFile(
filename: string,
content: string | ArrayBuffer,
mimeType?: string,
options?: { filePath?: string; mimeType?: string },
): Promise<boolean> {
try {
const blob = new Blob([content], { type: mimeType || 'application/octet-stream' });
const blob = new Blob([content], { type: options?.mimeType || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;

View file

@ -112,7 +112,11 @@ export interface AppService {
selectDirectory(mode: SelectDirectoryMode): Promise<string>;
selectFiles(name: string, extensions: string[]): Promise<string[]>;
readDirectory(path: string, base: BaseDir): Promise<FileItem[]>;
saveFile(filename: string, content: string | ArrayBuffer, mimeType?: string): Promise<boolean>;
saveFile(
filename: string,
content: string | ArrayBuffer,
options?: { filePath?: string; mimeType?: string },
): Promise<boolean>;
getDefaultViewSettings(): ViewSettings;
loadSettings(): Promise<SystemSettings>;

View file

@ -1,4 +1,10 @@
export const configureZip = async () => {
import { Configuration } from '@zip.js/zip.js';
export const configureZip = async (configuration?: Partial<Configuration>) => {
const { configure } = await import('@zip.js/zip.js');
configure({ useWebWorkers: false, useCompressionStream: false });
configure({
useWebWorkers: false,
useCompressionStream: false,
...(configuration ? configuration : {}),
});
};

@ -1 +1 @@
Subproject commit f5f68063e459c33522f44a04df3120d9d039ec13
Subproject commit 5183e314cbabc179228e232992ad67025f30272d