forked from akai/readest
feat(sync): S3-compatible cloud sync provider (#4990)
* feat(sync): S3-compatible cloud sync provider with premium-gated chooser Add a third file-sync backend for any SigV4 object store (Cloudflare R2, AWS S3, MinIO, Backblaze B2), end to end: SigV4 transport via aws4fetch (path-style addressing, ListObjectsV2 with page draining, per-key deletes, presigned streaming on Tauri, Drive-style error mapping and backoff), S3 settings slice and defaults, exclusive provider activation and cross-window flag broadcast, registry memoization, and an Integrations chooser entry plus connect form that validates the bucket with one signed listing. Shared helpers settingsKeyForBackend and cloudProviderDisplayName replace the scattered per-kind ternaries across the reader and library sync hooks, fleet detection, and the settings surfaces. The chooser now marks third-party providers with a Premium badge and enforces the paywall (CLOUD_SYNC_REQUIRES_PREMIUM on): free plans see the rows but route to the upgrade page instead of the config sub-pages, and a downgraded account's still-selected provider is paused rather than silently falling back to Readest Cloud uploads. Manual provider sync now reports "N book(s) synced" like the native cloud sync, from the engine result returned by runActiveFileLibrarySync. The S3 transport passes the same provider semantic contract as WebDAV and Google Drive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(sync): make the library row the ground truth for local file presence Every sync run re-walked all books whose file is recorded nowhere and paid two plugin:fs|exists IPC per book per run on Tauri, just to relearn "no local source", ending in 0 books synced. The library row already tracks local presence reliably (import, download, and delete all stamp downloadedAt, and the metadata merge keeps it device-local), so the file-push gate now trusts the row: a book the row marks as absent costs zero filesystem and zero remote probes, keeping incremental sync a pure metadata diff at any library size. A session-scoped per-provider memo additionally suppresses re-probes of drifted rows (the row claims a file the filesystem no longer has), keyed to the book's updatedAt so any local change re-qualifies it. Row-vs-filesystem split-brain in either direction is healed by Full Sync, which bypasses the gate, the memo, and the uploaded-file record and audits the real filesystem. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sync): tiered request timeouts for the WebDAV client An unreachable or dead server (a LAN host that went away) left PROPFIND and HEAD requests pending indefinitely, pinning the Integrations panel on "Syncing..." and the browse pane on a spinner. Metadata round-trips (PROPFIND, HEAD, MKCOL, DELETE) answer with headers only, so they now abort after 5 seconds; GET and PUT carry book-sized bodies over possibly slow links and keep a 5 minute ceiling instead. Expiry aborts the request via AbortController and surfaces as a "Request timed out" NETWORK failure through the existing WebDAVRequestError taxonomy. Since every library sync run opens with the HEAD etag probe on library.json, a dead server now fails the whole run within seconds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): provider panel status and layout fixes Three small fixes across the provider settings panels: - A completed manual "Sync now" clears the provider's lastError so the Cloud Sync chooser row and the SettingsMenu sync row stop reading "Sync failed" after the server comes back; a failed manual run now records the error for those surfaces too. Covered by a render harness that drives the real form against a mocked engine. - The sync row shows a relative "Synced a few seconds ago" label (same wording as the SettingsMenu row) instead of an absolute timestamp. - The Google Drive configured-but-inactive state rendered its Tips above the action buttons; Tips now close the page in every provider panel state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(i18n): translate the S3 provider and premium gating strings New keys from the S3-compatible provider (form fields, chooser entry, tips), the Premium badge, and the parameterized provider tips, translated across all 33 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(memory): record the S3 provider and sync optimization notes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
- [Transfer Queue clear not persisted](transfer-queue-clear-persistence.md) hook mutated store directly, skipped `persistQueue()`; route clears through `transferManager`
|
||||
- [Multi-window settings clobber (#4580)](multiwindow-settings-clobber-4580.md)
|
||||
- Google Drive: [research](gdrive-sync-provider-research.md); [multi-PR status](gdrive-provider-multipr-status.md); [full walk every sync](gdrive-fullwalk-every-sync-no-source-cursor.md) no-source books never recorded in uploadedHashes + focus refires pullLibrary
|
||||
- [S3/R2 provider](s3-r2-sync-provider.md) third backend, aws4fetch SigV4 path-style, full slice on dev uncommitted; live R2 verify pending
|
||||
- [Hardcover edition_id (#4792)](hardcover-progress-edition-id-4792.md)
|
||||
## Build, Testing & CI
|
||||
- [Nightly quick-sharun hang #4906](nightly-quick-sharun-hang-4906.md) pin via cache pre-seed + step timeouts
|
||||
|
||||
@@ -16,6 +16,8 @@ Two compounding causes:
|
||||
Fix v2 (the real fix, on dev 2026-07-07, initially uncommitted): reorder `pushBookFile` to resolve the LOCAL source before any remote probe (`probeRemoteHead` closure, lazy); `no-source` now costs zero requests and the `remoteExists` plumbing from v1 was removed again. Test: 'spends no remote request on a no-source book' in engine-sync-paths.test.ts. The 654 books stay in booksToPush (progress counter still shows them) but the sweep is network-free. Their files land on Drive only when a device that HAS the bytes (desktop) syncs with Upload Book Files on; that device records the hashes and everyone skips thereafter.
|
||||
Worktree was discarded per user; work continues directly on the bare repo dev branch (dev server localhost:3000 runs from there).
|
||||
|
||||
Round 5 (dev, uncommitted): Tauri plugin:fs|exists storm (5726 IPC/sync) killed by making the LIBRARY ROW the ground truth for local file presence in `needsFilePush`: gate `hasLocalFile(b) = !!(b.downloadedAt || b.filePath)` (import/download/delete all stamp downloadedAt; mergeBookMetadata keeps it device-local, verified) + session-scoped `noSourceVerdicts` WeakMap (provider-keyed, updatedAt-keyed) that suppresses re-probes of DRIFTED rows (row claims file, fs disagrees). Per user decision: NO automatic row-correction — Full Sync is the single split-brain healer (bypasses gate + memo + uploadedHashes and audits the real fs). Incremental sync with clean state = zero local and zero remote per-book probes. Harness in engine-sync-paths.test.ts: row-gate test, drifted-row memo tests, fullSync bypass; #4856 fixtures stamped downloadedAt.
|
||||
|
||||
Round 4 (dev, uncommitted): per-book cloud buttons (Book Details + bookshelf + open-non-local-book) route to the selected provider instead of the gated Readest Cloud queue ("Uploads to Readest Cloud are paused..." toast). `FileSyncEngine.downloadBookFile` (hash-dir listing resolves filename; stream on Tauri, buffered on web; cover+config best-effort) + `runActiveFileBookUpload/Download` in runLibrarySync.ts (stamps downloadedAt; caller persists via updateBook + toasts, existing transferMessages i18n keys). Reader hint parity same day: `remoteProgressApplied` in useFileSync dispatches 'Reading Progress Synced' hint on applied remote position. NOT done: provider path has no transfer-queue/progress UI; uploadedAt not stamped (means Readest-Cloud backup; provider uploaded-state could later key off index uploadedHashes).
|
||||
|
||||
Round 2 optimizations (dev, uncommitted as of 2026-07-07 03:10): provider memoized per connection key in `createFileSyncProvider` (warm Drive idCache across reader hook / library auto-sync / Sync Now; `resetFileSyncProviderCache()` called on Drive connect/disconnect); `writeBinary` PATCHes cached id without files.list lookup (404 evict+fallback); dev-only request diagnostics `[gdrive] op ...` / `[gdrive] #n ...` in GoogleDriveProvider.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: s3-r2-sync-provider
|
||||
description: "S3/R2 file-sync provider (third backend after WebDAV/GDrive) — full vertical slice on dev, uncommitted; aws4fetch SigV4, path-style, generic S3-compatible"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 894e0d6d-ce01-402b-8f2d-0f0670986a88
|
||||
---
|
||||
|
||||
Built 2026-07-07 (approved design in `.agents/plans/2026-07-07-s3-provider-design.md`), full vertical slice on the bare-repo dev branch, UNCOMMITTED alongside the day's gdrive optimization work.
|
||||
|
||||
- **Transport** `src/services/sync/providers/s3/S3Provider.ts`: SigV4 via `aws4fetch` (was already a dep, server-side `utils/r2.ts` uses it; zero new deps). Path-style `<endpoint>/<bucket>/<key>`; keys map 1:1 from logical paths. GET/HEAD/PUT; ListObjectsV2 XML via DOMParser with delimiter + continuation-token draining; ensureDir no-op; deleteDir = list prefix + per-key DELETE (DeleteObjects needs Content-MD5, WebCrypto has none). head etag = md5 → engine's index change-detection works. Tauri streaming via presigned `signQuery` URLs → tauriUpload/Download. Injected fetch (web fetch / tauri plugin-http) + injected sleep; Drive-style error map + backoff. Passes `runSemanticContract` + 10 transport tests (stageAbsent dispatches by request shape: 404 for objects, empty-200 for listings).
|
||||
- **Settings** `S3Settings` (endpoint/region='auto'/bucket/accessKeyId/secretAccessKey + shared sub-toggles) in types/settings.ts + DEFAULT_S3_SETTINGS in constants.ts; slice `settings.s3`.
|
||||
- **Derivation/activation**: FileSyncBackendKind gains 's3'; getCloudSyncProvider order webdav > gdrive > s3; withActiveCloudProvider keeps 3 slices exclusive (+syncBooks/providerSelectedAt stamp); CloudSyncProviderFlags in settingsSync.ts gained optional s3 slice (multi-window switch protection, #4580 class).
|
||||
- **Shared helpers** added to cloudSyncProvider.ts and swept everywhere: `settingsKeyForBackend(kind)` (5 sites) and `cloudProviderDisplayName(kind)` (4 sites) replaced scattered gdrive ternaries.
|
||||
- **UI**: `S3Form.tsx` (WebDAVForm pattern; Connect probes `list('/Readest')` — 403=auth, 404=bucket, empty-200=ok); IntegrationsPanel: 's3' SubPage + chooser CloudProviderRow (RiDatabase2Line, "S3-Compatible Storage") + deep-link `requestedSubPage === 's3'`; Tips include R2 endpoint format + web CORS requirement.
|
||||
- **i18n**: 14 new keys translated into all 33 locales (462 entries).
|
||||
- Everything else (engine, FileSyncForm, fileSyncStore, fleet probe, per-book upload/download routing, reader hint) was already backend-generic and needed zero changes.
|
||||
|
||||
NOT done: R2 account-ID preset, multipart upload, virtual-host addressing, remote-browser pane for S3. Live verification against a real R2 bucket pending (user tests on localhost:3000).
|
||||
@@ -1953,10 +1953,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "زامن مكتبتك وتقدم القراءة والتمييزات مع Google Drive الخاص بك.",
|
||||
"Configured": "تم الإعداد",
|
||||
"Third-party Cloud Sync": "مزامنة سحابية من جهة خارجية",
|
||||
"Use WebDAV": "استخدام WebDAV",
|
||||
"Cloud Sync": "المزامنة السحابية",
|
||||
"Available on Plus, Pro, or Lifetime": "متاح في Plus أو Pro أو Lifetime",
|
||||
"Extra Large": "كبير جدًا",
|
||||
"Hint size": "حجم التلميح",
|
||||
"Gloss text size above the word": "حجم نص التلميح الظاهر فوق الكلمة",
|
||||
@@ -1967,15 +1965,12 @@
|
||||
"Stopped reading aloud": "تم إيقاف القراءة بصوت عالٍ",
|
||||
"Read aloud stopped": "توقفت القراءة بصوت عالٍ",
|
||||
"Cloud provider switched": "تم تغيير مزوّد السحابة",
|
||||
"Synced via {{provider}}": "تمت المزامنة عبر {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "رفع الملفات إلى Readest Cloud متوقف مؤقتًا ما دامت مزامنة {{provider}} محددة",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "يديره {{provider}} ما دام مزوّد المزامنة السحابية لديك",
|
||||
"Not signed in": "لم يتم تسجيل الدخول",
|
||||
"Paused — plan required": "متوقف مؤقتًا — يلزم اشتراك",
|
||||
"Active · Book file uploads off": "نشط · رفع ملفات الكتب متوقف",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "ما دام WebDAV محددًا، تتم مزامنة الكتب والتقدم والتمييزات مع خادمك فقط.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "تستمر مزامنة إعدادات التطبيق وإحصاءات القراءة والقواميس عبر حساب Readest الخاص بك ما دمت مسجلاً الدخول.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "ما دام Google Drive محددًا، تتم مزامنة الكتب والتقدم والتمييزات مع Drive الخاص بك فقط.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "زامن مكتبتك وتقدم القراءة والتمييزات مع Readest Cloud.",
|
||||
"Account and Storage": "الحساب والتخزين",
|
||||
@@ -1994,5 +1989,23 @@
|
||||
"Cloud sync session expired": "انتهت صلاحية جلسة المزامنة السحابية",
|
||||
"Uploads book files to your other devices": "يرفع ملفات الكتب إلى أجهزتك الأخرى",
|
||||
"Re-check every book instead of only changed ones": "إعادة فحص كل كتاب بدلاً من الكتب المتغيرة فقط",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "لم يتم العثور على الحاوية",
|
||||
"Endpoint": "نقطة النهاية",
|
||||
"Bucket": "الحاوية",
|
||||
"Region": "المنطقة",
|
||||
"Access Key ID": "معرّف مفتاح الوصول",
|
||||
"Your Access Key ID": "معرّف مفتاح الوصول الخاص بك",
|
||||
"Secret Access Key": "مفتاح الوصول السري",
|
||||
"Your Secret Access Key": "مفتاح الوصول السري الخاص بك",
|
||||
"S3-Compatible Storage": "تخزين متوافق مع S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "زامن مكتبتك وتقدم القراءة والتظليلات مع حاوية متوافقة مع S3 مثل Cloudflare R2 أو AWS S3 أو MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "في المتصفح، يجب أن تسمح سياسة CORS للحاوية بأصل هذا الموقع.",
|
||||
"Use S3": "استخدام S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "تأكد من وجود الحاوية ومن أن بيانات الاعتماد لديها صلاحية القراءة والكتابة قبل الاتصال.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "عند اختيار {{provider}}، تتم مزامنة الكتب والتقدم والتعليقات التوضيحية إلى خادمك فقط.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "عند اختيار {{provider}}، تتم مزامنة الكتب والتقدم والتعليقات التوضيحية إلى Drive الخاص بك فقط.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "عند اختيار {{provider}}، تتم مزامنة الكتب والتقدم والتعليقات التوضيحية إلى حاويتك فقط.",
|
||||
"Premium": "بريميوم",
|
||||
"S3 Storage": "تخزين S3"
|
||||
}
|
||||
|
||||
@@ -1817,10 +1817,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "আপনার লাইব্রেরি, পড়ার অগ্রগতি এবং হাইলাইটগুলি আপনার Google Drive-এর সাথে সিঙ্ক করুন।",
|
||||
"Configured": "কনফিগার করা হয়েছে",
|
||||
"Third-party Cloud Sync": "তৃতীয় পক্ষের ক্লাউড সিঙ্ক",
|
||||
"Use WebDAV": "WebDAV ব্যবহার করুন",
|
||||
"Cloud Sync": "ক্লাউড সিঙ্ক",
|
||||
"Available on Plus, Pro, or Lifetime": "Plus, Pro বা Lifetime-এ উপলব্ধ",
|
||||
"Extra Large": "অতিরিক্ত বড়",
|
||||
"Hint size": "ইঙ্গিতের আকার",
|
||||
"Gloss text size above the word": "শব্দের উপরে ইঙ্গিত টেক্সটের আকার",
|
||||
@@ -1831,15 +1829,12 @@
|
||||
"Stopped reading aloud": "উচ্চস্বরে পড়া বন্ধ করা হয়েছে",
|
||||
"Read aloud stopped": "উচ্চস্বরে পড়া বন্ধ হয়েছে",
|
||||
"Cloud provider switched": "ক্লাউড প্রদানকারী পরিবর্তিত হয়েছে",
|
||||
"Synced via {{provider}}": "{{provider}}-এর মাধ্যমে সিঙ্ক হয়েছে",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "{{provider}} সিঙ্ক নির্বাচিত থাকাকালীন Readest Cloud-এ আপলোড স্থগিত থাকে",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "{{provider}} আপনার ক্লাউড সিঙ্ক প্রদানকারী থাকাকালীন এটি {{provider}} দ্বারা পরিচালিত হয়",
|
||||
"Not signed in": "সাইন ইন করা হয়নি",
|
||||
"Paused — plan required": "স্থগিত — প্ল্যান প্রয়োজন",
|
||||
"Active · Book file uploads off": "সক্রিয় · বইয়ের ফাইল আপলোড বন্ধ",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "WebDAV নির্বাচিত থাকাকালীন বই, অগ্রগতি এবং হাইলাইট শুধুমাত্র আপনার সার্ভারের সাথে সিঙ্ক হয়।",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "অ্যাপ সেটিংস, পড়ার পরিসংখ্যান এবং অভিধান সাইন ইন থাকাকালীন আপনার Readest অ্যাকাউন্টের মাধ্যমে সিঙ্ক হতে থাকে।",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Google Drive নির্বাচিত থাকাকালীন বই, অগ্রগতি এবং হাইলাইট শুধুমাত্র আপনার Drive-এর সাথে সিঙ্ক হয়।",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "আপনার লাইব্রেরি, পড়ার অগ্রগতি এবং হাইলাইটগুলি Readest Cloud-এর সাথে সিঙ্ক করুন।",
|
||||
"Account and Storage": "অ্যাকাউন্ট এবং স্টোরেজ",
|
||||
@@ -1854,5 +1849,23 @@
|
||||
"Cloud sync session expired": "ক্লাউড সিঙ্ক সেশনের মেয়াদ শেষ হয়েছে",
|
||||
"Uploads book files to your other devices": "আপনার অন্যান্য ডিভাইসে বইয়ের ফাইল আপলোড করে",
|
||||
"Re-check every book instead of only changed ones": "শুধু পরিবর্তিত নয়, প্রতিটি বই আবার যাচাই করুন",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "বাকেট পাওয়া যায়নি",
|
||||
"Endpoint": "এন্ডপয়েন্ট",
|
||||
"Bucket": "বাকেট",
|
||||
"Region": "রিজিয়ন",
|
||||
"Access Key ID": "অ্যাক্সেস কী ID",
|
||||
"Your Access Key ID": "আপনার অ্যাক্সেস কী ID",
|
||||
"Secret Access Key": "সিক্রেট অ্যাক্সেস কী",
|
||||
"Your Secret Access Key": "আপনার সিক্রেট অ্যাক্সেস কী",
|
||||
"S3-Compatible Storage": "S3-সামঞ্জস্যপূর্ণ স্টোরেজ",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Cloudflare R2, AWS S3 বা MinIO-এর মতো S3-সামঞ্জস্যপূর্ণ বাকেটের সাথে আপনার লাইব্রেরি, পড়ার অগ্রগতি ও হাইলাইট সিঙ্ক করুন।",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "ব্রাউজারে ব্যবহারের জন্য বাকেটের CORS নীতিতে এই সাইটের অরিজিন অনুমোদিত হতে হবে।",
|
||||
"Use S3": "S3 ব্যবহার করুন",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "সংযোগের আগে নিশ্চিত করুন যে বাকেটটি বিদ্যমান এবং শংসাপত্রগুলির পড়া/লেখার অনুমতি আছে।",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "{{provider}} নির্বাচিত থাকাকালীন বই, অগ্রগতি ও টীকা শুধুমাত্র আপনার সার্ভারে সিঙ্ক হয়।",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}} নির্বাচিত থাকাকালীন বই, অগ্রগতি ও টীকা শুধুমাত্র আপনার Drive-এ সিঙ্ক হয়।",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}} নির্বাচিত থাকাকালীন বই, অগ্রগতি ও টীকা শুধুমাত্র আপনার বাকেটে সিঙ্ক হয়।",
|
||||
"Premium": "প্রিমিয়াম",
|
||||
"S3 Storage": "S3 স্টোরেজ"
|
||||
}
|
||||
|
||||
@@ -1783,10 +1783,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "ཁྱེད་ཀྱི་དཔེ་མཛོད་དང་། ཀློག་པའི་ཡར་འཕེལ། གཙོ་གནད་བཅས་ཁྱེད་ཀྱི་ Google Drive དང་མཉམ་སྒྲིག་བྱེད།",
|
||||
"Configured": "བཀོད་སྒྲིག་ཟིན།",
|
||||
"Third-party Cloud Sync": "ཕྱོགས་གསུམ་པའི་སྤྲིན་མཉམ་སྒྲིག",
|
||||
"Use WebDAV": "WebDAV བེད་སྤྱོད།",
|
||||
"Cloud Sync": "སྤྲིན་མཉམ་སྒྲིག",
|
||||
"Available on Plus, Pro, or Lifetime": "Plus, Pro, Lifetime འཆར་གཞི་ལ་ཐོབ་ཐུབ།",
|
||||
"Extra Large": "ཧ་ཅང་ཆེ་བ།",
|
||||
"Hint size": "བརྡ་སྟོན་གྱི་ཆེ་ཆུང་།",
|
||||
"Gloss text size above the word": "ཚིག་གི་སྟེང་གི་བརྡ་སྟོན་ཡི་གེའི་ཆེ་ཆུང་།",
|
||||
@@ -1797,15 +1795,12 @@
|
||||
"Stopped reading aloud": "སྒྲ་ཀློག་མཚམས་བཞག་ཟིན།",
|
||||
"Read aloud stopped": "སྒྲ་ཀློག་མཚམས་ཆད་ཟིན།",
|
||||
"Cloud provider switched": "སྤྲིན་གྱི་ཞབས་ཞུ་བ་བརྗེས་ཟིན།",
|
||||
"Synced via {{provider}}": "{{provider}} བརྒྱུད་ནས་མཉམ་སྒྲིག་བྱས་ཟིན།",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "{{provider}} མཉམ་སྒྲིག་འདེམས་ཡོད་རིང་ Readest Cloud ལ་ཡར་འགོད་མཚམས་འཇོག་ཡིན།",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "{{provider}} ཁྱེད་ཀྱི་སྤྲིན་མཉམ་སྒྲིག་ཞབས་ཞུ་བ་ཡིན་རིང་དེས་དོ་དམ་བྱེད།",
|
||||
"Not signed in": "ནང་འཛུལ་བྱས་མེད།",
|
||||
"Paused — plan required": "མཚམས་བཞག་པ་ — འཆར་གཞི་དགོས།",
|
||||
"Active · Book file uploads off": "བྱེད་བཞིན་པ་ · དཔེ་ཆའི་ཡིག་ཆ་ཡར་འགོད་སྒོ་བརྒྱབ།",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "WebDAV འདེམས་ཡོད་རིང་ དཔེ་ཆ་དང་ཡར་འཕེལ། གཙོ་གནད་བཅས་ཁྱེད་ཀྱི་ཞབས་ཞུ་འཕྲུལ་ཆས་དང་ཁོ་ན་མཉམ་སྒྲིག་བྱེད།",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "ནང་འཛུལ་བྱས་ཡོད་རིང་ མཉེན་ཆས་སྒྲིག་འགོད་དང་ཀློག་པའི་སྡོམ་རྩིས། ཚིག་མཛོད་བཅས་ད་དུང་ Readest རྩིས་ཐོ་བརྒྱུད་ནས་མཉམ་སྒྲིག་བྱེད།",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Google Drive འདེམས་ཡོད་རིང་ དཔེ་ཆ་དང་ཡར་འཕེལ། གཙོ་གནད་བཅས་ཁྱེད་ཀྱི་ Drive དང་ཁོ་ན་མཉམ་སྒྲིག་བྱེད།",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "ཁྱེད་ཀྱི་དཔེ་མཛོད་དང་། ཀློག་པའི་ཡར་འཕེལ། གཙོ་གནད་བཅས་ Readest Cloud དང་མཉམ་སྒྲིག་བྱེད།",
|
||||
"Account and Storage": "རྩིས་ཐོ་དང་ཉར་ཚགས།",
|
||||
@@ -1819,5 +1814,23 @@
|
||||
"Cloud sync session expired": "སྤྲིན་གྱི་མཉམ་སྒྲིག་ཐེངས་གྲངས་ཀྱི་དུས་ཡོལ་སོང་",
|
||||
"Uploads book files to your other devices": "དཔེ་ཆའི་ཡིག་ཆ་ཁྱེད་ཀྱི་སྒྲིག་ཆས་གཞན་ལ་ཡར་འཇུག་བྱེད",
|
||||
"Re-check every book instead of only changed ones": "བསྒྱུར་བཅོས་བྱས་པ་ཁོ་ན་མིན་པར་དཔེ་ཆ་རེ་རེ་ཡང་བསྐྱར་ཞིབ་བཤེར",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Bucket རྙེད་མ་སོང་།",
|
||||
"Endpoint": "Endpoint",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "ས་ཁུལ།",
|
||||
"Access Key ID": "Access Key ID",
|
||||
"Your Access Key ID": "ཁྱེད་ཀྱི་ Access Key ID",
|
||||
"Secret Access Key": "Secret Access Key",
|
||||
"Your Secret Access Key": "ཁྱེད་ཀྱི་ Secret Access Key",
|
||||
"S3-Compatible Storage": "S3 དང་མཐུན་པའི་གསོག་མཛོད།",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Cloudflare R2 དང་ AWS S3, MinIO ལྟ་བུའི་ S3 དང་མཐུན་པའི་ bucket དང་མཉམ་དུ་ཁྱེད་ཀྱི་དཔེ་མཛོད་དང་ཀློག་པའི་བགྲོད་རིམ། གསལ་མཚམས་བཅས་མཉམ་བསྒྲིག་བྱེད།",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "བཤར་ཆས་ནང་དུ། bucket གི་ CORS སྲིད་ཇུས་ཀྱིས་དྲ་ཚིགས་འདིའི་ origin ལ་ཆོག་མཆན་སྤྲོད་དགོས།",
|
||||
"Use S3": "S3 བེད་སྤྱོད།",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "སྦྲེལ་མཐུད་མ་བྱས་གོང་ bucket ཡོད་པ་དང་ credentials ལ་ཀློག་འབྲི་དབང་ཚད་ཡོད་པ་གཏན་འཁེལ་བྱེད།",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "{{provider}} བདམས་ཡོད་སྐབས། དཔེ་ཆ་དང་བགྲོད་རིམ། མཆན་འགྲེལ་བཅས་ཁྱེད་ཀྱི་ server ནང་ཁོ་ནར་མཉམ་བསྒྲིག་བྱེད།",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}} བདམས་ཡོད་སྐབས། དཔེ་ཆ་དང་བགྲོད་རིམ། མཆན་འགྲེལ་བཅས་ཁྱེད་ཀྱི་ Drive ནང་ཁོ་ནར་མཉམ་བསྒྲིག་བྱེད།",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}} བདམས་ཡོད་སྐབས། དཔེ་ཆ་དང་བགྲོད་རིམ། མཆན་འགྲེལ་བཅས་ཁྱེད་ཀྱི་ bucket ནང་ཁོ་ནར་མཉམ་བསྒྲིག་བྱེད།",
|
||||
"Premium": "སྤུས་ལྡན།",
|
||||
"S3 Storage": "S3 གསོག་མཛོད།"
|
||||
}
|
||||
|
||||
@@ -1817,10 +1817,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Synchronisieren Sie Ihre Bibliothek, Ihren Lesefortschritt und Ihre Markierungen mit Ihrem Google Drive.",
|
||||
"Configured": "Konfiguriert",
|
||||
"Third-party Cloud Sync": "Cloud-Synchronisierung von Drittanbietern",
|
||||
"Use WebDAV": "WebDAV verwenden",
|
||||
"Cloud Sync": "Cloud-Synchronisierung",
|
||||
"Available on Plus, Pro, or Lifetime": "Verfügbar mit Plus, Pro oder Lifetime",
|
||||
"Extra Large": "Extra groß",
|
||||
"Hint size": "Hinweisgröße",
|
||||
"Gloss text size above the word": "Textgröße des Hinweises über dem Wort",
|
||||
@@ -1831,15 +1829,12 @@
|
||||
"Stopped reading aloud": "Vorlesen gestoppt",
|
||||
"Read aloud stopped": "Vorlesen wurde gestoppt",
|
||||
"Cloud provider switched": "Cloud-Anbieter gewechselt",
|
||||
"Synced via {{provider}}": "Über {{provider}} synchronisiert",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Uploads zu Readest Cloud sind pausiert, solange die {{provider}}-Synchronisierung ausgewählt ist",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Wird von {{provider}} verwaltet, solange es Ihr Cloud-Sync-Anbieter ist",
|
||||
"Not signed in": "Nicht angemeldet",
|
||||
"Paused — plan required": "Pausiert — Tarif erforderlich",
|
||||
"Active · Book file uploads off": "Aktiv · Hochladen von Buchdateien aus",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Solange WebDAV ausgewählt ist, werden Bücher, Lesefortschritt und Markierungen nur mit Ihrem Server synchronisiert.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "App-Einstellungen, Lesestatistiken und Wörterbücher werden weiterhin über Ihr Readest-Konto synchronisiert, solange Sie angemeldet sind.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Solange Google Drive ausgewählt ist, werden Bücher, Lesefortschritt und Markierungen nur mit Ihrem Drive synchronisiert.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Synchronisieren Sie Ihre Bibliothek, Ihren Lesefortschritt und Ihre Markierungen mit Readest Cloud.",
|
||||
"Account and Storage": "Konto und Speicher",
|
||||
@@ -1854,5 +1849,23 @@
|
||||
"Cloud sync session expired": "Cloud-Synchronisierungssitzung abgelaufen",
|
||||
"Uploads book files to your other devices": "Lädt Buchdateien auf deine anderen Geräte hoch",
|
||||
"Re-check every book instead of only changed ones": "Jedes Buch erneut prüfen statt nur geänderte",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Bucket nicht gefunden",
|
||||
"Endpoint": "Endpunkt",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Region",
|
||||
"Access Key ID": "Zugriffsschlüssel-ID",
|
||||
"Your Access Key ID": "Ihre Zugriffsschlüssel-ID",
|
||||
"Secret Access Key": "Geheimer Zugriffsschlüssel",
|
||||
"Your Secret Access Key": "Ihr geheimer Zugriffsschlüssel",
|
||||
"S3-Compatible Storage": "S3-kompatibler Speicher",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Synchronisieren Sie Ihre Bibliothek, Ihren Lesefortschritt und Ihre Markierungen mit einem S3-kompatiblen Bucket wie Cloudflare R2, AWS S3 oder MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "Im Browser muss die CORS-Richtlinie des Buckets den Ursprung dieser Website zulassen.",
|
||||
"Use S3": "S3 verwenden",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Stellen Sie vor dem Verbinden sicher, dass der Bucket existiert und die Zugangsdaten Lese-/Schreibzugriff haben.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Solange {{provider}} ausgewählt ist, werden Bücher, Fortschritt und Anmerkungen nur mit Ihrem Server synchronisiert.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Solange {{provider}} ausgewählt ist, werden Bücher, Fortschritt und Anmerkungen nur mit Ihrem Drive synchronisiert.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Solange {{provider}} ausgewählt ist, werden Bücher, Fortschritt und Anmerkungen nur mit Ihrem Bucket synchronisiert.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "S3-Speicher"
|
||||
}
|
||||
|
||||
@@ -1817,10 +1817,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Συγχρονίστε τη βιβλιοθήκη, την πρόοδο ανάγνωσης και τις επισημάνσεις σας με το Google Drive σας.",
|
||||
"Configured": "Ρυθμισμένο",
|
||||
"Third-party Cloud Sync": "Συγχρονισμός cloud τρίτων",
|
||||
"Use WebDAV": "Χρήση WebDAV",
|
||||
"Cloud Sync": "Συγχρονισμός cloud",
|
||||
"Available on Plus, Pro, or Lifetime": "Διαθέσιμο με Plus, Pro ή Lifetime",
|
||||
"Extra Large": "Πολύ μεγάλο",
|
||||
"Hint size": "Μέγεθος υπόδειξης",
|
||||
"Gloss text size above the word": "Μέγεθος κειμένου της υπόδειξης πάνω από τη λέξη",
|
||||
@@ -1831,15 +1829,12 @@
|
||||
"Stopped reading aloud": "Η ανάγνωση σταμάτησε",
|
||||
"Read aloud stopped": "Η ανάγνωση διακόπηκε",
|
||||
"Cloud provider switched": "Ο πάροχος cloud άλλαξε",
|
||||
"Synced via {{provider}}": "Συγχρονίστηκε μέσω {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Οι μεταφορτώσεις στο Readest Cloud είναι σε παύση όσο είναι επιλεγμένος ο συγχρονισμός {{provider}}",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Διαχειρίζεται από το {{provider}} όσο είναι ο πάροχος συγχρονισμού cloud σας",
|
||||
"Not signed in": "Δεν έχετε συνδεθεί",
|
||||
"Paused — plan required": "Σε παύση — απαιτείται πρόγραμμα",
|
||||
"Active · Book file uploads off": "Ενεργό · Η μεταφόρτωση αρχείων βιβλίων είναι ανενεργή",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Όσο είναι επιλεγμένο το WebDAV, τα βιβλία, η πρόοδος και οι επισημάνσεις συγχρονίζονται μόνο με τον διακομιστή σας.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Οι ρυθμίσεις της εφαρμογής, τα στατιστικά ανάγνωσης και τα λεξικά εξακολουθούν να συγχρονίζονται μέσω του λογαριασμού Readest όσο είστε συνδεδεμένοι.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Όσο είναι επιλεγμένο το Google Drive, τα βιβλία, η πρόοδος και οι επισημάνσεις συγχρονίζονται μόνο με το Drive σας.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Συγχρονίστε τη βιβλιοθήκη, την πρόοδο ανάγνωσης και τις επισημάνσεις σας με το Readest Cloud.",
|
||||
"Account and Storage": "Λογαριασμός και αποθήκευση",
|
||||
@@ -1854,5 +1849,23 @@
|
||||
"Cloud sync session expired": "Η συνεδρία συγχρονισμού cloud έληξε",
|
||||
"Uploads book files to your other devices": "Μεταφορτώνει αρχεία βιβλίων στις άλλες συσκευές σας",
|
||||
"Re-check every book instead of only changed ones": "Επανέλεγχος κάθε βιβλίου αντί μόνο των αλλαγμένων",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Ο κάδος δεν βρέθηκε",
|
||||
"Endpoint": "Τελικό σημείο",
|
||||
"Bucket": "Κάδος",
|
||||
"Region": "Περιοχή",
|
||||
"Access Key ID": "Access Key ID",
|
||||
"Your Access Key ID": "Το Access Key ID σας",
|
||||
"Secret Access Key": "Secret Access Key",
|
||||
"Your Secret Access Key": "Το Secret Access Key σας",
|
||||
"S3-Compatible Storage": "Αποθήκευση συμβατή με S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Συγχρονίστε τη βιβλιοθήκη, την πρόοδο ανάγνωσης και τις επισημάνσεις σας με έναν κάδο συμβατό με S3, όπως Cloudflare R2, AWS S3 ή MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "Στο πρόγραμμα περιήγησης, η πολιτική CORS του κάδου πρέπει να επιτρέπει την προέλευση αυτού του ιστότοπου.",
|
||||
"Use S3": "Χρήση S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Πριν από τη σύνδεση, βεβαιωθείτε ότι ο κάδος υπάρχει και ότι τα διαπιστευτήρια έχουν πρόσβαση ανάγνωσης/εγγραφής.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Όσο είναι επιλεγμένο το {{provider}}, τα βιβλία, η πρόοδος και οι σημειώσεις συγχρονίζονται μόνο με τον διακομιστή σας.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Όσο είναι επιλεγμένο το {{provider}}, τα βιβλία, η πρόοδος και οι σημειώσεις συγχρονίζονται μόνο με το Drive σας.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Όσο είναι επιλεγμένο το {{provider}}, τα βιβλία, η πρόοδος και οι σημειώσεις συγχρονίζονται μόνο με τον κάδο σας.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "Αποθήκευση S3"
|
||||
}
|
||||
|
||||
@@ -1851,10 +1851,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Sincroniza tu biblioteca, progreso de lectura y resaltados con tu Google Drive.",
|
||||
"Configured": "Configurado",
|
||||
"Third-party Cloud Sync": "Sincronización en la nube de terceros",
|
||||
"Use WebDAV": "Usar WebDAV",
|
||||
"Cloud Sync": "Sincronización en la nube",
|
||||
"Available on Plus, Pro, or Lifetime": "Disponible con Plus, Pro o De por vida",
|
||||
"Extra Large": "Extra grande",
|
||||
"Hint size": "Tamaño de la pista",
|
||||
"Gloss text size above the word": "Tamaño del texto de la pista sobre la palabra",
|
||||
@@ -1865,15 +1863,12 @@
|
||||
"Stopped reading aloud": "Lectura en voz alta detenida",
|
||||
"Read aloud stopped": "Lectura en voz alta detenida",
|
||||
"Cloud provider switched": "Proveedor de nube cambiado",
|
||||
"Synced via {{provider}}": "Sincronizado vía {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Las subidas a Readest Cloud están en pausa mientras la sincronización con {{provider}} esté seleccionada",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Gestionado por {{provider}} mientras sea tu proveedor de sincronización en la nube",
|
||||
"Not signed in": "Sesión no iniciada",
|
||||
"Paused — plan required": "En pausa — se requiere un plan",
|
||||
"Active · Book file uploads off": "Activo · Subida de archivos de libros desactivada",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Mientras WebDAV esté seleccionado, los libros, el progreso y los resaltados se sincronizan solo con tu servidor.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Los ajustes de la aplicación, las estadísticas de lectura y los diccionarios se siguen sincronizando a través de tu cuenta de Readest mientras tengas la sesión iniciada.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Mientras Google Drive esté seleccionado, los libros, el progreso y los resaltados se sincronizan solo con tu Drive.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Sincroniza tu biblioteca, progreso de lectura y resaltados con Readest Cloud.",
|
||||
"Account and Storage": "Cuenta y almacenamiento",
|
||||
@@ -1889,5 +1884,23 @@
|
||||
"Cloud sync session expired": "La sesión de sincronización en la nube ha caducado",
|
||||
"Uploads book files to your other devices": "Sube los archivos de libros a tus otros dispositivos",
|
||||
"Re-check every book instead of only changed ones": "Volver a comprobar cada libro en lugar de solo los modificados",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Bucket no encontrado",
|
||||
"Endpoint": "Endpoint",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Región",
|
||||
"Access Key ID": "ID de clave de acceso",
|
||||
"Your Access Key ID": "Tu ID de clave de acceso",
|
||||
"Secret Access Key": "Clave de acceso secreta",
|
||||
"Your Secret Access Key": "Tu clave de acceso secreta",
|
||||
"S3-Compatible Storage": "Almacenamiento compatible con S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Sincroniza tu biblioteca, progreso de lectura y subrayados con un bucket compatible con S3 como Cloudflare R2, AWS S3 o MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "En el navegador, la política CORS del bucket debe permitir el origen de este sitio.",
|
||||
"Use S3": "Usar S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Antes de conectar, asegúrate de que el bucket exista y de que las credenciales tengan acceso de lectura y escritura.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Mientras {{provider}} esté seleccionado, los libros, el progreso y las anotaciones se sincronizan solo con tu servidor.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Mientras {{provider}} esté seleccionado, los libros, el progreso y las anotaciones se sincronizan solo con tu Drive.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Mientras {{provider}} esté seleccionado, los libros, el progreso y las anotaciones se sincronizan solo con tu bucket.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "Almacenamiento S3"
|
||||
}
|
||||
|
||||
@@ -1817,10 +1817,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "کتابخانه، وضعیت مطالعه و هایلایتهای خود را با Google Drive خود همگامسازی کنید.",
|
||||
"Configured": "پیکربندی شده",
|
||||
"Third-party Cloud Sync": "همگامسازی ابری شخص ثالث",
|
||||
"Use WebDAV": "استفاده از WebDAV",
|
||||
"Cloud Sync": "همگامسازی ابری",
|
||||
"Available on Plus, Pro, or Lifetime": "در Plus، Pro یا Lifetime در دسترس است",
|
||||
"Extra Large": "خیلی بزرگ",
|
||||
"Hint size": "اندازه توضیح",
|
||||
"Gloss text size above the word": "اندازه متن توضیح بالای کلمه",
|
||||
@@ -1831,15 +1829,12 @@
|
||||
"Stopped reading aloud": "خواندن با صدای بلند متوقف شد",
|
||||
"Read aloud stopped": "خواندن با صدای بلند متوقف شده است",
|
||||
"Cloud provider switched": "ارائهدهنده ابری تغییر کرد",
|
||||
"Synced via {{provider}}": "از طریق {{provider}} همگامسازی شد",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "تا زمانی که همگامسازی {{provider}} انتخاب شده باشد، بارگذاری در Readest Cloud متوقف است",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "تا زمانی که {{provider}} ارائهدهنده همگامسازی ابری شماست، توسط آن مدیریت میشود",
|
||||
"Not signed in": "وارد نشدهاید",
|
||||
"Paused — plan required": "متوقف شده — به طرح اشتراک نیاز است",
|
||||
"Active · Book file uploads off": "فعال · بارگذاری فایلهای کتاب خاموش است",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "تا زمانی که WebDAV انتخاب شده باشد، کتابها، پیشرفت و هایلایتها فقط با سرور شما همگامسازی میشوند.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "تنظیمات برنامه، آمار مطالعه و واژهنامهها تا زمانی که وارد شده باشید همچنان از طریق حساب Readest شما همگامسازی میشوند.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "تا زمانی که Google Drive انتخاب شده باشد، کتابها، پیشرفت و هایلایتها فقط با Drive شما همگامسازی میشوند.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "کتابخانه، وضعیت مطالعه و هایلایتهای خود را با Readest Cloud همگامسازی کنید.",
|
||||
"Account and Storage": "حساب و فضای ذخیرهسازی",
|
||||
@@ -1854,5 +1849,23 @@
|
||||
"Cloud sync session expired": "نشست همگامسازی ابری منقضی شده است",
|
||||
"Uploads book files to your other devices": "فایلهای کتاب را روی دستگاههای دیگر شما بارگذاری میکند",
|
||||
"Re-check every book instead of only changed ones": "بررسی مجدد هر کتاب بهجای فقط کتابهای تغییریافته",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "باکت پیدا نشد",
|
||||
"Endpoint": "نقطه پایانی",
|
||||
"Bucket": "باکت",
|
||||
"Region": "ناحیه",
|
||||
"Access Key ID": "شناسه کلید دسترسی",
|
||||
"Your Access Key ID": "شناسه کلید دسترسی شما",
|
||||
"Secret Access Key": "کلید دسترسی محرمانه",
|
||||
"Your Secret Access Key": "کلید دسترسی محرمانه شما",
|
||||
"S3-Compatible Storage": "فضای ذخیرهسازی سازگار با S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "کتابخانه، پیشرفت مطالعه و هایلایتهای خود را با یک باکت سازگار با S3 مانند Cloudflare R2، AWS S3 یا MinIO همگامسازی کنید.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "در مرورگر، سیاست CORS باکت باید مبدأ این سایت را مجاز کند.",
|
||||
"Use S3": "استفاده از S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "پیش از اتصال مطمئن شوید باکت وجود دارد و اعتبارنامهها دسترسی خواندن/نوشتن دارند.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "تا زمانی که {{provider}} انتخاب شده باشد، کتابها، پیشرفت و یادداشتها فقط با سرور شما همگامسازی میشوند.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "تا زمانی که {{provider}} انتخاب شده باشد، کتابها، پیشرفت و یادداشتها فقط با Drive شما همگامسازی میشوند.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "تا زمانی که {{provider}} انتخاب شده باشد، کتابها، پیشرفت و یادداشتها فقط با باکت شما همگامسازی میشوند.",
|
||||
"Premium": "پریمیوم",
|
||||
"S3 Storage": "فضای ذخیرهسازی S3"
|
||||
}
|
||||
|
||||
@@ -1851,10 +1851,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Synchronisez votre bibliothèque, votre progression de lecture et vos surlignages avec votre Google Drive.",
|
||||
"Configured": "Configuré",
|
||||
"Third-party Cloud Sync": "Synchronisation cloud tierce",
|
||||
"Use WebDAV": "Utiliser WebDAV",
|
||||
"Cloud Sync": "Synchronisation cloud",
|
||||
"Available on Plus, Pro, or Lifetime": "Disponible avec Plus, Pro ou À vie",
|
||||
"Extra Large": "Très grand",
|
||||
"Hint size": "Taille de l'indice",
|
||||
"Gloss text size above the word": "Taille du texte de l'indice au-dessus du mot",
|
||||
@@ -1865,15 +1863,12 @@
|
||||
"Stopped reading aloud": "Lecture à haute voix arrêtée",
|
||||
"Read aloud stopped": "Lecture à haute voix arrêtée",
|
||||
"Cloud provider switched": "Fournisseur cloud changé",
|
||||
"Synced via {{provider}}": "Synchronisé via {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Les téléversements vers Readest Cloud sont en pause tant que la synchronisation {{provider}} est sélectionnée",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Géré par {{provider}} tant qu'il est votre fournisseur de synchronisation cloud",
|
||||
"Not signed in": "Non connecté",
|
||||
"Paused — plan required": "En pause — abonnement requis",
|
||||
"Active · Book file uploads off": "Actif · Téléversement des fichiers de livres désactivé",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Tant que WebDAV est sélectionné, les livres, la progression et les surlignages ne se synchronisent qu’avec votre serveur.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Les paramètres de l'application, les statistiques de lecture et les dictionnaires continuent de se synchroniser via votre compte Readest tant que vous êtes connecté.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Tant que Google Drive est sélectionné, les livres, la progression et les surlignages ne se synchronisent qu’avec votre Drive.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Synchronisez votre bibliothèque, votre progression de lecture et vos surlignages avec Readest Cloud.",
|
||||
"Account and Storage": "Compte et stockage",
|
||||
@@ -1889,5 +1884,23 @@
|
||||
"Cloud sync session expired": "Session de synchronisation cloud expirée",
|
||||
"Uploads book files to your other devices": "Téléverse les fichiers de livres vers vos autres appareils",
|
||||
"Re-check every book instead of only changed ones": "Revérifier chaque livre au lieu des seuls livres modifiés",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Bucket introuvable",
|
||||
"Endpoint": "Point de terminaison",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Région",
|
||||
"Access Key ID": "ID de clé d'accès",
|
||||
"Your Access Key ID": "Votre ID de clé d'accès",
|
||||
"Secret Access Key": "Clé d'accès secrète",
|
||||
"Your Secret Access Key": "Votre clé d'accès secrète",
|
||||
"S3-Compatible Storage": "Stockage compatible S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Synchronisez votre bibliothèque, votre progression de lecture et vos surlignages avec un bucket compatible S3 tel que Cloudflare R2, AWS S3 ou MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "Dans le navigateur, la politique CORS du bucket doit autoriser l'origine de ce site.",
|
||||
"Use S3": "Utiliser S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Avant de vous connecter, assurez-vous que le bucket existe et que les identifiants disposent d'un accès en lecture/écriture.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Tant que {{provider}} est sélectionné, les livres, la progression et les annotations ne se synchronisent qu'avec votre serveur.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Tant que {{provider}} est sélectionné, les livres, la progression et les annotations ne se synchronisent qu'avec votre Drive.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Tant que {{provider}} est sélectionné, les livres, la progression et les annotations ne se synchronisent qu'avec votre bucket.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "Stockage S3"
|
||||
}
|
||||
|
||||
@@ -1851,10 +1851,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "סנכרן את הספרייה, התקדמות הקריאה וההדגשות שלך עם Google Drive שלך.",
|
||||
"Configured": "מוגדר",
|
||||
"Third-party Cloud Sync": "סנכרון ענן של צד שלישי",
|
||||
"Use WebDAV": "השתמש ב-WebDAV",
|
||||
"Cloud Sync": "סנכרון ענן",
|
||||
"Available on Plus, Pro, or Lifetime": "זמין ב-Plus, Pro או Lifetime",
|
||||
"Extra Large": "גדול במיוחד",
|
||||
"Hint size": "גודל הרמז",
|
||||
"Gloss text size above the word": "גודל טקסט הרמז שמעל המילה",
|
||||
@@ -1865,15 +1863,12 @@
|
||||
"Stopped reading aloud": "ההקראה בקול הופסקה",
|
||||
"Read aloud stopped": "ההקראה הופסקה",
|
||||
"Cloud provider switched": "ספק הענן הוחלף",
|
||||
"Synced via {{provider}}": "סונכרן דרך {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "העלאות ל-Readest Cloud מושהות כל עוד סנכרון {{provider}} נבחר",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "מנוהל על ידי {{provider}} כל עוד הוא ספק סנכרון הענן שלך",
|
||||
"Not signed in": "לא מחובר",
|
||||
"Paused — plan required": "מושהה — נדרש מינוי",
|
||||
"Active · Book file uploads off": "פעיל · העלאת קובצי ספרים כבויה",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "כל עוד WebDAV נבחר, ספרים, התקדמות והדגשות מסתנכרנים רק עם השרת שלך.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "הגדרות האפליקציה, סטטיסטיקות קריאה ומילונים ממשיכים להסתנכרן דרך חשבון Readest שלך כל עוד אתה מחובר.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "כל עוד Google Drive נבחר, ספרים, התקדמות והדגשות מסתנכרנים רק עם ה-Drive שלך.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "סנכרן את הספרייה, התקדמות הקריאה וההדגשות שלך עם Readest Cloud.",
|
||||
"Account and Storage": "חשבון ואחסון",
|
||||
@@ -1889,5 +1884,23 @@
|
||||
"Cloud sync session expired": "פג תוקף ההפעלה של סנכרון הענן",
|
||||
"Uploads book files to your other devices": "מעלה קובצי ספרים למכשירים האחרים שלך",
|
||||
"Re-check every book instead of only changed ones": "בדיקה מחדש של כל ספר במקום רק אלה שהשתנו",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "הדלי לא נמצא",
|
||||
"Endpoint": "נקודת קצה",
|
||||
"Bucket": "דלי",
|
||||
"Region": "אזור",
|
||||
"Access Key ID": "מזהה מפתח גישה",
|
||||
"Your Access Key ID": "מזהה מפתח הגישה שלך",
|
||||
"Secret Access Key": "מפתח גישה סודי",
|
||||
"Your Secret Access Key": "מפתח הגישה הסודי שלך",
|
||||
"S3-Compatible Storage": "אחסון תואם S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "סנכרן את הספרייה, התקדמות הקריאה וההדגשות שלך עם דלי תואם S3 כגון Cloudflare R2, AWS S3 או MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "בדפדפן, מדיניות ה-CORS של הדלי חייבת לאפשר את המקור של אתר זה.",
|
||||
"Use S3": "שימוש ב-S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "לפני ההתחברות ודא שהדלי קיים ושלאישורי הגישה יש הרשאות קריאה/כתיבה.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "כאשר {{provider}} נבחר, ספרים, התקדמות והערות מסתנכרנים רק עם השרת שלך.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "כאשר {{provider}} נבחר, ספרים, התקדמות והערות מסתנכרנים רק עם ה-Drive שלך.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "כאשר {{provider}} נבחר, ספרים, התקדמות והערות מסתנכרנים רק עם הדלי שלך.",
|
||||
"Premium": "פרימיום",
|
||||
"S3 Storage": "אחסון S3"
|
||||
}
|
||||
|
||||
@@ -1817,10 +1817,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "अपनी लाइब्रेरी, पढ़ने की प्रगति और हाइलाइट्स को अपनी Google Drive से सिंक करें।",
|
||||
"Configured": "कॉन्फ़िगर किया गया",
|
||||
"Third-party Cloud Sync": "तृतीय-पक्ष क्लाउड सिंक",
|
||||
"Use WebDAV": "WebDAV का उपयोग करें",
|
||||
"Cloud Sync": "क्लाउड सिंक",
|
||||
"Available on Plus, Pro, or Lifetime": "Plus, Pro या Lifetime में उपलब्ध",
|
||||
"Extra Large": "बहुत बड़ा",
|
||||
"Hint size": "संकेत का आकार",
|
||||
"Gloss text size above the word": "शब्द के ऊपर दिखने वाले संकेत टेक्स्ट का आकार",
|
||||
@@ -1831,15 +1829,12 @@
|
||||
"Stopped reading aloud": "उच्च स्वर में पढ़ना रोक दिया गया",
|
||||
"Read aloud stopped": "उच्च स्वर में पढ़ना रुक गया",
|
||||
"Cloud provider switched": "क्लाउड प्रदाता बदला गया",
|
||||
"Synced via {{provider}}": "{{provider}} के माध्यम से सिंक हुआ",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "जब तक {{provider}} सिंक चयनित है, Readest Cloud पर अपलोड रुके रहेंगे",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "जब तक {{provider}} आपका क्लाउड सिंक प्रदाता है, इसे {{provider}} प्रबंधित करता है",
|
||||
"Not signed in": "साइन इन नहीं किया गया",
|
||||
"Paused — plan required": "रुका हुआ — प्लान आवश्यक",
|
||||
"Active · Book file uploads off": "सक्रिय · पुस्तक फ़ाइल अपलोड बंद",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "जब तक WebDAV चयनित है, पुस्तकें, प्रगति और हाइलाइट्स केवल आपके सर्वर से सिंक होते हैं।",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "ऐप सेटिंग्स, पढ़ने के आँकड़े और शब्दकोश साइन इन रहने तक आपके Readest खाते के माध्यम से सिंक होते रहते हैं।",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "जब तक Google Drive चयनित है, पुस्तकें, प्रगति और हाइलाइट्स केवल आपकी Drive से सिंक होते हैं।",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "अपनी लाइब्रेरी, पढ़ने की प्रगति और हाइलाइट्स को Readest Cloud से सिंक करें।",
|
||||
"Account and Storage": "खाता और स्टोरेज",
|
||||
@@ -1854,5 +1849,23 @@
|
||||
"Cloud sync session expired": "क्लाउड सिंक सत्र समाप्त हो गया",
|
||||
"Uploads book files to your other devices": "आपके अन्य उपकरणों पर पुस्तक फ़ाइलें अपलोड करता है",
|
||||
"Re-check every book instead of only changed ones": "केवल बदली गई पुस्तकों के बजाय हर पुस्तक की फिर से जाँच करें",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "बकेट नहीं मिला",
|
||||
"Endpoint": "एंडपॉइंट",
|
||||
"Bucket": "बकेट",
|
||||
"Region": "क्षेत्र",
|
||||
"Access Key ID": "एक्सेस की ID",
|
||||
"Your Access Key ID": "आपकी एक्सेस की ID",
|
||||
"Secret Access Key": "सीक्रेट एक्सेस की",
|
||||
"Your Secret Access Key": "आपकी सीक्रेट एक्सेस की",
|
||||
"S3-Compatible Storage": "S3-संगत स्टोरेज",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Cloudflare R2, AWS S3 या MinIO जैसे S3-संगत बकेट के साथ अपनी लाइब्रेरी, पढ़ने की प्रगति और हाइलाइट्स सिंक करें।",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "ब्राउज़र में, बकेट की CORS नीति को इस साइट के ऑरिजिन की अनुमति देनी होगी।",
|
||||
"Use S3": "S3 उपयोग करें",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "कनेक्ट करने से पहले सुनिश्चित करें कि बकेट मौजूद है और क्रेडेंशियल्स के पास पढ़ने/लिखने की अनुमति है।",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "जब {{provider}} चुना गया हो, तो किताबें, प्रगति और एनोटेशन केवल आपके सर्वर से सिंक होते हैं।",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "जब {{provider}} चुना गया हो, तो किताबें, प्रगति और एनोटेशन केवल आपके Drive से सिंक होते हैं।",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "जब {{provider}} चुना गया हो, तो किताबें, प्रगति और एनोटेशन केवल आपके बकेट से सिंक होते हैं।",
|
||||
"Premium": "प्रीमियम",
|
||||
"S3 Storage": "S3 स्टोरेज"
|
||||
}
|
||||
|
||||
@@ -1817,10 +1817,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Szinkronizálja könyvtárát, olvasási haladását és kiemeléseit a Google Drive-jával.",
|
||||
"Configured": "Beállítva",
|
||||
"Third-party Cloud Sync": "Külső felhőszinkronizálás",
|
||||
"Use WebDAV": "WebDAV használata",
|
||||
"Cloud Sync": "Felhőszinkronizálás",
|
||||
"Available on Plus, Pro, or Lifetime": "Elérhető Plus, Pro vagy Lifetime csomaggal",
|
||||
"Extra Large": "Extra nagy",
|
||||
"Hint size": "Felirat mérete",
|
||||
"Gloss text size above the word": "A szó feletti felirat szövegmérete",
|
||||
@@ -1831,15 +1829,12 @@
|
||||
"Stopped reading aloud": "A felolvasás leállt",
|
||||
"Read aloud stopped": "Felolvasás leállítva",
|
||||
"Cloud provider switched": "Felhőszolgáltató váltva",
|
||||
"Synced via {{provider}}": "Szinkronizálva {{provider}} útján",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "A Readest Cloudba történő feltöltések szünetelnek, amíg a {{provider}} szinkronizálás van kiválasztva",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "A {{provider}} kezeli, amíg az a felhőszinkronizálási szolgáltatója",
|
||||
"Not signed in": "Nincs bejelentkezve",
|
||||
"Paused — plan required": "Szüneteltetve — előfizetés szükséges",
|
||||
"Active · Book file uploads off": "Aktív · Könyvfájlok feltöltése kikapcsolva",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Amíg a WebDAV van kiválasztva, a könyvek, a haladás és a kiemelések csak az Ön szerverével szinkronizálódnak.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Az alkalmazásbeállítások, az olvasási statisztikák és a szótárak bejelentkezve továbbra is a Readest-fiókján keresztül szinkronizálódnak.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Amíg a Google Drive van kiválasztva, a könyvek, a haladás és a kiemelések csak az Ön Drive-jával szinkronizálódnak.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Szinkronizálja könyvtárát, olvasási haladását és kiemeléseit a Readest Clouddal.",
|
||||
"Account and Storage": "Fiók és tárhely",
|
||||
@@ -1854,5 +1849,23 @@
|
||||
"Cloud sync session expired": "A felhőszinkronizálási munkamenet lejárt",
|
||||
"Uploads book files to your other devices": "Feltölti a könyvfájlokat a többi eszközére",
|
||||
"Re-check every book instead of only changed ones": "Minden könyv újraellenőrzése a módosítottak helyett",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "A bucket nem található",
|
||||
"Endpoint": "Végpont",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Régió",
|
||||
"Access Key ID": "Hozzáférési kulcs azonosító",
|
||||
"Your Access Key ID": "Add meg a hozzáférési kulcs azonosítót",
|
||||
"Secret Access Key": "Titkos hozzáférési kulcs",
|
||||
"Your Secret Access Key": "Add meg a titkos hozzáférési kulcsot",
|
||||
"S3-Compatible Storage": "S3-kompatibilis tárhely",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Szinkronizáld a könyvtáradat, olvasási előrehaladásodat és kiemeléseidet egy S3-kompatibilis buckettel, például Cloudflare R2, AWS S3 vagy MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "Böngészőben a bucket CORS-szabályzatának engedélyeznie kell ennek az oldalnak az originjét.",
|
||||
"Use S3": "S3 használata",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Csatlakozás előtt győződj meg róla, hogy a bucket létezik, és a hitelesítő adatok olvasási/írási hozzáféréssel rendelkeznek.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Amíg a(z) {{provider}} van kiválasztva, a könyvek, az előrehaladás és a jegyzetek csak a szerveredre szinkronizálódnak.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Amíg a(z) {{provider}} van kiválasztva, a könyvek, az előrehaladás és a jegyzetek csak a Drive-odba szinkronizálódnak.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Amíg a(z) {{provider}} van kiválasztva, a könyvek, az előrehaladás és a jegyzetek csak a bucketedbe szinkronizálódnak.",
|
||||
"Premium": "Prémium",
|
||||
"S3 Storage": "S3-tárhely"
|
||||
}
|
||||
|
||||
@@ -1783,10 +1783,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Sinkronkan perpustakaan, progres membaca, dan sorotan Anda dengan Google Drive Anda.",
|
||||
"Configured": "Terkonfigurasi",
|
||||
"Third-party Cloud Sync": "Sinkronisasi Cloud Pihak Ketiga",
|
||||
"Use WebDAV": "Gunakan WebDAV",
|
||||
"Cloud Sync": "Sinkronisasi Cloud",
|
||||
"Available on Plus, Pro, or Lifetime": "Tersedia di paket Plus, Pro, atau Seumur Hidup",
|
||||
"Extra Large": "Sangat Besar",
|
||||
"Hint size": "Ukuran petunjuk",
|
||||
"Gloss text size above the word": "Ukuran teks keterangan di atas kata",
|
||||
@@ -1797,15 +1795,12 @@
|
||||
"Stopped reading aloud": "Pembacaan dihentikan",
|
||||
"Read aloud stopped": "Pembacaan telah berhenti",
|
||||
"Cloud provider switched": "Penyedia cloud diganti",
|
||||
"Synced via {{provider}}": "Disinkronkan via {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Unggahan ke Readest Cloud dijeda selama sinkronisasi {{provider}} dipilih",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Dikelola oleh {{provider}} selama menjadi penyedia sinkronisasi cloud Anda",
|
||||
"Not signed in": "Belum masuk",
|
||||
"Paused — plan required": "Dijeda — memerlukan paket",
|
||||
"Active · Book file uploads off": "Aktif · Unggah file buku nonaktif",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Selama WebDAV dipilih, buku, progres, dan sorotan hanya disinkronkan dengan server Anda.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Pengaturan aplikasi, statistik membaca, dan kamus tetap disinkronkan melalui akun Readest Anda selama Anda masuk.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Selama Google Drive dipilih, buku, progres, dan sorotan hanya disinkronkan dengan Drive Anda.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Sinkronkan perpustakaan, progres membaca, dan sorotan Anda dengan Readest Cloud.",
|
||||
"Account and Storage": "Akun dan penyimpanan",
|
||||
@@ -1819,5 +1814,23 @@
|
||||
"Cloud sync session expired": "Sesi sinkronisasi cloud telah kedaluwarsa",
|
||||
"Uploads book files to your other devices": "Mengunggah berkas buku ke perangkat Anda yang lain",
|
||||
"Re-check every book instead of only changed ones": "Periksa ulang setiap buku alih-alih hanya yang berubah",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Bucket tidak ditemukan",
|
||||
"Endpoint": "Endpoint",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Region",
|
||||
"Access Key ID": "ID Kunci Akses",
|
||||
"Your Access Key ID": "ID Kunci Akses Anda",
|
||||
"Secret Access Key": "Kunci Akses Rahasia",
|
||||
"Your Secret Access Key": "Kunci Akses Rahasia Anda",
|
||||
"S3-Compatible Storage": "Penyimpanan yang kompatibel dengan S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Sinkronkan perpustakaan, progres membaca, dan sorotan Anda dengan bucket yang kompatibel dengan S3 seperti Cloudflare R2, AWS S3, atau MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "Di browser, kebijakan CORS bucket harus mengizinkan origin situs ini.",
|
||||
"Use S3": "Gunakan S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Sebelum terhubung, pastikan bucket sudah ada dan kredensial memiliki akses baca/tulis.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Saat {{provider}} dipilih, buku, progres, dan anotasi hanya disinkronkan ke server Anda.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Saat {{provider}} dipilih, buku, progres, dan anotasi hanya disinkronkan ke Drive Anda.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Saat {{provider}} dipilih, buku, progres, dan anotasi hanya disinkronkan ke bucket Anda.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "Penyimpanan S3"
|
||||
}
|
||||
|
||||
@@ -1851,10 +1851,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Sincronizza la tua biblioteca, il progresso di lettura e le evidenziazioni con il tuo Google Drive.",
|
||||
"Configured": "Configurato",
|
||||
"Third-party Cloud Sync": "Sincronizzazione cloud di terze parti",
|
||||
"Use WebDAV": "Usa WebDAV",
|
||||
"Cloud Sync": "Sincronizzazione cloud",
|
||||
"Available on Plus, Pro, or Lifetime": "Disponibile con Plus, Pro o a Vita",
|
||||
"Extra Large": "Molto grande",
|
||||
"Hint size": "Dimensione del suggerimento",
|
||||
"Gloss text size above the word": "Dimensione del testo del suggerimento sopra la parola",
|
||||
@@ -1865,15 +1863,12 @@
|
||||
"Stopped reading aloud": "Lettura ad alta voce interrotta",
|
||||
"Read aloud stopped": "Lettura ad alta voce interrotta",
|
||||
"Cloud provider switched": "Provider cloud cambiato",
|
||||
"Synced via {{provider}}": "Sincronizzato via {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "I caricamenti su Readest Cloud sono in pausa finché è selezionata la sincronizzazione {{provider}}",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Gestito da {{provider}} finché è il tuo provider di sincronizzazione cloud",
|
||||
"Not signed in": "Accesso non effettuato",
|
||||
"Paused — plan required": "In pausa — è richiesto un piano",
|
||||
"Active · Book file uploads off": "Attivo · Caricamento dei file dei libri disattivato",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Finché WebDAV è selezionato, libri, progresso ed evidenziazioni si sincronizzano solo con il tuo server.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Le impostazioni dell'app, le statistiche di lettura e i dizionari continuano a sincronizzarsi tramite il tuo account Readest finché hai effettuato l'accesso.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Finché Google Drive è selezionato, libri, progresso ed evidenziazioni si sincronizzano solo con il tuo Drive.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Sincronizza la tua biblioteca, il progresso di lettura e le evidenziazioni con Readest Cloud.",
|
||||
"Account and Storage": "Account e archiviazione",
|
||||
@@ -1889,5 +1884,23 @@
|
||||
"Cloud sync session expired": "Sessione di sincronizzazione cloud scaduta",
|
||||
"Uploads book files to your other devices": "Carica i file dei libri sui tuoi altri dispositivi",
|
||||
"Re-check every book instead of only changed ones": "Ricontrolla ogni libro invece dei soli modificati",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Bucket non trovato",
|
||||
"Endpoint": "Endpoint",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Regione",
|
||||
"Access Key ID": "ID chiave di accesso",
|
||||
"Your Access Key ID": "Il tuo ID chiave di accesso",
|
||||
"Secret Access Key": "Chiave di accesso segreta",
|
||||
"Your Secret Access Key": "La tua chiave di accesso segreta",
|
||||
"S3-Compatible Storage": "Archiviazione compatibile con S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Sincronizza la tua libreria, i progressi di lettura e le evidenziazioni con un bucket compatibile con S3 come Cloudflare R2, AWS S3 o MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "Nel browser, la policy CORS del bucket deve consentire l'origine di questo sito.",
|
||||
"Use S3": "Usa S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Prima di connetterti, assicurati che il bucket esista e che le credenziali abbiano accesso in lettura/scrittura.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Finché {{provider}} è selezionato, libri, progressi e annotazioni si sincronizzano solo con il tuo server.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Finché {{provider}} è selezionato, libri, progressi e annotazioni si sincronizzano solo con il tuo Drive.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Finché {{provider}} è selezionato, libri, progressi e annotazioni si sincronizzano solo con il tuo bucket.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "Archiviazione S3"
|
||||
}
|
||||
|
||||
@@ -1783,10 +1783,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "ライブラリ、読書進捗、ハイライトを Google Drive と同期します。",
|
||||
"Configured": "設定済み",
|
||||
"Third-party Cloud Sync": "サードパーティのクラウド同期",
|
||||
"Use WebDAV": "WebDAV を使用",
|
||||
"Cloud Sync": "クラウド同期",
|
||||
"Available on Plus, Pro, or Lifetime": "Plus、Pro、Lifetime プランで利用可能",
|
||||
"Extra Large": "特大",
|
||||
"Hint size": "ヒントのサイズ",
|
||||
"Gloss text size above the word": "単語の上に表示される注釈の文字サイズ",
|
||||
@@ -1797,15 +1795,12 @@
|
||||
"Stopped reading aloud": "読み上げを停止しました",
|
||||
"Read aloud stopped": "読み上げが停止しました",
|
||||
"Cloud provider switched": "クラウドプロバイダーが切り替わりました",
|
||||
"Synced via {{provider}}": "{{provider}} 経由で同期しました",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "{{provider}} 同期が選択されている間、Readest Cloud へのアップロードは一時停止されます",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "{{provider}} がクラウド同期プロバイダーである間は {{provider}} が管理します",
|
||||
"Not signed in": "未ログイン",
|
||||
"Paused — plan required": "一時停止中 — プランが必要です",
|
||||
"Active · Book file uploads off": "有効 · 書籍ファイルのアップロードはオフ",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "WebDAV が選択されている間、書籍・進捗・ハイライトはお使いのサーバーにのみ同期されます。",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "アプリ設定、読書統計、辞書は、ログイン中は引き続き Readest アカウント経由で同期されます。",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Google Drive が選択されている間、書籍・進捗・ハイライトはお使いの Drive にのみ同期されます。",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "ライブラリ、読書進捗、ハイライトを Readest Cloud と同期します。",
|
||||
"Account and Storage": "アカウントとストレージ",
|
||||
@@ -1819,5 +1814,23 @@
|
||||
"Cloud sync session expired": "クラウド同期のセッションが期限切れです",
|
||||
"Uploads book files to your other devices": "書籍ファイルを他のデバイスにアップロードします",
|
||||
"Re-check every book instead of only changed ones": "変更されたものだけでなく、すべての書籍を再確認",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "バケットが見つかりません",
|
||||
"Endpoint": "エンドポイント",
|
||||
"Bucket": "バケット",
|
||||
"Region": "リージョン",
|
||||
"Access Key ID": "アクセスキー ID",
|
||||
"Your Access Key ID": "あなたのアクセスキー ID",
|
||||
"Secret Access Key": "シークレットアクセスキー",
|
||||
"Your Secret Access Key": "あなたのシークレットアクセスキー",
|
||||
"S3-Compatible Storage": "S3 互換ストレージ",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Cloudflare R2、AWS S3、MinIO などの S3 互換バケットとライブラリ、読書進捗、ハイライトを同期します。",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "ブラウザで使用する場合、バケットの CORS ポリシーでこのサイトのオリジンを許可する必要があります。",
|
||||
"Use S3": "S3 を使用",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "接続する前に、バケットが存在し、認証情報に読み取り/書き込み権限があることを確認してください。",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "{{provider}} を選択している間、書籍・進捗・注釈はサーバーにのみ同期されます。",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}} を選択している間、書籍・進捗・注釈は Drive にのみ同期されます。",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}} を選択している間、書籍・進捗・注釈はバケットにのみ同期されます。",
|
||||
"Premium": "プレミアム",
|
||||
"S3 Storage": "S3 ストレージ"
|
||||
}
|
||||
|
||||
@@ -1783,10 +1783,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "라이브러리, 읽기 진행 상황, 하이라이트를 Google Drive와 동기화합니다.",
|
||||
"Configured": "설정됨",
|
||||
"Third-party Cloud Sync": "타사 클라우드 동기화",
|
||||
"Use WebDAV": "WebDAV 사용",
|
||||
"Cloud Sync": "클라우드 동기화",
|
||||
"Available on Plus, Pro, or Lifetime": "Plus, Pro, Lifetime 요금제에서 사용 가능",
|
||||
"Extra Large": "매우 큼",
|
||||
"Hint size": "힌트 크기",
|
||||
"Gloss text size above the word": "단어 위에 표시되는 주석 텍스트 크기",
|
||||
@@ -1797,15 +1795,12 @@
|
||||
"Stopped reading aloud": "음성 읽기를 중지했습니다",
|
||||
"Read aloud stopped": "음성 읽기가 중지되었습니다",
|
||||
"Cloud provider switched": "클라우드 제공자가 전환되었습니다",
|
||||
"Synced via {{provider}}": "{{provider}}을(를) 통해 동기화됨",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "{{provider}} 동기화가 선택되어 있는 동안 Readest Cloud 업로드가 일시 중지됩니다",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "{{provider}}이(가) 클라우드 동기화 제공자인 동안 {{provider}}이(가) 관리합니다",
|
||||
"Not signed in": "로그인하지 않음",
|
||||
"Paused — plan required": "일시 중지됨 — 요금제 필요",
|
||||
"Active · Book file uploads off": "활성 · 책 파일 업로드 꺼짐",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "WebDAV가 선택되어 있는 동안 책, 진행 상황, 하이라이트는 사용자의 서버와만 동기화됩니다.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "앱 설정, 독서 통계, 사전은 로그인되어 있는 동안 Readest 계정을 통해 계속 동기화됩니다.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Google Drive가 선택되어 있는 동안 책, 진행 상황, 하이라이트는 사용자의 Drive와만 동기화됩니다.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "라이브러리, 읽기 진행 상황, 하이라이트를 Readest Cloud와 동기화합니다.",
|
||||
"Account and Storage": "계정 및 저장소",
|
||||
@@ -1819,5 +1814,23 @@
|
||||
"Cloud sync session expired": "클라우드 동기화 세션이 만료되었습니다",
|
||||
"Uploads book files to your other devices": "책 파일을 다른 기기에 업로드합니다",
|
||||
"Re-check every book instead of only changed ones": "변경된 책뿐 아니라 모든 책을 다시 확인",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "버킷을 찾을 수 없습니다",
|
||||
"Endpoint": "엔드포인트",
|
||||
"Bucket": "버킷",
|
||||
"Region": "리전",
|
||||
"Access Key ID": "액세스 키 ID",
|
||||
"Your Access Key ID": "내 액세스 키 ID",
|
||||
"Secret Access Key": "시크릿 액세스 키",
|
||||
"Your Secret Access Key": "내 시크릿 액세스 키",
|
||||
"S3-Compatible Storage": "S3 호환 스토리지",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Cloudflare R2, AWS S3, MinIO 등 S3 호환 버킷과 라이브러리, 읽기 진행률, 하이라이트를 동기화합니다.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "브라우저에서는 버킷의 CORS 정책이 이 사이트의 오리진을 허용해야 합니다.",
|
||||
"Use S3": "S3 사용",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "연결하기 전에 버킷이 존재하고 자격 증명에 읽기/쓰기 권한이 있는지 확인하세요.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "{{provider}}를 선택하면 책, 진행률, 주석이 서버에만 동기화됩니다.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}}를 선택하면 책, 진행률, 주석이 Drive에만 동기화됩니다.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}}를 선택하면 책, 진행률, 주석이 버킷에만 동기화됩니다.",
|
||||
"Premium": "프리미엄",
|
||||
"S3 Storage": "S3 스토리지"
|
||||
}
|
||||
|
||||
@@ -1783,10 +1783,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Segerakkan perpustakaan, kemajuan bacaan dan penonjolan anda dengan Google Drive anda.",
|
||||
"Configured": "Telah dikonfigurasi",
|
||||
"Third-party Cloud Sync": "Segerak Awan Pihak Ketiga",
|
||||
"Use WebDAV": "Guna WebDAV",
|
||||
"Cloud Sync": "Segerak Awan",
|
||||
"Available on Plus, Pro, or Lifetime": "Tersedia pada pelan Plus, Pro atau Seumur Hidup",
|
||||
"Extra Large": "Sangat Besar",
|
||||
"Hint size": "Saiz petunjuk",
|
||||
"Gloss text size above the word": "Saiz teks keterangan di atas perkataan",
|
||||
@@ -1797,15 +1795,12 @@
|
||||
"Stopped reading aloud": "Berhenti membaca dengan kuat",
|
||||
"Read aloud stopped": "Bacaan kuat dihentikan",
|
||||
"Cloud provider switched": "Penyedia awan ditukar",
|
||||
"Synced via {{provider}}": "Disegerakkan melalui {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Muat naik ke Readest Cloud dijeda selagi penyegerakan {{provider}} dipilih",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Diuruskan oleh {{provider}} selagi ia penyedia penyegerakan awan anda",
|
||||
"Not signed in": "Belum log masuk",
|
||||
"Paused — plan required": "Dijeda — pelan diperlukan",
|
||||
"Active · Book file uploads off": "Aktif · Muat naik fail buku dimatikan",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Selagi WebDAV dipilih, buku, kemajuan dan penonjolan hanya disegerakkan dengan pelayan anda.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Tetapan aplikasi, statistik bacaan dan kamus terus disegerakkan melalui akaun Readest anda selagi anda log masuk.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Selagi Google Drive dipilih, buku, kemajuan dan penonjolan hanya disegerakkan dengan Drive anda.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Segerakkan perpustakaan, kemajuan bacaan dan penonjolan anda dengan Readest Cloud.",
|
||||
"Account and Storage": "Akaun dan storan",
|
||||
@@ -1819,5 +1814,23 @@
|
||||
"Cloud sync session expired": "Sesi penyegerakan awan telah tamat tempoh",
|
||||
"Uploads book files to your other devices": "Memuat naik fail buku ke peranti anda yang lain",
|
||||
"Re-check every book instead of only changed ones": "Semak semula setiap buku dan bukan hanya yang berubah",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Bucket tidak ditemui",
|
||||
"Endpoint": "Titik akhir",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Wilayah",
|
||||
"Access Key ID": "ID Kunci Akses",
|
||||
"Your Access Key ID": "ID Kunci Akses anda",
|
||||
"Secret Access Key": "Kunci Akses Rahsia",
|
||||
"Your Secret Access Key": "Kunci Akses Rahsia anda",
|
||||
"S3-Compatible Storage": "Storan serasi S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Segerakkan perpustakaan, kemajuan bacaan dan sorotan anda dengan bucket serasi S3 seperti Cloudflare R2, AWS S3 atau MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "Dalam pelayar, dasar CORS bucket mesti membenarkan origin laman ini.",
|
||||
"Use S3": "Guna S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Sebelum menyambung, pastikan bucket wujud dan kelayakan mempunyai akses baca/tulis.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Semasa {{provider}} dipilih, buku, kemajuan dan anotasi hanya disegerakkan ke pelayan anda.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Semasa {{provider}} dipilih, buku, kemajuan dan anotasi hanya disegerakkan ke Drive anda.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Semasa {{provider}} dipilih, buku, kemajuan dan anotasi hanya disegerakkan ke bucket anda.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "Storan S3"
|
||||
}
|
||||
|
||||
@@ -1817,10 +1817,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Synchroniseer je bibliotheek, leesvoortgang en markeringen met je Google Drive.",
|
||||
"Configured": "Geconfigureerd",
|
||||
"Third-party Cloud Sync": "Cloudsynchronisatie van derden",
|
||||
"Use WebDAV": "WebDAV gebruiken",
|
||||
"Cloud Sync": "Cloudsynchronisatie",
|
||||
"Available on Plus, Pro, or Lifetime": "Beschikbaar met Plus, Pro of Lifetime",
|
||||
"Extra Large": "Extra groot",
|
||||
"Hint size": "Hintgrootte",
|
||||
"Gloss text size above the word": "Tekstgrootte van de hint boven het woord",
|
||||
@@ -1831,15 +1829,12 @@
|
||||
"Stopped reading aloud": "Voorlezen gestopt",
|
||||
"Read aloud stopped": "Voorlezen is gestopt",
|
||||
"Cloud provider switched": "Cloudprovider gewisseld",
|
||||
"Synced via {{provider}}": "Gesynchroniseerd via {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Uploads naar Readest Cloud zijn gepauzeerd zolang {{provider}}-synchronisatie is geselecteerd",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Beheerd door {{provider}} zolang het je cloudsynchronisatieprovider is",
|
||||
"Not signed in": "Niet ingelogd",
|
||||
"Paused — plan required": "Gepauzeerd — abonnement vereist",
|
||||
"Active · Book file uploads off": "Actief · Uploaden van boekbestanden uit",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Zolang WebDAV is geselecteerd, worden boeken, voortgang en markeringen alleen met je server gesynchroniseerd.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "App-instellingen, leesstatistieken en woordenboeken blijven via je Readest-account synchroniseren zolang je bent ingelogd.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Zolang Google Drive is geselecteerd, worden boeken, voortgang en markeringen alleen met je Drive gesynchroniseerd.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Synchroniseer je bibliotheek, leesvoortgang en markeringen met Readest Cloud.",
|
||||
"Account and Storage": "Account en opslag",
|
||||
@@ -1854,5 +1849,23 @@
|
||||
"Cloud sync session expired": "Cloudsynchronisatiesessie verlopen",
|
||||
"Uploads book files to your other devices": "Uploadt boekbestanden naar je andere apparaten",
|
||||
"Re-check every book instead of only changed ones": "Controleer elk boek opnieuw in plaats van alleen gewijzigde",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Bucket niet gevonden",
|
||||
"Endpoint": "Endpoint",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Regio",
|
||||
"Access Key ID": "Toegangssleutel-ID",
|
||||
"Your Access Key ID": "Uw toegangssleutel-ID",
|
||||
"Secret Access Key": "Geheime toegangssleutel",
|
||||
"Your Secret Access Key": "Uw geheime toegangssleutel",
|
||||
"S3-Compatible Storage": "S3-compatibele opslag",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Synchroniseer uw bibliotheek, leesvoortgang en markeringen met een S3-compatibele bucket zoals Cloudflare R2, AWS S3 of MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "In de browser moet het CORS-beleid van de bucket de origin van deze site toestaan.",
|
||||
"Use S3": "S3 gebruiken",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Zorg er vóór het verbinden voor dat de bucket bestaat en dat de inloggegevens lees-/schrijftoegang hebben.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Zolang {{provider}} is geselecteerd, worden boeken, voortgang en annotaties alleen met uw server gesynchroniseerd.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Zolang {{provider}} is geselecteerd, worden boeken, voortgang en annotaties alleen met uw Drive gesynchroniseerd.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Zolang {{provider}} is geselecteerd, worden boeken, voortgang en annotaties alleen met uw bucket gesynchroniseerd.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "S3-opslag"
|
||||
}
|
||||
|
||||
@@ -1885,10 +1885,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Synchronizuj bibliotekę, postęp czytania i zaznaczenia ze swoim Google Drive.",
|
||||
"Configured": "Skonfigurowano",
|
||||
"Third-party Cloud Sync": "Zewnętrzna synchronizacja w chmurze",
|
||||
"Use WebDAV": "Użyj WebDAV",
|
||||
"Cloud Sync": "Synchronizacja w chmurze",
|
||||
"Available on Plus, Pro, or Lifetime": "Dostępne w planach Plus, Pro lub Lifetime",
|
||||
"Extra Large": "Bardzo duży",
|
||||
"Hint size": "Rozmiar podpowiedzi",
|
||||
"Gloss text size above the word": "Rozmiar tekstu podpowiedzi nad słowem",
|
||||
@@ -1899,15 +1897,12 @@
|
||||
"Stopped reading aloud": "Zatrzymano czytanie na głos",
|
||||
"Read aloud stopped": "Czytanie na głos zatrzymane",
|
||||
"Cloud provider switched": "Zmieniono dostawcę chmury",
|
||||
"Synced via {{provider}}": "Zsynchronizowano przez {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Przesyłanie do Readest Cloud jest wstrzymane, dopóki wybrana jest synchronizacja {{provider}}",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Zarządzane przez {{provider}}, dopóki jest Twoim dostawcą synchronizacji w chmurze",
|
||||
"Not signed in": "Nie zalogowano",
|
||||
"Paused — plan required": "Wstrzymano — wymagany plan",
|
||||
"Active · Book file uploads off": "Aktywne · Przesyłanie plików książek wyłączone",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Dopóki wybrany jest WebDAV, książki, postęp i zaznaczenia synchronizują się tylko z Twoim serwerem.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Ustawienia aplikacji, statystyki czytania i słowniki nadal synchronizują się przez Twoje konto Readest, dopóki jesteś zalogowany.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Dopóki wybrany jest Google Drive, książki, postęp i zaznaczenia synchronizują się tylko z Twoim Drive.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Synchronizuj bibliotekę, postęp czytania i zaznaczenia z Readest Cloud.",
|
||||
"Account and Storage": "Konto i przechowywanie",
|
||||
@@ -1924,5 +1919,23 @@
|
||||
"Cloud sync session expired": "Sesja synchronizacji w chmurze wygasła",
|
||||
"Uploads book files to your other devices": "Przesyła pliki książek na Twoje inne urządzenia",
|
||||
"Re-check every book instead of only changed ones": "Ponownie sprawdź każdą książkę zamiast tylko zmienionych",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Nie znaleziono bucketa",
|
||||
"Endpoint": "Punkt końcowy",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Region",
|
||||
"Access Key ID": "ID klucza dostępu",
|
||||
"Your Access Key ID": "Twoje ID klucza dostępu",
|
||||
"Secret Access Key": "Tajny klucz dostępu",
|
||||
"Your Secret Access Key": "Twój tajny klucz dostępu",
|
||||
"S3-Compatible Storage": "Magazyn zgodny z S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Synchronizuj bibliotekę, postęp czytania i zaznaczenia z bucketem zgodnym z S3, takim jak Cloudflare R2, AWS S3 lub MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "W przeglądarce polityka CORS bucketa musi zezwalać na origin tej witryny.",
|
||||
"Use S3": "Użyj S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Przed połączeniem upewnij się, że bucket istnieje, a dane uwierzytelniające mają dostęp do odczytu i zapisu.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Gdy wybrany jest {{provider}}, książki, postęp i adnotacje synchronizują się tylko z Twoim serwerem.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Gdy wybrany jest {{provider}}, książki, postęp i adnotacje synchronizują się tylko z Twoim Drive.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Gdy wybrany jest {{provider}}, książki, postęp i adnotacje synchronizują się tylko z Twoim bucketem.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "Magazyn S3"
|
||||
}
|
||||
|
||||
@@ -1851,10 +1851,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Sincronize sua biblioteca, progresso de leitura e destaques com o seu Google Drive.",
|
||||
"Configured": "Configurado",
|
||||
"Third-party Cloud Sync": "Sincronização na nuvem de terceiros",
|
||||
"Use WebDAV": "Usar WebDAV",
|
||||
"Cloud Sync": "Sincronização na nuvem",
|
||||
"Available on Plus, Pro, or Lifetime": "Disponível com Plus, Pro ou Vitalício",
|
||||
"Extra Large": "Extra grande",
|
||||
"Hint size": "Tamanho da dica",
|
||||
"Gloss text size above the word": "Tamanho do texto da dica sobre a palavra",
|
||||
@@ -1865,15 +1863,12 @@
|
||||
"Stopped reading aloud": "Leitura em voz alta interrompida",
|
||||
"Read aloud stopped": "Leitura em voz alta interrompida",
|
||||
"Cloud provider switched": "Provedor de nuvem alterado",
|
||||
"Synced via {{provider}}": "Sincronizado via {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Os envios para o Readest Cloud estão pausados enquanto a sincronização {{provider}} estiver selecionada",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Gerenciado por {{provider}} enquanto for o seu provedor de sincronização na nuvem",
|
||||
"Not signed in": "Não conectado",
|
||||
"Paused — plan required": "Pausado — é necessário um plano",
|
||||
"Active · Book file uploads off": "Ativo · Envio de arquivos de livros desligado",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Enquanto o WebDAV estiver selecionado, os livros, o progresso e os destaques sincronizam apenas com o seu servidor.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "As configurações do aplicativo, as estatísticas de leitura e os dicionários continuam sincronizando pela sua conta Readest enquanto você estiver conectado.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Enquanto o Google Drive estiver selecionado, os livros, o progresso e os destaques sincronizam apenas com o seu Drive.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Sincronize sua biblioteca, progresso de leitura e destaques com o Readest Cloud.",
|
||||
"Account and Storage": "Conta e armazenamento",
|
||||
@@ -1889,5 +1884,23 @@
|
||||
"Cloud sync session expired": "A sessão de sincronização na nuvem expirou",
|
||||
"Uploads book files to your other devices": "Envia arquivos de livros para seus outros dispositivos",
|
||||
"Re-check every book instead of only changed ones": "Verificar novamente todos os livros em vez de apenas os alterados",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Bucket não encontrado",
|
||||
"Endpoint": "Endpoint",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Região",
|
||||
"Access Key ID": "ID da chave de acesso",
|
||||
"Your Access Key ID": "Seu ID da chave de acesso",
|
||||
"Secret Access Key": "Chave de acesso secreta",
|
||||
"Your Secret Access Key": "Sua chave de acesso secreta",
|
||||
"S3-Compatible Storage": "Armazenamento compatível com S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Sincronize sua biblioteca, progresso de leitura e destaques com um bucket compatível com S3, como Cloudflare R2, AWS S3 ou MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "No navegador, a política de CORS do bucket deve permitir a origem deste site.",
|
||||
"Use S3": "Usar S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Antes de conectar, verifique se o bucket existe e se as credenciais têm acesso de leitura/gravação.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Enquanto o {{provider}} estiver selecionado, livros, progresso e anotações são sincronizados apenas com seu servidor.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Enquanto o {{provider}} estiver selecionado, livros, progresso e anotações são sincronizados apenas com seu Drive.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Enquanto o {{provider}} estiver selecionado, livros, progresso e anotações são sincronizados apenas com seu bucket.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "Armazenamento S3"
|
||||
}
|
||||
|
||||
@@ -1851,10 +1851,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Sincronize a sua biblioteca, o progresso de leitura e os destaques com o seu Google Drive.",
|
||||
"Configured": "Configurado",
|
||||
"Third-party Cloud Sync": "Sincronização na Nuvem de Terceiros",
|
||||
"Use WebDAV": "Usar WebDAV",
|
||||
"Cloud Sync": "Sincronização na Nuvem",
|
||||
"Available on Plus, Pro, or Lifetime": "Disponível com Plus, Pro ou Vitalício",
|
||||
"Extra Large": "Extra Grande",
|
||||
"Hint size": "Tamanho da dica",
|
||||
"Gloss text size above the word": "Tamanho do texto da dica sobre a palavra",
|
||||
@@ -1865,15 +1863,12 @@
|
||||
"Stopped reading aloud": "Leitura em voz alta interrompida",
|
||||
"Read aloud stopped": "Leitura em voz alta interrompida",
|
||||
"Cloud provider switched": "Fornecedor de nuvem alterado",
|
||||
"Synced via {{provider}}": "Sincronizado via {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Os envios para o Readest Cloud estão em pausa enquanto a sincronização {{provider}} estiver selecionada",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Gerido por {{provider}} enquanto for o seu fornecedor de sincronização na nuvem",
|
||||
"Not signed in": "Sessão não iniciada",
|
||||
"Paused — plan required": "Em pausa — é necessário um plano",
|
||||
"Active · Book file uploads off": "Ativo · Envio de ficheiros de livros desligado",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Enquanto o WebDAV estiver selecionado, os livros, o progresso e os destaques sincronizam apenas com o seu servidor.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "As definições da aplicação, as estatísticas de leitura e os dicionários continuam a sincronizar através da sua conta Readest enquanto tiver sessão iniciada.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Enquanto o Google Drive estiver selecionado, os livros, o progresso e os destaques sincronizam apenas com o seu Drive.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Sincronize a sua biblioteca, o progresso de leitura e os destaques com o Readest Cloud.",
|
||||
"Account and Storage": "Conta e armazenamento",
|
||||
@@ -1889,5 +1884,23 @@
|
||||
"Cloud sync session expired": "A sessão de sincronização na nuvem expirou",
|
||||
"Uploads book files to your other devices": "Carrega ficheiros de livros para os seus outros dispositivos",
|
||||
"Re-check every book instead of only changed ones": "Verificar novamente todos os livros em vez de apenas os alterados",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Bucket não encontrado",
|
||||
"Endpoint": "Endpoint",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Região",
|
||||
"Access Key ID": "ID da chave de acesso",
|
||||
"Your Access Key ID": "O seu ID da chave de acesso",
|
||||
"Secret Access Key": "Chave de acesso secreta",
|
||||
"Your Secret Access Key": "A sua chave de acesso secreta",
|
||||
"S3-Compatible Storage": "Armazenamento compatível com S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Sincronize a sua biblioteca, o progresso de leitura e os destaques com um bucket compatível com S3, como Cloudflare R2, AWS S3 ou MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "No navegador, a política de CORS do bucket tem de permitir a origem deste site.",
|
||||
"Use S3": "Usar S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Antes de ligar, certifique-se de que o bucket existe e de que as credenciais têm acesso de leitura/escrita.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Enquanto o {{provider}} estiver selecionado, os livros, o progresso e as anotações são sincronizados apenas com o seu servidor.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Enquanto o {{provider}} estiver selecionado, os livros, o progresso e as anotações são sincronizados apenas com o seu Drive.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Enquanto o {{provider}} estiver selecionado, os livros, o progresso e as anotações são sincronizados apenas com o seu bucket.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "Armazenamento S3"
|
||||
}
|
||||
|
||||
@@ -1851,10 +1851,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Sincronizează-ți biblioteca, progresul citirii și evidențierile cu Google Drive-ul tău.",
|
||||
"Configured": "Configurat",
|
||||
"Third-party Cloud Sync": "Sincronizare în cloud prin terți",
|
||||
"Use WebDAV": "Folosește WebDAV",
|
||||
"Cloud Sync": "Sincronizare în cloud",
|
||||
"Available on Plus, Pro, or Lifetime": "Disponibil cu Plus, Pro sau Pe viață",
|
||||
"Extra Large": "Foarte mare",
|
||||
"Hint size": "Dimensiunea indiciului",
|
||||
"Gloss text size above the word": "Dimensiunea textului afișat deasupra cuvântului",
|
||||
@@ -1865,15 +1863,12 @@
|
||||
"Stopped reading aloud": "Citirea cu voce tare a fost oprită",
|
||||
"Read aloud stopped": "Citirea cu voce tare a fost oprită",
|
||||
"Cloud provider switched": "Furnizorul de cloud a fost schimbat",
|
||||
"Synced via {{provider}}": "Sincronizat prin {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Încărcările în Readest Cloud sunt suspendate cât timp este selectată sincronizarea {{provider}}",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Gestionat de {{provider}} cât timp este furnizorul tău de sincronizare cloud",
|
||||
"Not signed in": "Neautentificat",
|
||||
"Paused — plan required": "Suspendat — este necesar un abonament",
|
||||
"Active · Book file uploads off": "Activ · Încărcarea fișierelor de carte este oprită",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Cât timp WebDAV este selectat, cărțile, progresul și evidențierile se sincronizează doar cu serverul tău.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Setările aplicației, statisticile de citire și dicționarele continuă să se sincronizeze prin contul tău Readest cât timp ești autentificat.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Cât timp Google Drive este selectat, cărțile, progresul și evidențierile se sincronizează doar cu Drive-ul tău.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Sincronizează-ți biblioteca, progresul citirii și evidențierile cu Readest Cloud.",
|
||||
"Account and Storage": "Cont și stocare",
|
||||
@@ -1889,5 +1884,23 @@
|
||||
"Cloud sync session expired": "Sesiunea de sincronizare în cloud a expirat",
|
||||
"Uploads book files to your other devices": "Încarcă fișierele cărților pe celelalte dispozitive ale tale",
|
||||
"Re-check every book instead of only changed ones": "Reverifică fiecare carte în loc de doar cele modificate",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Bucketul nu a fost găsit",
|
||||
"Endpoint": "Endpoint",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Regiune",
|
||||
"Access Key ID": "ID cheie de acces",
|
||||
"Your Access Key ID": "ID-ul cheii tale de acces",
|
||||
"Secret Access Key": "Cheie de acces secretă",
|
||||
"Your Secret Access Key": "Cheia ta de acces secretă",
|
||||
"S3-Compatible Storage": "Stocare compatibilă S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Sincronizează-ți biblioteca, progresul de citire și evidențierile cu un bucket compatibil S3, precum Cloudflare R2, AWS S3 sau MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "În browser, politica CORS a bucketului trebuie să permită originea acestui site.",
|
||||
"Use S3": "Folosește S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Înainte de conectare, asigură-te că bucketul există și că datele de autentificare au acces de citire/scriere.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Cât timp {{provider}} este selectat, cărțile, progresul și adnotările se sincronizează doar cu serverul tău.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Cât timp {{provider}} este selectat, cărțile, progresul și adnotările se sincronizează doar cu Drive-ul tău.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Cât timp {{provider}} este selectat, cărțile, progresul și adnotările se sincronizează doar cu bucketul tău.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "Stocare S3"
|
||||
}
|
||||
|
||||
@@ -1885,10 +1885,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Синхронизируйте свою библиотеку, прогресс чтения и выделения с вашим Google Drive.",
|
||||
"Configured": "Настроено",
|
||||
"Third-party Cloud Sync": "Сторонняя облачная синхронизация",
|
||||
"Use WebDAV": "Использовать WebDAV",
|
||||
"Cloud Sync": "Облачная синхронизация",
|
||||
"Available on Plus, Pro, or Lifetime": "Доступно в тарифах Plus, Pro или Lifetime",
|
||||
"Extra Large": "Очень большой",
|
||||
"Hint size": "Размер подсказки",
|
||||
"Gloss text size above the word": "Размер текста подсказки над словом",
|
||||
@@ -1899,15 +1897,12 @@
|
||||
"Stopped reading aloud": "Чтение вслух остановлено",
|
||||
"Read aloud stopped": "Озвучивание остановлено",
|
||||
"Cloud provider switched": "Облачный провайдер изменён",
|
||||
"Synced via {{provider}}": "Синхронизировано через {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Загрузки в Readest Cloud приостановлены, пока выбрана синхронизация {{provider}}",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Управляется {{provider}}, пока это ваш провайдер облачной синхронизации",
|
||||
"Not signed in": "Вход не выполнен",
|
||||
"Paused — plan required": "Приостановлено — требуется тарифный план",
|
||||
"Active · Book file uploads off": "Активно · Загрузка файлов книг выключена",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Пока выбран WebDAV, книги, прогресс и выделения синхронизируются только с вашим сервером.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Настройки приложения, статистика чтения и словари продолжают синхронизироваться через ваш аккаунт Readest, пока вы вошли в систему.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Пока выбран Google Drive, книги, прогресс и выделения синхронизируются только с вашим Drive.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Синхронизируйте свою библиотеку, прогресс чтения и выделения с Readest Cloud.",
|
||||
"Account and Storage": "Аккаунт и хранилище",
|
||||
@@ -1924,5 +1919,23 @@
|
||||
"Cloud sync session expired": "Сеанс облачной синхронизации истёк",
|
||||
"Uploads book files to your other devices": "Загружает файлы книг на другие ваши устройства",
|
||||
"Re-check every book instead of only changed ones": "Повторно проверять каждую книгу, а не только изменённые",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Бакет не найден",
|
||||
"Endpoint": "Конечная точка",
|
||||
"Bucket": "Бакет",
|
||||
"Region": "Регион",
|
||||
"Access Key ID": "ID ключа доступа",
|
||||
"Your Access Key ID": "Ваш ID ключа доступа",
|
||||
"Secret Access Key": "Секретный ключ доступа",
|
||||
"Your Secret Access Key": "Ваш секретный ключ доступа",
|
||||
"S3-Compatible Storage": "S3-совместимое хранилище",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Синхронизируйте библиотеку, прогресс чтения и выделения с S3-совместимым бакетом, например Cloudflare R2, AWS S3 или MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "В браузере политика CORS бакета должна разрешать источник этого сайта.",
|
||||
"Use S3": "Использовать S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Перед подключением убедитесь, что бакет существует, а учётные данные имеют доступ на чтение и запись.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Пока выбран {{provider}}, книги, прогресс и аннотации синхронизируются только с вашим сервером.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Пока выбран {{provider}}, книги, прогресс и аннотации синхронизируются только с вашим Drive.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Пока выбран {{provider}}, книги, прогресс и аннотации синхронизируются только с вашим бакетом.",
|
||||
"Premium": "Премиум",
|
||||
"S3 Storage": "Хранилище S3"
|
||||
}
|
||||
|
||||
@@ -1817,10 +1817,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "ඔබේ පුස්තකාලය, කියවීමේ ප්රගතිය සහ ඉස්මතු කිරීම් ඔබේ Google Drive සමඟ සමමුහුර්ත කරන්න.",
|
||||
"Configured": "වින්යාස කර ඇත",
|
||||
"Third-party Cloud Sync": "තෙවන පාර්ශ්ව cloud සමමුහුර්තය",
|
||||
"Use WebDAV": "WebDAV භාවිතා කරන්න",
|
||||
"Cloud Sync": "Cloud සමමුහුර්තය",
|
||||
"Available on Plus, Pro, or Lifetime": "Plus, Pro හෝ Lifetime හි ලබා ගත හැක",
|
||||
"Extra Large": "ඉතා විශාල",
|
||||
"Hint size": "ඉඟි ප්රමාණය",
|
||||
"Gloss text size above the word": "වචනයට ඉහළින් ඇති ඉඟි අකුරු ප්රමාණය",
|
||||
@@ -1831,15 +1829,12 @@
|
||||
"Stopped reading aloud": "පවසා කියවීම නවත්වන ලදී",
|
||||
"Read aloud stopped": "පවසා කියවීම නතර විය",
|
||||
"Cloud provider switched": "වලාකුළු සැපයුම්කරු වෙනස් විය",
|
||||
"Synced via {{provider}}": "{{provider}} හරහා සමමුහුර්ත විය",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "{{provider}} සමමුහුර්තකරණය තෝරා ඇති අතරතුර Readest Cloud වෙත උඩුගත කිරීම් නවතා ඇත",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "{{provider}} ඔබේ වලාකුළු සමමුහුර්ත සැපයුම්කරු ලෙස පවතින අතරතුර එය මගින් කළමනාකරණය වේ",
|
||||
"Not signed in": "පුරනය වී නැත",
|
||||
"Paused — plan required": "නවතා ඇත — සැලසුමක් අවශ්යයි",
|
||||
"Active · Book file uploads off": "සක්රීය · පොත් ගොනු උඩුගත කිරීම අක්රීයයි",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "WebDAV තෝරා ඇති අතරතුර, පොත්, ප්රගතිය සහ ඉස්මතු කිරීම් ඔබේ සේවාදායකය සමඟ පමණක් සමමුහුර්ත වේ.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "පුරනය වී සිටින අතරතුර යෙදුම් සැකසුම්, කියවීම් සංඛ්යාලේඛන සහ ශබ්දකෝෂ ඔබේ Readest ගිණුම හරහා දිගටම සමමුහුර්ත වේ.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Google Drive තෝරා ඇති අතරතුර, පොත්, ප්රගතිය සහ ඉස්මතු කිරීම් ඔබේ Drive සමඟ පමණක් සමමුහුර්ත වේ.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "ඔබේ පුස්තකාලය, කියවීමේ ප්රගතිය සහ ඉස්මතු කිරීම් Readest Cloud සමඟ සමමුහුර්ත කරන්න.",
|
||||
"Account and Storage": "ගිණුම සහ ගබඩාව",
|
||||
@@ -1854,5 +1849,23 @@
|
||||
"Cloud sync session expired": "වලාකුළු සමමුහූර්ත සැසිය කල් ඉකුත් වී ඇත",
|
||||
"Uploads book files to your other devices": "පොත් ගොනු ඔබගේ අනෙක් උපාංගවලට උඩුගත කරයි",
|
||||
"Re-check every book instead of only changed ones": "වෙනස් වූ ඒවා පමණක් නොව සෑම පොතක්ම නැවත පරීක්ෂා කරන්න",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "බකට් හමු නොවීය",
|
||||
"Endpoint": "එන්ඩ්පොයින්ට්",
|
||||
"Bucket": "බකට්",
|
||||
"Region": "කලාපය",
|
||||
"Access Key ID": "ප්රවේශ යතුරු ID",
|
||||
"Your Access Key ID": "ඔබගේ ප්රවේශ යතුරු ID",
|
||||
"Secret Access Key": "රහස් ප්රවේශ යතුර",
|
||||
"Your Secret Access Key": "ඔබගේ රහස් ප්රවේශ යතුර",
|
||||
"S3-Compatible Storage": "S3-ගැළපෙන ගබඩාව",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Cloudflare R2, AWS S3 හෝ MinIO වැනි S3-ගැළපෙන බකට් එකක් සමඟ ඔබේ පුස්තකාලය, කියවීමේ ප්රගතිය සහ ඉස්මතු කිරීම් සමමුහුර්ත කරන්න.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "බ්රවුසරයේදී, බකට් එකේ CORS ප්රතිපත්තිය මෙම අඩවියේ origin ට ඉඩ දිය යුතුය.",
|
||||
"Use S3": "S3 භාවිතා කරන්න",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "සම්බන්ධ වීමට පෙර බකට් එක පවතින බවත්, අක්තපත්රවලට කියවීමේ/ලිවීමේ ප්රවේශය ඇති බවත් තහවුරු කරගන්න.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "{{provider}} තෝරා ඇති විට, පොත්, ප්රගතිය සහ සටහන් ඔබේ සේවාදායකයට පමණක් සමමුහුර්ත වේ.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}} තෝරා ඇති විට, පොත්, ප්රගතිය සහ සටහන් ඔබේ Drive වෙත පමණක් සමමුහුර්ත වේ.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}} තෝරා ඇති විට, පොත්, ප්රගතිය සහ සටහන් ඔබේ බකට් එකට පමණක් සමමුහුර්ත වේ.",
|
||||
"Premium": "ප්රිමියම්",
|
||||
"S3 Storage": "S3 ගබඩාව"
|
||||
}
|
||||
|
||||
@@ -1885,10 +1885,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Sinhronizirajte knjižnico, napredek branja in označbe s svojim Google Drive.",
|
||||
"Configured": "Nastavljeno",
|
||||
"Third-party Cloud Sync": "Zunanja sinhronizacija v oblaku",
|
||||
"Use WebDAV": "Uporabi WebDAV",
|
||||
"Cloud Sync": "Sinhronizacija v oblaku",
|
||||
"Available on Plus, Pro, or Lifetime": "Na voljo v paketih Plus, Pro ali Lifetime",
|
||||
"Extra Large": "Zelo veliko",
|
||||
"Hint size": "Velikost namiga",
|
||||
"Gloss text size above the word": "Velikost besedila namiga nad besedo",
|
||||
@@ -1899,15 +1897,12 @@
|
||||
"Stopped reading aloud": "Branje na glas ustavljeno",
|
||||
"Read aloud stopped": "Branje na glas se je ustavilo",
|
||||
"Cloud provider switched": "Ponudnik oblaka zamenjan",
|
||||
"Synced via {{provider}}": "Sinhronizirano prek {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Nalaganja v Readest Cloud so ustavljena, dokler je izbrana sinhronizacija {{provider}}",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Upravlja {{provider}}, dokler je vaš ponudnik oblačne sinhronizacije",
|
||||
"Not signed in": "Niste prijavljeni",
|
||||
"Paused — plan required": "Ustavljeno — potreben je paket",
|
||||
"Active · Book file uploads off": "Aktivno · Nalaganje datotek knjig je izklopljeno",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Dokler je izbran WebDAV, se knjige, napredek in označbe sinhronizirajo samo z vašim strežnikom.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Nastavitve aplikacije, statistika branja in slovarji se še naprej sinhronizirajo prek vašega računa Readest, dokler ste prijavljeni.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Dokler je izbran Google Drive, se knjige, napredek in označbe sinhronizirajo samo z vašim Drivom.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Sinhronizirajte knjižnico, napredek branja in označbe z Readest Cloud.",
|
||||
"Account and Storage": "Račun in shramba",
|
||||
@@ -1924,5 +1919,23 @@
|
||||
"Cloud sync session expired": "Seja sinhronizacije v oblaku je potekla",
|
||||
"Uploads book files to your other devices": "Naloži datoteke knjig v vaše druge naprave",
|
||||
"Re-check every book instead of only changed ones": "Znova preveri vsako knjigo namesto le spremenjenih",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Vedro ni bilo najdeno",
|
||||
"Endpoint": "Končna točka",
|
||||
"Bucket": "Vedro",
|
||||
"Region": "Regija",
|
||||
"Access Key ID": "ID dostopnega ključa",
|
||||
"Your Access Key ID": "Vaš ID dostopnega ključa",
|
||||
"Secret Access Key": "Skrivni dostopni ključ",
|
||||
"Your Secret Access Key": "Vaš skrivni dostopni ključ",
|
||||
"S3-Compatible Storage": "Shramba, združljiva s S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Sinhronizirajte svojo knjižnico, bralni napredek in označbe z vedrom, združljivim s S3, kot so Cloudflare R2, AWS S3 ali MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "V brskalniku mora politika CORS vedra dovoliti izvor tega spletnega mesta.",
|
||||
"Use S3": "Uporabi S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Pred povezovanjem se prepričajte, da vedro obstaja in da imajo poverilnice dostop za branje/pisanje.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Dokler je izbran {{provider}}, se knjige, napredek in opombe sinhronizirajo samo z vašim strežnikom.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Dokler je izbran {{provider}}, se knjige, napredek in opombe sinhronizirajo samo z vašim Drive.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Dokler je izbran {{provider}}, se knjige, napredek in opombe sinhronizirajo samo z vašim vedrom.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "Shramba S3"
|
||||
}
|
||||
|
||||
@@ -1817,10 +1817,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Synka ditt bibliotek, dina läsframsteg och markeringar med din Google Drive.",
|
||||
"Configured": "Konfigurerad",
|
||||
"Third-party Cloud Sync": "Tredjeparts molnsynk",
|
||||
"Use WebDAV": "Använd WebDAV",
|
||||
"Cloud Sync": "Molnsynk",
|
||||
"Available on Plus, Pro, or Lifetime": "Tillgänglig med Plus, Pro eller Lifetime",
|
||||
"Extra Large": "Extra stor",
|
||||
"Hint size": "Hintstorlek",
|
||||
"Gloss text size above the word": "Textstorlek för hinten ovanför ordet",
|
||||
@@ -1831,15 +1829,12 @@
|
||||
"Stopped reading aloud": "Uppläsningen stoppades",
|
||||
"Read aloud stopped": "Uppläsning stoppad",
|
||||
"Cloud provider switched": "Molnleverantör bytt",
|
||||
"Synced via {{provider}}": "Synkroniserades via {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Uppladdningar till Readest Cloud är pausade så länge {{provider}}-synkning är vald",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Hanteras av {{provider}} så länge det är din molnsynkleverantör",
|
||||
"Not signed in": "Inte inloggad",
|
||||
"Paused — plan required": "Pausad — abonnemang krävs",
|
||||
"Active · Book file uploads off": "Aktiv · Uppladdning av bokfiler av",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Så länge WebDAV är valt synkas böcker, framsteg och markeringar endast med din server.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Appinställningar, läsestatistik och ordböcker fortsätter att synkas via ditt Readest-konto så länge du är inloggad.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Så länge Google Drive är valt synkas böcker, framsteg och markeringar endast med din Drive.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Synka ditt bibliotek, dina läsframsteg och markeringar med Readest Cloud.",
|
||||
"Account and Storage": "Konto och lagring",
|
||||
@@ -1854,5 +1849,23 @@
|
||||
"Cloud sync session expired": "Molnsynkroniseringssessionen har upphört",
|
||||
"Uploads book files to your other devices": "Laddar upp bokfiler till dina andra enheter",
|
||||
"Re-check every book instead of only changed ones": "Kontrollera varje bok igen i stället för bara ändrade",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Bucketen hittades inte",
|
||||
"Endpoint": "Slutpunkt",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Region",
|
||||
"Access Key ID": "Åtkomstnyckel-ID",
|
||||
"Your Access Key ID": "Ditt åtkomstnyckel-ID",
|
||||
"Secret Access Key": "Hemlig åtkomstnyckel",
|
||||
"Your Secret Access Key": "Din hemliga åtkomstnyckel",
|
||||
"S3-Compatible Storage": "S3-kompatibel lagring",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Synkronisera ditt bibliotek, din läsprogress och dina markeringar med en S3-kompatibel bucket som Cloudflare R2, AWS S3 eller MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "I webbläsaren måste bucketens CORS-policy tillåta den här webbplatsens ursprung.",
|
||||
"Use S3": "Använd S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Innan du ansluter, se till att bucketen finns och att autentiseringsuppgifterna har läs-/skrivåtkomst.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "När {{provider}} är valt synkroniseras böcker, framsteg och anteckningar endast med din server.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "När {{provider}} är valt synkroniseras böcker, framsteg och anteckningar endast med din Drive.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "När {{provider}} är valt synkroniseras böcker, framsteg och anteckningar endast med din bucket.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "S3-lagring"
|
||||
}
|
||||
|
||||
@@ -1817,10 +1817,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "உங்கள் நூலகம், வாசிப்பு முன்னேற்றம் மற்றும் சிறப்பம்சங்களை உங்கள் Google Drive உடன் ஒத்திசைக்கவும்.",
|
||||
"Configured": "கட்டமைக்கப்பட்டது",
|
||||
"Third-party Cloud Sync": "மூன்றாம் தரப்பு cloud ஒத்திசைவு",
|
||||
"Use WebDAV": "WebDAV ஐப் பயன்படுத்தவும்",
|
||||
"Cloud Sync": "Cloud ஒத்திசைவு",
|
||||
"Available on Plus, Pro, or Lifetime": "Plus, Pro அல்லது Lifetime இல் கிடைக்கும்",
|
||||
"Extra Large": "மிகப் பெரிய",
|
||||
"Hint size": "குறிப்பு அளவு",
|
||||
"Gloss text size above the word": "வார்த்தைக்கு மேலே உள்ள குறிப்பு உரையின் அளவு",
|
||||
@@ -1831,15 +1829,12 @@
|
||||
"Stopped reading aloud": "உரை வாசிப்பு நிறுத்தப்பட்டது",
|
||||
"Read aloud stopped": "உரை வாசிப்பு நின்றது",
|
||||
"Cloud provider switched": "கிளவுட் வழங்குநர் மாற்றப்பட்டது",
|
||||
"Synced via {{provider}}": "{{provider}} வழியாக ஒத்திசைக்கப்பட்டது",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "{{provider}} ஒத்திசைவு தேர்ந்தெடுக்கப்பட்டிருக்கும் வரை Readest Cloud-க்கு பதிவேற்றங்கள் இடைநிறுத்தப்படும்",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "{{provider}} உங்கள் கிளவுட் ஒத்திசைவு வழங்குநராக இருக்கும் வரை அதனால் நிர்வகிக்கப்படுகிறது",
|
||||
"Not signed in": "உள்நுழையவில்லை",
|
||||
"Paused — plan required": "இடைநிறுத்தப்பட்டது — திட்டம் தேவை",
|
||||
"Active · Book file uploads off": "செயலில் · புத்தக கோப்பு பதிவேற்றம் அணைக்கப்பட்டுள்ளது",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "WebDAV தேர்ந்தெடுக்கப்பட்டிருக்கும் வரை, புத்தகங்கள், முன்னேற்றம் மற்றும் சிறப்பம்சங்கள் உங்கள் சேவையகத்துடன் மட்டுமே ஒத்திசைக்கப்படும்.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "உள்நுழைந்திருக்கும் வரை ஆப் அமைப்புகள், வாசிப்பு புள்ளிவிவரங்கள் மற்றும் அகராதிகள் உங்கள் Readest கணக்கு வழியாக ஒத்திசைக்கப்பட்டுக்கொண்டே இருக்கும்.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Google Drive தேர்ந்தெடுக்கப்பட்டிருக்கும் வரை, புத்தகங்கள், முன்னேற்றம் மற்றும் சிறப்பம்சங்கள் உங்கள் Drive-உடன் மட்டுமே ஒத்திசைக்கப்படும்.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "உங்கள் நூலகம், வாசிப்பு முன்னேற்றம் மற்றும் சிறப்பம்சங்களை Readest Cloud-உடன் ஒத்திசைக்கவும்.",
|
||||
"Account and Storage": "கணக்கு மற்றும் சேமிப்பகம்",
|
||||
@@ -1854,5 +1849,23 @@
|
||||
"Cloud sync session expired": "கிளவுட் ஒத்திசைவு அமர்வு காலாவதியானது",
|
||||
"Uploads book files to your other devices": "புத்தகக் கோப்புகளை உங்கள் மற்ற சாதனங்களுக்குப் பதிவேற்றுகிறது",
|
||||
"Re-check every book instead of only changed ones": "மாற்றப்பட்டவை மட்டுமல்லாமல் ஒவ்வொரு புத்தகத்தையும் மீண்டும் சரிபார்க்கவும்",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "பக்கெட் கிடைக்கவில்லை",
|
||||
"Endpoint": "எண்ட்பாயின்ட்",
|
||||
"Bucket": "பக்கெட்",
|
||||
"Region": "மண்டலம்",
|
||||
"Access Key ID": "அணுகல் விசை ID",
|
||||
"Your Access Key ID": "உங்கள் அணுகல் விசை ID",
|
||||
"Secret Access Key": "இரகசிய அணுகல் விசை",
|
||||
"Your Secret Access Key": "உங்கள் இரகசிய அணுகல் விசை",
|
||||
"S3-Compatible Storage": "S3-இணக்கமான சேமிப்பகம்",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Cloudflare R2, AWS S3 அல்லது MinIO போன்ற S3-இணக்கமான பக்கெட்டுடன் உங்கள் நூலகம், வாசிப்பு முன்னேற்றம் மற்றும் சிறப்பு குறிப்புகளை ஒத்திசைக்கவும்.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "உலாவியில், பக்கெட்டின் CORS கொள்கை இந்த தளத்தின் origin-ஐ அனுமதிக்க வேண்டும்.",
|
||||
"Use S3": "S3 பயன்படுத்து",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "இணைப்பதற்கு முன், பக்கெட் இருப்பதையும் சான்றுகளுக்கு படிக்க/எழுத அணுகல் இருப்பதையும் உறுதிசெய்யவும்.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "{{provider}} தேர்ந்தெடுக்கப்பட்டிருக்கும் போது, புத்தகங்கள், முன்னேற்றம் மற்றும் குறிப்புகள் உங்கள் சேவையகத்துடன் மட்டுமே ஒத்திசைக்கப்படும்.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}} தேர்ந்தெடுக்கப்பட்டிருக்கும் போது, புத்தகங்கள், முன்னேற்றம் மற்றும் குறிப்புகள் உங்கள் Drive உடன் மட்டுமே ஒத்திசைக்கப்படும்.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}} தேர்ந்தெடுக்கப்பட்டிருக்கும் போது, புத்தகங்கள், முன்னேற்றம் மற்றும் குறிப்புகள் உங்கள் பக்கெட்டுடன் மட்டுமே ஒத்திசைக்கப்படும்.",
|
||||
"Premium": "பிரீமியம்",
|
||||
"S3 Storage": "S3 சேமிப்பகம்"
|
||||
}
|
||||
|
||||
@@ -1783,10 +1783,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "ซิงค์คลังหนังสือ ความคืบหน้าการอ่าน และไฮไลต์ของคุณกับ Google Drive ของคุณ",
|
||||
"Configured": "กำหนดค่าแล้ว",
|
||||
"Third-party Cloud Sync": "ซิงค์คลาวด์ของบุคคลที่สาม",
|
||||
"Use WebDAV": "ใช้ WebDAV",
|
||||
"Cloud Sync": "ซิงค์คลาวด์",
|
||||
"Available on Plus, Pro, or Lifetime": "ใช้ได้ในแผน Plus, Pro หรือตลอดชีพ",
|
||||
"Extra Large": "ใหญ่พิเศษ",
|
||||
"Hint size": "ขนาดคำใบ้",
|
||||
"Gloss text size above the word": "ขนาดข้อความคำใบ้ที่แสดงเหนือคำศัพท์",
|
||||
@@ -1797,15 +1795,12 @@
|
||||
"Stopped reading aloud": "หยุดอ่านออกเสียงแล้ว",
|
||||
"Read aloud stopped": "การอ่านออกเสียงหยุดแล้ว",
|
||||
"Cloud provider switched": "เปลี่ยนผู้ให้บริการคลาวด์แล้ว",
|
||||
"Synced via {{provider}}": "ซิงค์ผ่าน {{provider}} แล้ว",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "การอัปโหลดไปยัง Readest Cloud หยุดชั่วคราวขณะที่เลือกการซิงค์ {{provider}}",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "จัดการโดย {{provider}} ขณะที่เป็นผู้ให้บริการซิงค์คลาวด์ของคุณ",
|
||||
"Not signed in": "ยังไม่ได้เข้าสู่ระบบ",
|
||||
"Paused — plan required": "หยุดชั่วคราว — ต้องมีแพ็กเกจ",
|
||||
"Active · Book file uploads off": "ใช้งานอยู่ · ปิดการอัปโหลดไฟล์หนังสือ",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "ขณะที่เลือก WebDAV หนังสือ ความคืบหน้า และไฮไลต์จะซิงค์กับเซิร์ฟเวอร์ของคุณเท่านั้น",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "การตั้งค่าแอป สถิติการอ่าน และพจนานุกรมยังคงซิงค์ผ่านบัญชี Readest ของคุณขณะที่เข้าสู่ระบบอยู่",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "ขณะที่เลือก Google Drive หนังสือ ความคืบหน้า และไฮไลต์จะซิงค์กับ Drive ของคุณเท่านั้น",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "ซิงค์คลังหนังสือ ความคืบหน้าการอ่าน และไฮไลต์ของคุณกับ Readest Cloud",
|
||||
"Account and Storage": "บัญชีและพื้นที่จัดเก็บ",
|
||||
@@ -1819,5 +1814,23 @@
|
||||
"Cloud sync session expired": "เซสชันการซิงค์บนคลาวด์หมดอายุแล้ว",
|
||||
"Uploads book files to your other devices": "อัปโหลดไฟล์หนังสือไปยังอุปกรณ์อื่นของคุณ",
|
||||
"Re-check every book instead of only changed ones": "ตรวจสอบหนังสือทุกเล่มอีกครั้งแทนที่จะเฉพาะเล่มที่เปลี่ยนแปลง",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "ไม่พบบักเก็ต",
|
||||
"Endpoint": "เอ็นด์พอยต์",
|
||||
"Bucket": "บักเก็ต",
|
||||
"Region": "ภูมิภาค",
|
||||
"Access Key ID": "Access Key ID",
|
||||
"Your Access Key ID": "Access Key ID ของคุณ",
|
||||
"Secret Access Key": "Secret Access Key",
|
||||
"Your Secret Access Key": "Secret Access Key ของคุณ",
|
||||
"S3-Compatible Storage": "พื้นที่จัดเก็บที่รองรับ S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "ซิงค์คลังหนังสือ ความคืบหน้าการอ่าน และไฮไลต์ของคุณกับบักเก็ตที่รองรับ S3 เช่น Cloudflare R2, AWS S3 หรือ MinIO",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "ในเบราว์เซอร์ นโยบาย CORS ของบักเก็ตต้องอนุญาต origin ของเว็บไซต์นี้",
|
||||
"Use S3": "ใช้ S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "ก่อนเชื่อมต่อ ตรวจสอบว่าบักเก็ตมีอยู่และข้อมูลรับรองมีสิทธิ์อ่าน/เขียน",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "เมื่อเลือก {{provider}} หนังสือ ความคืบหน้า และคำอธิบายประกอบจะซิงค์ไปยังเซิร์ฟเวอร์ของคุณเท่านั้น",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "เมื่อเลือก {{provider}} หนังสือ ความคืบหน้า และคำอธิบายประกอบจะซิงค์ไปยัง Drive ของคุณเท่านั้น",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "เมื่อเลือก {{provider}} หนังสือ ความคืบหน้า และคำอธิบายประกอบจะซิงค์ไปยังบักเก็ตของคุณเท่านั้น",
|
||||
"Premium": "พรีเมียม",
|
||||
"S3 Storage": "พื้นที่จัดเก็บ S3"
|
||||
}
|
||||
|
||||
@@ -1817,10 +1817,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Kütüphanenizi, okuma ilerlemenizi ve vurgularınızı Google Drive'ınızla senkronize edin.",
|
||||
"Configured": "Yapılandırıldı",
|
||||
"Third-party Cloud Sync": "Üçüncü Taraf Bulut Senkronizasyonu",
|
||||
"Use WebDAV": "WebDAV Kullan",
|
||||
"Cloud Sync": "Bulut Senkronizasyonu",
|
||||
"Available on Plus, Pro, or Lifetime": "Plus, Pro veya Lifetime planlarında kullanılabilir",
|
||||
"Extra Large": "Çok Büyük",
|
||||
"Hint size": "İpucu boyutu",
|
||||
"Gloss text size above the word": "Kelimenin üzerindeki açıklama metninin boyutu",
|
||||
@@ -1831,15 +1829,12 @@
|
||||
"Stopped reading aloud": "Sesli okuma durduruldu",
|
||||
"Read aloud stopped": "Sesli okuma durdu",
|
||||
"Cloud provider switched": "Bulut sağlayıcı değiştirildi",
|
||||
"Synced via {{provider}}": "{{provider}} üzerinden senkronize edildi",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "{{provider}} senkronizasyonu seçili olduğu sürece Readest Cloud yüklemeleri duraklatılır",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Bulut senkronizasyon sağlayıcınız olduğu sürece {{provider}} tarafından yönetilir",
|
||||
"Not signed in": "Oturum açılmadı",
|
||||
"Paused — plan required": "Duraklatıldı — plan gerekli",
|
||||
"Active · Book file uploads off": "Etkin · Kitap dosyası yükleme kapalı",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "WebDAV seçili olduğu sürece kitaplar, ilerleme ve vurgular yalnızca sunucunuzla senkronize edilir.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Uygulama ayarları, okuma istatistikleri ve sözlükler, oturum açık olduğu sürece Readest hesabınız üzerinden senkronize edilmeye devam eder.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Google Drive seçili olduğu sürece kitaplar, ilerleme ve vurgular yalnızca Drive’ınızla senkronize edilir.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Kütüphanenizi, okuma ilerlemenizi ve vurgularınızı Readest Cloud ile senkronize edin.",
|
||||
"Account and Storage": "Hesap ve depolama",
|
||||
@@ -1854,5 +1849,23 @@
|
||||
"Cloud sync session expired": "Bulut eşitleme oturumunun süresi doldu",
|
||||
"Uploads book files to your other devices": "Kitap dosyalarını diğer cihazlarınıza yükler",
|
||||
"Re-check every book instead of only changed ones": "Yalnızca değişenleri değil her kitabı yeniden denetle",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Bucket bulunamadı",
|
||||
"Endpoint": "Uç nokta",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Bölge",
|
||||
"Access Key ID": "Erişim Anahtarı Kimliği",
|
||||
"Your Access Key ID": "Erişim Anahtarı Kimliğiniz",
|
||||
"Secret Access Key": "Gizli Erişim Anahtarı",
|
||||
"Your Secret Access Key": "Gizli Erişim Anahtarınız",
|
||||
"S3-Compatible Storage": "S3 uyumlu depolama",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Kitaplığınızı, okuma ilerlemenizi ve vurgularınızı Cloudflare R2, AWS S3 veya MinIO gibi S3 uyumlu bir bucket ile senkronize edin.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "Tarayıcıda, bucket'ın CORS politikası bu sitenin kaynağına izin vermelidir.",
|
||||
"Use S3": "S3 kullan",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Bağlanmadan önce bucket'ın var olduğundan ve kimlik bilgilerinin okuma/yazma erişimine sahip olduğundan emin olun.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "{{provider}} seçili olduğunda kitaplar, ilerleme ve açıklamalar yalnızca sunucunuzla senkronize edilir.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}} seçili olduğunda kitaplar, ilerleme ve açıklamalar yalnızca Drive'ınızla senkronize edilir.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}} seçili olduğunda kitaplar, ilerleme ve açıklamalar yalnızca bucket'ınızla senkronize edilir.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "S3 depolama"
|
||||
}
|
||||
|
||||
@@ -1885,10 +1885,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Синхронізуйте свою бібліотеку, проґрес читання та виділення з вашим Google Drive.",
|
||||
"Configured": "Налаштовано",
|
||||
"Third-party Cloud Sync": "Стороння хмарна синхронізація",
|
||||
"Use WebDAV": "Використовувати WebDAV",
|
||||
"Cloud Sync": "Хмарна синхронізація",
|
||||
"Available on Plus, Pro, or Lifetime": "Доступно в планах Plus, Pro або Lifetime",
|
||||
"Extra Large": "Дуже великий",
|
||||
"Hint size": "Розмір підказки",
|
||||
"Gloss text size above the word": "Розмір тексту підказки над словом",
|
||||
@@ -1899,15 +1897,12 @@
|
||||
"Stopped reading aloud": "Читання вголос зупинено",
|
||||
"Read aloud stopped": "Озвучування зупинено",
|
||||
"Cloud provider switched": "Хмарного провайдера змінено",
|
||||
"Synced via {{provider}}": "Синхронізовано через {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Вивантаження до Readest Cloud призупинено, поки вибрано синхронізацію {{provider}}",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Керується {{provider}}, поки це ваш провайдер хмарної синхронізації",
|
||||
"Not signed in": "Не виконано вхід",
|
||||
"Paused — plan required": "Призупинено — потрібен тарифний план",
|
||||
"Active · Book file uploads off": "Активно · Вивантаження файлів книг вимкнено",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Поки вибрано WebDAV, книги, проґрес і виділення синхронізуються лише з вашим сервером.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Налаштування застосунку, статистика читання та словники й далі синхронізуються через ваш обліковий запис Readest, поки ви ввійшли.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Поки вибрано Google Drive, книги, проґрес і виділення синхронізуються лише з вашим Drive.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Синхронізуйте свою бібліотеку, проґрес читання та виділення з Readest Cloud.",
|
||||
"Account and Storage": "Обліковий запис і сховище",
|
||||
@@ -1924,5 +1919,23 @@
|
||||
"Cloud sync session expired": "Сеанс хмарної синхронізації завершився",
|
||||
"Uploads book files to your other devices": "Завантажує файли книжок на інші ваші пристрої",
|
||||
"Re-check every book instead of only changed ones": "Повторно перевіряти кожну книжку, а не лише змінені",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Бакет не знайдено",
|
||||
"Endpoint": "Кінцева точка",
|
||||
"Bucket": "Бакет",
|
||||
"Region": "Регіон",
|
||||
"Access Key ID": "ID ключа доступу",
|
||||
"Your Access Key ID": "Ваш ID ключа доступу",
|
||||
"Secret Access Key": "Секретний ключ доступу",
|
||||
"Your Secret Access Key": "Ваш секретний ключ доступу",
|
||||
"S3-Compatible Storage": "S3-сумісне сховище",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Синхронізуйте бібліотеку, прогрес читання та виділення з S3-сумісним бакетом, наприклад Cloudflare R2, AWS S3 або MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "У браузері політика CORS бакета має дозволяти джерело цього сайту.",
|
||||
"Use S3": "Використовувати S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Перед підключенням переконайтеся, що бакет існує, а облікові дані мають доступ на читання й запис.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Поки вибрано {{provider}}, книги, прогрес і анотації синхронізуються лише з вашим сервером.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Поки вибрано {{provider}}, книги, прогрес і анотації синхронізуються лише з вашим Drive.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Поки вибрано {{provider}}, книги, прогрес і анотації синхронізуються лише з вашим бакетом.",
|
||||
"Premium": "Преміум",
|
||||
"S3 Storage": "Сховище S3"
|
||||
}
|
||||
|
||||
@@ -1817,10 +1817,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Kutubxonangiz, oʻqish jarayoni va belgilashlaringizni Google Drive bilan sinxronlang.",
|
||||
"Configured": "Sozlangan",
|
||||
"Third-party Cloud Sync": "Uchinchi tomon bulutli sinxronlash",
|
||||
"Use WebDAV": "WebDAV-dan foydalanish",
|
||||
"Cloud Sync": "Bulutli sinxronlash",
|
||||
"Available on Plus, Pro, or Lifetime": "Plus, Pro yoki Umrbod rejalarida mavjud",
|
||||
"Extra Large": "Juda katta",
|
||||
"Hint size": "Izoh oʻlchami",
|
||||
"Gloss text size above the word": "Soʻz ustida koʻrsatiladigan izoh matni oʻlchami",
|
||||
@@ -1831,15 +1829,12 @@
|
||||
"Stopped reading aloud": "Ovoz chiqarib oʻqish toʻxtatildi",
|
||||
"Read aloud stopped": "Ovoz chiqarib oʻqish toʻxtadi",
|
||||
"Cloud provider switched": "Bulut provayderi almashtirildi",
|
||||
"Synced via {{provider}}": "{{provider}} orqali sinxronlandi",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "{{provider}} sinxronlash tanlangan paytda Readest Cloudga yuklashlar toʻxtatib turiladi",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "{{provider}} sizning bulutli sinxronlash provayderingiz boʻlib turgan paytda u tomonidan boshqariladi",
|
||||
"Not signed in": "Tizimga kirilmagan",
|
||||
"Paused — plan required": "Toʻxtatilgan — tarif reja kerak",
|
||||
"Active · Book file uploads off": "Faol · Kitob fayllarini yuklash oʻchirilgan",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "WebDAV tanlangan paytda kitoblar, jarayon va belgilashlar faqat sizning serveringiz bilan sinxronlanadi.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Ilova sozlamalari, oʻqish statistikasi va lugʻatlar tizimga kirgan paytingizda Readest hisobingiz orqali sinxronlanishda davom etadi.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Google Drive tanlangan paytda kitoblar, jarayon va belgilashlar faqat sizning Drive bilan sinxronlanadi.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Kutubxonangiz, oʻqish jarayoni va belgilashlaringizni Readest Cloud bilan sinxronlang.",
|
||||
"Account and Storage": "Hisob va xotira",
|
||||
@@ -1854,5 +1849,23 @@
|
||||
"Cloud sync session expired": "Bulutli sinxronlash seansi muddati tugadi",
|
||||
"Uploads book files to your other devices": "Kitob fayllarini boshqa qurilmalaringizga yuklaydi",
|
||||
"Re-check every book instead of only changed ones": "Faqat o'zgarganlar o'rniga har bir kitobni qayta tekshirish",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Bucket topilmadi",
|
||||
"Endpoint": "Endpoint",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Mintaqa",
|
||||
"Access Key ID": "Kirish kaliti ID",
|
||||
"Your Access Key ID": "Kirish kaliti ID'ingiz",
|
||||
"Secret Access Key": "Maxfiy kirish kaliti",
|
||||
"Your Secret Access Key": "Maxfiy kirish kalitingiz",
|
||||
"S3-Compatible Storage": "S3 bilan mos saqlash",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Kutubxonangiz, o'qish jarayoni va belgilaringizni Cloudflare R2, AWS S3 yoki MinIO kabi S3 bilan mos bucket bilan sinxronlang.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "Brauzerda bucketning CORS siyosati ushbu sayt originiga ruxsat berishi kerak.",
|
||||
"Use S3": "S3 dan foydalanish",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Ulanishdan oldin bucket mavjudligiga va hisob ma'lumotlari o‘qish/yozish huquqiga ega ekaniga ishonch hosil qiling.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "{{provider}} tanlangan paytda kitoblar, jarayon va izohlar faqat serveringizga sinxronlanadi.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}} tanlangan paytda kitoblar, jarayon va izohlar faqat Drive'ingizga sinxronlanadi.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}} tanlangan paytda kitoblar, jarayon va izohlar faqat bucketingizga sinxronlanadi.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "S3 saqlash"
|
||||
}
|
||||
|
||||
@@ -1783,10 +1783,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "Đồng bộ thư viện, tiến trình đọc và điểm nổi bật của bạn với Google Drive của bạn.",
|
||||
"Configured": "Đã cấu hình",
|
||||
"Third-party Cloud Sync": "Đồng bộ đám mây bên thứ ba",
|
||||
"Use WebDAV": "Dùng WebDAV",
|
||||
"Cloud Sync": "Đồng bộ đám mây",
|
||||
"Available on Plus, Pro, or Lifetime": "Khả dụng với gói Plus, Pro hoặc Trọn đời",
|
||||
"Extra Large": "Rất lớn",
|
||||
"Hint size": "Cỡ gợi ý",
|
||||
"Gloss text size above the word": "Cỡ chữ của chú giải phía trên từ",
|
||||
@@ -1797,15 +1795,12 @@
|
||||
"Stopped reading aloud": "Đã dừng đọc to",
|
||||
"Read aloud stopped": "Đọc to đã dừng",
|
||||
"Cloud provider switched": "Đã chuyển nhà cung cấp đám mây",
|
||||
"Synced via {{provider}}": "Đã đồng bộ qua {{provider}}",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "Tải lên Readest Cloud tạm dừng khi đồng bộ {{provider}} đang được chọn",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "Do {{provider}} quản lý khi nó là nhà cung cấp đồng bộ đám mây của bạn",
|
||||
"Not signed in": "Chưa đăng nhập",
|
||||
"Paused — plan required": "Tạm dừng — cần gói đăng ký",
|
||||
"Active · Book file uploads off": "Đang hoạt động · Tải lên tệp sách đang tắt",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "Khi WebDAV được chọn, sách, tiến trình và điểm nổi bật chỉ đồng bộ với máy chủ của bạn.",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "Cài đặt ứng dụng, thống kê đọc và từ điển vẫn đồng bộ qua tài khoản Readest của bạn khi bạn đã đăng nhập.",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "Khi Google Drive được chọn, sách, tiến trình và điểm nổi bật chỉ đồng bộ với Drive của bạn.",
|
||||
"Readest Cloud": "Readest Cloud",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "Đồng bộ thư viện, tiến trình đọc và điểm nổi bật của bạn với Readest Cloud.",
|
||||
"Account and Storage": "Tài khoản và lưu trữ",
|
||||
@@ -1819,5 +1814,23 @@
|
||||
"Cloud sync session expired": "Phiên đồng bộ đám mây đã hết hạn",
|
||||
"Uploads book files to your other devices": "Tải tệp sách lên các thiết bị khác của bạn",
|
||||
"Re-check every book instead of only changed ones": "Kiểm tra lại mọi cuốn sách thay vì chỉ những cuốn đã thay đổi",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "Không tìm thấy bucket",
|
||||
"Endpoint": "Endpoint",
|
||||
"Bucket": "Bucket",
|
||||
"Region": "Khu vực",
|
||||
"Access Key ID": "ID khóa truy cập",
|
||||
"Your Access Key ID": "ID khóa truy cập của bạn",
|
||||
"Secret Access Key": "Khóa truy cập bí mật",
|
||||
"Your Secret Access Key": "Khóa truy cập bí mật của bạn",
|
||||
"S3-Compatible Storage": "Lưu trữ tương thích S3",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "Đồng bộ thư viện, tiến độ đọc và phần tô sáng của bạn với một bucket tương thích S3 như Cloudflare R2, AWS S3 hoặc MinIO.",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "Trên trình duyệt, chính sách CORS của bucket phải cho phép origin của trang này.",
|
||||
"Use S3": "Dùng S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "Trước khi kết nối, hãy đảm bảo bucket tồn tại và thông tin xác thực có quyền đọc/ghi.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "Khi {{provider}} được chọn, sách, tiến độ và chú thích chỉ đồng bộ với máy chủ của bạn.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Khi {{provider}} được chọn, sách, tiến độ và chú thích chỉ đồng bộ với Drive của bạn.",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Khi {{provider}} được chọn, sách, tiến độ và chú thích chỉ đồng bộ với bucket của bạn.",
|
||||
"Premium": "Premium",
|
||||
"S3 Storage": "Lưu trữ S3"
|
||||
}
|
||||
|
||||
@@ -1783,10 +1783,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "将您的书库、阅读进度和高亮与您的 Google Drive 同步。",
|
||||
"Configured": "已配置",
|
||||
"Third-party Cloud Sync": "第三方云同步",
|
||||
"Use WebDAV": "使用 WebDAV",
|
||||
"Cloud Sync": "云同步",
|
||||
"Available on Plus, Pro, or Lifetime": "Plus、Pro 或 Lifetime 套餐可用",
|
||||
"Extra Large": "特大",
|
||||
"Hint size": "提示大小",
|
||||
"Gloss text size above the word": "单词上方注释文字的大小",
|
||||
@@ -1797,15 +1795,12 @@
|
||||
"Stopped reading aloud": "已停止语音朗读",
|
||||
"Read aloud stopped": "语音朗读已停止",
|
||||
"Cloud provider switched": "云服务提供方已切换",
|
||||
"Synced via {{provider}}": "已通过 {{provider}} 同步",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "选择 {{provider}} 同步期间,上传到 Readest 云端已暂停",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "当 {{provider}} 是您的云同步提供方时由其管理",
|
||||
"Not signed in": "未登录",
|
||||
"Paused — plan required": "已暂停 — 需要订阅方案",
|
||||
"Active · Book file uploads off": "已启用 · 书籍文件上传已关闭",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "选择 WebDAV 期间,书籍、进度和高亮仅与您的服务器同步。",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "登录期间,应用设置、阅读统计和词典仍会通过您的 Readest 账户同步。",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "选择 Google Drive 期间,书籍、进度和高亮仅与您的 Drive 同步。",
|
||||
"Readest Cloud": "Readest 云端",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "将您的书库、阅读进度和高亮与 Readest 云端同步。",
|
||||
"Account and Storage": "账户与存储",
|
||||
@@ -1819,5 +1814,23 @@
|
||||
"Cloud sync session expired": "云同步会话已过期",
|
||||
"Uploads book files to your other devices": "将书籍文件上传到您的其他设备",
|
||||
"Re-check every book instead of only changed ones": "重新检查每一本书,而不仅仅是已更改的书",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "未找到存储桶",
|
||||
"Endpoint": "端点",
|
||||
"Bucket": "存储桶",
|
||||
"Region": "区域",
|
||||
"Access Key ID": "访问密钥 ID",
|
||||
"Your Access Key ID": "您的访问密钥 ID",
|
||||
"Secret Access Key": "秘密访问密钥",
|
||||
"Your Secret Access Key": "您的秘密访问密钥",
|
||||
"S3-Compatible Storage": "S3 兼容存储",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "通过 Cloudflare R2、AWS S3 或 MinIO 等 S3 兼容存储桶同步您的书库、阅读进度和标注。",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "在浏览器中使用时,存储桶的 CORS 策略必须允许本站点的来源。",
|
||||
"Use S3": "使用 S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "连接前请确保存储桶已存在,且凭证具有读写权限。",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "选择 {{provider}} 后,书籍、进度和标注仅同步到您的服务器。",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "选择 {{provider}} 后,书籍、进度和标注仅同步到您的 Drive。",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "选择 {{provider}} 后,书籍、进度和标注仅同步到您的存储桶。",
|
||||
"Premium": "高级版",
|
||||
"S3 Storage": "S3 存储"
|
||||
}
|
||||
|
||||
@@ -1783,10 +1783,8 @@
|
||||
"Google Drive": "Google Drive",
|
||||
"Sync your library, reading progress, and highlights with your Google Drive.": "將您的書庫、閱讀進度和高亮與您的 Google Drive 同步。",
|
||||
"Configured": "已設定",
|
||||
"Third-party Cloud Sync": "第三方雲同步",
|
||||
"Use WebDAV": "使用 WebDAV",
|
||||
"Cloud Sync": "雲同步",
|
||||
"Available on Plus, Pro, or Lifetime": "Plus、Pro 或 Lifetime 方案可用",
|
||||
"Extra Large": "特大",
|
||||
"Hint size": "提示大小",
|
||||
"Gloss text size above the word": "單詞上方註釋文字的大小",
|
||||
@@ -1797,15 +1795,12 @@
|
||||
"Stopped reading aloud": "已停止朗讀",
|
||||
"Read aloud stopped": "朗讀已停止",
|
||||
"Cloud provider switched": "雲端服務提供方已切換",
|
||||
"Synced via {{provider}}": "已透過 {{provider}} 同步",
|
||||
"Uploads to Readest Cloud are paused while {{provider}} sync is selected": "選擇 {{provider}} 同步期間,上傳到 Readest 雲端已暫停",
|
||||
"Managed by {{provider}} while it is your cloud sync provider": "當 {{provider}} 是您的雲端同步提供方時由其管理",
|
||||
"Not signed in": "未登入",
|
||||
"Paused — plan required": "已暫停 — 需要訂閱方案",
|
||||
"Active · Book file uploads off": "已啟用 · 書籍檔案上傳已關閉",
|
||||
"While WebDAV is selected, books, progress, and annotations sync only to your server.": "選擇 WebDAV 期間,書籍、進度和高亮僅與您的伺服器同步。",
|
||||
"App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.": "登入期間,應用程式設定、閱讀統計和詞典仍會透過您的 Readest 帳戶同步。",
|
||||
"While Google Drive is selected, books, progress, and annotations sync only to your Drive.": "選擇 Google Drive 期間,書籍、進度和高亮僅與您的 Drive 同步。",
|
||||
"Readest Cloud": "Readest 雲端",
|
||||
"Sync your library, reading progress, and highlights with Readest Cloud.": "將您的書庫、閱讀進度和高亮與 Readest 雲端同步。",
|
||||
"Account and Storage": "帳戶與儲存",
|
||||
@@ -1819,5 +1814,23 @@
|
||||
"Cloud sync session expired": "雲端同步工作階段已過期",
|
||||
"Uploads book files to your other devices": "將書籍檔案上傳到您的其他裝置",
|
||||
"Re-check every book instead of only changed ones": "重新檢查每一本書,而非僅限已變更的",
|
||||
"KOReader": "KOReader"
|
||||
"KOReader": "KOReader",
|
||||
"Bucket not found": "未找到儲存桶",
|
||||
"Endpoint": "端點",
|
||||
"Bucket": "儲存桶",
|
||||
"Region": "區域",
|
||||
"Access Key ID": "存取金鑰 ID",
|
||||
"Your Access Key ID": "您的存取金鑰 ID",
|
||||
"Secret Access Key": "秘密存取金鑰",
|
||||
"Your Secret Access Key": "您的秘密存取金鑰",
|
||||
"S3-Compatible Storage": "S3 相容儲存",
|
||||
"Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.": "透過 Cloudflare R2、AWS S3 或 MinIO 等 S3 相容儲存桶同步您的書庫、閱讀進度和標註。",
|
||||
"In the browser, the bucket must allow this site's origin in its CORS policy.": "在瀏覽器中使用時,儲存桶的 CORS 政策必須允許本網站的來源。",
|
||||
"Use S3": "使用 S3",
|
||||
"Make sure the bucket exists and the credentials have read/write access before connecting.": "連線前請確保儲存桶已存在,且憑證具有讀寫權限。",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your server.": "選擇 {{provider}} 後,書籍、進度和標註僅同步到您的伺服器。",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "選擇 {{provider}} 後,書籍、進度和標註僅同步到您的 Drive。",
|
||||
"While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "選擇 {{provider}} 後,書籍、進度和標註僅同步到您的儲存桶。",
|
||||
"Premium": "進階版",
|
||||
"S3 Storage": "S3 儲存"
|
||||
}
|
||||
|
||||
@@ -23,6 +23,29 @@ describe('withActiveCloudProvider', () => {
|
||||
expect(next.googleDrive.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('enabling S3 disables WebDAV and Google Drive (exclusive)', () => {
|
||||
const withS3 = {
|
||||
...base,
|
||||
s3: { enabled: false, endpoint: 'https://acc.r2.cloudflarestorage.com', bucket: 'b' },
|
||||
} as unknown as SystemSettings;
|
||||
const next = withActiveCloudProvider(withS3, 's3');
|
||||
expect(next.s3.enabled).toBe(true);
|
||||
expect(next.webdav.enabled).toBe(false);
|
||||
expect(next.googleDrive.enabled).toBe(false);
|
||||
// Activation hands S3 the book-file channel and anchors fleet detection.
|
||||
expect(next.s3.syncBooks).toBe(true);
|
||||
expect(next.s3.providerSelectedAt).toBeTruthy();
|
||||
// Config survives deactivation elsewhere; endpoint untouched here.
|
||||
expect(next.s3.endpoint).toBe('https://acc.r2.cloudflarestorage.com');
|
||||
});
|
||||
|
||||
test('enabling WebDAV disables S3 (exclusive)', () => {
|
||||
const withS3 = { ...base, s3: { enabled: true } } as unknown as SystemSettings;
|
||||
const next = withActiveCloudProvider(withS3, 'webdav');
|
||||
expect(next.s3.enabled).toBe(false);
|
||||
expect(next.webdav.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('null disables both', () => {
|
||||
const next = withActiveCloudProvider(base, null);
|
||||
expect(next.webdav.enabled).toBe(false);
|
||||
@@ -146,12 +169,12 @@ describe('isCloudSyncInPlan', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCloudSyncAllowed (temporary ungate)', () => {
|
||||
test('cloud sync is currently ungated for every plan, including free', () => {
|
||||
// The feature ships ungated while it stabilises. When the paywall is
|
||||
// restored (flip CLOUD_SYNC_REQUIRES_PREMIUM back to true), update this.
|
||||
expect(CLOUD_SYNC_REQUIRES_PREMIUM).toBe(false);
|
||||
expect(isCloudSyncAllowed('free')).toBe(true);
|
||||
describe('isCloudSyncAllowed (premium paywall)', () => {
|
||||
test('third-party cloud sync requires a paid plan', () => {
|
||||
expect(CLOUD_SYNC_REQUIRES_PREMIUM).toBe(true);
|
||||
expect(isCloudSyncAllowed('free')).toBe(false);
|
||||
expect(isCloudSyncAllowed('plus')).toBe(true);
|
||||
expect(isCloudSyncAllowed('pro')).toBe(true);
|
||||
expect(isCloudSyncAllowed('purchase')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { SystemSettings } from '@/types/settings';
|
||||
import { useFileSyncStore } from '@/store/fileSyncStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
|
||||
/**
|
||||
* The manual "Sync now" must keep the provider health surfaces honest: the
|
||||
* Cloud Sync chooser row and the SettingsMenu sync row read
|
||||
* `lastErrorByKind`, so a completed manual run has to clear a stale error
|
||||
* (server restarted, sync works again → "Sync failed" must not stick) and a
|
||||
* failed one has to record it.
|
||||
*/
|
||||
|
||||
const syncLibrary = vi.fn();
|
||||
|
||||
vi.mock('@/context/EnvContext', () => ({
|
||||
useEnv: () => ({
|
||||
envConfig: { getAppService: async () => ({ loadLibraryBooks: async () => [] }) },
|
||||
appService: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useTranslation', () => ({
|
||||
useTranslation: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/sync/file/providerRegistry', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/services/sync/file/providerRegistry')>();
|
||||
return { ...actual, createFileSyncProvider: vi.fn(async () => ({}) as never) };
|
||||
});
|
||||
|
||||
vi.mock('@/services/sync/file/appLocalStore', () => ({
|
||||
createAppLocalStore: vi.fn(() => ({}) as never),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/sync/file/engine', () => ({
|
||||
FileSyncEngine: vi.fn(function (this: Record<string, unknown>) {
|
||||
this['syncLibrary'] = syncLibrary;
|
||||
}),
|
||||
}));
|
||||
|
||||
import FileSyncForm from '@/components/settings/integrations/FileSyncForm';
|
||||
|
||||
const stored = {
|
||||
enabled: true,
|
||||
deviceId: 'd1',
|
||||
strategy: 'silent' as const,
|
||||
syncBooks: false,
|
||||
};
|
||||
|
||||
const renderForm = () =>
|
||||
render(<FileSyncForm kind='webdav' stored={stored} persist={vi.fn(async () => {})} />);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
syncLibrary.mockResolvedValue({ booksSynced: 1, failures: 0, totalBooks: 1, failedBooks: [] });
|
||||
useSettingsStore.setState({
|
||||
settings: { webdav: stored } as unknown as SystemSettings,
|
||||
} as never);
|
||||
useLibraryStore.setState({ library: [], libraryLoaded: true } as never);
|
||||
useFileSyncStore.setState({ byKind: {}, activeKind: null, lastErrorByKind: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('FileSyncForm — Sync now health reporting', () => {
|
||||
test('a completed run clears a stale lastError', async () => {
|
||||
useFileSyncStore.getState().setLastError('webdav', 'server unreachable');
|
||||
renderForm();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sync now' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useFileSyncStore.getState().lastErrorByKind.webdav).toBeNull();
|
||||
});
|
||||
expect(syncLibrary).toHaveBeenCalledTimes(1);
|
||||
// Mutex released.
|
||||
expect(useFileSyncStore.getState().byKind.webdav?.isSyncing ?? false).toBe(false);
|
||||
});
|
||||
|
||||
test('a failed run records lastError for the health surfaces', async () => {
|
||||
syncLibrary.mockRejectedValueOnce(new Error('boom'));
|
||||
renderForm();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sync now' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useFileSyncStore.getState().lastErrorByKind.webdav).toBe('boom');
|
||||
});
|
||||
expect(useFileSyncStore.getState().byKind.webdav?.isSyncing ?? false).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -11,10 +11,12 @@ vi.mock('@/utils/access', async (importOriginal) => {
|
||||
|
||||
import {
|
||||
applySyncBooksAutoEnable,
|
||||
cloudProviderDisplayName,
|
||||
getCloudSyncProvider,
|
||||
isReadestCloudStorageActive,
|
||||
resolveCloudSyncGate,
|
||||
setCachedUserPlan,
|
||||
settingsKeyForBackend,
|
||||
} from '@/services/sync/cloudSyncProvider';
|
||||
import { isCloudSyncAllowed } from '@/utils/access';
|
||||
|
||||
@@ -45,6 +47,19 @@ describe('getCloudSyncProvider', () => {
|
||||
expect(getCloudSyncProvider(settings)).toBe('gdrive');
|
||||
});
|
||||
|
||||
test('derives s3 when s3 is enabled', () => {
|
||||
const settings = makeSettings({ s3: { enabled: true } } as Partial<SystemSettings>);
|
||||
expect(getCloudSyncProvider(settings)).toBe('s3');
|
||||
});
|
||||
|
||||
test('webdav wins deterministically over s3 when both are enabled (corrupt state)', () => {
|
||||
const settings = makeSettings({
|
||||
webdav: { enabled: true },
|
||||
s3: { enabled: true },
|
||||
} as Partial<SystemSettings>);
|
||||
expect(getCloudSyncProvider(settings)).toBe('webdav');
|
||||
});
|
||||
|
||||
test('webdav wins deterministically when both flags are enabled (corrupt state)', () => {
|
||||
const settings = makeSettings({
|
||||
webdav: { enabled: true },
|
||||
@@ -156,3 +171,20 @@ describe('isReadestCloudStorageActive', () => {
|
||||
expect(isReadestCloudStorageActive(settings, 'free')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settingsKeyForBackend', () => {
|
||||
test('maps each backend kind to its settings slice', () => {
|
||||
expect(settingsKeyForBackend('webdav')).toBe('webdav');
|
||||
expect(settingsKeyForBackend('gdrive')).toBe('googleDrive');
|
||||
expect(settingsKeyForBackend('s3')).toBe('s3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloudProviderDisplayName', () => {
|
||||
test('names every provider kind', () => {
|
||||
expect(cloudProviderDisplayName('webdav')).toBe('WebDAV');
|
||||
expect(cloudProviderDisplayName('gdrive')).toBe('Google Drive');
|
||||
expect(cloudProviderDisplayName('s3')).toBe('S3');
|
||||
expect(cloudProviderDisplayName('readest')).toBe('Readest Cloud');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -359,7 +359,7 @@ describe('FileSyncEngine.syncLibrary — incremental diff (default)', () => {
|
||||
});
|
||||
|
||||
const res = await new FileSyncEngine(provider, store).syncLibrary(
|
||||
[makeBook('h1', { updatedAt: 100 })],
|
||||
[makeBook('h1', { updatedAt: 100, downloadedAt: 1 })],
|
||||
{
|
||||
strategy: 'silent',
|
||||
syncBooks: true,
|
||||
@@ -398,7 +398,7 @@ describe('FileSyncEngine.syncLibrary — incremental diff (default)', () => {
|
||||
});
|
||||
|
||||
const res = await new FileSyncEngine(provider, store).syncLibrary(
|
||||
[makeBook('h1', { updatedAt: 100 })],
|
||||
[makeBook('h1', { updatedAt: 100, downloadedAt: 1 })],
|
||||
{
|
||||
strategy: 'silent',
|
||||
syncBooks: true,
|
||||
@@ -791,3 +791,113 @@ describe('FileSyncEngine.syncLibrary — empty-dir record', () => {
|
||||
expect(idx.emptyDirs ?? []).not.toContain('h9');
|
||||
});
|
||||
});
|
||||
|
||||
// Row-as-truth: the library row is authoritative for local file presence
|
||||
// (import / download / delete all stamp downloadedAt; merges never let a
|
||||
// peer clobber it). A book the row marks as absent is skipped without a
|
||||
// single filesystem or remote probe — incremental sync stays pure metadata
|
||||
// diffing. Row-vs-filesystem split-brain is healed by Full Sync, which
|
||||
// bypasses the gate and audits the real filesystem.
|
||||
describe('FileSyncEngine.syncLibrary — row-as-truth local file gate', () => {
|
||||
test('a book whose row marks no local file is never probed at all', async () => {
|
||||
const head = vi.fn(async (p: string) => (p.endsWith('library.json') ? { etag: 'E9' } : null));
|
||||
const provider = fakeProvider({
|
||||
head,
|
||||
readText: async (p) =>
|
||||
p.endsWith('library.json')
|
||||
? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })]))
|
||||
: null,
|
||||
captured: { writes: [] },
|
||||
});
|
||||
const loadBookFile = vi.fn(async () => null);
|
||||
const resolveLocalBookPath = vi.fn(async () => null);
|
||||
const store = fakeStore({
|
||||
loadConfig: async () => ({ updatedAt: 1, booknotes: [] }),
|
||||
loadBookFile,
|
||||
resolveLocalBookPath,
|
||||
});
|
||||
|
||||
// No downloadedAt, no filePath: this device has never had the file.
|
||||
await new FileSyncEngine(provider, store).syncLibrary([makeBook('h1', { updatedAt: 100 })], {
|
||||
strategy: 'silent',
|
||||
syncBooks: true,
|
||||
deviceId: 'd',
|
||||
});
|
||||
|
||||
expect(loadBookFile).not.toHaveBeenCalled();
|
||||
expect(resolveLocalBookPath).not.toHaveBeenCalled();
|
||||
expect(head).not.toHaveBeenCalledWith(expect.stringContaining('/books/'));
|
||||
});
|
||||
});
|
||||
|
||||
// The plugin:fs|exists storm (Tauri): every sync run re-walked all books
|
||||
// whose file is recorded nowhere, paying two local fs probes per book per
|
||||
// run just to relearn "no local source". The verdict is now memoised per
|
||||
// provider session, keyed to the book's updatedAt — an unchanged library
|
||||
// costs zero local and zero remote probes on repeat runs; only a locally
|
||||
// updated book, Full Sync, or a fresh session re-qualifies a probe.
|
||||
describe('FileSyncEngine.syncLibrary — no-source probe memo', () => {
|
||||
const opts = { strategy: 'silent', syncBooks: true, deviceId: 'd' } as const;
|
||||
|
||||
const makeProbeHarness = () => {
|
||||
const head = vi.fn(async (p: string) => (p.endsWith('library.json') ? { etag: 'E1' } : null));
|
||||
const readText = vi.fn(async (p: string) =>
|
||||
p.endsWith('library.json')
|
||||
? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })]))
|
||||
: null,
|
||||
);
|
||||
const provider = fakeProvider({ head, readText, captured: { writes: [] } });
|
||||
// Drifted row: downloadedAt claims a local file, the filesystem disagrees.
|
||||
const loadBookFile = vi.fn(async () => null);
|
||||
const resolveLocalBookPath = vi.fn(async () => null);
|
||||
const store = fakeStore({
|
||||
loadConfig: async () => ({ updatedAt: 1, booknotes: [] }),
|
||||
loadBookFile,
|
||||
resolveLocalBookPath,
|
||||
});
|
||||
return { provider, store, head, loadBookFile };
|
||||
};
|
||||
|
||||
test('an unchanged no-source book is probed once per provider session', async () => {
|
||||
const h = makeProbeHarness();
|
||||
await new FileSyncEngine(h.provider, h.store).syncLibrary(
|
||||
[makeBook('h1', { updatedAt: 100, downloadedAt: 1 })],
|
||||
opts,
|
||||
);
|
||||
expect(h.loadBookFile).toHaveBeenCalledTimes(1); // first run learns the verdict
|
||||
|
||||
// Fresh engine, same (memoised) provider — one engine build per sync run.
|
||||
await new FileSyncEngine(h.provider, h.store).syncLibrary(
|
||||
[makeBook('h1', { updatedAt: 100, downloadedAt: 1 })],
|
||||
opts,
|
||||
);
|
||||
expect(h.loadBookFile).toHaveBeenCalledTimes(1); // no local re-probe
|
||||
expect(h.head).not.toHaveBeenCalledWith(expect.stringContaining('/books/')); // no remote probe
|
||||
});
|
||||
|
||||
test('a locally updated book is probed again', async () => {
|
||||
const h = makeProbeHarness();
|
||||
await new FileSyncEngine(h.provider, h.store).syncLibrary(
|
||||
[makeBook('h1', { updatedAt: 100, downloadedAt: 1 })],
|
||||
opts,
|
||||
);
|
||||
await new FileSyncEngine(h.provider, h.store).syncLibrary(
|
||||
[makeBook('h1', { updatedAt: 200, downloadedAt: 1 })],
|
||||
opts,
|
||||
);
|
||||
expect(h.loadBookFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('fullSync bypasses the memo', async () => {
|
||||
const h = makeProbeHarness();
|
||||
await new FileSyncEngine(h.provider, h.store).syncLibrary(
|
||||
[makeBook('h1', { updatedAt: 100, downloadedAt: 1 })],
|
||||
opts,
|
||||
);
|
||||
await new FileSyncEngine(h.provider, h.store).syncLibrary(
|
||||
[makeBook('h1', { updatedAt: 100, downloadedAt: 1 })],
|
||||
{ ...opts, fullSync: true },
|
||||
);
|
||||
expect(h.loadBookFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
resetFileSyncProviderCache,
|
||||
} from '@/services/sync/file/providerRegistry';
|
||||
import type { FileSyncProvider } from '@/services/sync/file/provider';
|
||||
import type { WebDAVSettings } from '@/types/settings';
|
||||
import type { S3Settings, WebDAVSettings } from '@/types/settings';
|
||||
|
||||
const webdav: WebDAVSettings = {
|
||||
enabled: true,
|
||||
@@ -21,6 +21,15 @@ const webdav: WebDAVSettings = {
|
||||
rootPath: '/',
|
||||
};
|
||||
|
||||
const s3: S3Settings = {
|
||||
enabled: true,
|
||||
endpoint: 'https://acc.r2.cloudflarestorage.com',
|
||||
region: 'auto',
|
||||
bucket: 'readest',
|
||||
accessKeyId: 'AKID',
|
||||
secretAccessKey: 'SECRET',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetFileSyncProviderCache();
|
||||
@@ -53,6 +62,20 @@ describe('createFileSyncProvider', () => {
|
||||
expect(await createFileSyncProvider('webdav', {})).toBeNull();
|
||||
});
|
||||
|
||||
test('builds an S3 provider from its settings and memoises it', async () => {
|
||||
const first = await createFileSyncProvider('s3', { s3 });
|
||||
expect(first?.rootPath).toBe('/');
|
||||
const second = await createFileSyncProvider('s3', { s3 });
|
||||
expect(second).toBe(first);
|
||||
// A credential change rebuilds.
|
||||
const third = await createFileSyncProvider('s3', { s3: { ...s3, secretAccessKey: 'X' } });
|
||||
expect(third).not.toBe(first);
|
||||
});
|
||||
|
||||
test('returns null for s3 without settings', async () => {
|
||||
expect(await createFileSyncProvider('s3', {})).toBeNull();
|
||||
});
|
||||
|
||||
test('delegates gdrive to buildGoogleDriveProvider', async () => {
|
||||
const fake = { rootPath: '/' } as unknown as FileSyncProvider;
|
||||
vi.mocked(buildGoogleDriveProvider).mockResolvedValueOnce(fake);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { setCachedUserPlan } from '@/services/sync/cloudSyncProvider';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useFileSyncStore } from '@/store/fileSyncStore';
|
||||
import type { SystemSettings } from '@/types/settings';
|
||||
@@ -73,6 +74,8 @@ const setProvider = (patch: Partial<SystemSettings>): void => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Third-party cloud sync is premium; run the suite as a paid plan.
|
||||
setCachedUserPlan('pro');
|
||||
syncLibrary.mockClear().mockResolvedValue({ booksSynced: 0 });
|
||||
useFileSyncStore.setState({ byKind: {}, activeKind: null, lastErrorByKind: {} });
|
||||
useLibraryStore.setState({ library: [], libraryLoaded: true } as never);
|
||||
@@ -81,7 +84,7 @@ beforeEach(() => {
|
||||
describe('runActiveFileLibrarySync', () => {
|
||||
test('returns false without syncing when readest is the provider', async () => {
|
||||
setProvider({});
|
||||
expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBe(false);
|
||||
expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBeNull();
|
||||
expect(syncLibrary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -91,7 +94,10 @@ describe('runActiveFileLibrarySync', () => {
|
||||
} as Partial<SystemSettings>);
|
||||
useFileSyncStore.getState().setLastError('webdav', 'stale error');
|
||||
|
||||
expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBe(true);
|
||||
syncLibrary.mockResolvedValueOnce({ booksSynced: 3 });
|
||||
expect(await runActiveFileLibrarySync(envConfig, translationFn)).toMatchObject({
|
||||
booksSynced: 3,
|
||||
});
|
||||
|
||||
expect(syncLibrary).toHaveBeenCalledTimes(1);
|
||||
const [, options] = syncLibrary.mock.calls[0]!;
|
||||
@@ -108,7 +114,7 @@ describe('runActiveFileLibrarySync', () => {
|
||||
} as Partial<SystemSettings>);
|
||||
syncLibrary.mockRejectedValueOnce(new Error('server unreachable'));
|
||||
|
||||
expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBe(false);
|
||||
expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBeNull();
|
||||
|
||||
expect(useFileSyncStore.getState().lastErrorByKind.webdav).toContain('server unreachable');
|
||||
expect(useFileSyncStore.getState().activeKind).toBeNull();
|
||||
@@ -117,14 +123,14 @@ describe('runActiveFileLibrarySync', () => {
|
||||
test('skips when the library has not loaded (would push an empty index)', async () => {
|
||||
setProvider({ webdav: { enabled: true } } as Partial<SystemSettings>);
|
||||
useLibraryStore.setState({ libraryLoaded: false } as never);
|
||||
expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBe(false);
|
||||
expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBeNull();
|
||||
expect(syncLibrary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skips when another backend holds the library-sync mutex', async () => {
|
||||
setProvider({ webdav: { enabled: true } } as Partial<SystemSettings>);
|
||||
useFileSyncStore.getState().beginSync('gdrive', 'busy');
|
||||
expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBe(false);
|
||||
expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBeNull();
|
||||
expect(syncLibrary).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -193,3 +199,24 @@ describe('runActiveFileBookDownload', () => {
|
||||
expect(await runActiveFileBookDownload(envConfig, makeBook('h1'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Paused means paused (#4959 contract): a free plan with a still-enabled
|
||||
// third-party provider (downgrade) must not sync — neither the library run
|
||||
// nor the per-book actions.
|
||||
describe('premium gating of the active provider', () => {
|
||||
test('library sync is skipped for a free plan', async () => {
|
||||
setCachedUserPlan('free');
|
||||
setProvider({ webdav: { enabled: true, deviceId: 'd1' } } as Partial<SystemSettings>);
|
||||
expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBeNull();
|
||||
expect(syncLibrary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('per-book upload and download are skipped for a free plan', async () => {
|
||||
setCachedUserPlan('free');
|
||||
setProvider({ webdav: { enabled: true } } as Partial<SystemSettings>);
|
||||
expect(await runActiveFileBookUpload(envConfig, makeBook('h1'))).toBe(false);
|
||||
expect(await runActiveFileBookDownload(envConfig, makeBook('h1'))).toBe(false);
|
||||
expect(pushBookFile).not.toHaveBeenCalled();
|
||||
expect(downloadBookFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { createS3Provider, type S3FetchFn } from '@/services/sync/providers/s3/S3Provider';
|
||||
import { FileSyncError } from '@/services/sync/file/provider';
|
||||
import { runSemanticContract } from '@/__tests__/services/sync/file/providerSemanticContract';
|
||||
|
||||
const config = {
|
||||
endpoint: 'https://acc.r2.cloudflarestorage.com',
|
||||
region: 'auto',
|
||||
bucket: 'readest',
|
||||
accessKeyId: 'AKID',
|
||||
secretAccessKey: 'SECRET',
|
||||
};
|
||||
|
||||
const xml = (body: string, status = 200): Response =>
|
||||
new Response(body, { status, headers: { 'content-type': 'application/xml' } });
|
||||
const text = (body: string, status = 200): Response => new Response(body, { status });
|
||||
|
||||
const emptyList = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListBucketResult><IsTruncated>false</IsTruncated></ListBucketResult>`;
|
||||
|
||||
const listPage = (opts: {
|
||||
prefixes?: string[];
|
||||
keys?: { key: string; size?: number; modified?: string }[];
|
||||
next?: string;
|
||||
}): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListBucketResult>
|
||||
${opts.next ? `<NextContinuationToken>${opts.next}</NextContinuationToken>` : ''}
|
||||
${(opts.prefixes ?? []).map((p) => `<CommonPrefixes><Prefix>${p}</Prefix></CommonPrefixes>`).join('')}
|
||||
${(opts.keys ?? [])
|
||||
.map(
|
||||
(k) =>
|
||||
`<Contents><Key>${k.key}</Key><Size>${k.size ?? 0}</Size><LastModified>${
|
||||
k.modified ?? '2026-01-01T00:00:00.000Z'
|
||||
}</LastModified></Contents>`,
|
||||
)
|
||||
.join('')}
|
||||
</ListBucketResult>`;
|
||||
|
||||
interface Harness {
|
||||
provider: ReturnType<typeof createS3Provider>;
|
||||
fetchMock: ReturnType<typeof vi.fn>;
|
||||
sleep: ReturnType<typeof vi.fn>;
|
||||
url: (n: number) => string;
|
||||
method: (n: number) => string | undefined;
|
||||
headers: (n: number) => Record<string, string>;
|
||||
}
|
||||
|
||||
const makeS3 = (): Harness => {
|
||||
const fetchMock = vi.fn();
|
||||
const sleep = vi.fn(async () => {});
|
||||
const provider = createS3Provider(config, fetchMock as unknown as S3FetchFn, {
|
||||
sleep: sleep as unknown as (ms: number) => Promise<void>,
|
||||
});
|
||||
return {
|
||||
provider,
|
||||
fetchMock,
|
||||
sleep,
|
||||
url: (n) => fetchMock.mock.calls[n]?.[0] as string,
|
||||
method: (n) => (fetchMock.mock.calls[n]?.[1] as RequestInit | undefined)?.method,
|
||||
headers: (n) =>
|
||||
((fetchMock.mock.calls[n]?.[1] as RequestInit | undefined)?.headers ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>,
|
||||
};
|
||||
};
|
||||
|
||||
// S3 must satisfy the same semantics as WebDAV / Drive. Absent paths are a
|
||||
// 404 for object ops but an empty 200 listing for deleteDir's prefix scan,
|
||||
// so the staged response dispatches on the request shape.
|
||||
runSemanticContract('S3Provider', () => {
|
||||
const fetchMock = vi.fn();
|
||||
return {
|
||||
makeProvider: () =>
|
||||
createS3Provider(config, fetchMock as unknown as S3FetchFn, { sleep: async () => {} }),
|
||||
stageAbsent: () =>
|
||||
fetchMock.mockImplementationOnce(async (url: string) =>
|
||||
url.includes('list-type=2') ? xml(emptyList) : new Response(null, { status: 404 }),
|
||||
),
|
||||
stageAuthFailure: () => fetchMock.mockResolvedValueOnce(new Response(null, { status: 403 })),
|
||||
};
|
||||
});
|
||||
|
||||
describe('S3Provider — transport', () => {
|
||||
test('readText GETs the path-style object URL with a SigV4 authorization', async () => {
|
||||
const h = makeS3();
|
||||
h.fetchMock.mockResolvedValueOnce(text('HELLO'));
|
||||
|
||||
expect(await h.provider.readText('/Readest/library.json')).toBe('HELLO');
|
||||
expect(h.url(0)).toBe('https://acc.r2.cloudflarestorage.com/readest/Readest/library.json');
|
||||
expect(h.method(0)).toBe('GET');
|
||||
expect(h.headers(0)['authorization']).toContain('AWS4-HMAC-SHA256');
|
||||
});
|
||||
|
||||
test('percent-encodes unicode key segments', async () => {
|
||||
const h = makeS3();
|
||||
h.fetchMock.mockResolvedValueOnce(text('X'));
|
||||
|
||||
await h.provider.readText('/Readest/books/h1/白夜行.epub');
|
||||
expect(h.url(0)).toContain(`/readest/Readest/books/h1/${encodeURIComponent('白夜行.epub')}`);
|
||||
});
|
||||
|
||||
test('head returns size and the ETag stripped of quotes', async () => {
|
||||
const h = makeS3();
|
||||
h.fetchMock.mockResolvedValueOnce(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'content-length': '123', etag: '"abc123"' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await h.provider.head('/Readest/library.json')).toEqual({ size: 123, etag: 'abc123' });
|
||||
expect(h.method(0)).toBe('HEAD');
|
||||
});
|
||||
|
||||
test('list maps CommonPrefixes to dirs and Contents to files, draining pages', async () => {
|
||||
const h = makeS3();
|
||||
h.fetchMock
|
||||
.mockResolvedValueOnce(xml(listPage({ prefixes: ['Readest/books/h1/'], next: 'tok-2' })))
|
||||
.mockResolvedValueOnce(
|
||||
xml(listPage({ keys: [{ key: 'Readest/books/library.json', size: 42 }] })),
|
||||
);
|
||||
|
||||
const entries = await h.provider.list('/Readest/books');
|
||||
|
||||
expect(h.url(0)).toContain('list-type=2');
|
||||
expect(h.url(0)).toContain(`prefix=${encodeURIComponent('Readest/books/')}`);
|
||||
expect(h.url(0)).toContain('delimiter=%2F');
|
||||
expect(h.url(1)).toContain(`continuation-token=${encodeURIComponent('tok-2')}`);
|
||||
expect(entries).toEqual([
|
||||
{ name: 'h1', path: '/Readest/books/h1', isDirectory: true },
|
||||
{
|
||||
name: 'library.json',
|
||||
path: '/Readest/books/library.json',
|
||||
isDirectory: false,
|
||||
size: 42,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('writeText PUTs the body with its content type; ensureDir never fetches', async () => {
|
||||
const h = makeS3();
|
||||
await h.provider.ensureDir(['/Readest', '/Readest/books']);
|
||||
expect(h.fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
h.fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
await h.provider.writeText('/Readest/library.json', '{"a":1}');
|
||||
expect(h.method(0)).toBe('PUT');
|
||||
expect(h.url(0)).toContain('/readest/Readest/library.json');
|
||||
expect(h.fetchMock.mock.calls[0]?.[1]?.body).toBeDefined();
|
||||
});
|
||||
|
||||
test('deleteDir lists the prefix and DELETEs every key', async () => {
|
||||
const h = makeS3();
|
||||
h.fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
xml(
|
||||
listPage({
|
||||
keys: [{ key: 'Readest/books/h1/config.json' }, { key: 'Readest/books/h1/B.epub' }],
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 404 })); // concurrently gone — fine
|
||||
|
||||
await h.provider.deleteDir('/Readest/books/h1');
|
||||
|
||||
expect(h.url(0)).toContain(`prefix=${encodeURIComponent('Readest/books/h1/')}`);
|
||||
expect(h.url(0)).not.toContain('delimiter');
|
||||
expect(h.method(1)).toBe('DELETE');
|
||||
expect(h.method(2)).toBe('DELETE');
|
||||
expect(h.url(1)).toContain('config.json');
|
||||
});
|
||||
|
||||
test('retries a transient 503 with backoff and then succeeds', async () => {
|
||||
const h = makeS3();
|
||||
h.fetchMock
|
||||
.mockResolvedValueOnce(new Response(null, { status: 503 }))
|
||||
.mockResolvedValueOnce(text('OK'));
|
||||
|
||||
expect(await h.provider.readText('/Readest/library.json')).toBe('OK');
|
||||
expect(h.sleep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('maps statuses to FileSyncError codes (403 auth, 404 write, 409 conflict)', async () => {
|
||||
const h = makeS3();
|
||||
h.fetchMock.mockResolvedValue(new Response(null, { status: 403 }));
|
||||
let err = await h.provider.list('/Readest/books').catch((e: unknown) => e);
|
||||
expect((err as FileSyncError).code).toBe('AUTH_FAILED');
|
||||
|
||||
h.fetchMock.mockReset().mockResolvedValue(new Response(null, { status: 404 }));
|
||||
err = await h.provider.writeText('/Readest/library.json', 'x').catch((e: unknown) => e);
|
||||
expect((err as FileSyncError).code).toBe('NOT_FOUND');
|
||||
|
||||
h.fetchMock.mockReset().mockResolvedValue(new Response(null, { status: 409 }));
|
||||
err = await h.provider.writeText('/Readest/library.json', 'x').catch((e: unknown) => e);
|
||||
expect((err as FileSyncError).code).toBe('CONFLICT');
|
||||
});
|
||||
|
||||
test('maps a thrown fetch (offline) to a NETWORK FileSyncError after retries', async () => {
|
||||
const h = makeS3();
|
||||
h.fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
|
||||
const err = await h.provider.readText('/Readest/library.json').catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(FileSyncError);
|
||||
expect((err as FileSyncError).code).toBe('NETWORK');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
getFileBinary,
|
||||
headFile,
|
||||
listDirectory,
|
||||
type WebDAVConfig,
|
||||
} from '@/services/sync/providers/webdav/client';
|
||||
|
||||
/**
|
||||
* Per-request timeouts against an unreachable/dead server. Metadata
|
||||
* round-trips (PROPFIND / HEAD / MKCOL / DELETE) answer with headers only, so
|
||||
* they fail fast (~5 s) instead of pinning the sync UI on a dead LAN host;
|
||||
* GET / PUT carry book-sized bodies over possibly slow links, so they keep a
|
||||
* generous ceiling.
|
||||
*/
|
||||
|
||||
const ORIGINAL_FETCH = globalThis.fetch;
|
||||
|
||||
const config: WebDAVConfig = {
|
||||
serverUrl: 'https://dav.example.com',
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
};
|
||||
|
||||
/** A fetch that never resolves — only rejects when its signal aborts. */
|
||||
const hangingFetch = () =>
|
||||
vi.fn(
|
||||
(_url: string, init?: RequestInit) =>
|
||||
new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('Aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
globalThis.fetch = hangingFetch() as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('WebDAV request timeouts', () => {
|
||||
test('HEAD fails fast with a timeout after 5 s', async () => {
|
||||
const p = headFile(config, '/Readest/library.json');
|
||||
const assertion = expect(p).rejects.toThrow(/timed out/i);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await assertion;
|
||||
});
|
||||
|
||||
test('PROPFIND listing fails fast with a timeout after 5 s', async () => {
|
||||
const p = listDirectory(config, '/');
|
||||
const assertion = expect(p).rejects.toThrow(/timed out/i);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await assertion;
|
||||
});
|
||||
|
||||
test('GET keeps the long transfer ceiling (still pending at 5 s)', async () => {
|
||||
let settled = false;
|
||||
const p = getFileBinary(config, '/Readest/books/h1/big.epub');
|
||||
p.catch(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(settled).toBe(false); // large transfers must not be cut at 5 s
|
||||
|
||||
await vi.advanceTimersByTimeAsync(295_000);
|
||||
expect(settled).toBe(true); // but a dead link still ends eventually
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,11 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useQuotaStats } from '@/hooks/useQuotaStats';
|
||||
import { useFileSyncStore } from '@/store/fileSyncStore';
|
||||
import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
|
||||
import {
|
||||
getCloudSyncProvider,
|
||||
cloudProviderDisplayName,
|
||||
settingsKeyForBackend,
|
||||
} from '@/services/sync/cloudSyncProvider';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
@@ -303,15 +307,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
|
||||
// file engine's health instead — otherwise it reads "Synced 3 months ago"
|
||||
// forever and looks broken.
|
||||
const cloudProvider = getCloudSyncProvider(settings);
|
||||
const cloudProviderName = cloudProvider === 'gdrive' ? 'Google Drive' : 'WebDAV';
|
||||
const cloudProviderName = cloudProviderDisplayName(cloudProvider);
|
||||
const providerSyncing = cloudProvider !== 'readest' && !!fileSyncByKind[cloudProvider]?.isSyncing;
|
||||
const providerLastError =
|
||||
cloudProvider !== 'readest' ? fileSyncLastError[cloudProvider] : undefined;
|
||||
const lastSyncTime =
|
||||
cloudProvider !== 'readest'
|
||||
? (cloudProvider === 'gdrive'
|
||||
? settings.googleDrive?.lastSyncedAt
|
||||
: settings.webdav?.lastSyncedAt) || 0
|
||||
? settings[settingsKeyForBackend(cloudProvider)]?.lastSyncedAt || 0
|
||||
: Math.max(
|
||||
settings.lastSyncedAtBooks || 0,
|
||||
settings.lastSyncedAtConfigs || 0,
|
||||
|
||||
@@ -70,13 +70,14 @@ export const useBooksSync = () => {
|
||||
if (isPullingRef.current) return;
|
||||
try {
|
||||
isPullingRef.current = true;
|
||||
const ok = await runActiveFileLibrarySync(envConfig, _);
|
||||
const result = await runActiveFileLibrarySync(envConfig, _);
|
||||
if (verbose) {
|
||||
const providerName = provider === 'gdrive' ? 'Google Drive' : 'WebDAV';
|
||||
// Same message as the native Readest Cloud sync below, so every
|
||||
// provider reports its work the same way.
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: ok ? 'info' : 'error',
|
||||
message: ok
|
||||
? _('Synced via {{provider}}', { provider: providerName })
|
||||
type: result ? 'info' : 'error',
|
||||
message: result
|
||||
? _('{{count}} book(s) synced', { count: result.booksSynced })
|
||||
: _('Sync failed'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
createFileSyncProvider,
|
||||
type FileSyncBackendKind,
|
||||
} from '@/services/sync/file/providerRegistry';
|
||||
import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
|
||||
import { getCloudSyncProvider, settingsKeyForBackend } from '@/services/sync/cloudSyncProvider';
|
||||
|
||||
/**
|
||||
* Library-scoped auto-sync for the active third-party cloud provider (WebDAV /
|
||||
@@ -45,9 +45,6 @@ import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
|
||||
/** Quiet window before an auto library sync fires; collapses import bursts. */
|
||||
const SYNC_DEBOUNCE_MS = 5_000;
|
||||
|
||||
const settingsKeyFor = (kind: FileSyncBackendKind): 'webdav' | 'googleDrive' =>
|
||||
kind === 'gdrive' ? 'googleDrive' : 'webdav';
|
||||
|
||||
export const useLibraryFileSync = () => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
@@ -111,7 +108,7 @@ export const useLibraryFileSync = () => {
|
||||
|
||||
const ensureDeviceId = useCallback((): string => {
|
||||
const latest = useSettingsStore.getState().settings;
|
||||
const key = activeKind ? settingsKeyFor(activeKind) : 'webdav';
|
||||
const key = activeKind ? settingsKeyForBackend(activeKind) : 'webdav';
|
||||
let id = latest[key]?.deviceId;
|
||||
if (!id) {
|
||||
id = uuidv4();
|
||||
@@ -125,7 +122,7 @@ export const useLibraryFileSync = () => {
|
||||
const updateLastSyncedAt = useCallback(
|
||||
async (ts: number) => {
|
||||
const latest = useSettingsStore.getState().settings;
|
||||
const key = activeKind ? settingsKeyFor(activeKind) : 'webdav';
|
||||
const key = activeKind ? settingsKeyForBackend(activeKind) : 'webdav';
|
||||
const next = { ...latest, [key]: { ...latest[key], lastSyncedAt: ts } };
|
||||
setSettings(next);
|
||||
await saveSettings(envConfig, next);
|
||||
@@ -141,7 +138,7 @@ export const useLibraryFileSync = () => {
|
||||
|
||||
const kind = activeKind;
|
||||
const latest = useSettingsStore.getState().settings;
|
||||
const ps = kind === 'gdrive' ? latest.googleDrive : latest.webdav;
|
||||
const ps = latest[settingsKeyForBackend(kind)];
|
||||
const strategy = ps?.strategy ?? 'silent';
|
||||
|
||||
const syncStore = useFileSyncStore.getState();
|
||||
|
||||
@@ -25,6 +25,7 @@ import { transferManager } from '@/services/transferManager';
|
||||
import {
|
||||
getCloudSyncProvider,
|
||||
isReadestCloudStorageActive,
|
||||
cloudProviderDisplayName,
|
||||
} from '@/services/sync/cloudSyncProvider';
|
||||
import {
|
||||
runActiveFileBookDownload,
|
||||
@@ -981,7 +982,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
type: 'info',
|
||||
timeout: 5000,
|
||||
message: _('Uploads to Readest Cloud are paused while {{provider}} sync is selected', {
|
||||
provider: provider === 'gdrive' ? 'Google Drive' : 'WebDAV',
|
||||
provider: cloudProviderDisplayName(provider),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1690,7 +1691,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
style={{
|
||||
paddingRight: `${insets.right}px`,
|
||||
paddingLeft: `${insets.left}px`,
|
||||
paddingBottom: 'var(--now-playing-inset, 0px)',
|
||||
}}
|
||||
>
|
||||
<DropIndicator />
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
createFileSyncProvider,
|
||||
type FileSyncBackendKind,
|
||||
} from '@/services/sync/file/providerRegistry';
|
||||
import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
|
||||
import { getCloudSyncProvider, settingsKeyForBackend } from '@/services/sync/cloudSyncProvider';
|
||||
import { removeBookNoteOverlays } from '../utils/annotatorUtil';
|
||||
import { useWindowActiveChanged } from './useWindowActiveChanged';
|
||||
|
||||
@@ -73,10 +73,6 @@ export const remoteProgressApplied = (
|
||||
mergedLocation: string | null | undefined,
|
||||
): boolean => !!mergedLocation && mergedLocation !== localLocation;
|
||||
|
||||
/** Settings key for a backend kind. */
|
||||
const settingsKeyFor = (kind: FileSyncBackendKind): 'webdav' | 'googleDrive' =>
|
||||
kind === 'gdrive' ? 'googleDrive' : 'webdav';
|
||||
|
||||
export const useFileSync = (bookKey: string) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
@@ -93,7 +89,7 @@ export const useFileSync = (bookKey: string) => {
|
||||
// The single active cloud provider (WebDAV and Google Drive are exclusive).
|
||||
const provider = getCloudSyncProvider(settings);
|
||||
const activeKind: FileSyncBackendKind | null = provider === 'readest' ? null : provider;
|
||||
const providerSettings = activeKind === 'gdrive' ? settings.googleDrive : settings.webdav;
|
||||
const providerSettings = activeKind ? settings[settingsKeyForBackend(activeKind)] : undefined;
|
||||
|
||||
/** Flips true on the first local change after a push, false right before each push. */
|
||||
const dirtyRef = useRef(false);
|
||||
@@ -123,7 +119,7 @@ export const useFileSync = (bookKey: string) => {
|
||||
// opens, and a closure-based merge could clobber a sibling write.
|
||||
const ensureDeviceId = useCallback((): string => {
|
||||
const latest = useSettingsStore.getState().settings;
|
||||
const key = activeKind ? settingsKeyFor(activeKind) : 'webdav';
|
||||
const key = activeKind ? settingsKeyForBackend(activeKind) : 'webdav';
|
||||
let id = latest[key]?.deviceId;
|
||||
if (!id) {
|
||||
id = uuidv4();
|
||||
@@ -137,7 +133,7 @@ export const useFileSync = (bookKey: string) => {
|
||||
const updateLastSyncedAt = useCallback(
|
||||
async (ts: number) => {
|
||||
const latest = useSettingsStore.getState().settings;
|
||||
const key = activeKind ? settingsKeyFor(activeKind) : 'webdav';
|
||||
const key = activeKind ? settingsKeyForBackend(activeKind) : 'webdav';
|
||||
const next = { ...latest, [key]: { ...latest[key], lastSyncedAt: ts } };
|
||||
setSettings(next);
|
||||
await saveSettings(envConfig, next);
|
||||
@@ -159,6 +155,10 @@ export const useFileSync = (bookKey: string) => {
|
||||
return !!(w?.enabled && w?.serverUrl && w?.username);
|
||||
}
|
||||
if (activeKind === 'gdrive') return !!settings.googleDrive?.enabled;
|
||||
if (activeKind === 's3') {
|
||||
const c = settings.s3;
|
||||
return !!(c?.enabled && c?.endpoint && c?.bucket && c?.accessKeyId && c?.secretAccessKey);
|
||||
}
|
||||
return false;
|
||||
}, [isPremium, activeKind, settings.webdav, settings.googleDrive]);
|
||||
|
||||
@@ -176,6 +176,10 @@ export const useFileSync = (bookKey: string) => {
|
||||
return `webdav:${w?.enabled}:${w?.serverUrl}:${w?.username}:${w?.password}:${w?.rootPath}`;
|
||||
}
|
||||
if (activeKind === 'gdrive') return `gdrive:${settings.googleDrive?.enabled}`;
|
||||
if (activeKind === 's3') {
|
||||
const c = settings.s3;
|
||||
return `s3:${c?.enabled}:${c?.endpoint}:${c?.region}:${c?.bucket}:${c?.accessKeyId}:${c?.secretAccessKey}`;
|
||||
}
|
||||
return 'none';
|
||||
}, [activeKind, settings.webdav, settings.googleDrive]);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import clsx from 'clsx';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
|
||||
import { getCloudSyncProvider, cloudProviderDisplayName } from '@/services/sync/cloudSyncProvider';
|
||||
import {
|
||||
SYNC_CATEGORIES,
|
||||
isSyncCategoryLocked,
|
||||
@@ -73,7 +73,7 @@ export function SyncCategoriesSection() {
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const copy = useCategoryCopy();
|
||||
const cloudProvider = getCloudSyncProvider(settings);
|
||||
const cloudProviderName = cloudProvider === 'gdrive' ? 'Google Drive' : 'WebDAV';
|
||||
const cloudProviderName = cloudProviderDisplayName(cloudProvider);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RiSendPlaneLine,
|
||||
RiCloudLine,
|
||||
RiCloudFill,
|
||||
RiDatabase2Line,
|
||||
RiGoogleLine,
|
||||
} from 'react-icons/ri';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -33,6 +34,7 @@ import HardcoverForm from './integrations/HardcoverForm';
|
||||
import SendToReadestForm from './integrations/SendToReadestForm';
|
||||
import WebDAVForm from './integrations/WebDAVForm';
|
||||
import GoogleDriveForm from './integrations/GoogleDriveForm';
|
||||
import S3Form from './integrations/S3Form';
|
||||
import { persistActiveCloudProvider } from './integrations/cloudSync';
|
||||
import { getReadestCloudRowStatus, getThirdPartyRowStatus } from './integrations/cloudSyncStatus';
|
||||
import {
|
||||
@@ -48,6 +50,7 @@ type SubPage =
|
||||
| 'kosync'
|
||||
| 'webdav'
|
||||
| 'gdrive'
|
||||
| 's3'
|
||||
| 'readest-cloud'
|
||||
| 'readwise'
|
||||
| 'hardcover'
|
||||
@@ -80,8 +83,10 @@ const IntegrationsPanel: React.FC = () => {
|
||||
// when they back out of the WebDAV sub-page or close the dialog.
|
||||
const isWebDAVSyncing = useFileSyncStore((s) => s.byKind.webdav?.isSyncing ?? false);
|
||||
const isGDriveSyncing = useFileSyncStore((s) => s.byKind.gdrive?.isSyncing ?? false);
|
||||
const isS3Syncing = useFileSyncStore((s) => s.byKind.s3?.isSyncing ?? false);
|
||||
const webdavLastError = useFileSyncStore((s) => s.lastErrorByKind.webdav);
|
||||
const gdriveLastError = useFileSyncStore((s) => s.lastErrorByKind.gdrive);
|
||||
const s3LastError = useFileSyncStore((s) => s.lastErrorByKind.s3);
|
||||
// Third-party cloud sync will be a premium feature (any paid plan), but it is
|
||||
// temporarily UNGATED while the feature stabilises — `isCloudSyncAllowed`
|
||||
// returns true for every plan until `CLOUD_SYNC_REQUIRES_PREMIUM` is flipped
|
||||
@@ -122,6 +127,7 @@ const IntegrationsPanel: React.FC = () => {
|
||||
const isCloudRequest =
|
||||
requestedSubPage === 'webdav' ||
|
||||
requestedSubPage === 'gdrive' ||
|
||||
requestedSubPage === 's3' ||
|
||||
requestedSubPage === 'cloudsync';
|
||||
// Cloud-sync sub-pages are premium-gated. If the plan is still loading, wait
|
||||
// (don't consume the request); once known, only honor it for paid plans.
|
||||
@@ -134,6 +140,7 @@ const IntegrationsPanel: React.FC = () => {
|
||||
requestedSubPage === 'kosync' ||
|
||||
requestedSubPage === 'webdav' ||
|
||||
requestedSubPage === 'gdrive' ||
|
||||
requestedSubPage === 's3' ||
|
||||
requestedSubPage === 'readwise' ||
|
||||
requestedSubPage === 'hardcover' ||
|
||||
requestedSubPage === 'opds' ||
|
||||
@@ -174,7 +181,8 @@ const IntegrationsPanel: React.FC = () => {
|
||||
<Tips>
|
||||
<li>
|
||||
{_(
|
||||
'While WebDAV is selected, books, progress, and annotations sync only to your server.',
|
||||
'While {{provider}} is selected, books, progress, and annotations sync only to your server.',
|
||||
{ provider: _('WebDAV') },
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
@@ -204,7 +212,8 @@ const IntegrationsPanel: React.FC = () => {
|
||||
<Tips>
|
||||
<li>
|
||||
{_(
|
||||
'While Google Drive is selected, books, progress, and annotations sync only to your Drive.',
|
||||
'While {{provider}} is selected, books, progress, and annotations sync only to your Drive.',
|
||||
{ provider: _('Google Drive') },
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
@@ -217,6 +226,51 @@ const IntegrationsPanel: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
if (subPage === 's3')
|
||||
return (
|
||||
<div className='my-4 w-full'>
|
||||
<SubPageHeader
|
||||
parentLabel={_('Integrations')}
|
||||
currentLabel={_('S3-Compatible Storage')}
|
||||
description={_(
|
||||
'Sync your library, reading progress, and highlights with an S3-compatible bucket such as Cloudflare R2, AWS S3, or MinIO.',
|
||||
)}
|
||||
onBack={() => setSubPage(null)}
|
||||
/>
|
||||
<S3Form />
|
||||
<div className='mt-5'>
|
||||
<Tips>
|
||||
{
|
||||
<li>
|
||||
{_(
|
||||
'While {{provider}} is selected, books, progress, and annotations sync only to your bucket.',
|
||||
{ provider: _('S3-Compatible Storage') },
|
||||
)}
|
||||
</li>
|
||||
}
|
||||
{
|
||||
<li>
|
||||
{_(
|
||||
'App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.',
|
||||
)}
|
||||
</li>
|
||||
}
|
||||
{
|
||||
<li>
|
||||
{_(
|
||||
'Make sure the bucket exists and the credentials have read/write access before connecting.',
|
||||
)}
|
||||
</li>
|
||||
}
|
||||
{isWebAppPlatform() && (
|
||||
<li>
|
||||
{_("In the browser, the bucket must allow this site's origin in its CORS policy.")}
|
||||
</li>
|
||||
)}
|
||||
</Tips>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (subPage === 'readest-cloud')
|
||||
return (
|
||||
<div className='my-4 w-full'>
|
||||
@@ -301,6 +355,20 @@ const IntegrationsPanel: React.FC = () => {
|
||||
lastError: gdriveLastError,
|
||||
syncBooks: settings.googleDrive?.syncBooks ?? false,
|
||||
});
|
||||
const s3Configured = !!(
|
||||
settings.s3?.endpoint &&
|
||||
settings.s3?.bucket &&
|
||||
settings.s3?.accessKeyId &&
|
||||
settings.s3?.secretAccessKey
|
||||
);
|
||||
const s3Status = getThirdPartyRowStatus(_, {
|
||||
enabled: !!settings.s3?.enabled,
|
||||
configured: s3Configured,
|
||||
syncing: isS3Syncing,
|
||||
paused: cloudGate.paused && cloudProvider === 's3',
|
||||
lastError: s3LastError,
|
||||
syncBooks: settings.s3?.syncBooks ?? false,
|
||||
});
|
||||
const readestStatus = getReadestCloudRowStatus(_, {
|
||||
signedIn: !!user,
|
||||
planLoading: userProfilePlan === undefined,
|
||||
@@ -367,45 +435,50 @@ const IntegrationsPanel: React.FC = () => {
|
||||
onOpen={() => (user ? setSubPage('readest-cloud') : navigateToLogin(router))}
|
||||
activateLabel={_('Use Readest Cloud')}
|
||||
/>
|
||||
{isCloudSyncPremium ? (
|
||||
<>
|
||||
{(appService?.isDesktopApp ||
|
||||
appService?.isAndroidApp ||
|
||||
appService?.isIOSApp ||
|
||||
// Web: only when a Web-type GIS client id is configured for this build.
|
||||
(isWebAppPlatform() && !!getGoogleWebClientId())) && (
|
||||
<CloudProviderRow
|
||||
icon={RiGoogleLine}
|
||||
title={_('Google Drive')}
|
||||
status={gdriveStatus}
|
||||
isActive={activeCloudKind === 'gdrive'}
|
||||
canActivate={gdriveConfigured}
|
||||
onActivate={() => activateCloudProvider('gdrive')}
|
||||
onOpen={() => setSubPage('gdrive')}
|
||||
activateLabel={_('Use Google Drive')}
|
||||
/>
|
||||
)}
|
||||
<CloudProviderRow
|
||||
icon={RiCloudLine}
|
||||
title={_('WebDAV')}
|
||||
status={webdavStatus}
|
||||
isActive={activeCloudKind === 'webdav'}
|
||||
canActivate={webdavConfigured}
|
||||
onActivate={() => activateCloudProvider('webdav')}
|
||||
onOpen={() => setSubPage('webdav')}
|
||||
activateLabel={_('Use WebDAV')}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Premium-gated: free users keep the Readest Cloud row above and
|
||||
// get an upgrade prompt in place of the third-party rows.
|
||||
<IntegrationRow
|
||||
icon={RiCloudLine}
|
||||
title={_('Third-party Cloud Sync')}
|
||||
status={_('Available on Plus, Pro, or Lifetime')}
|
||||
onClick={() => navigateToProfile(router)}
|
||||
{/* Third-party providers are premium: every row carries the tier
|
||||
badge; on a free plan the radio is disabled and opening a row
|
||||
routes to the upgrade page instead of the config sub-page. */}
|
||||
{(appService?.isDesktopApp ||
|
||||
appService?.isAndroidApp ||
|
||||
appService?.isIOSApp ||
|
||||
// Web: only when a Web-type GIS client id is configured for this build.
|
||||
(isWebAppPlatform() && !!getGoogleWebClientId())) && (
|
||||
<CloudProviderRow
|
||||
icon={RiGoogleLine}
|
||||
title={_('Google Drive')}
|
||||
status={gdriveStatus}
|
||||
badge={_('Premium')}
|
||||
isActive={activeCloudKind === 'gdrive'}
|
||||
canActivate={isCloudSyncPremium && gdriveConfigured}
|
||||
onActivate={() => activateCloudProvider('gdrive')}
|
||||
onOpen={() =>
|
||||
isCloudSyncPremium ? setSubPage('gdrive') : navigateToProfile(router)
|
||||
}
|
||||
activateLabel={_('Use Google Drive')}
|
||||
/>
|
||||
)}
|
||||
<CloudProviderRow
|
||||
icon={RiCloudLine}
|
||||
title={_('WebDAV')}
|
||||
status={webdavStatus}
|
||||
badge={_('Premium')}
|
||||
isActive={activeCloudKind === 'webdav'}
|
||||
canActivate={isCloudSyncPremium && webdavConfigured}
|
||||
onActivate={() => activateCloudProvider('webdav')}
|
||||
onOpen={() => (isCloudSyncPremium ? setSubPage('webdav') : navigateToProfile(router))}
|
||||
activateLabel={_('Use WebDAV')}
|
||||
/>
|
||||
<CloudProviderRow
|
||||
icon={RiDatabase2Line}
|
||||
title={_('S3 Storage')}
|
||||
status={s3Status}
|
||||
badge={_('Premium')}
|
||||
isActive={activeCloudKind === 's3'}
|
||||
canActivate={isCloudSyncPremium && s3Configured}
|
||||
onActivate={() => activateCloudProvider('s3')}
|
||||
onOpen={() => (isCloudSyncPremium ? setSubPage('s3') : navigateToProfile(router))}
|
||||
activateLabel={_('Use S3')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -499,6 +572,8 @@ interface CloudProviderRowProps {
|
||||
onOpen: () => void;
|
||||
/** Accessible label for the activate radio (e.g. "Use WebDAV"). */
|
||||
activateLabel: string;
|
||||
/** End-aligned tier chip (e.g. "Premium") — uniform column before the radio. */
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -516,6 +591,7 @@ const CloudProviderRow: React.FC<CloudProviderRowProps> = ({
|
||||
onActivate,
|
||||
onOpen,
|
||||
activateLabel,
|
||||
badge,
|
||||
}) => {
|
||||
return (
|
||||
<div className='group flex w-full items-center gap-3 px-4 py-3'>
|
||||
@@ -542,6 +618,7 @@ const CloudProviderRow: React.FC<CloudProviderRowProps> = ({
|
||||
<span className='text-base-content/65 truncate text-[0.85em]'>{status}</span>
|
||||
</div>
|
||||
</button>
|
||||
{badge && <span className='badge badge-sm badge-ghost shrink-0'>{badge}</span>}
|
||||
<input
|
||||
type='radio'
|
||||
name='cloud-sync-active'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import dayjs from 'dayjs';
|
||||
import React from 'react';
|
||||
import { MdCloudSync } from 'react-icons/md';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -89,6 +90,7 @@ const FileSyncForm: React.FC<FileSyncFormProps> = ({
|
||||
const beginSync = useFileSyncStore((s) => s.beginSync);
|
||||
const updateProgress = useFileSyncStore((s) => s.updateProgress);
|
||||
const endSync = useFileSyncStore((s) => s.endSync);
|
||||
const setLastError = useFileSyncStore((s) => s.setLastError);
|
||||
|
||||
const handleToggleSyncBooks = () => persist({ syncBooks: !(stored.syncBooks ?? false) });
|
||||
const handleToggleFullSync = () => persist({ fullSync: !(stored.fullSync ?? false) });
|
||||
@@ -161,6 +163,10 @@ const FileSyncForm: React.FC<FileSyncFormProps> = ({
|
||||
});
|
||||
|
||||
await persist({ lastSyncedAt: Date.now() });
|
||||
// A completed run heals the provider's health surfaces (the Cloud Sync
|
||||
// chooser row, the SettingsMenu sync row) — otherwise a pre-restart
|
||||
// failure keeps reading "Sync failed" after a successful manual sync.
|
||||
setLastError(kind, null);
|
||||
if (result.failures > 0) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'warning',
|
||||
@@ -176,6 +182,7 @@ const FileSyncForm: React.FC<FileSyncFormProps> = ({
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setLastError(kind, e instanceof Error ? e.message : String(e));
|
||||
eventDispatcher.dispatch('toast', { type: 'error', message: formatSyncError(_, e) });
|
||||
} finally {
|
||||
endSync(kind);
|
||||
@@ -213,7 +220,7 @@ const FileSyncForm: React.FC<FileSyncFormProps> = ({
|
||||
syncProgressLabel
|
||||
? syncProgressLabel
|
||||
: stored.lastSyncedAt
|
||||
? _('Last synced {{when}}', { when: new Date(stored.lastSyncedAt).toLocaleString() })
|
||||
? _('Synced {{time}}', { time: dayjs(stored.lastSyncedAt).fromNow() })
|
||||
: _('Never synced')
|
||||
}
|
||||
description={
|
||||
|
||||
@@ -143,12 +143,6 @@ const GoogleDriveForm: React.FC = () => {
|
||||
if (isConfigured) {
|
||||
return (
|
||||
<div className='space-y-5'>
|
||||
<Tips>
|
||||
<li>
|
||||
{_('Connected as {{account}}', { account: stored.accountLabel })}
|
||||
{_('. Make Google Drive the active cloud provider.')}
|
||||
</li>
|
||||
</Tips>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<button type='button' onClick={handleDisconnect} className={disconnectButtonClass}>
|
||||
{_('Disconnect')}
|
||||
@@ -157,6 +151,12 @@ const GoogleDriveForm: React.FC = () => {
|
||||
{_('Use Google Drive')}
|
||||
</button>
|
||||
</div>
|
||||
<Tips>
|
||||
<li>
|
||||
{_('Connected as {{account}}', { account: stored.accountLabel })}
|
||||
{_('. Make Google Drive the active cloud provider.')}
|
||||
</li>
|
||||
</Tips>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
import { MdVisibility, MdVisibilityOff } from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation, type TranslationFunc } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { FileSyncError } from '@/services/sync/file/provider';
|
||||
import { createS3Provider } from '@/services/sync/providers/s3/S3Provider';
|
||||
import { SectionTitle } from '../primitives';
|
||||
import FileSyncForm from './FileSyncForm';
|
||||
import { persistActiveCloudProvider } from './cloudSync';
|
||||
|
||||
/**
|
||||
* Translate a connection-probe failure into a user-facing string. Each branch
|
||||
* is a literal `_('...')` call so the i18next-scanner picks the keys up.
|
||||
*/
|
||||
const formatConnectError = (_: TranslationFunc, e: unknown): string => {
|
||||
if (e instanceof FileSyncError) {
|
||||
switch (e.code) {
|
||||
case 'AUTH_FAILED':
|
||||
return _('Authentication failed');
|
||||
case 'NOT_FOUND':
|
||||
return _('Bucket not found');
|
||||
case 'NETWORK':
|
||||
return _('Network error');
|
||||
}
|
||||
if (typeof e.status === 'number') {
|
||||
return _('Unexpected server response (status {{status}})', { status: e.status });
|
||||
}
|
||||
}
|
||||
return _('Network error');
|
||||
};
|
||||
|
||||
/**
|
||||
* S3-compatible provider panel, embedded in the Integrations S3 sub-page
|
||||
* (which owns the header). Same two states as {@link WebDAVForm}:
|
||||
*
|
||||
* - **Active** (`s3.enabled`): the shared {@link FileSyncForm} sync controls +
|
||||
* a Disconnect button.
|
||||
* - **Inactive**: the endpoint/bucket/credentials form (pre-filled from saved
|
||||
* settings, so a previously-configured bucket reconnects in one click).
|
||||
* Connect probes the bucket with one signed listing — wrong keys surface as
|
||||
* an auth failure, a wrong bucket as not-found — then makes S3 the single
|
||||
* active cloud provider (providers are mutually exclusive).
|
||||
*/
|
||||
const S3Form: React.FC = () => {
|
||||
const _ = useTranslation();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const { envConfig } = useEnv();
|
||||
|
||||
const stored = settings.s3;
|
||||
const isActive = !!stored?.enabled;
|
||||
|
||||
const [endpoint, setEndpoint] = useState(stored?.endpoint || '');
|
||||
const [region, setRegion] = useState(stored?.region || 'auto');
|
||||
const [bucket, setBucket] = useState(stored?.bucket || '');
|
||||
const [accessKeyId, setAccessKeyId] = useState(stored?.accessKeyId || '');
|
||||
const [secretAccessKey, setSecretAccessKey] = useState(stored?.secretAccessKey || '');
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
|
||||
const canSubmit = !!(endpoint && bucket && accessKeyId && secretAccessKey);
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!canSubmit) return;
|
||||
setIsConnecting(true);
|
||||
const draft = {
|
||||
endpoint: endpoint.trim(),
|
||||
region: region.trim() || 'auto',
|
||||
bucket: bucket.trim(),
|
||||
accessKeyId: accessKeyId.trim(),
|
||||
secretAccessKey: secretAccessKey.trim(),
|
||||
};
|
||||
try {
|
||||
// One signed listing under the sync namespace validates endpoint,
|
||||
// bucket, and keys in a single round-trip (empty result included).
|
||||
await createS3Provider(draft).list('/Readest');
|
||||
} catch (e) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: `${_('Failed to connect')}: ${formatConnectError(_, e)}`,
|
||||
});
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
// Merge the connection into the s3 slice (preserving deviceId and
|
||||
// sub-toggles), then make S3 the single active cloud provider.
|
||||
// persistActiveCloudProvider owns activation, persistence, and the
|
||||
// cross-window provider broadcast.
|
||||
await persistActiveCloudProvider(envConfig, 's3', (s) => ({
|
||||
...s,
|
||||
s3: { ...s.s3, ...draft },
|
||||
}));
|
||||
setIsConnecting(false);
|
||||
eventDispatcher.dispatch('toast', { type: 'info', message: _('Connected') });
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
// Deactivate (keep the credentials so a later reconnect is one click).
|
||||
await persistActiveCloudProvider(envConfig, null);
|
||||
setShowSecret(false);
|
||||
eventDispatcher.dispatch('toast', { type: 'info', message: _('Disconnected') });
|
||||
};
|
||||
|
||||
const persistS3 = async (patch: Partial<typeof stored>) => {
|
||||
const latest = useSettingsStore.getState().settings;
|
||||
const next = { ...latest, s3: { ...latest.s3, ...patch } };
|
||||
setSettings(next);
|
||||
await saveSettings(envConfig, next);
|
||||
};
|
||||
|
||||
if (isActive) {
|
||||
return (
|
||||
<div className='space-y-5'>
|
||||
<FileSyncForm kind='s3' stored={stored} persist={persistS3} />
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleDisconnect}
|
||||
className={clsx(
|
||||
'eink-bordered',
|
||||
'h-10 rounded-lg px-4 text-sm font-medium',
|
||||
'text-error hover:bg-error/10',
|
||||
'transition-colors duration-150',
|
||||
'focus-visible:ring-error/40 focus-visible:outline-none focus-visible:ring-2',
|
||||
)}
|
||||
>
|
||||
{_('Disconnect')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className='space-y-4'
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleConnect();
|
||||
}}
|
||||
>
|
||||
<div className='space-y-1.5'>
|
||||
<SectionTitle as='label' htmlFor='s3-endpoint' className='block'>
|
||||
{_('Endpoint')}
|
||||
</SectionTitle>
|
||||
<input
|
||||
id='s3-endpoint'
|
||||
type='text'
|
||||
placeholder='https://<account-id>.r2.cloudflarestorage.com'
|
||||
className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none'
|
||||
spellCheck='false'
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<SectionTitle as='label' htmlFor='s3-bucket' className='block'>
|
||||
{_('Bucket')}
|
||||
</SectionTitle>
|
||||
<input
|
||||
id='s3-bucket'
|
||||
type='text'
|
||||
placeholder='readest'
|
||||
className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none'
|
||||
spellCheck='false'
|
||||
value={bucket}
|
||||
onChange={(e) => setBucket(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<SectionTitle as='label' htmlFor='s3-region' className='block'>
|
||||
{_('Region')}
|
||||
</SectionTitle>
|
||||
<input
|
||||
id='s3-region'
|
||||
type='text'
|
||||
placeholder='auto'
|
||||
className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none'
|
||||
spellCheck='false'
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<SectionTitle as='label' htmlFor='s3-access-key-id' className='block'>
|
||||
{_('Access Key ID')}
|
||||
</SectionTitle>
|
||||
<input
|
||||
id='s3-access-key-id'
|
||||
type='text'
|
||||
placeholder={_('Your Access Key ID')}
|
||||
className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none'
|
||||
spellCheck='false'
|
||||
value={accessKeyId}
|
||||
onChange={(e) => setAccessKeyId(e.target.value)}
|
||||
autoComplete='off'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<SectionTitle as='label' htmlFor='s3-secret-access-key' className='block'>
|
||||
{_('Secret Access Key')}
|
||||
</SectionTitle>
|
||||
<div className='relative'>
|
||||
<input
|
||||
id='s3-secret-access-key'
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
placeholder={_('Your Secret Access Key')}
|
||||
className='input input-bordered eink-bordered h-11 w-full pe-11 text-sm focus:outline-none'
|
||||
value={secretAccessKey}
|
||||
onChange={(e) => setSecretAccessKey(e.target.value)}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowSecret((v) => !v)}
|
||||
className={clsx(
|
||||
'absolute end-2 top-1/2 -translate-y-1/2',
|
||||
'flex h-8 w-8 items-center justify-center rounded',
|
||||
'text-base-content/60 hover:text-base-content',
|
||||
'hover:bg-base-200/60 transition-colors duration-150',
|
||||
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
|
||||
)}
|
||||
aria-label={showSecret ? _('Hide password') : _('Show password')}
|
||||
title={showSecret ? _('Hide password') : _('Show password')}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showSecret ? (
|
||||
<MdVisibilityOff className='h-4 w-4' />
|
||||
) : (
|
||||
<MdVisibility className='h-4 w-4' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end pt-1'>
|
||||
<button
|
||||
type='submit'
|
||||
disabled={isConnecting || !canSubmit}
|
||||
className={clsx(
|
||||
'btn btn-contrast',
|
||||
'h-10 min-h-10 rounded-lg border-0 px-5 text-sm font-medium',
|
||||
'focus-visible:ring-base-content/40 focus-visible:outline-none focus-visible:ring-2',
|
||||
isConnecting && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? <span className='loading loading-spinner loading-sm' /> : _('Connect')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default S3Form;
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
SystemSettings,
|
||||
WebDAVSettings,
|
||||
GoogleDriveSettings,
|
||||
S3Settings,
|
||||
} from '@/types/settings';
|
||||
import { UserStorageQuota, UserDailyTranslationQuota } from '@/types/quota';
|
||||
import { getDefaultMaxBlockSize, getDefaultMaxInlineSize } from '@/utils/config';
|
||||
@@ -113,6 +114,21 @@ export const DEFAULT_GOOGLE_DRIVE_SETTINGS = {
|
||||
lastSyncedAt: 0,
|
||||
} as GoogleDriveSettings;
|
||||
|
||||
export const DEFAULT_S3_SETTINGS = {
|
||||
enabled: false,
|
||||
endpoint: '',
|
||||
region: 'auto',
|
||||
bucket: '',
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
syncProgress: true,
|
||||
syncNotes: true,
|
||||
syncBooks: false,
|
||||
strategy: 'silent',
|
||||
deviceId: '',
|
||||
lastSyncedAt: 0,
|
||||
} as S3Settings;
|
||||
|
||||
export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||||
keepLogin: false,
|
||||
autoUpload: true,
|
||||
@@ -172,6 +188,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||||
hardcover: DEFAULT_HARDCOVER_SETTINGS,
|
||||
webdav: DEFAULT_WEBDAV_SETTINGS,
|
||||
googleDrive: DEFAULT_GOOGLE_DRIVE_SETTINGS,
|
||||
s3: DEFAULT_S3_SETTINGS,
|
||||
aiSettings: DEFAULT_AI_SETTINGS,
|
||||
|
||||
lastSyncedAtBooks: 0,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { broadcastGlobalSettings } from '@/utils/settingsSync';
|
||||
export type CloudSyncActivationKind = CloudSyncProviderKind | null;
|
||||
|
||||
const isThirdParty = (active: CloudSyncActivationKind): active is FileSyncBackendKind =>
|
||||
active === 'webdav' || active === 'gdrive';
|
||||
active === 'webdav' || active === 'gdrive' || active === 's3';
|
||||
|
||||
/**
|
||||
* Return settings with exactly one third-party cloud-sync provider active (or
|
||||
@@ -50,6 +50,13 @@ export const withActiveCloudProvider = (
|
||||
? { syncBooks: true, providerSelectedAt: Date.now() }
|
||||
: {}),
|
||||
},
|
||||
s3: {
|
||||
...settings.s3,
|
||||
enabled: active === 's3',
|
||||
...(active === 's3' && !settings.s3?.enabled
|
||||
? { syncBooks: true, providerSelectedAt: Date.now() }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,10 +31,31 @@ export interface CloudSyncGate {
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
/** Settings slice key for a third-party backend kind. */
|
||||
export const settingsKeyForBackend = (
|
||||
kind: FileSyncBackendKind,
|
||||
): 'webdav' | 'googleDrive' | 's3' => (kind === 'gdrive' ? 'googleDrive' : kind);
|
||||
|
||||
/** Human-readable provider name (product names — deliberately untranslated). */
|
||||
export const cloudProviderDisplayName = (kind: CloudSyncProviderKind): string =>
|
||||
kind === 'gdrive'
|
||||
? 'Google Drive'
|
||||
: kind === 'webdav'
|
||||
? 'WebDAV'
|
||||
: kind === 's3'
|
||||
? 'S3'
|
||||
: 'Readest Cloud';
|
||||
|
||||
export const getCloudSyncProvider = (
|
||||
settings: SystemSettings | null | undefined,
|
||||
): CloudSyncProviderKind =>
|
||||
settings?.webdav?.enabled ? 'webdav' : settings?.googleDrive?.enabled ? 'gdrive' : 'readest';
|
||||
settings?.webdav?.enabled
|
||||
? 'webdav'
|
||||
: settings?.googleDrive?.enabled
|
||||
? 'gdrive'
|
||||
: settings?.s3?.enabled
|
||||
? 's3'
|
||||
: 'readest';
|
||||
|
||||
/**
|
||||
* `isCloudSyncAllowed` needs the UserPlan, which comes from the async
|
||||
|
||||
@@ -190,6 +190,17 @@ const remoteIndexCache = new WeakMap<
|
||||
{ etag: string; index: RemoteLibraryIndex }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Per-provider memo of "this device holds no source for this book" verdicts,
|
||||
* keyed to the book's `updatedAt` at the time of the verdict. Without it,
|
||||
* every sync run re-walks all books whose file is recorded nowhere and pays
|
||||
* two local fs probes per book per run (the Tauri plugin:fs|exists storm)
|
||||
* just to relearn the same answer. A book re-qualifies for a probe only when
|
||||
* its local row changes (any download or progress save bumps `updatedAt`),
|
||||
* on Full Sync, or in a fresh session (same lifetime as the provider memo).
|
||||
*/
|
||||
const noSourceVerdicts = new WeakMap<FileSyncProvider, Map<string, number>>();
|
||||
|
||||
/** Order-insensitive string-array equality (duplicates collapse). */
|
||||
const sameStringSet = (a: string[], b: string[]): boolean => {
|
||||
if (a.length !== b.length) return false;
|
||||
@@ -548,11 +559,30 @@ export class FileSyncEngine {
|
||||
// forward (plus this run's uploads) into the re-pushed index. Empty in send
|
||||
// mode / on a fresh remote, so the first sync verifies every file once.
|
||||
const uploadedHashes = new Set<string>(remoteIndex?.uploadedHashes ?? []);
|
||||
// A file needs (re)uploading only when syncBooks is on and the remote copy
|
||||
// isn't recorded yet. Full Sync bypasses the record as an escape hatch for
|
||||
// drift (e.g. a file deleted out-of-band via the browse pane).
|
||||
// A file needs (re)uploading only when syncBooks is on, the remote copy
|
||||
// isn't recorded yet, and the LIBRARY ROW says this device holds the file.
|
||||
// The row is authoritative (import / download / delete all stamp
|
||||
// downloadedAt; merges keep it device-local), so a book the row marks as
|
||||
// absent costs zero filesystem and zero remote probes — incremental sync
|
||||
// stays pure metadata diffing instead of an O(library) fs walk (the
|
||||
// Tauri plugin:fs|exists storm). The noSourceVerdicts memo additionally
|
||||
// suppresses re-probes of DRIFTED rows (row claims a file the filesystem
|
||||
// no longer has) within a session. Row-vs-filesystem split-brain in
|
||||
// either direction is healed by Full Sync, which bypasses all three
|
||||
// records and audits the real filesystem.
|
||||
let noSourceMemo = noSourceVerdicts.get(this.provider);
|
||||
if (!noSourceMemo) {
|
||||
noSourceMemo = new Map();
|
||||
noSourceVerdicts.set(this.provider, noSourceMemo);
|
||||
}
|
||||
const knownNoSource = noSourceMemo;
|
||||
const hasLocalFile = (b: Book): boolean => !!(b.downloadedAt || b.filePath);
|
||||
const needsFilePush = (book: Book): boolean =>
|
||||
options.syncBooks && (fullSync || !uploadedHashes.has(book.hash));
|
||||
options.syncBooks &&
|
||||
(fullSync ||
|
||||
(!uploadedHashes.has(book.hash) &&
|
||||
hasLocalFile(book) &&
|
||||
knownNoSource.get(book.hash) !== (book.updatedAt ?? 0)));
|
||||
|
||||
const remoteBooksToDownload: Book[] = [];
|
||||
// The remote source of truth for a book's on-disk filename is the per-hash
|
||||
@@ -939,11 +969,13 @@ export class FileSyncEngine {
|
||||
} else if (fileResult.reason === 'remote-matches') {
|
||||
result.filesAlreadyInSync += 1;
|
||||
uploadedHashes.add(book.hash);
|
||||
} else if (fileResult.reason === 'no-source') {
|
||||
// The file isn't on this device; remember the verdict for this
|
||||
// exact row so the next run doesn't re-pay the fs probes. It
|
||||
// stays out of uploadedHashes so a device that does have the
|
||||
// file can upload and record it later.
|
||||
knownNoSource.set(book.hash, book.updatedAt ?? 0);
|
||||
}
|
||||
// 'no-source' → the file isn't on this device; the verdict is
|
||||
// reached from local state alone (no remote probe) and stays
|
||||
// unrecorded so a device that does have the file can upload
|
||||
// and record it later.
|
||||
}
|
||||
} catch (e) {
|
||||
noteAbort(e);
|
||||
|
||||
@@ -11,16 +11,18 @@
|
||||
* keychain token, so it needs no settings to construct.
|
||||
*/
|
||||
import type { FileSyncProvider } from './provider';
|
||||
import type { WebDAVSettings } from '@/types/settings';
|
||||
import type { S3Settings, WebDAVSettings } from '@/types/settings';
|
||||
import { createWebDAVProvider } from '@/services/sync/providers/webdav/WebDAVProvider';
|
||||
import { buildGoogleDriveProvider } from '@/services/sync/providers/gdrive/buildGoogleDriveProvider';
|
||||
import { createS3Provider } from '@/services/sync/providers/s3/S3Provider';
|
||||
|
||||
export type FileSyncBackendKind = 'webdav' | 'gdrive';
|
||||
export type FileSyncBackendKind = 'webdav' | 'gdrive' | 's3';
|
||||
|
||||
/** Minimal settings the registry reads to pick + build backends. */
|
||||
export interface FileSyncBackendsSettings {
|
||||
webdav?: WebDAVSettings;
|
||||
googleDrive?: { enabled?: boolean };
|
||||
s3?: S3Settings;
|
||||
}
|
||||
|
||||
/** The backends the user has switched on, in a stable order. */
|
||||
@@ -30,6 +32,7 @@ export const getEnabledFileSyncBackends = (
|
||||
const enabled: FileSyncBackendKind[] = [];
|
||||
if (settings.webdav?.enabled) enabled.push('webdav');
|
||||
if (settings.googleDrive?.enabled) enabled.push('gdrive');
|
||||
if (settings.s3?.enabled) enabled.push('s3');
|
||||
return enabled;
|
||||
};
|
||||
|
||||
@@ -55,6 +58,10 @@ const providerCacheKey = (
|
||||
const w = settings.webdav;
|
||||
return `webdav:${w?.enabled}:${w?.serverUrl}:${w?.username}:${w?.password}:${w?.rootPath}`;
|
||||
}
|
||||
if (kind === 's3') {
|
||||
const c = settings.s3;
|
||||
return `s3:${c?.enabled}:${c?.endpoint}:${c?.region}:${c?.bucket}:${c?.accessKeyId}:${c?.secretAccessKey}`;
|
||||
}
|
||||
return 'gdrive';
|
||||
};
|
||||
|
||||
@@ -78,7 +85,11 @@ export const createFileSyncProvider = async (
|
||||
? settings.webdav
|
||||
? createWebDAVProvider(settings.webdav)
|
||||
: null
|
||||
: await buildGoogleDriveProvider();
|
||||
: kind === 's3'
|
||||
? settings.s3
|
||||
? createS3Provider(settings.s3)
|
||||
: null
|
||||
: await buildGoogleDriveProvider();
|
||||
if (provider) cachedProvider = { key, provider };
|
||||
return provider;
|
||||
};
|
||||
|
||||
@@ -5,10 +5,10 @@ import type { TranslationFunc } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useFileSyncStore } from '@/store/fileSyncStore';
|
||||
import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
|
||||
import { resolveCloudSyncGate, settingsKeyForBackend } from '@/services/sync/cloudSyncProvider';
|
||||
import { createFileSyncProvider } from '@/services/sync/file/providerRegistry';
|
||||
import { createAppLocalStore } from '@/services/sync/file/appLocalStore';
|
||||
import { FileSyncEngine } from '@/services/sync/file/engine';
|
||||
import { FileSyncEngine, type SyncLibraryResult } from '@/services/sync/file/engine';
|
||||
|
||||
/**
|
||||
* Run one library-wide sync against the ACTIVE third-party provider —
|
||||
@@ -18,30 +18,34 @@ import { FileSyncEngine } from '@/services/sync/file/engine';
|
||||
*
|
||||
* Honours the same guards as `useLibraryFileSync`: never syncs a
|
||||
* not-yet-loaded library (an empty index push would clobber the remote)
|
||||
* and respects the global library-sync mutex. Returns true only when a
|
||||
* sync actually ran to completion; failures record `lastError` on the
|
||||
* and respects the global library-sync mutex. Returns the engine's
|
||||
* {@link SyncLibraryResult} when a sync ran to completion — callers
|
||||
* surface `booksSynced` in the toast, same as the native cloud sync —
|
||||
* and null when skipped or failed; failures record `lastError` on the
|
||||
* fileSyncStore for the health surfaces.
|
||||
*/
|
||||
export const runActiveFileLibrarySync = async (
|
||||
envConfig: EnvConfigType,
|
||||
_: TranslationFunc,
|
||||
): Promise<boolean> => {
|
||||
const provider = getCloudSyncProvider(useSettingsStore.getState().settings);
|
||||
if (provider === 'readest') return false;
|
||||
const kind = provider;
|
||||
): Promise<SyncLibraryResult | null> => {
|
||||
const gate = resolveCloudSyncGate(useSettingsStore.getState().settings);
|
||||
// Paused means paused (#4959): a downgraded account's still-selected
|
||||
// provider must not sync, and must not fall back to Readest Cloud either.
|
||||
if (gate.provider === 'readest' || gate.paused) return null;
|
||||
const kind = gate.provider;
|
||||
|
||||
if (!useLibraryStore.getState().libraryLoaded) return false;
|
||||
if (!useLibraryStore.getState().libraryLoaded) return null;
|
||||
|
||||
const syncStore = useFileSyncStore.getState();
|
||||
if (!syncStore.beginSync(kind, _('Syncing…'))) return false;
|
||||
if (!syncStore.beginSync(kind, _('Syncing…'))) return null;
|
||||
|
||||
try {
|
||||
const appService = await envConfig.getAppService();
|
||||
const current = useSettingsStore.getState().settings;
|
||||
const fileProvider = await createFileSyncProvider(kind, current);
|
||||
if (!fileProvider) return false;
|
||||
if (!fileProvider) return null;
|
||||
|
||||
const key = kind === 'gdrive' ? 'googleDrive' : 'webdav';
|
||||
const key = settingsKeyForBackend(kind);
|
||||
const ps = current[key];
|
||||
let deviceId = ps?.deviceId;
|
||||
if (!deviceId) {
|
||||
@@ -54,7 +58,7 @@ export const runActiveFileLibrarySync = async (
|
||||
const store = createAppLocalStore({ appService, settings: current, envConfig });
|
||||
const engine = new FileSyncEngine(fileProvider, store);
|
||||
const strategy = ps?.strategy ?? 'silent';
|
||||
await engine.syncLibrary(useLibraryStore.getState().library, {
|
||||
const result = await engine.syncLibrary(useLibraryStore.getState().library, {
|
||||
strategy: strategy === 'prompt' ? 'silent' : strategy,
|
||||
syncBooks: ps?.syncBooks ?? false,
|
||||
fullSync: false,
|
||||
@@ -76,12 +80,12 @@ export const runActiveFileLibrarySync = async (
|
||||
await appService.saveSettings(next);
|
||||
|
||||
useFileSyncStore.getState().setLastError(kind, null);
|
||||
return true;
|
||||
return result;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
useFileSyncStore.getState().setLastError(kind, message);
|
||||
console.warn('[cloudSync] library file sync failed', kind, e);
|
||||
return false;
|
||||
return null;
|
||||
} finally {
|
||||
useFileSyncStore.getState().endSync(kind);
|
||||
}
|
||||
@@ -94,8 +98,9 @@ export const runActiveFileLibrarySync = async (
|
||||
*/
|
||||
const buildActiveEngine = async (envConfig: EnvConfigType): Promise<FileSyncEngine | null> => {
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
const kind = getCloudSyncProvider(settings);
|
||||
if (kind === 'readest') return null;
|
||||
const gate = resolveCloudSyncGate(settings);
|
||||
if (gate.provider === 'readest' || gate.paused) return null;
|
||||
const kind = gate.provider;
|
||||
const appService = await envConfig.getAppService();
|
||||
const fileProvider = await createFileSyncProvider(kind, settings);
|
||||
if (!fileProvider) return null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { SyncClient } from '@/libs/sync';
|
||||
import type { SystemSettings } from '@/types/settings';
|
||||
import type { TranslationFunc } from '@/hooks/useTranslation';
|
||||
import { useFileSyncStore } from '@/store/fileSyncStore';
|
||||
import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
|
||||
import { getCloudSyncProvider, settingsKeyForBackend } from '@/services/sync/cloudSyncProvider';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
/**
|
||||
@@ -32,7 +32,7 @@ export const checkMixedFleetOnce = async (
|
||||
if (provider === 'readest') return false;
|
||||
if (useFileSyncStore.getState().fleetNoticeShown) return false;
|
||||
|
||||
const key = provider === 'gdrive' ? 'googleDrive' : 'webdav';
|
||||
const key = settingsKeyForBackend(provider);
|
||||
const selectedAt = settings[key]?.providerSelectedAt;
|
||||
if (!selectedAt) return false;
|
||||
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* S3-compatible object-store implementation of {@link FileSyncProvider} — the
|
||||
* third concrete backend for the provider-agnostic file-sync engine (after
|
||||
* WebDAV and Google Drive). Targets any SigV4 endpoint: Cloudflare R2, AWS
|
||||
* S3, MinIO, Backblaze B2.
|
||||
*
|
||||
* Shape of the mapping:
|
||||
*
|
||||
* - **Path-style addressing.** Every logical sync path maps 1:1 to an object
|
||||
* key under `<endpoint>/<bucket>/` (`/Readest/books/<hash>/config.json` →
|
||||
* key `Readest/books/<hash>/config.json`). No id resolution, no caches —
|
||||
* unlike Drive, one logical operation is one HTTP request.
|
||||
* - **Object stores have no directories.** `ensureDir` is a no-op; `list`
|
||||
* emulates a directory with `ListObjectsV2` + `delimiter=/` (CommonPrefixes
|
||||
* → subdirs, Contents → files), draining `NextContinuationToken` pages;
|
||||
* `deleteDir` lists the prefix (no delimiter) and DELETEs each key —
|
||||
* deliberately not `DeleteObjects`, whose Content-MD5 requirement WebCrypto
|
||||
* cannot satisfy; a book dir holds at most three objects.
|
||||
* - **`head` returns the ETag as `etag`** — for single-part PUTs that is the
|
||||
* content md5, which feeds the engine's index change-detection exactly like
|
||||
* Drive's md5Checksum.
|
||||
*
|
||||
* Signing is SigV4 via `aws4fetch` (already a dependency; the server-side
|
||||
* `utils/r2.ts` uses the same client). Requests are signed with
|
||||
* `AwsClient.sign()` and dispatched through an injected fetch — the platform
|
||||
* fetch on web, the Tauri HTTP plugin on native — so the provider stays
|
||||
* unit-testable against a mocked wire, like the Drive provider. Streaming
|
||||
* upload/download (Tauri only) hand a presigned query URL (`signQuery`) to
|
||||
* the native transfer plugin, the `utils/r2.ts` presign pattern.
|
||||
*
|
||||
* Every HTTP failure is translated into the engine's neutral
|
||||
* {@link FileSyncError}; 429/5xx and thrown transports are retried with
|
||||
* `Retry-After`-aware backoff, mirroring the Drive provider.
|
||||
*/
|
||||
|
||||
import { AwsClient } from 'aws4fetch';
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { tauriDownload, tauriUpload } from '@/utils/transfer';
|
||||
import {
|
||||
FileEntry,
|
||||
FileHead,
|
||||
FileSyncError,
|
||||
FileSyncErrorCode,
|
||||
FileSyncProvider,
|
||||
} from '@/services/sync/file/provider';
|
||||
|
||||
/** Connection slice the provider needs (a subset of `S3Settings`). */
|
||||
export interface S3ProviderConfig {
|
||||
/** Service endpoint origin, e.g. `https://<account-id>.r2.cloudflarestorage.com`. */
|
||||
endpoint: string;
|
||||
/** SigV4 region; 'auto' works for R2/MinIO, AWS wants the bucket region. */
|
||||
region?: string;
|
||||
bucket: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
}
|
||||
|
||||
/** Injected `fetch`, typed to what the provider uses (string URL + init). */
|
||||
export type S3FetchFn = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
/** Injected sleep so the retry backoff is instant under test. */
|
||||
export type S3SleepFn = (ms: number) => Promise<void>;
|
||||
|
||||
export interface S3ProviderOptions {
|
||||
sleep?: S3SleepFn;
|
||||
}
|
||||
|
||||
const HTTP_GET = 'GET';
|
||||
const HTTP_PUT = 'PUT';
|
||||
const HTTP_HEAD = 'HEAD';
|
||||
const HTTP_DELETE = 'DELETE';
|
||||
|
||||
const HTTP_UNAUTHORIZED = 401;
|
||||
const HTTP_FORBIDDEN = 403;
|
||||
const HTTP_NOT_FOUND = 404;
|
||||
const HTTP_REQUEST_TIMEOUT = 408;
|
||||
const HTTP_CONFLICT = 409;
|
||||
const HTTP_TOO_MANY_REQUESTS = 429;
|
||||
const HTTP_NO_CONTENT = 204;
|
||||
const HTTP_SERVER_ERROR_FLOOR = 500;
|
||||
|
||||
const DEFAULT_TEXT_CONTENT_TYPE = 'application/json';
|
||||
const DEFAULT_BINARY_CONTENT_TYPE = 'application/octet-stream';
|
||||
|
||||
/** Backoff: up to this many retries on a transient (429/5xx) response. */
|
||||
const MAX_BACKOFF_RETRIES = 4;
|
||||
/** Base delay for exponential backoff when no `Retry-After` is supplied. */
|
||||
const BASE_BACKOFF_MS = 500;
|
||||
const MS_PER_SEC = 1000;
|
||||
|
||||
/** Lifetime of presigned streaming URLs (seconds) — generous for large books. */
|
||||
const PRESIGN_EXPIRES_SEC = 3600;
|
||||
|
||||
/** Request-name for error messages; names which operation/path failed. */
|
||||
type S3Operation = 'list' | 'download' | 'upload' | 'stat' | 'delete';
|
||||
|
||||
/** An HTTP failure from an S3 request, carrying the status for mapping. */
|
||||
class S3HttpError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'S3HttpError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate any S3 failure into the engine's neutral {@link FileSyncError}.
|
||||
* 401/403 → AUTH_FAILED (bad or expired credentials — S3 has no rate-limit
|
||||
* 403 overload like Drive; throttling is 429/503); 404 → NOT_FOUND;
|
||||
* 408/429/5xx → NETWORK; 409 → CONFLICT; a thrown fetch → NETWORK.
|
||||
*/
|
||||
const mapS3Error = (e: unknown): FileSyncError => {
|
||||
if (e instanceof FileSyncError) return e;
|
||||
if (e instanceof S3HttpError) {
|
||||
let code: FileSyncErrorCode;
|
||||
if (e.status === HTTP_UNAUTHORIZED || e.status === HTTP_FORBIDDEN) {
|
||||
code = 'AUTH_FAILED';
|
||||
} else if (e.status === HTTP_NOT_FOUND) {
|
||||
code = 'NOT_FOUND';
|
||||
} else if (
|
||||
e.status === HTTP_REQUEST_TIMEOUT ||
|
||||
e.status === HTTP_TOO_MANY_REQUESTS ||
|
||||
e.status >= HTTP_SERVER_ERROR_FLOOR
|
||||
) {
|
||||
code = 'NETWORK';
|
||||
} else if (e.status === HTTP_CONFLICT) {
|
||||
code = 'CONFLICT';
|
||||
} else {
|
||||
code = 'UNKNOWN';
|
||||
}
|
||||
return new FileSyncError(e.message, code, e.status);
|
||||
}
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
const networkLike =
|
||||
e instanceof TypeError ||
|
||||
/sending request|network|connection|timed out|timeout|dns|fetch/i.test(message);
|
||||
return new FileSyncError(message, networkLike ? 'NETWORK' : 'UNKNOWN');
|
||||
};
|
||||
|
||||
/** Run a provider operation, mapping any S3 failure to a {@link FileSyncError}. */
|
||||
const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
throw mapS3Error(e);
|
||||
}
|
||||
};
|
||||
|
||||
/** Logical absolute path → object key (leading slashes stripped). */
|
||||
const keyFor = (path: string): string =>
|
||||
path
|
||||
.split('/')
|
||||
.filter((s) => s.length > 0)
|
||||
.join('/');
|
||||
|
||||
/** Percent-encode a key for the URL path, keeping `/` separators. */
|
||||
const encodeKey = (key: string): string => key.split('/').map(encodeURIComponent).join('/');
|
||||
|
||||
/** Parse a `Retry-After` header (delta-seconds) into ms, or undefined. */
|
||||
const parseRetryAfterMs = (header: string | null): number | undefined => {
|
||||
if (!header) return undefined;
|
||||
const seconds = Number(header);
|
||||
return Number.isFinite(seconds) ? seconds * MS_PER_SEC : undefined;
|
||||
};
|
||||
|
||||
const defaultSleep: S3SleepFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const textOf = (parent: Element | Document, tag: string): string | undefined =>
|
||||
parent.getElementsByTagName(tag)[0]?.textContent ?? undefined;
|
||||
|
||||
class S3ProviderImpl {
|
||||
readonly rootPath = '/';
|
||||
|
||||
private readonly client: AwsClient;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
config: S3ProviderConfig,
|
||||
private readonly fetchFn: S3FetchFn,
|
||||
private readonly sleep: S3SleepFn = defaultSleep,
|
||||
) {
|
||||
this.client = new AwsClient({
|
||||
service: 's3',
|
||||
region: config.region || 'auto',
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
});
|
||||
this.baseUrl = `${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`;
|
||||
}
|
||||
|
||||
private urlFor(key: string): string {
|
||||
return `${this.baseUrl}/${encodeKey(key)}`;
|
||||
}
|
||||
|
||||
async readText(path: string): Promise<string | null> {
|
||||
const res = await this.getObject(path);
|
||||
return res ? res.text() : null;
|
||||
}
|
||||
|
||||
async readBinary(path: string): Promise<ArrayBuffer | null> {
|
||||
const res = await this.getObject(path);
|
||||
return res ? res.arrayBuffer() : null;
|
||||
}
|
||||
|
||||
private async getObject(path: string): Promise<Response | null> {
|
||||
const res = await this.request(HTTP_GET, this.urlFor(keyFor(path)));
|
||||
if (res.status === HTTP_NOT_FOUND) return null;
|
||||
await this.ensureOk(res, 'download', path);
|
||||
return res;
|
||||
}
|
||||
|
||||
async head(path: string): Promise<FileHead | null> {
|
||||
const res = await this.request(HTTP_HEAD, this.urlFor(keyFor(path)));
|
||||
if (res.status === HTTP_NOT_FOUND) return null;
|
||||
await this.ensureOk(res, 'stat', path);
|
||||
const length = res.headers.get('content-length');
|
||||
const etag = res.headers.get('etag');
|
||||
return {
|
||||
size: length !== null ? Number(length) : undefined,
|
||||
etag: etag ? etag.replace(/^"|"$/g, '') : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async list(path: string): Promise<FileEntry[]> {
|
||||
const key = keyFor(path);
|
||||
const prefix = key ? `${key}/` : '';
|
||||
const entries: FileEntry[] = [];
|
||||
let token: string | undefined;
|
||||
do {
|
||||
const url =
|
||||
`${this.baseUrl}?list-type=2&prefix=${encodeURIComponent(prefix)}&delimiter=%2F` +
|
||||
(token ? `&continuation-token=${encodeURIComponent(token)}` : '');
|
||||
const res = await this.request(HTTP_GET, url);
|
||||
await this.ensureOk(res, 'list', path);
|
||||
const doc = new DOMParser().parseFromString(await res.text(), 'application/xml');
|
||||
for (const cp of Array.from(doc.getElementsByTagName('CommonPrefixes'))) {
|
||||
const p = textOf(cp, 'Prefix');
|
||||
if (!p) continue;
|
||||
const dirKey = p.replace(/\/$/, '');
|
||||
entries.push({
|
||||
name: dirKey.slice(prefix.length),
|
||||
path: `/${dirKey}`,
|
||||
isDirectory: true,
|
||||
});
|
||||
}
|
||||
for (const c of Array.from(doc.getElementsByTagName('Contents'))) {
|
||||
const objectKey = textOf(c, 'Key');
|
||||
// Skip the prefix's own placeholder object and (defensively) any
|
||||
// nested key the delimiter should already have folded away.
|
||||
if (!objectKey || objectKey === prefix) continue;
|
||||
const name = objectKey.slice(prefix.length);
|
||||
if (!name || name.includes('/')) continue;
|
||||
const size = textOf(c, 'Size');
|
||||
entries.push({
|
||||
name,
|
||||
path: `/${objectKey}`,
|
||||
isDirectory: false,
|
||||
size: size !== undefined ? Number(size) : undefined,
|
||||
lastModified: textOf(c, 'LastModified'),
|
||||
});
|
||||
}
|
||||
token = textOf(doc, 'NextContinuationToken');
|
||||
} while (token);
|
||||
return entries;
|
||||
}
|
||||
|
||||
writeText(
|
||||
path: string,
|
||||
body: string,
|
||||
contentType: string = DEFAULT_TEXT_CONTENT_TYPE,
|
||||
): Promise<void> {
|
||||
return this.putObject(path, body, contentType);
|
||||
}
|
||||
|
||||
writeBinary(
|
||||
path: string,
|
||||
body: ArrayBuffer,
|
||||
contentType: string = DEFAULT_BINARY_CONTENT_TYPE,
|
||||
): Promise<void> {
|
||||
return this.putObject(path, body, contentType);
|
||||
}
|
||||
|
||||
private async putObject(
|
||||
path: string,
|
||||
body: string | ArrayBuffer,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
const res = await this.request(HTTP_PUT, this.urlFor(keyFor(path)), body, {
|
||||
'Content-Type': contentType,
|
||||
});
|
||||
await this.ensureOk(res, 'upload', path);
|
||||
}
|
||||
|
||||
/** Object stores have no directories — nothing to create. */
|
||||
async ensureDir(_paths: string[]): Promise<void> {}
|
||||
|
||||
async deleteDir(path: string): Promise<void> {
|
||||
const prefix = `${keyFor(path)}/`;
|
||||
let token: string | undefined;
|
||||
do {
|
||||
const url =
|
||||
`${this.baseUrl}?list-type=2&prefix=${encodeURIComponent(prefix)}` +
|
||||
(token ? `&continuation-token=${encodeURIComponent(token)}` : '');
|
||||
const res = await this.request(HTTP_GET, url);
|
||||
await this.ensureOk(res, 'list', path);
|
||||
const doc = new DOMParser().parseFromString(await res.text(), 'application/xml');
|
||||
for (const c of Array.from(doc.getElementsByTagName('Contents'))) {
|
||||
const objectKey = textOf(c, 'Key');
|
||||
if (!objectKey) continue;
|
||||
const del = await this.request(HTTP_DELETE, this.urlFor(objectKey));
|
||||
// Tolerate a 404: deleted concurrently, which satisfies the intent.
|
||||
if (del.status !== HTTP_NOT_FOUND) await this.ensureOk(del, 'delete', objectKey);
|
||||
}
|
||||
token = textOf(doc, 'NextContinuationToken');
|
||||
} while (token);
|
||||
}
|
||||
|
||||
// --- streaming (Tauri only) ----------------------------------------------
|
||||
|
||||
/**
|
||||
* Streaming upload via a presigned PUT URL: the native transfer plugin
|
||||
* streams the file off the disk, so a gigabyte-scale book never lands in
|
||||
* the JS heap. Returns `false` on a swallowed failure (provider contract:
|
||||
* the engine re-ensures dirs and retries once, then throws).
|
||||
*/
|
||||
async uploadStream(remotePath: string, localPath: string): Promise<boolean> {
|
||||
try {
|
||||
const url = await this.presign(HTTP_PUT, remotePath);
|
||||
await tauriUpload(url, localPath, 'PUT');
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('S3Provider.uploadStream failed', remotePath, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Streaming download via a presigned GET URL; same contract as upload. */
|
||||
async downloadStream(remotePath: string, localPath: string): Promise<boolean> {
|
||||
try {
|
||||
const url = await this.presign(HTTP_GET, remotePath);
|
||||
await tauriDownload(url, localPath);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('S3Provider.downloadStream failed', remotePath, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async presign(method: string, path: string): Promise<string> {
|
||||
const url = `${this.urlFor(keyFor(path))}?X-Amz-Expires=${PRESIGN_EXPIRES_SEC}`;
|
||||
const signed = await this.client.sign(url, { method, aws: { signQuery: true } });
|
||||
return signed.url;
|
||||
}
|
||||
|
||||
// --- request plumbing ----------------------------------------------------
|
||||
|
||||
/** Sign and issue one request, retried on transient failures. */
|
||||
private async request(
|
||||
method: string,
|
||||
url: string,
|
||||
body?: string | ArrayBuffer,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<Response> {
|
||||
return this.withBackoff(async () => {
|
||||
const signed = await this.client.sign(url, { method, headers, body });
|
||||
return this.fetchFn(signed.url, {
|
||||
method,
|
||||
headers: Object.fromEntries(signed.headers.entries()),
|
||||
body,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry on 429/5xx — or a thrown transport error — with `Retry-After`-aware
|
||||
* exponential backoff, mirroring the Drive provider (S3 throttling surfaces
|
||||
* as 429 SlowDown / 503).
|
||||
*/
|
||||
private async withBackoff(fn: () => Promise<Response>): Promise<Response> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fn();
|
||||
} catch (e) {
|
||||
if (attempt >= MAX_BACKOFF_RETRIES) throw e;
|
||||
await this.sleep(BASE_BACKOFF_MS * 2 ** attempt);
|
||||
continue;
|
||||
}
|
||||
const transient =
|
||||
res.status === HTTP_TOO_MANY_REQUESTS || res.status >= HTTP_SERVER_ERROR_FLOOR;
|
||||
if (!transient || attempt >= MAX_BACKOFF_RETRIES) return res;
|
||||
const retryAfter = parseRetryAfterMs(res.headers.get('Retry-After'));
|
||||
await this.sleep(retryAfter ?? BASE_BACKOFF_MS * 2 ** attempt);
|
||||
}
|
||||
}
|
||||
|
||||
/** Throw a status-carrying error for any non-success response. */
|
||||
private async ensureOk(res: Response, operation: S3Operation, path: string): Promise<void> {
|
||||
if (res.ok || res.status === HTTP_NO_CONTENT) return;
|
||||
throw new S3HttpError(res.status, `S3 ${operation} failed: HTTP ${res.status} for ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
const resolveFetch = (): S3FetchFn =>
|
||||
(isTauriAppPlatform() ? tauriFetch : globalThis.fetch.bind(globalThis)) as S3FetchFn;
|
||||
|
||||
/**
|
||||
* Build an S3-compatible {@link FileSyncProvider}. Public methods are wrapped
|
||||
* so failures surface as {@link FileSyncError}s the engine can branch on.
|
||||
* Streaming methods attach on Tauri only (they hand presigned URLs to the
|
||||
* native transfer plugin); on web the engine falls back to buffered I/O. They
|
||||
* are deliberately NOT `wrap`ped — they own the boolean swallow-to-`false`
|
||||
* contract, matching the WebDAV and Drive providers.
|
||||
*/
|
||||
export const createS3Provider = (
|
||||
config: S3ProviderConfig,
|
||||
fetchFn: S3FetchFn = resolveFetch(),
|
||||
options: S3ProviderOptions = {},
|
||||
): FileSyncProvider => {
|
||||
const impl = new S3ProviderImpl(config, fetchFn, options.sleep);
|
||||
const provider: FileSyncProvider = {
|
||||
rootPath: impl.rootPath,
|
||||
readText: (path) => wrap(() => impl.readText(path)),
|
||||
readBinary: (path) => wrap(() => impl.readBinary(path)),
|
||||
head: (path) => wrap(() => impl.head(path)),
|
||||
list: (path) => wrap(() => impl.list(path)),
|
||||
writeText: (path, body, contentType) => wrap(() => impl.writeText(path, body, contentType)),
|
||||
writeBinary: (path, body, contentType) => wrap(() => impl.writeBinary(path, body, contentType)),
|
||||
ensureDir: (paths) => wrap(() => impl.ensureDir(paths)),
|
||||
deleteDir: (path) => wrap(() => impl.deleteDir(path)),
|
||||
};
|
||||
|
||||
if (isTauriAppPlatform()) {
|
||||
provider.uploadStream = (remotePath, localPath) => impl.uploadStream(remotePath, localPath);
|
||||
provider.downloadStream = (remotePath, localPath) => impl.downloadStream(remotePath, localPath);
|
||||
}
|
||||
|
||||
return provider;
|
||||
};
|
||||
@@ -162,6 +162,42 @@ export const buildBasicAuthHeader = buildAuthHeader;
|
||||
|
||||
const getFetch = () => (isTauriAppPlatform() ? tauriFetch : window.fetch.bind(window));
|
||||
|
||||
/**
|
||||
* Per-request timeouts. Metadata round-trips (PROPFIND / HEAD / MKCOL /
|
||||
* DELETE) answer with headers only, so a server that has not replied within
|
||||
* seconds is down or unreachable — fail fast instead of pinning the sync UI
|
||||
* on a dead LAN host. GET / PUT carry book-sized bodies over possibly slow
|
||||
* links, so they keep a generous ceiling instead.
|
||||
*/
|
||||
const METADATA_TIMEOUT_MS = 5_000;
|
||||
const TRANSFER_TIMEOUT_MS = 300_000;
|
||||
const METADATA_METHODS = new Set(['PROPFIND', 'HEAD', 'MKCOL', 'DELETE']);
|
||||
const timeoutForMethod = (method: string): number =>
|
||||
METADATA_METHODS.has(method.toUpperCase()) ? METADATA_TIMEOUT_MS : TRANSFER_TIMEOUT_MS;
|
||||
|
||||
/**
|
||||
* `fetch` with an AbortController deadline. On expiry the request is aborted
|
||||
* and a plain 'Request timed out' error is thrown — call sites wrap it into
|
||||
* the WebDAVRequestError taxonomy as a NETWORK failure.
|
||||
*/
|
||||
const fetchWithTimeout = async (
|
||||
fetchFn: ReturnType<typeof getFetch>,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetchFn(url, { ...init, signal: controller.signal });
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) throw new Error('Request timed out');
|
||||
throw e;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pull the inner text of the first matching tag, regardless of namespace
|
||||
* prefix. WebDAV servers are inconsistent about prefixes (`d:`, `D:`, none)
|
||||
@@ -266,15 +302,20 @@ export const checkConnection = async (
|
||||
const url = buildUrl(config.serverUrl, rootPath);
|
||||
const fetchFn = getFetch();
|
||||
try {
|
||||
const response = await fetchFn(url, {
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
Authorization: buildAuthHeader(config.username, config.password),
|
||||
Depth: '0',
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
const response = await fetchWithTimeout(
|
||||
fetchFn,
|
||||
url,
|
||||
{
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
Authorization: buildAuthHeader(config.username, config.password),
|
||||
Depth: '0',
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
},
|
||||
body: PROPFIND_BODY,
|
||||
},
|
||||
body: PROPFIND_BODY,
|
||||
});
|
||||
METADATA_TIMEOUT_MS,
|
||||
);
|
||||
if (response.status === 207 || response.status === 200) {
|
||||
return { success: true, status: response.status };
|
||||
}
|
||||
@@ -312,15 +353,20 @@ export const listDirectory = async (
|
||||
// not-found / network) instead of flattening every failure to UNKNOWN.
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchFn(url, {
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
Authorization: buildAuthHeader(config.username, config.password),
|
||||
Depth: '1',
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
response = await fetchWithTimeout(
|
||||
fetchFn,
|
||||
url,
|
||||
{
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
Authorization: buildAuthHeader(config.username, config.password),
|
||||
Depth: '1',
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
},
|
||||
body: PROPFIND_BODY,
|
||||
},
|
||||
body: PROPFIND_BODY,
|
||||
});
|
||||
METADATA_TIMEOUT_MS,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new WebDAVRequestError((e as Error).message || 'Network error', undefined, 'NETWORK');
|
||||
}
|
||||
@@ -413,7 +459,12 @@ const requestWithMethod = async (
|
||||
...(init.headers || {}),
|
||||
};
|
||||
try {
|
||||
return await fetchFn(url, { method, headers, body: init.body ?? null });
|
||||
return await fetchWithTimeout(
|
||||
fetchFn,
|
||||
url,
|
||||
{ method, headers, body: init.body ?? null },
|
||||
timeoutForMethod(method),
|
||||
);
|
||||
} catch (e) {
|
||||
throw new WebDAVRequestError((e as Error).message || 'Network error', undefined, 'NETWORK');
|
||||
}
|
||||
|
||||
@@ -173,6 +173,34 @@ export interface GoogleDriveSettings {
|
||||
providerSelectedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* S3-compatible object-store file-sync settings — the third file-sync
|
||||
* backend alongside {@link WebDAVSettings} and {@link GoogleDriveSettings},
|
||||
* sharing the same engine, sub-toggles, and strategy vocabulary. Covers any
|
||||
* SigV4 endpoint: Cloudflare R2, AWS S3, MinIO, Backblaze B2. Addressing is
|
||||
* path-style (`<endpoint>/<bucket>/<key>`). Credentials live here like
|
||||
* WebDAV's (same encrypted cross-device credential-sync semantics).
|
||||
*/
|
||||
export interface S3Settings {
|
||||
enabled: boolean;
|
||||
/** Service endpoint origin, e.g. `https://<account-id>.r2.cloudflarestorage.com`. */
|
||||
endpoint: string;
|
||||
/** SigV4 region; 'auto' works for R2/MinIO, AWS wants the bucket region. */
|
||||
region?: string;
|
||||
bucket: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
syncProgress?: boolean;
|
||||
syncNotes?: boolean;
|
||||
syncBooks?: boolean;
|
||||
fullSync?: boolean;
|
||||
strategy?: KOSyncStrategy;
|
||||
deviceId?: string;
|
||||
lastSyncedAt?: number;
|
||||
/** See {@link WebDAVSettings.providerSelectedAt}. */
|
||||
providerSelectedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing sync categories. 'progress' gates the existing book-config
|
||||
* (reading progress) sync, 'note' gates annotations, 'book' gates book
|
||||
@@ -341,6 +369,7 @@ export interface SystemSettings {
|
||||
hardcover: HardcoverSettings;
|
||||
webdav: WebDAVSettings;
|
||||
googleDrive: GoogleDriveSettings;
|
||||
s3: S3Settings;
|
||||
|
||||
aiSettings: AISettings;
|
||||
/**
|
||||
|
||||
@@ -57,18 +57,20 @@ export const isCloudSyncInPlan = (plan: UserPlan): boolean =>
|
||||
(CLOUD_SYNC_PLANS as readonly UserPlan[]).includes(plan);
|
||||
|
||||
/**
|
||||
* Master switch for the third-party cloud-sync premium paywall. Temporarily
|
||||
* OFF: cloud sync (WebDAV / Google Drive) ships ungated to every plan — including
|
||||
* free — while the feature stabilises, and will be re-gated to
|
||||
* {@link CLOUD_SYNC_PLANS} in an upcoming release. Every gate goes through
|
||||
* {@link isCloudSyncAllowed}, so flipping this back to `true` is the whole toggle.
|
||||
* Master switch for the third-party cloud-sync premium paywall. ON: cloud
|
||||
* sync (WebDAV / Google Drive / S3) requires a {@link CLOUD_SYNC_PLANS} plan —
|
||||
* free users see the provider rows with a Premium badge and an upgrade route
|
||||
* instead of the config sub-pages, and a downgraded account's still-selected
|
||||
* provider is paused (never a silent fallback to Readest Cloud uploads, #4959).
|
||||
* Every gate goes through {@link isCloudSyncAllowed}, so this flag is the
|
||||
* whole toggle.
|
||||
*/
|
||||
export const CLOUD_SYNC_REQUIRES_PREMIUM = false;
|
||||
export const CLOUD_SYNC_REQUIRES_PREMIUM = true;
|
||||
|
||||
/**
|
||||
* Whether third-party cloud sync is available for a plan. While
|
||||
* {@link CLOUD_SYNC_REQUIRES_PREMIUM} is off this is always true; once it is
|
||||
* re-enabled it falls back to the {@link isCloudSyncInPlan} paywall.
|
||||
* Whether third-party cloud sync is available for a plan. Falls back to the
|
||||
* {@link isCloudSyncInPlan} paywall while {@link CLOUD_SYNC_REQUIRES_PREMIUM}
|
||||
* is on; flipping the switch off ungates every plan.
|
||||
*/
|
||||
export const isCloudSyncAllowed = (plan: UserPlan): boolean =>
|
||||
!CLOUD_SYNC_REQUIRES_PREMIUM || isCloudSyncInPlan(plan);
|
||||
|
||||
@@ -34,6 +34,8 @@ export const SETTINGS_SYNC_EVENT = 'global-settings-window-sync';
|
||||
export interface CloudSyncProviderFlags {
|
||||
webdav: { enabled: boolean; providerSelectedAt?: number };
|
||||
googleDrive: { enabled: boolean; providerSelectedAt?: number };
|
||||
/** Optional: absent on payloads from pre-S3 windows (treated as unchanged). */
|
||||
s3?: { enabled: boolean; providerSelectedAt?: number };
|
||||
}
|
||||
|
||||
export interface SettingsSyncPayload {
|
||||
@@ -69,6 +71,9 @@ export const mergeSyncedGlobalSettings = (
|
||||
if (payload.cloudSyncProviders) {
|
||||
merged.webdav = { ...local.webdav, ...payload.cloudSyncProviders.webdav };
|
||||
merged.googleDrive = { ...local.googleDrive, ...payload.cloudSyncProviders.googleDrive };
|
||||
if (payload.cloudSyncProviders.s3) {
|
||||
merged.s3 = { ...local.s3, ...payload.cloudSyncProviders.s3 };
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
@@ -99,6 +104,10 @@ export const broadcastGlobalSettings = async (
|
||||
enabled: !!settings.googleDrive?.enabled,
|
||||
providerSelectedAt: settings.googleDrive?.providerSelectedAt,
|
||||
},
|
||||
s3: {
|
||||
enabled: !!settings.s3?.enabled,
|
||||
providerSelectedAt: settings.s3?.providerSelectedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
await emit(SETTINGS_SYNC_EVENT, payload);
|
||||
|
||||
Reference in New Issue
Block a user