mirror of
https://github.com/readest/readest
synced 2026-04-21 13:37:44 +00:00
feat(library): backup to and restore from a zip file (#3571)
This commit is contained in:
parent
e8f70b896e
commit
91bc4ddec7
51 changed files with 1580 additions and 138 deletions
234
Cargo.lock
generated
234
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
10
Cargo.toml
10
Cargo.toml
|
|
@ -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
|
||||
|
|
@ -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": "فشلت العملية"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "অপারেশন ব্যর্থ"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "བཀོལ་སྤྱོད་བྱེད་མ་ཐུབ"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Η λειτουργία απέτυχε"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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ó"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "عملیات ناموفق"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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é"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "הפעולה נכשלה"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "ऑपरेशन विफल"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "操作に失敗しました"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "작업 실패"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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ę"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Операция не удалась"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "මෙහෙයුම අසාර්ථකයි"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "செயல்பாடு தோல்வி"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "การดำเนินการล้มเหลว"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Операція не вдалася"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "操作失败"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "操作失敗"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
183
apps/readest-app/src/__tests__/services/backup-service.test.ts
Normal file
183
apps/readest-app/src/__tests__/services/backup-service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
302
apps/readest-app/src/app/library/components/BackupWindow.tsx
Normal file
302
apps/readest-app/src/app/library/components/BackupWindow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
350
apps/readest-app/src/services/backupService.ts
Normal file
350
apps/readest-app/src/services/backupService.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in a new issue