diff --git a/.husky/pre-push b/.husky/pre-push index 3c9e20e1..931039a4 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,2 +1,3 @@ +pnpm -C apps/readest-app format:check pnpm -C apps/readest-app lint pnpm -C apps/readest-app test diff --git a/apps/readest-app/extension/send-to-readest/README.md b/apps/readest-app/extension/send-to-readest/README.md new file mode 100644 index 00000000..baad6353 --- /dev/null +++ b/apps/readest-app/extension/send-to-readest/README.md @@ -0,0 +1,28 @@ +# Send to Readest — browser extension + +One-click capture of the current web page into your Readest library. + +## How it works + +1. A content script on `web.readest.com` copies the signed-in Supabase access + token into the extension's local storage (no credentials are stored by the + extension itself). +2. The popup's **Send this page** button POSTs the active tab's URL to + `POST /api/send/inbox` with that token. +3. The URL lands in the user's `send_inbox`; the next Readest client to sync + drains it — fetching the article, converting it to EPUB, and adding it to + the library. + +## Load it for development + +1. Open `chrome://extensions`, enable **Developer mode**. +2. **Load unpacked** → select this `send-to-readest/` directory. +3. Sign in at once so the token is captured. +4. Click the extension icon on any page. + +## Before publishing to the Chrome Web Store + +- Add `icons` (16/32/48/128 px) to `manifest.json`. +- Replace the localStorage token bridge with a proper OAuth flow + (`chrome.identity.launchWebAuthFlow`) if the Supabase token format changes. +- Submit through the Chrome Web Store Developer Dashboard (review required). diff --git a/apps/readest-app/extension/send-to-readest/content.js b/apps/readest-app/extension/send-to-readest/content.js new file mode 100644 index 00000000..8d18c3d2 --- /dev/null +++ b/apps/readest-app/extension/send-to-readest/content.js @@ -0,0 +1,24 @@ +// Runs on web.readest.com. Readest stores its Supabase session in +// localStorage; copy the access token into extension storage so the popup can +// authenticate to /api/send/inbox without the extension holding credentials. +(function () { + function findAccessToken() { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && /^sb-.*-auth-token$/.test(key)) { + try { + const value = JSON.parse(localStorage.getItem(key)); + if (value && value.access_token) return value.access_token; + } catch { + /* ignore malformed entries */ + } + } + } + return null; + } + + const token = findAccessToken(); + if (token) { + chrome.storage.local.set({ readestAccessToken: token, readestTokenAt: Date.now() }); + } +})(); diff --git a/apps/readest-app/extension/send-to-readest/manifest.json b/apps/readest-app/extension/send-to-readest/manifest.json new file mode 100644 index 00000000..4a908693 --- /dev/null +++ b/apps/readest-app/extension/send-to-readest/manifest.json @@ -0,0 +1,19 @@ +{ + "manifest_version": 3, + "name": "Send to Readest", + "version": "0.1.0", + "description": "Send the current web page to your Readest library.", + "permissions": ["activeTab", "storage"], + "host_permissions": ["https://web.readest.com/*"], + "action": { + "default_popup": "popup.html", + "default_title": "Send to Readest" + }, + "content_scripts": [ + { + "matches": ["https://web.readest.com/*"], + "js": ["content.js"], + "run_at": "document_idle" + } + ] +} diff --git a/apps/readest-app/extension/send-to-readest/popup.html b/apps/readest-app/extension/send-to-readest/popup.html new file mode 100644 index 00000000..3ba7919e --- /dev/null +++ b/apps/readest-app/extension/send-to-readest/popup.html @@ -0,0 +1,64 @@ + + + + + + + +

Send to Readest

+

Loading…

+ +

+ + + diff --git a/apps/readest-app/extension/send-to-readest/popup.js b/apps/readest-app/extension/send-to-readest/popup.js new file mode 100644 index 00000000..87716c76 --- /dev/null +++ b/apps/readest-app/extension/send-to-readest/popup.js @@ -0,0 +1,74 @@ +// Send to Readest — popup. Posts the current tab to /api/send/inbox; the +// captured URL lands in the user's inbox and a Readest client converts it to +// EPUB on next sync. +const INBOX_ENDPOINT = 'https://web.readest.com/api/send/inbox'; +const LOGIN_URL = 'https://web.readest.com/'; + +const urlEl = document.getElementById('url'); +const sendEl = document.getElementById('send'); +const statusEl = document.getElementById('status'); + +let currentTab = null; + +function setStatus(message, kind) { + statusEl.textContent = message; + statusEl.className = kind || ''; +} + +async function getActiveTab() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + return tab; +} + +async function getToken() { + const { readestAccessToken } = await chrome.storage.local.get('readestAccessToken'); + return readestAccessToken || null; +} + +async function init() { + currentTab = await getActiveTab(); + if (!currentTab || !/^https?:/i.test(currentTab.url || '')) { + urlEl.textContent = 'This page cannot be sent.'; + return; + } + urlEl.textContent = currentTab.url; + sendEl.disabled = false; +} + +sendEl.addEventListener('click', async () => { + sendEl.disabled = true; + setStatus('Sending…'); + + const token = await getToken(); + if (!token) { + setStatus('Sign in to Readest first.', 'err'); + chrome.tabs.create({ url: LOGIN_URL }); + return; + } + + try { + const res = await fetch(INBOX_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ url: currentTab.url, title: currentTab.title }), + }); + if (res.ok) { + setStatus('Sent — it will appear in your library shortly.', 'ok'); + } else if (res.status === 403) { + setStatus('Session expired. Open Readest to sign in again.', 'err'); + chrome.tabs.create({ url: LOGIN_URL }); + } else { + const data = await res.json().catch(() => ({})); + setStatus(data.error || `Failed (${res.status})`, 'err'); + sendEl.disabled = false; + } + } catch { + setStatus('Network error — try again.', 'err'); + sendEl.disabled = false; + } +}); + +init(); diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index bbac2111..b160e312 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -94,6 +94,7 @@ "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@fabianlars/tauri-plugin-oauth": "2", + "@mozilla/readability": "^0.6.0", "@napi-rs/wasm-runtime": "^1.1.1", "@opennextjs/cloudflare": "^1.19.4", "@radix-ui/react-collapsible": "^1.1.12", @@ -162,6 +163,7 @@ "jwt-decode": "^4.0.0", "lucide-react": "^0.562.0", "lunr": "^2.3.9", + "mammoth": "^1.12.0", "marked": "^15.0.12", "nanoid": "^5.1.6", "next": "16.2.6", diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 9666d98a..04210b6a 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1566,5 +1566,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "أنشئ نسخة احتياطية من مكتبتك وإعداداتك أو استعد من نسخة احتياطية سابقة. سيتم دمج الاستعادة مع مكتبتك الحالية.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "تضمين بيانات اعتماد الحساب (رموز المزامنة، كلمات المرور). ملف النسخة الاحتياطية غير مشفّر.", "Your library and settings have been saved to the selected location.": "تم حفظ مكتبتك وإعداداتك في الموقع المحدد.", - "Settings have been restored.": "تمت استعادة الإعدادات." + "Settings have been restored.": "تمت استعادة الإعدادات.", + "Could not fetch this page": "تعذّر جلب هذه الصفحة", + "Send to Readest": "إرسال إلى Readest", + "Sign in to send books and articles to your library.": "سجّل الدخول لإرسال الكتب والمقالات إلى مكتبتك.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "أفلِت كتابًا أو مستندًا، أو الصق رابط مقالة. تتم مزامنته إلى جميع أجهزتك.", + "Drop a book or document, or tap to choose": "أفلِت كتابًا أو مستندًا، أو انقر للاختيار", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "الصق رابط مقالة", + "Added to your library — it will sync to your other devices.": "أُضيف إلى مكتبتك — ستتم مزامنته إلى أجهزتك الأخرى.", + "Working…": "جارٍ العمل…", + "Could not load Send to Readest settings": "تعذّر تحميل إعدادات الإرسال إلى Readest", + "Address copied": "تم نسخ العنوان", + "Could not copy address": "تعذّر نسخ العنوان", + "Enter a name for your address": "أدخل اسمًا لعنوانك", + "Address updated": "تم تحديث العنوان", + "Could not update address": "تعذّر تحديث العنوان", + "Enter a valid email address": "أدخل عنوان بريد إلكتروني صالحًا", + "Could not add sender": "تعذّر إضافة المُرسِل", + "Could not approve sender": "تعذّر اعتماد المُرسِل", + "Could not remove sender": "تعذّر إزالة المُرسِل", + "Email books and articles straight into your library.": "أرسِل الكتب والمقالات مباشرة إلى مكتبتك بالبريد الإلكتروني.", + "Your inbound address": "عنوان الاستلام الخاص بك", + "Change address name": "تغيير اسم العنوان", + "Copy address": "نسخ العنوان", + "Customize your address name": "تخصيص اسم عنوانك", + "your-name": "your-name", + "Approved senders": "المُرسِلون المعتمدون", + "No approved senders yet. Add an email to let it send to your library.": "لا يوجد مُرسِلون معتمدون بعد. أضِف بريدًا إلكترونيًا للسماح له بالإرسال إلى مكتبتك.", + "Pending approval": "في انتظار الاعتماد", + "Approve": "اعتماد", + "Add a sender": "إضافة مُرسِل", + "name@example.com": "name@example.com", + "Recent activity": "النشاط الأخير", + "Nothing sent yet. Email a book to your address above.": "لم يُرسَل شيء بعد. أرسِل كتابًا إلى عنوانك أعلاه بالبريد الإلكتروني.", + "Process incoming items on this device": "معالجة العناصر الواردة على هذا الجهاز", + "Email books to your library": "إرسال الكتب إلى مكتبتك بالبريد الإلكتروني", + "Email a book or document to this address from an approved sender below.": "أرسل كتابًا أو مستندًا إلى هذا العنوان من مُرسِل معتمد أدناه.", + "Download and import books emailed to your address.": "تنزيل واستيراد الكتب المُرسَلة إلى عنوانك عبر البريد الإلكتروني." } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 04161fd1..12e97307 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1454,5 +1454,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "আপনার লাইব্রেরি এবং সেটিংসের একটি ব্যাকআপ তৈরি করুন বা পূর্ববর্তী ব্যাকআপ থেকে পুনরুদ্ধার করুন। পুনরুদ্ধার করলে তা আপনার বর্তমান লাইব্রেরির সাথে মিশে যাবে।", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "অ্যাকাউন্ট শংসাপত্র (সিঙ্ক টোকেন, পাসওয়ার্ড) অন্তর্ভুক্ত করুন। ব্যাকআপ ফাইলটি এনক্রিপ্ট করা নেই।", "Your library and settings have been saved to the selected location.": "আপনার লাইব্রেরি এবং সেটিংস নির্বাচিত স্থানে সংরক্ষণ করা হয়েছে।", - "Settings have been restored.": "সেটিংস পুনরুদ্ধার করা হয়েছে।" + "Settings have been restored.": "সেটিংস পুনরুদ্ধার করা হয়েছে।", + "Could not fetch this page": "এই পৃষ্ঠাটি আনা যায়নি", + "Send to Readest": "Readest-এ পাঠান", + "Sign in to send books and articles to your library.": "আপনার লাইব্রেরিতে বই ও নিবন্ধ পাঠাতে সাইন ইন করুন।", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "একটি বই বা নথি ছেড়ে দিন, অথবা একটি নিবন্ধের লিঙ্ক পেস্ট করুন। এটি আপনার সব ডিভাইসে সিঙ্ক হয়।", + "Drop a book or document, or tap to choose": "একটি বই বা নথি ছেড়ে দিন, অথবা বেছে নিতে ট্যাপ করুন", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "একটি নিবন্ধের URL পেস্ট করুন", + "Added to your library — it will sync to your other devices.": "আপনার লাইব্রেরিতে যোগ করা হয়েছে — এটি আপনার অন্যান্য ডিভাইসে সিঙ্ক হবে।", + "Working…": "কাজ চলছে…", + "Could not load Send to Readest settings": "Readest-এ পাঠান সেটিংস লোড করা যায়নি", + "Address copied": "ঠিকানা কপি করা হয়েছে", + "Could not copy address": "ঠিকানা কপি করা যায়নি", + "Enter a name for your address": "আপনার ঠিকানার জন্য একটি নাম লিখুন", + "Address updated": "ঠিকানা আপডেট করা হয়েছে", + "Could not update address": "ঠিকানা আপডেট করা যায়নি", + "Enter a valid email address": "একটি বৈধ ইমেল ঠিকানা লিখুন", + "Could not add sender": "প্রেরক যোগ করা যায়নি", + "Could not approve sender": "প্রেরক অনুমোদন করা যায়নি", + "Could not remove sender": "প্রেরক সরানো যায়নি", + "Email books and articles straight into your library.": "বই ও নিবন্ধ সরাসরি আপনার লাইব্রেরিতে ইমেল করুন।", + "Your inbound address": "আপনার ইনবাউন্ড ঠিকানা", + "Change address name": "ঠিকানার নাম পরিবর্তন করুন", + "Copy address": "ঠিকানা কপি করুন", + "Customize your address name": "আপনার ঠিকানার নাম কাস্টমাইজ করুন", + "your-name": "your-name", + "Approved senders": "অনুমোদিত প্রেরক", + "No approved senders yet. Add an email to let it send to your library.": "এখনও কোনো অনুমোদিত প্রেরক নেই। আপনার লাইব্রেরিতে পাঠানোর অনুমতি দিতে একটি ইমেল যোগ করুন।", + "Pending approval": "অনুমোদনের অপেক্ষায়", + "Approve": "অনুমোদন করুন", + "Add a sender": "একজন প্রেরক যোগ করুন", + "name@example.com": "name@example.com", + "Recent activity": "সাম্প্রতিক কার্যকলাপ", + "Nothing sent yet. Email a book to your address above.": "এখনও কিছু পাঠানো হয়নি। উপরের আপনার ঠিকানায় একটি বই ইমেল করুন।", + "Process incoming items on this device": "এই ডিভাইসে আগত আইটেমগুলি প্রক্রিয়া করুন", + "Email books to your library": "আপনার লাইব্রেরিতে বই ইমেল করুন", + "Email a book or document to this address from an approved sender below.": "নিচের অনুমোদিত কোনো প্রেরক থেকে এই ঠিকানায় একটি বই বা নথি ইমেল করুন।", + "Download and import books emailed to your address.": "আপনার ঠিকানায় ইমেল করা বই ডাউনলোড ও আমদানি করুন।" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index a7b3e8b7..ac73afe0 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1426,5 +1426,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "ཁྱེད་ཀྱི་དཔེ་མཛོད་དང་སྒྲིག་འགོད་ཀྱི་ཉར་ཚགས་ཤིག་བཟོ་བའམ་སྔོན་གྱི་ཉར་ཚགས་ཤིག་ནས་སོར་ཆུད་བྱེད། སོར་ཆུད་བྱས་ཚེ་ད་ལྟའི་དཔེ་མཛོད་དང་ཟླ་སྒྲིལ་འགྲོ།", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "རྩིས་ཐོའི་ངོས་སྦྱོར་ཆ་འཕྲིན་ (ཟུང་སྒྲིག་མཚོན་རྟགས། གསང་ཨང་) ཚུད་པར་བྱེད། ཉར་ཚགས་ཡིག་ཆ་གསང་སྦས་མ་བྱས།", "Your library and settings have been saved to the selected location.": "ཁྱེད་ཀྱི་དཔེ་མཛོད་དང་སྒྲིག་འགོད་འདེམས་པའི་གནས་སར་ཉར་ཚགས་བྱས་ཟིན།", - "Settings have been restored.": "སྒྲིག་འགོད་སོར་ཆུད་ཟིན།" + "Settings have been restored.": "སྒྲིག་འགོད་སོར་ཆུད་ཟིན།", + "Could not fetch this page": "ཤོག་ངོས་འདི་ལེན་མ་ཐུབ།", + "Send to Readest": "Readest ལ་སྐུར།", + "Sign in to send books and articles to your library.": "ཁྱེད་ཀྱི་དཔེ་མཛོད་ལ་དེབ་དང་རྩོམ་ཡིག་སྐུར་བར་ནང་བསྐྱོད་གྱིས།", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "དེབ་བམ་ཡིག་ཆ་ཞིག་འཇོག་གམ། ཡང་ན་རྩོམ་ཡིག་གི་དྲ་སྦྲེལ་ཞིག་སྦྱར། དེ་ནི་ཁྱེད་ཀྱི་སྒྲིག་ཆས་ཡོངས་ལ་མཉམ་སྒྲིག་བྱེད།", + "Drop a book or document, or tap to choose": "དེབ་བམ་ཡིག་ཆ་ཞིག་འཇོག་གམ། ཡང་ན་འདེམས་པར་མནན།", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "རྩོམ་ཡིག་གི་ URL ཞིག་སྦྱར།", + "Added to your library — it will sync to your other devices.": "ཁྱེད་ཀྱི་དཔེ་མཛོད་ལ་ཁ་སྣོན་བྱས། དེ་ཁྱེད་ཀྱི་སྒྲིག་ཆས་གཞན་ལ་མཉམ་སྒྲིག་བྱེད་འགྱུར།", + "Working…": "ལས་ཀ་བྱེད་བཞིན་པ…", + "Could not load Send to Readest settings": "Readest ལ་སྐུར་བའི་སྒྲིག་སྟངས་འཇུག་མ་ཐུབ།", + "Address copied": "ཁ་བྱང་འདྲ་བཤུས་བྱས་ཟིན།", + "Could not copy address": "ཁ་བྱང་འདྲ་བཤུས་བྱེད་མ་ཐུབ།", + "Enter a name for your address": "ཁྱེད་ཀྱི་ཁ་བྱང་ལ་མིང་ཞིག་འཇུག", + "Address updated": "ཁ་བྱང་གསར་སྒྱུར་བྱས་ཟིན།", + "Could not update address": "ཁ་བྱང་གསར་སྒྱུར་བྱེད་མ་ཐུབ།", + "Enter a valid email address": "གློག་འཕྲིན་ཁ་བྱང་ཚད་ལྡན་ཞིག་འཇུག", + "Could not add sender": "སྐུར་མཁན་ཁ་སྣོན་བྱེད་མ་ཐུབ།", + "Could not approve sender": "སྐུར་མཁན་ཆོག་མཆན་སྤྲོད་མ་ཐུབ།", + "Could not remove sender": "སྐུར་མཁན་བསུབ་མ་ཐུབ།", + "Email books and articles straight into your library.": "དེབ་དང་རྩོམ་ཡིག་ཐད་ཀར་ཁྱེད་ཀྱི་དཔེ་མཛོད་ལ་གློག་འཕྲིན་གྱིས་སྐུར།", + "Your inbound address": "ཁྱེད་ཀྱི་ནང་འབྱོར་ཁ་བྱང།", + "Change address name": "ཁ་བྱང་གི་མིང་བརྗེ།", + "Copy address": "ཁ་བྱང་འདྲ་བཤུས།", + "Customize your address name": "ཁྱེད་ཀྱི་ཁ་བྱང་གི་མིང་རང་མོས་སྒྲིག", + "your-name": "your-name", + "Approved senders": "ཆོག་མཆན་ཐོབ་པའི་སྐུར་མཁན།", + "No approved senders yet. Add an email to let it send to your library.": "ད་དུང་ཆོག་མཆན་ཐོབ་པའི་སྐུར་མཁན་མེད། ཁྱེད་ཀྱི་དཔེ་མཛོད་ལ་སྐུར་ཆོག་པར་གློག་འཕྲིན་ཞིག་ཁ་སྣོན་གྱིས།", + "Pending approval": "ཆོག་མཆན་སྒུག་བཞིན་པ།", + "Approve": "ཆོག་མཆན་སྤྲོད།", + "Add a sender": "སྐུར་མཁན་ཞིག་ཁ་སྣོན།", + "name@example.com": "name@example.com", + "Recent activity": "ཉེ་ཆར་གྱི་བྱ་སྤྱོད།", + "Nothing sent yet. Email a book to your address above.": "ད་དུང་གང་ཡང་སྐུར་མེད། གོང་གི་ཁྱེད་ཀྱི་ཁ་བྱང་ལ་དེབ་ཅིག་གློག་འཕྲིན་གྱིས་སྐུར།", + "Process incoming items on this device": "སྒྲིག་ཆས་འདིའི་སྟེང་ནང་འབྱོར་གྱི་དངོས་པོ་སྒྲིག་སྦྱོར་བྱེད།", + "Email books to your library": "ཁྱེད་ཀྱི་དཔེ་མཛོད་ལ་དེབ་གློག་འཕྲིན་གྱིས་སྐུར།", + "Email a book or document to this address from an approved sender below.": "འོག་གི་ཆོག་མཆན་ཐོབ་པའི་སྐུར་མཁན་ཞིག་ནས་དཔེ་ཆའམ་ཡིག་ཆ་ཞིག་ཁ་བྱང་འདིར་གློག་འཕྲིན་གྱིས་སྐུར་རོགས།", + "Download and import books emailed to your address.": "ཁྱེད་ཀྱི་ཁ་བྱང་ལ་གློག་འཕྲིན་གྱིས་སྐུར་བའི་དཔེ་ཆ་རྣམས་ཕབ་ལེན་དང་ནང་འདྲེན་བྱེད།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index feea2f77..a6adb269 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1454,5 +1454,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Erstellen Sie eine Sicherung Ihrer Bibliothek und Einstellungen oder stellen Sie eine frühere Sicherung wieder her. Die Wiederherstellung wird mit Ihrer aktuellen Bibliothek zusammengeführt.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Anmeldedaten einschließen (Sync-Tokens, Passwörter). Die Sicherungsdatei ist nicht verschlüsselt.", "Your library and settings have been saved to the selected location.": "Ihre Bibliothek und Einstellungen wurden am ausgewählten Ort gespeichert.", - "Settings have been restored.": "Einstellungen wurden wiederhergestellt." + "Settings have been restored.": "Einstellungen wurden wiederhergestellt.", + "Could not fetch this page": "Diese Seite konnte nicht abgerufen werden", + "Send to Readest": "An Readest senden", + "Sign in to send books and articles to your library.": "Melde dich an, um Bücher und Artikel an deine Bibliothek zu senden.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Lege ein Buch oder Dokument ab oder füge einen Artikel-Link ein. Es wird mit allen deinen Geräten synchronisiert.", + "Drop a book or document, or tap to choose": "Buch oder Dokument ablegen oder zur Auswahl tippen", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Artikel-URL einfügen", + "Added to your library — it will sync to your other devices.": "Zu deiner Bibliothek hinzugefügt – wird mit deinen anderen Geräten synchronisiert.", + "Working…": "Wird bearbeitet…", + "Could not load Send to Readest settings": "Einstellungen für „An Readest senden“ konnten nicht geladen werden", + "Address copied": "Adresse kopiert", + "Could not copy address": "Adresse konnte nicht kopiert werden", + "Enter a name for your address": "Gib einen Namen für deine Adresse ein", + "Address updated": "Adresse aktualisiert", + "Could not update address": "Adresse konnte nicht aktualisiert werden", + "Enter a valid email address": "Gib eine gültige E-Mail-Adresse ein", + "Could not add sender": "Absender konnte nicht hinzugefügt werden", + "Could not approve sender": "Absender konnte nicht freigegeben werden", + "Could not remove sender": "Absender konnte nicht entfernt werden", + "Email books and articles straight into your library.": "Sende Bücher und Artikel direkt in deine Bibliothek per E-Mail.", + "Your inbound address": "Deine Eingangsadresse", + "Change address name": "Adressnamen ändern", + "Copy address": "Adresse kopieren", + "Customize your address name": "Adressnamen anpassen", + "your-name": "your-name", + "Approved senders": "Freigegebene Absender", + "No approved senders yet. Add an email to let it send to your library.": "Noch keine freigegebenen Absender. Füge eine E-Mail-Adresse hinzu, damit sie an deine Bibliothek senden kann.", + "Pending approval": "Ausstehende Freigabe", + "Approve": "Freigeben", + "Add a sender": "Absender hinzufügen", + "name@example.com": "name@example.com", + "Recent activity": "Letzte Aktivität", + "Nothing sent yet. Email a book to your address above.": "Noch nichts gesendet. Sende ein Buch per E-Mail an deine Adresse oben.", + "Process incoming items on this device": "Eingehende Elemente auf diesem Gerät verarbeiten", + "Email books to your library": "Bücher an deine Bibliothek per E-Mail senden", + "Email a book or document to this address from an approved sender below.": "Senden Sie ein Buch oder Dokument von einem genehmigten Absender unten per E-Mail an diese Adresse.", + "Download and import books emailed to your address.": "Bücher herunterladen und importieren, die an Ihre Adresse gesendet wurden." } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 5bf9f01e..5943390d 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1454,5 +1454,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Δημιουργήστε ένα αντίγραφο ασφαλείας της βιβλιοθήκης και των ρυθμίσεών σας ή κάντε επαναφορά από προηγούμενο αντίγραφο ασφαλείας. Η επαναφορά θα συγχωνευτεί με την τρέχουσα βιβλιοθήκη σας.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Συμπερίληψη διαπιστευτηρίων λογαριασμού (διακριτικά συγχρονισμού, κωδικοί πρόσβασης). Το αρχείο αντιγράφου ασφαλείας δεν είναι κρυπτογραφημένο.", "Your library and settings have been saved to the selected location.": "Η βιβλιοθήκη και οι ρυθμίσεις σας αποθηκεύτηκαν στην επιλεγμένη τοποθεσία.", - "Settings have been restored.": "Οι ρυθμίσεις επαναφέρθηκαν." + "Settings have been restored.": "Οι ρυθμίσεις επαναφέρθηκαν.", + "Could not fetch this page": "Δεν ήταν δυνατή η ανάκτηση αυτής της σελίδας", + "Send to Readest": "Αποστολή στο Readest", + "Sign in to send books and articles to your library.": "Συνδεθείτε για να στέλνετε βιβλία και άρθρα στη βιβλιοθήκη σας.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Σύρετε ένα βιβλίο ή έγγραφο ή επικολλήστε έναν σύνδεσμο άρθρου. Συγχρονίζεται σε όλες τις συσκευές σας.", + "Drop a book or document, or tap to choose": "Σύρετε ένα βιβλίο ή έγγραφο ή πατήστε για επιλογή", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Επικολλήστε τη διεύθυνση URL ενός άρθρου", + "Added to your library — it will sync to your other devices.": "Προστέθηκε στη βιβλιοθήκη σας — θα συγχρονιστεί στις άλλες συσκευές σας.", + "Working…": "Σε εξέλιξη…", + "Could not load Send to Readest settings": "Δεν ήταν δυνατή η φόρτωση των ρυθμίσεων Αποστολή στο Readest", + "Address copied": "Η διεύθυνση αντιγράφηκε", + "Could not copy address": "Δεν ήταν δυνατή η αντιγραφή της διεύθυνσης", + "Enter a name for your address": "Εισαγάγετε ένα όνομα για τη διεύθυνσή σας", + "Address updated": "Η διεύθυνση ενημερώθηκε", + "Could not update address": "Δεν ήταν δυνατή η ενημέρωση της διεύθυνσης", + "Enter a valid email address": "Εισαγάγετε μια έγκυρη διεύθυνση email", + "Could not add sender": "Δεν ήταν δυνατή η προσθήκη αποστολέα", + "Could not approve sender": "Δεν ήταν δυνατή η έγκριση του αποστολέα", + "Could not remove sender": "Δεν ήταν δυνατή η αφαίρεση του αποστολέα", + "Email books and articles straight into your library.": "Στείλτε βιβλία και άρθρα απευθείας στη βιβλιοθήκη σας μέσω email.", + "Your inbound address": "Η εισερχόμενη διεύθυνσή σας", + "Change address name": "Αλλαγή ονόματος διεύθυνσης", + "Copy address": "Αντιγραφή διεύθυνσης", + "Customize your address name": "Προσαρμόστε το όνομα της διεύθυνσής σας", + "your-name": "your-name", + "Approved senders": "Εγκεκριμένοι αποστολείς", + "No approved senders yet. Add an email to let it send to your library.": "Δεν υπάρχουν ακόμη εγκεκριμένοι αποστολείς. Προσθέστε ένα email για να επιτρέψετε την αποστολή στη βιβλιοθήκη σας.", + "Pending approval": "Εκκρεμεί έγκριση", + "Approve": "Έγκριση", + "Add a sender": "Προσθήκη αποστολέα", + "name@example.com": "name@example.com", + "Recent activity": "Πρόσφατη δραστηριότητα", + "Nothing sent yet. Email a book to your address above.": "Δεν έχει σταλεί τίποτα ακόμη. Στείλτε ένα βιβλίο μέσω email στη διεύθυνσή σας παραπάνω.", + "Process incoming items on this device": "Επεξεργασία εισερχόμενων στοιχείων σε αυτή τη συσκευή", + "Email books to your library": "Στείλτε βιβλία στη βιβλιοθήκη σας μέσω email", + "Email a book or document to this address from an approved sender below.": "Στείλτε ένα βιβλίο ή έγγραφο σε αυτή τη διεύθυνση από εγκεκριμένο αποστολέα παρακάτω.", + "Download and import books emailed to your address.": "Λήψη και εισαγωγή βιβλίων που στάλθηκαν με email στη διεύθυνσή σας." } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 72d2630a..e9391210 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1482,5 +1482,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Crea una copia de seguridad de tu biblioteca y ajustes o restaura desde una copia anterior. La restauración se combinará con tu biblioteca actual.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Incluir credenciales de cuenta (tokens de sincronización, contraseñas). El archivo de copia de seguridad no está cifrado.", "Your library and settings have been saved to the selected location.": "Tu biblioteca y ajustes se han guardado en la ubicación seleccionada.", - "Settings have been restored.": "Se han restaurado los ajustes." + "Settings have been restored.": "Se han restaurado los ajustes.", + "Could not fetch this page": "No se pudo cargar esta página", + "Send to Readest": "Enviar a Readest", + "Sign in to send books and articles to your library.": "Inicia sesión para enviar libros y artículos a tu biblioteca.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Suelta un libro o documento, o pega el enlace de un artículo. Se sincroniza con todos tus dispositivos.", + "Drop a book or document, or tap to choose": "Suelta un libro o documento, o toca para elegir", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Pega la URL de un artículo", + "Added to your library — it will sync to your other devices.": "Añadido a tu biblioteca: se sincronizará con tus otros dispositivos.", + "Working…": "Procesando…", + "Could not load Send to Readest settings": "No se pudo cargar la configuración de Enviar a Readest", + "Address copied": "Dirección copiada", + "Could not copy address": "No se pudo copiar la dirección", + "Enter a name for your address": "Introduce un nombre para tu dirección", + "Address updated": "Dirección actualizada", + "Could not update address": "No se pudo actualizar la dirección", + "Enter a valid email address": "Introduce una dirección de correo electrónico válida", + "Could not add sender": "No se pudo añadir el remitente", + "Could not approve sender": "No se pudo aprobar el remitente", + "Could not remove sender": "No se pudo eliminar el remitente", + "Email books and articles straight into your library.": "Envía libros y artículos directamente a tu biblioteca por correo electrónico.", + "Your inbound address": "Tu dirección de recepción", + "Change address name": "Cambiar el nombre de la dirección", + "Copy address": "Copiar dirección", + "Customize your address name": "Personalizar el nombre de tu dirección", + "your-name": "your-name", + "Approved senders": "Remitentes aprobados", + "No approved senders yet. Add an email to let it send to your library.": "Aún no hay remitentes aprobados. Añade un correo electrónico para permitir que envíe a tu biblioteca.", + "Pending approval": "Pendiente de aprobación", + "Approve": "Aprobar", + "Add a sender": "Añadir un remitente", + "name@example.com": "name@example.com", + "Recent activity": "Actividad reciente", + "Nothing sent yet. Email a book to your address above.": "Aún no se ha enviado nada. Envía un libro por correo electrónico a tu dirección de arriba.", + "Process incoming items on this device": "Procesar elementos entrantes en este dispositivo", + "Email books to your library": "Enviar libros a tu biblioteca por correo electrónico", + "Email a book or document to this address from an approved sender below.": "Envía un libro o documento a esta dirección desde un remitente aprobado de abajo.", + "Download and import books emailed to your address.": "Descarga e importa los libros enviados por correo a tu dirección." } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 990b217e..b57d3fdc 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1454,5 +1454,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "از کتابخانه و تنظیمات خود نسخه پشتیبان تهیه کنید یا از یک نسخه پشتیبان قبلی بازیابی کنید. بازیابی با کتابخانه فعلی شما ادغام می‌شود.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "گنجاندن اطلاعات ورود حساب (توکن‌های همگام‌سازی، گذرواژه‌ها). فایل پشتیبان رمزگذاری نشده است.", "Your library and settings have been saved to the selected location.": "کتابخانه و تنظیمات شما در محل انتخاب‌شده ذخیره شد.", - "Settings have been restored.": "تنظیمات بازیابی شد." + "Settings have been restored.": "تنظیمات بازیابی شد.", + "Could not fetch this page": "بارگیری این صفحه ممکن نشد", + "Send to Readest": "ارسال به Readest", + "Sign in to send books and articles to your library.": "برای ارسال کتاب‌ها و مقاله‌ها به کتابخانه‌تان وارد شوید.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "یک کتاب یا سند را رها کنید، یا پیوند یک مقاله را بچسبانید. با همه دستگاه‌های شما همگام‌سازی می‌شود.", + "Drop a book or document, or tap to choose": "یک کتاب یا سند را رها کنید، یا برای انتخاب ضربه بزنید", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "نشانی اینترنتی یک مقاله را بچسبانید", + "Added to your library — it will sync to your other devices.": "به کتابخانه‌تان افزوده شد — با دستگاه‌های دیگر شما همگام‌سازی خواهد شد.", + "Working…": "در حال پردازش…", + "Could not load Send to Readest settings": "بارگذاری تنظیمات ارسال به Readest ممکن نشد", + "Address copied": "نشانی کپی شد", + "Could not copy address": "کپی نشانی ممکن نشد", + "Enter a name for your address": "یک نام برای نشانی خود وارد کنید", + "Address updated": "نشانی به‌روزرسانی شد", + "Could not update address": "به‌روزرسانی نشانی ممکن نشد", + "Enter a valid email address": "یک نشانی ایمیل معتبر وارد کنید", + "Could not add sender": "افزودن فرستنده ممکن نشد", + "Could not approve sender": "تأیید فرستنده ممکن نشد", + "Could not remove sender": "حذف فرستنده ممکن نشد", + "Email books and articles straight into your library.": "کتاب‌ها و مقاله‌ها را مستقیماً با ایمیل به کتابخانه‌تان بفرستید.", + "Your inbound address": "نشانی دریافت شما", + "Change address name": "تغییر نام نشانی", + "Copy address": "کپی نشانی", + "Customize your address name": "سفارشی‌سازی نام نشانی شما", + "your-name": "your-name", + "Approved senders": "فرستندگان تأییدشده", + "No approved senders yet. Add an email to let it send to your library.": "هنوز فرستنده تأییدشده‌ای وجود ندارد. یک ایمیل اضافه کنید تا اجازه ارسال به کتابخانه‌تان را داشته باشد.", + "Pending approval": "در انتظار تأیید", + "Approve": "تأیید", + "Add a sender": "افزودن فرستنده", + "name@example.com": "name@example.com", + "Recent activity": "فعالیت اخیر", + "Nothing sent yet. Email a book to your address above.": "هنوز چیزی ارسال نشده است. یک کتاب را با ایمیل به نشانی بالای خود بفرستید.", + "Process incoming items on this device": "پردازش موارد ورودی روی این دستگاه", + "Email books to your library": "ارسال کتاب‌ها با ایمیل به کتابخانه‌تان", + "Email a book or document to this address from an approved sender below.": "از فرستندهٔ تأییدشدهٔ زیر، کتاب یا سندی را به این نشانی ایمیل کنید.", + "Download and import books emailed to your address.": "دانلود و وارد کردن کتاب‌هایی که به نشانی شما ایمیل شده‌اند." } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 7206b629..ebb008b7 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1482,5 +1482,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Créez une sauvegarde de votre bibliothèque et de vos paramètres, ou restaurez à partir d’une sauvegarde précédente. La restauration fusionnera avec votre bibliothèque actuelle.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Inclure les identifiants du compte (jetons de synchronisation, mots de passe). Le fichier de sauvegarde n’est pas chiffré.", "Your library and settings have been saved to the selected location.": "Votre bibliothèque et vos paramètres ont été enregistrés à l’emplacement sélectionné.", - "Settings have been restored.": "Les paramètres ont été restaurés." + "Settings have been restored.": "Les paramètres ont été restaurés.", + "Could not fetch this page": "Impossible de récupérer cette page", + "Send to Readest": "Envoyer vers Readest", + "Sign in to send books and articles to your library.": "Connectez-vous pour envoyer des livres et des articles vers votre bibliothèque.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Déposez un livre ou un document, ou collez le lien d'un article. Il se synchronise sur tous vos appareils.", + "Drop a book or document, or tap to choose": "Déposez un livre ou un document, ou touchez pour choisir", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Coller l'URL d'un article", + "Added to your library — it will sync to your other devices.": "Ajouté à votre bibliothèque — la synchronisation vers vos autres appareils suivra.", + "Working…": "En cours…", + "Could not load Send to Readest settings": "Impossible de charger les paramètres d’Envoyer vers Readest", + "Address copied": "Adresse copiée", + "Could not copy address": "Impossible de copier l'adresse", + "Enter a name for your address": "Saisissez un nom pour votre adresse", + "Address updated": "Adresse mise à jour", + "Could not update address": "Impossible de mettre à jour l'adresse", + "Enter a valid email address": "Saisissez une adresse e-mail valide", + "Could not add sender": "Impossible d'ajouter l'expéditeur", + "Could not approve sender": "Impossible d'approuver l'expéditeur", + "Could not remove sender": "Impossible de supprimer l'expéditeur", + "Email books and articles straight into your library.": "Envoyez des livres et des articles directement dans votre bibliothèque par e-mail.", + "Your inbound address": "Votre adresse de réception", + "Change address name": "Modifier le nom de l'adresse", + "Copy address": "Copier l'adresse", + "Customize your address name": "Personnaliser le nom de votre adresse", + "your-name": "your-name", + "Approved senders": "Expéditeurs approuvés", + "No approved senders yet. Add an email to let it send to your library.": "Aucun expéditeur approuvé pour le moment. Ajoutez une adresse e-mail pour lui permettre d’envoyer vers votre bibliothèque.", + "Pending approval": "En attente d’approbation", + "Approve": "Approuver", + "Add a sender": "Ajouter un expéditeur", + "name@example.com": "name@example.com", + "Recent activity": "Activité récente", + "Nothing sent yet. Email a book to your address above.": "Rien envoyé pour le moment. Envoyez un livre par e-mail à votre adresse ci-dessus.", + "Process incoming items on this device": "Traiter les éléments entrants sur cet appareil", + "Email books to your library": "Envoyer des livres vers votre bibliothèque par e-mail", + "Email a book or document to this address from an approved sender below.": "Envoyez un livre ou un document à cette adresse depuis un expéditeur approuvé ci-dessous.", + "Download and import books emailed to your address.": "Téléchargez et importez les livres envoyés par e-mail à votre adresse." } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index bb431d6a..a686a9e4 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1482,5 +1482,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "צור גיבוי של הספרייה וההגדרות שלך או שחזר מגיבוי קודם. השחזור ימוזג עם הספרייה הנוכחית שלך.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "כלול את פרטי הכניסה לחשבון (אסימוני סנכרון, סיסמאות). קובץ הגיבוי אינו מוצפן.", "Your library and settings have been saved to the selected location.": "הספרייה וההגדרות שלך נשמרו במיקום שנבחר.", - "Settings have been restored.": "ההגדרות שוחזרו." + "Settings have been restored.": "ההגדרות שוחזרו.", + "Could not fetch this page": "לא ניתן היה לטעון את הדף הזה", + "Send to Readest": "שליחה אל Readest", + "Sign in to send books and articles to your library.": "התחבר כדי לשלוח ספרים ומאמרים אל הספרייה שלך.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "גרור ספר או מסמך, או הדבק קישור למאמר. הוא יסונכרן לכל המכשירים שלך.", + "Drop a book or document, or tap to choose": "גרור ספר או מסמך, או הקש לבחירה", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "הדבק כתובת URL של מאמר", + "Added to your library — it will sync to your other devices.": "נוסף לספרייה שלך — הוא יסונכרן למכשירים האחרים שלך.", + "Working…": "מעבד…", + "Could not load Send to Readest settings": "לא ניתן היה לטעון את הגדרות השליחה אל Readest", + "Address copied": "הכתובת הועתקה", + "Could not copy address": "לא ניתן היה להעתיק את הכתובת", + "Enter a name for your address": "הזן שם לכתובת שלך", + "Address updated": "הכתובת עודכנה", + "Could not update address": "לא ניתן היה לעדכן את הכתובת", + "Enter a valid email address": "הזן כתובת אימייל תקינה", + "Could not add sender": "לא ניתן היה להוסיף שולח", + "Could not approve sender": "לא ניתן היה לאשר שולח", + "Could not remove sender": "לא ניתן היה להסיר שולח", + "Email books and articles straight into your library.": "שלח ספרים ומאמרים ישירות אל הספרייה שלך באימייל.", + "Your inbound address": "כתובת הקבלה שלך", + "Change address name": "שינוי שם הכתובת", + "Copy address": "העתק כתובת", + "Customize your address name": "התאמה אישית של שם הכתובת שלך", + "your-name": "your-name", + "Approved senders": "שולחים מאושרים", + "No approved senders yet. Add an email to let it send to your library.": "אין עדיין שולחים מאושרים. הוסף אימייל כדי לאפשר לו לשלוח אל הספרייה שלך.", + "Pending approval": "ממתין לאישור", + "Approve": "אשר", + "Add a sender": "הוספת שולח", + "name@example.com": "name@example.com", + "Recent activity": "פעילות אחרונה", + "Nothing sent yet. Email a book to your address above.": "עדיין לא נשלח דבר. שלח ספר אל הכתובת שלך למעלה באימייל.", + "Process incoming items on this device": "עיבוד פריטים נכנסים במכשיר זה", + "Email books to your library": "שליחת ספרים אל הספרייה שלך באימייל", + "Email a book or document to this address from an approved sender below.": "שלחו ספר או מסמך לכתובת זו משולח מאושר מהרשימה למטה.", + "Download and import books emailed to your address.": "הורדה וייבוא של ספרים שנשלחו לכתובת שלך בדוא״ל." } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 2095b2ca..875df964 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1454,5 +1454,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "अपनी लाइब्रेरी और सेटिंग्स का बैकअप बनाएं या किसी पिछले बैकअप से पुनर्स्थापित करें। पुनर्स्थापित करने पर यह आपकी वर्तमान लाइब्रेरी के साथ मर्ज हो जाएगा।", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "खाता क्रेडेंशियल (सिंक टोकन, पासवर्ड) शामिल करें। बैकअप फ़ाइल एन्क्रिप्टेड नहीं है।", "Your library and settings have been saved to the selected location.": "आपकी लाइब्रेरी और सेटिंग्स चयनित स्थान पर सहेज दी गई हैं।", - "Settings have been restored.": "सेटिंग्स पुनर्स्थापित कर दी गई हैं।" + "Settings have been restored.": "सेटिंग्स पुनर्स्थापित कर दी गई हैं।", + "Could not fetch this page": "यह पेज लोड नहीं किया जा सका", + "Send to Readest": "Readest को भेजें", + "Sign in to send books and articles to your library.": "अपनी लाइब्रेरी में किताबें और लेख भेजने के लिए साइन इन करें।", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "कोई किताब या दस्तावेज़ छोड़ें, या किसी लेख का लिंक पेस्ट करें। यह आपके सभी डिवाइस पर सिंक होता है।", + "Drop a book or document, or tap to choose": "कोई किताब या दस्तावेज़ छोड़ें, या चुनने के लिए टैप करें", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "किसी लेख का URL पेस्ट करें", + "Added to your library — it will sync to your other devices.": "आपकी लाइब्रेरी में जोड़ा गया — यह आपके अन्य डिवाइस पर सिंक होगा।", + "Working…": "काम चल रहा है…", + "Could not load Send to Readest settings": "Readest को भेजें सेटिंग्स लोड नहीं की जा सकीं", + "Address copied": "पता कॉपी किया गया", + "Could not copy address": "पता कॉपी नहीं किया जा सका", + "Enter a name for your address": "अपने पते के लिए एक नाम दर्ज करें", + "Address updated": "पता अपडेट किया गया", + "Could not update address": "पता अपडेट नहीं किया जा सका", + "Enter a valid email address": "एक मान्य ईमेल पता दर्ज करें", + "Could not add sender": "प्रेषक नहीं जोड़ा जा सका", + "Could not approve sender": "प्रेषक को स्वीकृत नहीं किया जा सका", + "Could not remove sender": "प्रेषक को हटाया नहीं जा सका", + "Email books and articles straight into your library.": "किताबें और लेख सीधे अपनी लाइब्रेरी में ईमेल करें।", + "Your inbound address": "आपका इनबाउंड पता", + "Change address name": "पते का नाम बदलें", + "Copy address": "पता कॉपी करें", + "Customize your address name": "अपने पते का नाम कस्टमाइज़ करें", + "your-name": "your-name", + "Approved senders": "स्वीकृत प्रेषक", + "No approved senders yet. Add an email to let it send to your library.": "अभी तक कोई स्वीकृत प्रेषक नहीं। किसी ईमेल को अपनी लाइब्रेरी में भेजने की अनुमति देने के लिए उसे जोड़ें।", + "Pending approval": "स्वीकृति लंबित", + "Approve": "स्वीकृत करें", + "Add a sender": "प्रेषक जोड़ें", + "name@example.com": "name@example.com", + "Recent activity": "हालिया गतिविधि", + "Nothing sent yet. Email a book to your address above.": "अभी तक कुछ भी नहीं भेजा गया। ऊपर अपने पते पर कोई किताब ईमेल करें।", + "Process incoming items on this device": "इस डिवाइस पर आने वाली वस्तुओं को प्रोसेस करें", + "Email books to your library": "अपनी लाइब्रेरी में किताबें ईमेल करें", + "Email a book or document to this address from an approved sender below.": "नीचे दिए गए किसी अनुमोदित प्रेषक से इस पते पर कोई पुस्तक या दस्तावेज़ ईमेल करें।", + "Download and import books emailed to your address.": "आपके पते पर ईमेल की गई पुस्तकें डाउनलोड और आयात करें।" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 887d2f93..8237482a 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -1454,5 +1454,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Készíts biztonsági mentést a könyvtáradról és a beállításaidról, vagy állítsd vissza egy korábbi biztonsági mentésből. A visszaállítás összevonásra kerül a jelenlegi könyvtáraddal.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Fiók hitelesítő adatainak (szinkronizációs tokenek, jelszavak) felvétele. A biztonsági mentés fájlja nincs titkosítva.", "Your library and settings have been saved to the selected location.": "A könyvtárad és a beállításaid a kiválasztott helyre lettek mentve.", - "Settings have been restored.": "A beállítások visszaállítva." + "Settings have been restored.": "A beállítások visszaállítva.", + "Could not fetch this page": "Az oldalt nem sikerült lekérni", + "Send to Readest": "Küldés a Readestbe", + "Sign in to send books and articles to your library.": "Jelentkezzen be, hogy könyveket és cikkeket küldhessen a könyvtárába.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Húzzon ide egy könyvet vagy dokumentumot, vagy illesszen be egy cikkhivatkozást. Minden eszközével szinkronizálódik.", + "Drop a book or document, or tap to choose": "Húzzon ide egy könyvet vagy dokumentumot, vagy koppintson a kiválasztáshoz", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Illesszen be egy cikk URL-címét", + "Added to your library — it will sync to your other devices.": "Hozzáadva a könyvtárához — szinkronizálódni fog a többi eszközével.", + "Working…": "Folyamatban…", + "Could not load Send to Readest settings": "A Küldés a Readestbe beállításait nem sikerült betölteni", + "Address copied": "Cím másolva", + "Could not copy address": "A címet nem sikerült másolni", + "Enter a name for your address": "Adjon meg egy nevet a címéhez", + "Address updated": "Cím frissítve", + "Could not update address": "A címet nem sikerült frissíteni", + "Enter a valid email address": "Adjon meg egy érvényes e-mail címet", + "Could not add sender": "A feladót nem sikerült hozzáadni", + "Could not approve sender": "A feladót nem sikerült jóváhagyni", + "Could not remove sender": "A feladót nem sikerült eltávolítani", + "Email books and articles straight into your library.": "Küldjön könyveket és cikkeket közvetlenül a könyvtárába e-mailben.", + "Your inbound address": "Az Ön bejövő címe", + "Change address name": "Cím nevének módosítása", + "Copy address": "Cím másolása", + "Customize your address name": "Szabja testre a címe nevét", + "your-name": "your-name", + "Approved senders": "Jóváhagyott feladók", + "No approved senders yet. Add an email to let it send to your library.": "Még nincsenek jóváhagyott feladók. Adjon hozzá egy e-mail címet, hogy küldhessen a könyvtárába.", + "Pending approval": "Jóváhagyásra vár", + "Approve": "Jóváhagyás", + "Add a sender": "Feladó hozzáadása", + "name@example.com": "name@example.com", + "Recent activity": "Legutóbbi tevékenység", + "Nothing sent yet. Email a book to your address above.": "Még semmit sem küldött. Küldjön egy könyvet e-mailben a fenti címére.", + "Process incoming items on this device": "Bejövő elemek feldolgozása ezen az eszközön", + "Email books to your library": "Küldjön könyveket a könyvtárába e-mailben", + "Email a book or document to this address from an approved sender below.": "Küldjön egy könyvet vagy dokumentumot erre a címre egy lent jóváhagyott feladótól.", + "Download and import books emailed to your address.": "Az e-mailben a címedre küldött könyvek letöltése és importálása." } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index c573bbaf..dbe40520 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1426,5 +1426,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Buat cadangan pustaka dan pengaturan Anda atau pulihkan dari cadangan sebelumnya. Pemulihan akan digabungkan dengan pustaka Anda saat ini.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Sertakan kredensial akun (token sinkronisasi, kata sandi). File cadangan tidak dienkripsi.", "Your library and settings have been saved to the selected location.": "Pustaka dan pengaturan Anda telah disimpan ke lokasi yang dipilih.", - "Settings have been restored.": "Pengaturan telah dipulihkan." + "Settings have been restored.": "Pengaturan telah dipulihkan.", + "Could not fetch this page": "Tidak dapat mengambil halaman ini", + "Send to Readest": "Kirim ke Readest", + "Sign in to send books and articles to your library.": "Masuk untuk mengirim buku dan artikel ke perpustakaan Anda.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Jatuhkan buku atau dokumen, atau tempel tautan artikel. Ini akan disinkronkan ke semua perangkat Anda.", + "Drop a book or document, or tap to choose": "Jatuhkan buku atau dokumen, atau ketuk untuk memilih", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Tempel URL artikel", + "Added to your library — it will sync to your other devices.": "Ditambahkan ke perpustakaan Anda — ini akan disinkronkan ke perangkat Anda yang lain.", + "Working…": "Memproses…", + "Could not load Send to Readest settings": "Tidak dapat memuat pengaturan Kirim ke Readest", + "Address copied": "Alamat disalin", + "Could not copy address": "Tidak dapat menyalin alamat", + "Enter a name for your address": "Masukkan nama untuk alamat Anda", + "Address updated": "Alamat diperbarui", + "Could not update address": "Tidak dapat memperbarui alamat", + "Enter a valid email address": "Masukkan alamat email yang valid", + "Could not add sender": "Tidak dapat menambahkan pengirim", + "Could not approve sender": "Tidak dapat menyetujui pengirim", + "Could not remove sender": "Tidak dapat menghapus pengirim", + "Email books and articles straight into your library.": "Kirim buku dan artikel melalui email langsung ke perpustakaan Anda.", + "Your inbound address": "Alamat masuk Anda", + "Change address name": "Ubah nama alamat", + "Copy address": "Salin alamat", + "Customize your address name": "Sesuaikan nama alamat Anda", + "your-name": "your-name", + "Approved senders": "Pengirim yang disetujui", + "No approved senders yet. Add an email to let it send to your library.": "Belum ada pengirim yang disetujui. Tambahkan email agar dapat mengirim ke perpustakaan Anda.", + "Pending approval": "Menunggu persetujuan", + "Approve": "Setujui", + "Add a sender": "Tambah pengirim", + "name@example.com": "name@example.com", + "Recent activity": "Aktivitas terbaru", + "Nothing sent yet. Email a book to your address above.": "Belum ada yang dikirim. Kirim buku melalui email ke alamat Anda di atas.", + "Process incoming items on this device": "Proses item masuk di perangkat ini", + "Email books to your library": "Kirim buku ke perpustakaan Anda melalui email", + "Email a book or document to this address from an approved sender below.": "Kirim buku atau dokumen melalui email ke alamat ini dari pengirim yang disetujui di bawah.", + "Download and import books emailed to your address.": "Unduh dan impor buku yang dikirim melalui email ke alamat Anda." } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index b647da0a..0ec4f5bf 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1482,5 +1482,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Crea un backup della tua libreria e delle impostazioni o ripristina da un backup precedente. Il ripristino verrà unito alla tua libreria attuale.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Includi le credenziali dell’account (token di sincronizzazione, password). Il file di backup non è crittografato.", "Your library and settings have been saved to the selected location.": "La tua libreria e le impostazioni sono state salvate nel percorso selezionato.", - "Settings have been restored.": "Le impostazioni sono state ripristinate." + "Settings have been restored.": "Le impostazioni sono state ripristinate.", + "Could not fetch this page": "Impossibile recuperare questa pagina", + "Send to Readest": "Invia a Readest", + "Sign in to send books and articles to your library.": "Accedi per inviare libri e articoli alla tua libreria.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Trascina un libro o un documento, oppure incolla il link di un articolo. Si sincronizza su tutti i tuoi dispositivi.", + "Drop a book or document, or tap to choose": "Trascina un libro o un documento, oppure tocca per scegliere", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Incolla l'URL di un articolo", + "Added to your library — it will sync to your other devices.": "Aggiunto alla tua libreria: verrà sincronizzato con gli altri dispositivi.", + "Working…": "Elaborazione in corso…", + "Could not load Send to Readest settings": "Impossibile caricare le impostazioni di Invia a Readest", + "Address copied": "Indirizzo copiato", + "Could not copy address": "Impossibile copiare l'indirizzo", + "Enter a name for your address": "Inserisci un nome per il tuo indirizzo", + "Address updated": "Indirizzo aggiornato", + "Could not update address": "Impossibile aggiornare l'indirizzo", + "Enter a valid email address": "Inserisci un indirizzo email valido", + "Could not add sender": "Impossibile aggiungere il mittente", + "Could not approve sender": "Impossibile approvare il mittente", + "Could not remove sender": "Impossibile rimuovere il mittente", + "Email books and articles straight into your library.": "Invia libri e articoli direttamente nella tua libreria via email.", + "Your inbound address": "Il tuo indirizzo di ricezione", + "Change address name": "Cambia il nome dell'indirizzo", + "Copy address": "Copia indirizzo", + "Customize your address name": "Personalizza il nome del tuo indirizzo", + "your-name": "your-name", + "Approved senders": "Mittenti approvati", + "No approved senders yet. Add an email to let it send to your library.": "Ancora nessun mittente approvato. Aggiungi un indirizzo email per consentirgli di inviare alla tua libreria.", + "Pending approval": "In attesa di approvazione", + "Approve": "Approva", + "Add a sender": "Aggiungi un mittente", + "name@example.com": "name@example.com", + "Recent activity": "Attività recente", + "Nothing sent yet. Email a book to your address above.": "Ancora nulla inviato. Invia un libro via email al tuo indirizzo qui sopra.", + "Process incoming items on this device": "Elabora gli elementi in arrivo su questo dispositivo", + "Email books to your library": "Invia libri alla tua libreria via email", + "Email a book or document to this address from an approved sender below.": "Invia un libro o un documento a questo indirizzo da un mittente approvato qui sotto.", + "Download and import books emailed to your address.": "Scarica e importa i libri inviati via e-mail al tuo indirizzo." } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index c6fd9072..bfd88bca 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1426,5 +1426,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "ライブラリと設定のバックアップを作成するか、以前のバックアップから復元します。復元すると、現在のライブラリと統合されます。", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "アカウントの認証情報(同期トークン、パスワード)を含めます。バックアップファイルは暗号化されません。", "Your library and settings have been saved to the selected location.": "ライブラリと設定が選択した場所に保存されました。", - "Settings have been restored.": "設定が復元されました。" + "Settings have been restored.": "設定が復元されました。", + "Could not fetch this page": "このページを取得できませんでした", + "Send to Readest": "Readestに送信", + "Sign in to send books and articles to your library.": "サインインすると、本や記事をライブラリに送信できます。", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "本やドキュメントをドロップするか、記事のリンクを貼り付けてください。すべてのデバイスに同期されます。", + "Drop a book or document, or tap to choose": "本やドキュメントをドロップするか、タップして選択", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "記事のURLを貼り付け", + "Added to your library — it will sync to your other devices.": "ライブラリに追加しました — 他のデバイスにも同期されます。", + "Working…": "処理中…", + "Could not load Send to Readest settings": "Readestに送信の設定を読み込めませんでした", + "Address copied": "アドレスをコピーしました", + "Could not copy address": "アドレスをコピーできませんでした", + "Enter a name for your address": "アドレスの名前を入力してください", + "Address updated": "アドレスを更新しました", + "Could not update address": "アドレスを更新できませんでした", + "Enter a valid email address": "有効なメールアドレスを入力してください", + "Could not add sender": "送信者を追加できませんでした", + "Could not approve sender": "送信者を承認できませんでした", + "Could not remove sender": "送信者を削除できませんでした", + "Email books and articles straight into your library.": "本や記事をメールで直接ライブラリに送信できます。", + "Your inbound address": "受信用アドレス", + "Change address name": "アドレス名を変更", + "Copy address": "アドレスをコピー", + "Customize your address name": "アドレス名をカスタマイズ", + "your-name": "your-name", + "Approved senders": "承認済み送信者", + "No approved senders yet. Add an email to let it send to your library.": "承認済みの送信者はまだありません。メールアドレスを追加すると、ライブラリに送信できるようになります。", + "Pending approval": "承認待ち", + "Approve": "承認", + "Add a sender": "送信者を追加", + "name@example.com": "name@example.com", + "Recent activity": "最近のアクティビティ", + "Nothing sent yet. Email a book to your address above.": "まだ何も送信されていません。上記のアドレスに本をメールで送信してください。", + "Process incoming items on this device": "このデバイスで受信アイテムを処理", + "Email books to your library": "本をメールでライブラリに送信", + "Email a book or document to this address from an approved sender below.": "下記の承認済み送信者から、書籍やドキュメントをこのアドレスにメールで送信します。", + "Download and import books emailed to your address.": "あなたのアドレスにメールで送られた書籍をダウンロードして取り込みます。" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index a7cb7857..fa0af3f7 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1426,5 +1426,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "라이브러리와 설정의 백업을 만들거나 이전 백업에서 복원합니다. 복원하면 현재 라이브러리와 병합됩니다.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "계정 자격 증명(동기화 토큰, 비밀번호)을 포함합니다. 백업 파일은 암호화되지 않습니다.", "Your library and settings have been saved to the selected location.": "라이브러리와 설정이 선택한 위치에 저장되었습니다.", - "Settings have been restored.": "설정이 복원되었습니다." + "Settings have been restored.": "설정이 복원되었습니다.", + "Could not fetch this page": "이 페이지를 가져올 수 없습니다", + "Send to Readest": "Readest로 보내기", + "Sign in to send books and articles to your library.": "로그인하면 책과 기사를 라이브러리로 보낼 수 있습니다.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "책이나 문서를 끌어다 놓거나 기사 링크를 붙여넣으세요. 모든 기기에 동기화됩니다.", + "Drop a book or document, or tap to choose": "책이나 문서를 끌어다 놓거나 탭하여 선택하세요", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "기사 URL 붙여넣기", + "Added to your library — it will sync to your other devices.": "라이브러리에 추가되었습니다 — 다른 기기에도 동기화됩니다.", + "Working…": "처리 중…", + "Could not load Send to Readest settings": "Readest로 보내기 설정을 불러올 수 없습니다", + "Address copied": "주소가 복사되었습니다", + "Could not copy address": "주소를 복사할 수 없습니다", + "Enter a name for your address": "주소 이름을 입력하세요", + "Address updated": "주소가 업데이트되었습니다", + "Could not update address": "주소를 업데이트할 수 없습니다", + "Enter a valid email address": "유효한 이메일 주소를 입력하세요", + "Could not add sender": "발신자를 추가할 수 없습니다", + "Could not approve sender": "발신자를 승인할 수 없습니다", + "Could not remove sender": "발신자를 삭제할 수 없습니다", + "Email books and articles straight into your library.": "책과 기사를 이메일로 라이브러리에 바로 보내세요.", + "Your inbound address": "수신 주소", + "Change address name": "주소 이름 변경", + "Copy address": "주소 복사", + "Customize your address name": "주소 이름 사용자 지정", + "your-name": "your-name", + "Approved senders": "승인된 발신자", + "No approved senders yet. Add an email to let it send to your library.": "아직 승인된 발신자가 없습니다. 이메일을 추가하여 라이브러리로 보낼 수 있도록 하세요.", + "Pending approval": "승인 대기 중", + "Approve": "승인", + "Add a sender": "발신자 추가", + "name@example.com": "name@example.com", + "Recent activity": "최근 활동", + "Nothing sent yet. Email a book to your address above.": "아직 보낸 항목이 없습니다. 위 주소로 책을 이메일로 보내세요.", + "Process incoming items on this device": "이 기기에서 수신 항목 처리", + "Email books to your library": "책을 이메일로 라이브러리에 보내기", + "Email a book or document to this address from an approved sender below.": "아래의 승인된 발신자에서 책이나 문서를 이 주소로 이메일로 보내세요.", + "Download and import books emailed to your address.": "이 주소로 이메일로 보낸 책을 다운로드하여 가져옵니다." } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index d908fc05..d9f86cf0 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1426,5 +1426,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Cipta sandaran pustaka dan tetapan anda atau pulihkan daripada sandaran terdahulu. Pemulihan akan digabungkan dengan pustaka semasa anda.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Sertakan kelayakan akaun (token penyegerakan, kata laluan). Fail sandaran tidak disulitkan.", "Your library and settings have been saved to the selected location.": "Pustaka dan tetapan anda telah disimpan ke lokasi yang dipilih.", - "Settings have been restored.": "Tetapan telah dipulihkan." + "Settings have been restored.": "Tetapan telah dipulihkan.", + "Could not fetch this page": "Tidak dapat mengambil halaman ini", + "Send to Readest": "Hantar ke Readest", + "Sign in to send books and articles to your library.": "Log masuk untuk menghantar buku dan artikel ke perpustakaan anda.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Lepaskan buku atau dokumen, atau tampal pautan artikel. Ia akan disegerakkan ke semua peranti anda.", + "Drop a book or document, or tap to choose": "Lepaskan buku atau dokumen, atau ketik untuk memilih", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Tampal URL artikel", + "Added to your library — it will sync to your other devices.": "Ditambah ke perpustakaan anda — ia akan disegerakkan ke peranti anda yang lain.", + "Working…": "Sedang memproses…", + "Could not load Send to Readest settings": "Tidak dapat memuatkan tetapan Hantar ke Readest", + "Address copied": "Alamat disalin", + "Could not copy address": "Tidak dapat menyalin alamat", + "Enter a name for your address": "Masukkan nama untuk alamat anda", + "Address updated": "Alamat dikemas kini", + "Could not update address": "Tidak dapat mengemas kini alamat", + "Enter a valid email address": "Masukkan alamat e-mel yang sah", + "Could not add sender": "Tidak dapat menambah penghantar", + "Could not approve sender": "Tidak dapat meluluskan penghantar", + "Could not remove sender": "Tidak dapat mengalih keluar penghantar", + "Email books and articles straight into your library.": "Hantar buku dan artikel melalui e-mel terus ke perpustakaan anda.", + "Your inbound address": "Alamat masuk anda", + "Change address name": "Tukar nama alamat", + "Copy address": "Salin alamat", + "Customize your address name": "Sesuaikan nama alamat anda", + "your-name": "your-name", + "Approved senders": "Penghantar yang diluluskan", + "No approved senders yet. Add an email to let it send to your library.": "Belum ada penghantar yang diluluskan. Tambah e-mel untuk membenarkannya menghantar ke perpustakaan anda.", + "Pending approval": "Menunggu kelulusan", + "Approve": "Luluskan", + "Add a sender": "Tambah penghantar", + "name@example.com": "name@example.com", + "Recent activity": "Aktiviti terkini", + "Nothing sent yet. Email a book to your address above.": "Belum ada apa-apa dihantar. Hantar buku melalui e-mel ke alamat anda di atas.", + "Process incoming items on this device": "Proses item masuk pada peranti ini", + "Email books to your library": "Hantar buku ke perpustakaan anda melalui e-mel", + "Email a book or document to this address from an approved sender below.": "E-melkan buku atau dokumen ke alamat ini daripada penghantar yang diluluskan di bawah.", + "Download and import books emailed to your address.": "Muat turun dan import buku yang dihantar melalui e-mel ke alamat anda." } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index ba947872..4fe9fe66 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1454,5 +1454,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Maak een back-up van je bibliotheek en instellingen of herstel vanuit een eerdere back-up. Bij herstellen wordt samengevoegd met je huidige bibliotheek.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Accountgegevens opnemen (synchronisatietokens, wachtwoorden). Het back-upbestand is niet versleuteld.", "Your library and settings have been saved to the selected location.": "Je bibliotheek en instellingen zijn opgeslagen op de geselecteerde locatie.", - "Settings have been restored.": "Instellingen zijn hersteld." + "Settings have been restored.": "Instellingen zijn hersteld.", + "Could not fetch this page": "Kan deze pagina niet ophalen", + "Send to Readest": "Naar Readest sturen", + "Sign in to send books and articles to your library.": "Meld je aan om boeken en artikelen naar je bibliotheek te sturen.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Sleep een boek of document hierheen, of plak een artikellink. Het synchroniseert met al je apparaten.", + "Drop a book or document, or tap to choose": "Sleep een boek of document hierheen, of tik om te kiezen", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Plak een artikel-URL", + "Added to your library — it will sync to your other devices.": "Toegevoegd aan je bibliotheek — het wordt gesynchroniseerd met je andere apparaten.", + "Working…": "Bezig…", + "Could not load Send to Readest settings": "Kan de instellingen voor Naar Readest sturen niet laden", + "Address copied": "Adres gekopieerd", + "Could not copy address": "Kan het adres niet kopiëren", + "Enter a name for your address": "Voer een naam voor je adres in", + "Address updated": "Adres bijgewerkt", + "Could not update address": "Kan het adres niet bijwerken", + "Enter a valid email address": "Voer een geldig e-mailadres in", + "Could not add sender": "Kan afzender niet toevoegen", + "Could not approve sender": "Kan afzender niet goedkeuren", + "Could not remove sender": "Kan afzender niet verwijderen", + "Email books and articles straight into your library.": "Stuur boeken en artikelen rechtstreeks per e-mail naar je bibliotheek.", + "Your inbound address": "Je inkomende adres", + "Change address name": "Adresnaam wijzigen", + "Copy address": "Adres kopiëren", + "Customize your address name": "Pas je adresnaam aan", + "your-name": "your-name", + "Approved senders": "Goedgekeurde afzenders", + "No approved senders yet. Add an email to let it send to your library.": "Nog geen goedgekeurde afzenders. Voeg een e-mailadres toe zodat het naar je bibliotheek kan sturen.", + "Pending approval": "In afwachting van goedkeuring", + "Approve": "Goedkeuren", + "Add a sender": "Een afzender toevoegen", + "name@example.com": "name@example.com", + "Recent activity": "Recente activiteit", + "Nothing sent yet. Email a book to your address above.": "Nog niets verstuurd. Stuur een boek per e-mail naar je adres hierboven.", + "Process incoming items on this device": "Inkomende items op dit apparaat verwerken", + "Email books to your library": "Boeken per e-mail naar je bibliotheek sturen", + "Email a book or document to this address from an approved sender below.": "E-mail een boek of document naar dit adres vanaf een goedgekeurde afzender hieronder.", + "Download and import books emailed to your address.": "Download en importeer boeken die naar je adres zijn gemaild." } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 358168b0..0c161a25 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1510,5 +1510,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Utwórz kopię zapasową swojej biblioteki i ustawień lub przywróć z poprzedniej kopii zapasowej. Przywracanie zostanie scalone z bieżącą biblioteką.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Uwzględnij dane logowania do konta (tokeny synchronizacji, hasła). Plik kopii zapasowej nie jest zaszyfrowany.", "Your library and settings have been saved to the selected location.": "Twoja biblioteka i ustawienia zostały zapisane w wybranej lokalizacji.", - "Settings have been restored.": "Ustawienia zostały przywrócone." + "Settings have been restored.": "Ustawienia zostały przywrócone.", + "Could not fetch this page": "Nie udało się pobrać tej strony", + "Send to Readest": "Wyślij do Readest", + "Sign in to send books and articles to your library.": "Zaloguj się, aby wysyłać książki i artykuły do swojej biblioteki.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Upuść książkę lub dokument albo wklej link do artykułu. Zsynchronizuje się na wszystkich Twoich urządzeniach.", + "Drop a book or document, or tap to choose": "Upuść książkę lub dokument albo dotknij, aby wybrać", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Wklej adres URL artykułu", + "Added to your library — it will sync to your other devices.": "Dodano do Twojej biblioteki — zostanie zsynchronizowane z innymi urządzeniami.", + "Working…": "Trwa praca…", + "Could not load Send to Readest settings": "Nie udało się załadować ustawień Wyślij do Readest", + "Address copied": "Skopiowano adres", + "Could not copy address": "Nie udało się skopiować adresu", + "Enter a name for your address": "Wprowadź nazwę swojego adresu", + "Address updated": "Zaktualizowano adres", + "Could not update address": "Nie udało się zaktualizować adresu", + "Enter a valid email address": "Wprowadź prawidłowy adres e-mail", + "Could not add sender": "Nie udało się dodać nadawcy", + "Could not approve sender": "Nie udało się zatwierdzić nadawcy", + "Could not remove sender": "Nie udało się usunąć nadawcy", + "Email books and articles straight into your library.": "Wysyłaj książki i artykuły prosto do swojej biblioteki przez e-mail.", + "Your inbound address": "Twój adres przychodzący", + "Change address name": "Zmień nazwę adresu", + "Copy address": "Kopiuj adres", + "Customize your address name": "Dostosuj nazwę swojego adresu", + "your-name": "your-name", + "Approved senders": "Zatwierdzeni nadawcy", + "No approved senders yet. Add an email to let it send to your library.": "Brak zatwierdzonych nadawców. Dodaj adres e-mail, aby umożliwić wysyłanie do Twojej biblioteki.", + "Pending approval": "Oczekuje na zatwierdzenie", + "Approve": "Zatwierdź", + "Add a sender": "Dodaj nadawcę", + "name@example.com": "name@example.com", + "Recent activity": "Ostatnia aktywność", + "Nothing sent yet. Email a book to your address above.": "Nic jeszcze nie wysłano. Wyślij książkę e-mailem na swój adres powyżej.", + "Process incoming items on this device": "Przetwarzaj przychodzące elementy na tym urządzeniu", + "Email books to your library": "Wysyłaj książki do swojej biblioteki przez e-mail", + "Email a book or document to this address from an approved sender below.": "Wyślij książkę lub dokument na ten adres od zatwierdzonego nadawcy poniżej.", + "Download and import books emailed to your address.": "Pobieraj i importuj książki wysłane e-mailem na Twój adres." } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index d315ea17..0e30052f 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -1482,5 +1482,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Crie um backup da sua biblioteca e configurações ou restaure a partir de um backup anterior. A restauração será mesclada com a sua biblioteca atual.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Incluir credenciais da conta (tokens de sincronização, senhas). O arquivo de backup não é criptografado.", "Your library and settings have been saved to the selected location.": "Sua biblioteca e configurações foram salvas no local selecionado.", - "Settings have been restored.": "As configurações foram restauradas." + "Settings have been restored.": "As configurações foram restauradas.", + "Could not fetch this page": "Não foi possível carregar esta página", + "Send to Readest": "Enviar para o Readest", + "Sign in to send books and articles to your library.": "Faça login para enviar livros e artigos para a sua biblioteca.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Solte um livro ou documento, ou cole o link de um artigo. Ele sincroniza com todos os seus dispositivos.", + "Drop a book or document, or tap to choose": "Solte um livro ou documento, ou toque para escolher", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Cole a URL de um artigo", + "Added to your library — it will sync to your other devices.": "Adicionado à sua biblioteca — ele será sincronizado com os seus outros dispositivos.", + "Working…": "Processando…", + "Could not load Send to Readest settings": "Não foi possível carregar as configurações de Enviar para o Readest", + "Address copied": "Endereço copiado", + "Could not copy address": "Não foi possível copiar o endereço", + "Enter a name for your address": "Digite um nome para o seu endereço", + "Address updated": "Endereço atualizado", + "Could not update address": "Não foi possível atualizar o endereço", + "Enter a valid email address": "Digite um endereço de e-mail válido", + "Could not add sender": "Não foi possível adicionar o remetente", + "Could not approve sender": "Não foi possível aprovar o remetente", + "Could not remove sender": "Não foi possível remover o remetente", + "Email books and articles straight into your library.": "Envie livros e artigos diretamente para a sua biblioteca por e-mail.", + "Your inbound address": "O seu endereço de recebimento", + "Change address name": "Alterar o nome do endereço", + "Copy address": "Copiar endereço", + "Customize your address name": "Personalizar o nome do seu endereço", + "your-name": "your-name", + "Approved senders": "Remetentes aprovados", + "No approved senders yet. Add an email to let it send to your library.": "Ainda não há remetentes aprovados. Adicione um e-mail para permitir que ele envie para a sua biblioteca.", + "Pending approval": "Aprovação pendente", + "Approve": "Aprovar", + "Add a sender": "Adicionar um remetente", + "name@example.com": "name@example.com", + "Recent activity": "Atividade recente", + "Nothing sent yet. Email a book to your address above.": "Nada enviado ainda. Envie um livro por e-mail para o seu endereço acima.", + "Process incoming items on this device": "Processar itens recebidos neste dispositivo", + "Email books to your library": "Enviar livros para a sua biblioteca por e-mail", + "Email a book or document to this address from an approved sender below.": "Envie um livro ou documento para este endereço de um remetente aprovado abaixo.", + "Download and import books emailed to your address.": "Baixe e importe livros enviados por e-mail para o seu endereço." } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 19cc3ff7..ba77f618 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1482,5 +1482,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Crie uma cópia de segurança da sua biblioteca e definições ou restaure a partir de uma cópia de segurança anterior. A restauração será combinada com a sua biblioteca atual.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Incluir credenciais da conta (tokens de sincronização, palavras-passe). O ficheiro de cópia de segurança não está encriptado.", "Your library and settings have been saved to the selected location.": "A sua biblioteca e definições foram guardadas na localização selecionada.", - "Settings have been restored.": "As definições foram restauradas." + "Settings have been restored.": "As definições foram restauradas.", + "Could not fetch this page": "Não foi possível obter esta página", + "Send to Readest": "Enviar para o Readest", + "Sign in to send books and articles to your library.": "Inicie sessão para enviar livros e artigos para a sua biblioteca.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Largue um livro ou documento, ou cole a ligação de um artigo. Sincroniza com todos os seus dispositivos.", + "Drop a book or document, or tap to choose": "Largue um livro ou documento, ou toque para escolher", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Cole o URL de um artigo", + "Added to your library — it will sync to your other devices.": "Adicionado à sua biblioteca — será sincronizado com os seus outros dispositivos.", + "Working…": "A processar…", + "Could not load Send to Readest settings": "Não foi possível carregar as definições de Enviar para o Readest", + "Address copied": "Endereço copiado", + "Could not copy address": "Não foi possível copiar o endereço", + "Enter a name for your address": "Introduza um nome para o seu endereço", + "Address updated": "Endereço atualizado", + "Could not update address": "Não foi possível atualizar o endereço", + "Enter a valid email address": "Introduza um endereço de e-mail válido", + "Could not add sender": "Não foi possível adicionar o remetente", + "Could not approve sender": "Não foi possível aprovar o remetente", + "Could not remove sender": "Não foi possível remover o remetente", + "Email books and articles straight into your library.": "Envie livros e artigos diretamente para a sua biblioteca por e-mail.", + "Your inbound address": "O seu endereço de receção", + "Change address name": "Alterar o nome do endereço", + "Copy address": "Copiar endereço", + "Customize your address name": "Personalizar o nome do seu endereço", + "your-name": "your-name", + "Approved senders": "Remetentes aprovados", + "No approved senders yet. Add an email to let it send to your library.": "Ainda não há remetentes aprovados. Adicione um e-mail para permitir que envie para a sua biblioteca.", + "Pending approval": "Aprovação pendente", + "Approve": "Aprovar", + "Add a sender": "Adicionar um remetente", + "name@example.com": "name@example.com", + "Recent activity": "Atividade recente", + "Nothing sent yet. Email a book to your address above.": "Ainda nada enviado. Envie um livro por e-mail para o seu endereço acima.", + "Process incoming items on this device": "Processar itens recebidos neste dispositivo", + "Email books to your library": "Enviar livros para a sua biblioteca por e-mail", + "Email a book or document to this address from an approved sender below.": "Envie um livro ou documento para este endereço a partir de um remetente aprovado abaixo.", + "Download and import books emailed to your address.": "Transfira e importe livros enviados por e-mail para o seu endereço." } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index 11b339be..43e77650 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -1482,5 +1482,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Creează o copie de rezervă a bibliotecii și a setărilor tale sau restaurează dintr-o copie de rezervă anterioară. Restaurarea se va îmbina cu biblioteca ta curentă.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Include datele de autentificare ale contului (jetoane de sincronizare, parole). Fișierul de rezervă nu este criptat.", "Your library and settings have been saved to the selected location.": "Biblioteca și setările tale au fost salvate în locația selectată.", - "Settings have been restored.": "Setările au fost restaurate." + "Settings have been restored.": "Setările au fost restaurate.", + "Could not fetch this page": "Pagina nu a putut fi preluată", + "Send to Readest": "Trimite către Readest", + "Sign in to send books and articles to your library.": "Conectați-vă pentru a trimite cărți și articole în biblioteca dvs.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Trageți o carte sau un document ori lipiți un link de articol. Se sincronizează pe toate dispozitivele dvs.", + "Drop a book or document, or tap to choose": "Trageți o carte sau un document ori atingeți pentru a alege", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Lipiți adresa URL a unui articol", + "Added to your library — it will sync to your other devices.": "Adăugat în biblioteca dvs. — se va sincroniza cu celelalte dispozitive.", + "Working…": "În lucru…", + "Could not load Send to Readest settings": "Setările Trimite către Readest nu au putut fi încărcate", + "Address copied": "Adresă copiată", + "Could not copy address": "Adresa nu a putut fi copiată", + "Enter a name for your address": "Introduceți un nume pentru adresa dvs.", + "Address updated": "Adresă actualizată", + "Could not update address": "Adresa nu a putut fi actualizată", + "Enter a valid email address": "Introduceți o adresă de e-mail validă", + "Could not add sender": "Expeditorul nu a putut fi adăugat", + "Could not approve sender": "Expeditorul nu a putut fi aprobat", + "Could not remove sender": "Expeditorul nu a putut fi eliminat", + "Email books and articles straight into your library.": "Trimiteți cărți și articole direct în biblioteca dvs. prin e-mail.", + "Your inbound address": "Adresa dvs. de intrare", + "Change address name": "Schimbă numele adresei", + "Copy address": "Copiază adresa", + "Customize your address name": "Personalizați numele adresei dvs.", + "your-name": "your-name", + "Approved senders": "Expeditori aprobați", + "No approved senders yet. Add an email to let it send to your library.": "Încă nu există expeditori aprobați. Adăugați o adresă de e-mail pentru a-i permite să trimită în biblioteca dvs.", + "Pending approval": "În așteptarea aprobării", + "Approve": "Aprobă", + "Add a sender": "Adaugă un expeditor", + "name@example.com": "name@example.com", + "Recent activity": "Activitate recentă", + "Nothing sent yet. Email a book to your address above.": "Nu s-a trimis încă nimic. Trimiteți o carte prin e-mail la adresa dvs. de mai sus.", + "Process incoming items on this device": "Procesează elementele primite pe acest dispozitiv", + "Email books to your library": "Trimiteți cărți în biblioteca dvs. prin e-mail", + "Email a book or document to this address from an approved sender below.": "Trimite o carte sau un document la această adresă de la un expeditor aprobat de mai jos.", + "Download and import books emailed to your address.": "Descarcă și importă cărțile trimise prin e-mail la adresa ta." } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index c08b1b11..31d97412 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1510,5 +1510,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Создайте резервную копию своей библиотеки и настроек или восстановите из предыдущей резервной копии. Восстановление будет объединено с вашей текущей библиотекой.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Включить учётные данные аккаунта (токены синхронизации, пароли). Файл резервной копии не зашифрован.", "Your library and settings have been saved to the selected location.": "Ваша библиотека и настройки сохранены в выбранном месте.", - "Settings have been restored.": "Настройки восстановлены." + "Settings have been restored.": "Настройки восстановлены.", + "Could not fetch this page": "Не удалось загрузить эту страницу", + "Send to Readest": "Отправить в Readest", + "Sign in to send books and articles to your library.": "Войдите, чтобы отправлять книги и статьи в свою библиотеку.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Перетащите книгу или документ либо вставьте ссылку на статью. Она синхронизируется на всех ваших устройствах.", + "Drop a book or document, or tap to choose": "Перетащите книгу или документ либо нажмите для выбора", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Вставьте URL статьи", + "Added to your library — it will sync to your other devices.": "Добавлено в вашу библиотеку — будет синхронизировано на других устройствах.", + "Working…": "Выполняется…", + "Could not load Send to Readest settings": "Не удалось загрузить настройки «Отправить в Readest»", + "Address copied": "Адрес скопирован", + "Could not copy address": "Не удалось скопировать адрес", + "Enter a name for your address": "Введите имя для вашего адреса", + "Address updated": "Адрес обновлён", + "Could not update address": "Не удалось обновить адрес", + "Enter a valid email address": "Введите корректный адрес электронной почты", + "Could not add sender": "Не удалось добавить отправителя", + "Could not approve sender": "Не удалось подтвердить отправителя", + "Could not remove sender": "Не удалось удалить отправителя", + "Email books and articles straight into your library.": "Отправляйте книги и статьи прямо в свою библиотеку по электронной почте.", + "Your inbound address": "Ваш входящий адрес", + "Change address name": "Изменить имя адреса", + "Copy address": "Копировать адрес", + "Customize your address name": "Настройте имя вашего адреса", + "your-name": "your-name", + "Approved senders": "Подтверждённые отправители", + "No approved senders yet. Add an email to let it send to your library.": "Подтверждённых отправителей пока нет. Добавьте адрес электронной почты, чтобы разрешить отправку в вашу библиотеку.", + "Pending approval": "Ожидает подтверждения", + "Approve": "Подтвердить", + "Add a sender": "Добавить отправителя", + "name@example.com": "name@example.com", + "Recent activity": "Недавняя активность", + "Nothing sent yet. Email a book to your address above.": "Пока ничего не отправлено. Отправьте книгу по электронной почте на свой адрес выше.", + "Process incoming items on this device": "Обрабатывать входящие элементы на этом устройстве", + "Email books to your library": "Отправляйте книги в свою библиотеку по электронной почте", + "Email a book or document to this address from an approved sender below.": "Отправьте книгу или документ на этот адрес от одобренного отправителя из списка ниже.", + "Download and import books emailed to your address.": "Загружайте и импортируйте книги, отправленные на ваш адрес по электронной почте." } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 577e7ecd..8cb55b67 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1454,5 +1454,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "ඔබගේ පුස්තකාලයේ සහ සැකසීම්වල උපස්ථයක් සාදන්න හෝ පෙර උපස්ථයකින් ප්‍රතිස්ථාපනය කරන්න. ප්‍රතිස්ථාපනය ඔබගේ වත්මන් පුස්තකාලය සමඟ ඒකාබද්ධ වේ.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "ගිණුම් අක්තපත්‍ර (සමමුහුර්ත ටෝකන, මුරපද) ඇතුළත් කරන්න. උපස්ථ ගොනුව සංකේතනය කර නැත.", "Your library and settings have been saved to the selected location.": "ඔබගේ පුස්තකාලය සහ සැකසීම් තෝරාගත් ස්ථානයට සුරැකී ඇත.", - "Settings have been restored.": "සැකසීම් ප්‍රතිස්ථාපනය කර ඇත." + "Settings have been restored.": "සැකසීම් ප්‍රතිස්ථාපනය කර ඇත.", + "Could not fetch this page": "මෙම පිටුව ලබා ගත නොහැකි විය", + "Send to Readest": "Readest වෙත යවන්න", + "Sign in to send books and articles to your library.": "ඔබේ පුස්තකාලයට පොත් සහ ලිපි යැවීමට ඇතුල් වන්න.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "පොතක් හෝ ලේඛනයක් දමන්න, නැතහොත් ලිපි සබැඳියක් අලවන්න. එය ඔබේ සියලු උපාංගවලට සමමුහුර්ත වේ.", + "Drop a book or document, or tap to choose": "පොතක් හෝ ලේඛනයක් දමන්න, නැතහොත් තෝරා ගැනීමට තට්ටු කරන්න", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "ලිපියක URL එකක් අලවන්න", + "Added to your library — it will sync to your other devices.": "ඔබේ පුස්තකාලයට එක් කරන ලදී — එය ඔබේ අනෙකුත් උපාංගවලට සමමුහුර්ත වනු ඇත.", + "Working…": "ක්‍රියාත්මක වෙමින්…", + "Could not load Send to Readest settings": "Readest වෙත යවන්න සැකසුම් පූරණය කළ නොහැකි විය", + "Address copied": "ලිපිනය පිටපත් කරන ලදී", + "Could not copy address": "ලිපිනය පිටපත් කළ නොහැකි විය", + "Enter a name for your address": "ඔබේ ලිපිනය සඳහා නමක් ඇතුළු කරන්න", + "Address updated": "ලිපිනය යාවත්කාලීන කරන ලදී", + "Could not update address": "ලිපිනය යාවත්කාලීන කළ නොහැකි විය", + "Enter a valid email address": "වලංගු විද්‍යුත් තැපැල් ලිපිනයක් ඇතුළු කරන්න", + "Could not add sender": "යවන්නා එක් කළ නොහැකි විය", + "Could not approve sender": "යවන්නා අනුමත කළ නොහැකි විය", + "Could not remove sender": "යවන්නා ඉවත් කළ නොහැකි විය", + "Email books and articles straight into your library.": "පොත් සහ ලිපි සෘජුවම ඔබේ පුස්තකාලයට විද්‍යුත් තැපැල් කරන්න.", + "Your inbound address": "ඔබේ ලැබෙන ලිපිනය", + "Change address name": "ලිපිනයේ නම වෙනස් කරන්න", + "Copy address": "ලිපිනය පිටපත් කරන්න", + "Customize your address name": "ඔබේ ලිපිනයේ නම අභිරුචිකරණය කරන්න", + "your-name": "your-name", + "Approved senders": "අනුමත යවන්නන්", + "No approved senders yet. Add an email to let it send to your library.": "තවම අනුමත යවන්නන් නැත. ඔබේ පුස්තකාලයට යැවීමට ඉඩ දීමට විද්‍යුත් තැපෑලක් එක් කරන්න.", + "Pending approval": "අනුමැතිය බලාපොරොත්තුවෙන්", + "Approve": "අනුමත කරන්න", + "Add a sender": "යවන්නෙකු එක් කරන්න", + "name@example.com": "name@example.com", + "Recent activity": "මෑත ක්‍රියාකාරකම්", + "Nothing sent yet. Email a book to your address above.": "තවම කිසිවක් යවා නැත. ඉහත ඔබේ ලිපිනයට පොතක් විද්‍යුත් තැපැල් කරන්න.", + "Process incoming items on this device": "මෙම උපාංගයේ පැමිණෙන අයිතම සකසන්න", + "Email books to your library": "ඔබේ පුස්තකාලයට පොත් විද්‍යුත් තැපැල් කරන්න", + "Email a book or document to this address from an approved sender below.": "පහත අනුමත එවන්නෙකුගෙන් මෙම ලිපිනයට පොතක් හෝ ලේඛනයක් විද්‍යුත් තැපෑලෙන් එවන්න.", + "Download and import books emailed to your address.": "ඔබේ ලිපිනයට විද්‍යුත් තැපෑලෙන් එවූ පොත් බාගත කර ආයාත කරන්න." } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index c2f9eb7a..7db26241 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -1510,5 +1510,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Ustvarite varnostno kopijo svoje knjižnice in nastavitev ali jo obnovite iz prejšnje varnostne kopije. Obnovitev se bo združila z vašo trenutno knjižnico.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Vključi poverilnice računa (žetone za sinhronizacijo, gesla). Datoteka varnostne kopije ni šifrirana.", "Your library and settings have been saved to the selected location.": "Vaša knjižnica in nastavitve so bile shranjene na izbrano mesto.", - "Settings have been restored.": "Nastavitve so bile obnovljene." + "Settings have been restored.": "Nastavitve so bile obnovljene.", + "Could not fetch this page": "Te strani ni bilo mogoče pridobiti", + "Send to Readest": "Pošlji v Readest", + "Sign in to send books and articles to your library.": "Prijavite se za pošiljanje knjig in člankov v svojo knjižnico.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Spustite knjigo ali dokument oziroma prilepite povezavo do članka. Sinhronizira se z vsemi vašimi napravami.", + "Drop a book or document, or tap to choose": "Spustite knjigo ali dokument oziroma tapnite za izbiro", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Prilepite URL članka", + "Added to your library — it will sync to your other devices.": "Dodano v vašo knjižnico — sinhroniziralo se bo z vašimi drugimi napravami.", + "Working…": "Poteka…", + "Could not load Send to Readest settings": "Nastavitev Pošlji v Readest ni bilo mogoče naložiti", + "Address copied": "Naslov kopiran", + "Could not copy address": "Naslova ni bilo mogoče kopirati", + "Enter a name for your address": "Vnesite ime za svoj naslov", + "Address updated": "Naslov posodobljen", + "Could not update address": "Naslova ni bilo mogoče posodobiti", + "Enter a valid email address": "Vnesite veljaven e-poštni naslov", + "Could not add sender": "Pošiljatelja ni bilo mogoče dodati", + "Could not approve sender": "Pošiljatelja ni bilo mogoče odobriti", + "Could not remove sender": "Pošiljatelja ni bilo mogoče odstraniti", + "Email books and articles straight into your library.": "Pošiljajte knjige in članke neposredno v svojo knjižnico po e-pošti.", + "Your inbound address": "Vaš dohodni naslov", + "Change address name": "Spremeni ime naslova", + "Copy address": "Kopiraj naslov", + "Customize your address name": "Prilagodite ime svojega naslova", + "your-name": "your-name", + "Approved senders": "Odobreni pošiljatelji", + "No approved senders yet. Add an email to let it send to your library.": "Še ni odobrenih pošiljateljev. Dodajte e-poštni naslov, da bo lahko pošiljal v vašo knjižnico.", + "Pending approval": "Čaka na odobritev", + "Approve": "Odobri", + "Add a sender": "Dodaj pošiljatelja", + "name@example.com": "name@example.com", + "Recent activity": "Nedavna dejavnost", + "Nothing sent yet. Email a book to your address above.": "Še nič ni bilo poslano. Pošljite knjigo po e-pošti na svoj naslov zgoraj.", + "Process incoming items on this device": "Obdelaj dohodne elemente na tej napravi", + "Email books to your library": "Pošiljajte knjige v svojo knjižnico po e-pošti", + "Email a book or document to this address from an approved sender below.": "Pošljite knjigo ali dokument na ta naslov od odobrenega pošiljatelja spodaj.", + "Download and import books emailed to your address.": "Prenesite in uvozite knjige, poslane na vaš naslov po e-pošti." } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 7d2d8134..ca049527 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1454,5 +1454,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Skapa en säkerhetskopia av ditt bibliotek och dina inställningar eller återställ från en tidigare säkerhetskopia. Återställning slås samman med ditt nuvarande bibliotek.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Inkludera kontouppgifter (synkroniseringstoken, lösenord). Säkerhetskopian är inte krypterad.", "Your library and settings have been saved to the selected location.": "Ditt bibliotek och dina inställningar har sparats på den valda platsen.", - "Settings have been restored.": "Inställningarna har återställts." + "Settings have been restored.": "Inställningarna har återställts.", + "Could not fetch this page": "Det gick inte att hämta den här sidan", + "Send to Readest": "Skicka till Readest", + "Sign in to send books and articles to your library.": "Logga in för att skicka böcker och artiklar till ditt bibliotek.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Släpp en bok eller ett dokument, eller klistra in en artikellänk. Den synkroniseras till alla dina enheter.", + "Drop a book or document, or tap to choose": "Släpp en bok eller ett dokument, eller tryck för att välja", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Klistra in en artikel-URL", + "Added to your library — it will sync to your other devices.": "Tillagd i ditt bibliotek — den synkroniseras till dina andra enheter.", + "Working…": "Arbetar…", + "Could not load Send to Readest settings": "Det gick inte att läsa in inställningarna för Skicka till Readest", + "Address copied": "Adress kopierad", + "Could not copy address": "Det gick inte att kopiera adressen", + "Enter a name for your address": "Ange ett namn för din adress", + "Address updated": "Adressen uppdaterad", + "Could not update address": "Det gick inte att uppdatera adressen", + "Enter a valid email address": "Ange en giltig e-postadress", + "Could not add sender": "Det gick inte att lägga till avsändaren", + "Could not approve sender": "Det gick inte att godkänna avsändaren", + "Could not remove sender": "Det gick inte att ta bort avsändaren", + "Email books and articles straight into your library.": "Skicka böcker och artiklar direkt till ditt bibliotek via e-post.", + "Your inbound address": "Din inkommande adress", + "Change address name": "Ändra adressnamn", + "Copy address": "Kopiera adress", + "Customize your address name": "Anpassa ditt adressnamn", + "your-name": "your-name", + "Approved senders": "Godkända avsändare", + "No approved senders yet. Add an email to let it send to your library.": "Inga godkända avsändare ännu. Lägg till en e-postadress för att låta den skicka till ditt bibliotek.", + "Pending approval": "Väntar på godkännande", + "Approve": "Godkänn", + "Add a sender": "Lägg till en avsändare", + "name@example.com": "name@example.com", + "Recent activity": "Senaste aktivitet", + "Nothing sent yet. Email a book to your address above.": "Inget skickat ännu. Skicka en bok via e-post till din adress ovan.", + "Process incoming items on this device": "Bearbeta inkommande objekt på den här enheten", + "Email books to your library": "Skicka böcker till ditt bibliotek via e-post", + "Email a book or document to this address from an approved sender below.": "Mejla en bok eller ett dokument till den här adressen från en godkänd avsändare nedan.", + "Download and import books emailed to your address.": "Ladda ner och importera böcker som mejlats till din adress." } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 16c76418..da4bd70c 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1454,5 +1454,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "உங்கள் நூலகம் மற்றும் அமைப்புகளின் காப்புப்பிரதியை உருவாக்கவும் அல்லது முந்தைய காப்புப்பிரதியிலிருந்து மீட்டெடுக்கவும். மீட்டெடுப்பது உங்கள் தற்போதைய நூலகத்துடன் இணைக்கப்படும்.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "கணக்கு அறிமுகச் சான்றுகளை (ஒத்திசைவு டோக்கன்கள், கடவுச்சொற்கள்) சேர்க்கவும். காப்புப்பிரதி கோப்பு குறியாக்கம் செய்யப்படவில்லை.", "Your library and settings have been saved to the selected location.": "உங்கள் நூலகம் மற்றும் அமைப்புகள் தேர்ந்தெடுக்கப்பட்ட இடத்தில் சேமிக்கப்பட்டன.", - "Settings have been restored.": "அமைப்புகள் மீட்டெடுக்கப்பட்டன." + "Settings have been restored.": "அமைப்புகள் மீட்டெடுக்கப்பட்டன.", + "Could not fetch this page": "இந்தப் பக்கத்தைப் பெற முடியவில்லை", + "Send to Readest": "Readest-க்கு அனுப்பு", + "Sign in to send books and articles to your library.": "உங்கள் நூலகத்திற்குப் புத்தகங்களையும் கட்டுரைகளையும் அனுப்ப உள்நுழையவும்.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "ஒரு புத்தகம் அல்லது ஆவணத்தை விடவும், அல்லது ஒரு கட்டுரை இணைப்பை ஒட்டவும். இது உங்கள் எல்லா சாதனங்களுக்கும் ஒத்திசைக்கப்படும்.", + "Drop a book or document, or tap to choose": "ஒரு புத்தகம் அல்லது ஆவணத்தை விடவும், அல்லது தேர்வுசெய்யத் தட்டவும்", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "ஒரு கட்டுரையின் URL-ஐ ஒட்டவும்", + "Added to your library — it will sync to your other devices.": "உங்கள் நூலகத்தில் சேர்க்கப்பட்டது — இது உங்கள் மற்ற சாதனங்களுக்கு ஒத்திசைக்கப்படும்.", + "Working…": "செயலில் உள்ளது…", + "Could not load Send to Readest settings": "Readest-க்கு அனுப்பு அமைப்புகளை ஏற்ற முடியவில்லை", + "Address copied": "முகவரி நகலெடுக்கப்பட்டது", + "Could not copy address": "முகவரியை நகலெடுக்க முடியவில்லை", + "Enter a name for your address": "உங்கள் முகவரிக்கு ஒரு பெயரை உள்ளிடவும்", + "Address updated": "முகவரி புதுப்பிக்கப்பட்டது", + "Could not update address": "முகவரியைப் புதுப்பிக்க முடியவில்லை", + "Enter a valid email address": "சரியான மின்னஞ்சல் முகவரியை உள்ளிடவும்", + "Could not add sender": "அனுப்புநரைச் சேர்க்க முடியவில்லை", + "Could not approve sender": "அனுப்புநரை அங்கீகரிக்க முடியவில்லை", + "Could not remove sender": "அனுப்புநரை நீக்க முடியவில்லை", + "Email books and articles straight into your library.": "புத்தகங்களையும் கட்டுரைகளையும் நேரடியாக உங்கள் நூலகத்திற்கு மின்னஞ்சல் செய்யவும்.", + "Your inbound address": "உங்கள் உள்வரும் முகவரி", + "Change address name": "முகவரியின் பெயரை மாற்று", + "Copy address": "முகவரியை நகலெடு", + "Customize your address name": "உங்கள் முகவரியின் பெயரைத் தனிப்பயனாக்கவும்", + "your-name": "your-name", + "Approved senders": "அங்கீகரிக்கப்பட்ட அனுப்புநர்கள்", + "No approved senders yet. Add an email to let it send to your library.": "இன்னும் அங்கீகரிக்கப்பட்ட அனுப்புநர்கள் இல்லை. உங்கள் நூலகத்திற்கு அனுப்ப அனுமதிக்க ஒரு மின்னஞ்சலைச் சேர்க்கவும்.", + "Pending approval": "அங்கீகாரத்திற்காகக் காத்திருக்கிறது", + "Approve": "அங்கீகரி", + "Add a sender": "ஒரு அனுப்புநரைச் சேர்", + "name@example.com": "name@example.com", + "Recent activity": "சமீபத்திய செயல்பாடு", + "Nothing sent yet. Email a book to your address above.": "இன்னும் எதுவும் அனுப்பப்படவில்லை. மேலே உள்ள உங்கள் முகவரிக்கு ஒரு புத்தகத்தை மின்னஞ்சல் செய்யவும்.", + "Process incoming items on this device": "இந்தச் சாதனத்தில் உள்வரும் உருப்படிகளைச் செயலாக்கு", + "Email books to your library": "உங்கள் நூலகத்திற்குப் புத்தகங்களை மின்னஞ்சல் செய்யவும்", + "Email a book or document to this address from an approved sender below.": "கீழே உள்ள அங்கீகரிக்கப்பட்ட அனுப்புநரிடமிருந்து இந்த முகவரிக்கு ஒரு புத்தகம் அல்லது ஆவணத்தை மின்னஞ்சல் செய்யவும்.", + "Download and import books emailed to your address.": "உங்கள் முகவரிக்கு மின்னஞ்சல் செய்யப்பட்ட புத்தகங்களைப் பதிவிறக்கி இறக்குமதி செய்யவும்." } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 908b52ad..86a7cdd0 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1426,5 +1426,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "สร้างข้อมูลสำรองของคลังหนังสือและการตั้งค่าของคุณ หรือกู้คืนจากข้อมูลสำรองก่อนหน้า การกู้คืนจะรวมเข้ากับคลังหนังสือปัจจุบันของคุณ", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "รวมข้อมูลรับรองบัญชี (โทเค็นการซิงค์ รหัสผ่าน) ไฟล์ข้อมูลสำรองไม่ได้เข้ารหัส", "Your library and settings have been saved to the selected location.": "คลังหนังสือและการตั้งค่าของคุณถูกบันทึกไปยังตำแหน่งที่เลือกแล้ว", - "Settings have been restored.": "กู้คืนการตั้งค่าแล้ว" + "Settings have been restored.": "กู้คืนการตั้งค่าแล้ว", + "Could not fetch this page": "ไม่สามารถดึงข้อมูลหน้านี้ได้", + "Send to Readest": "ส่งไปยัง Readest", + "Sign in to send books and articles to your library.": "เข้าสู่ระบบเพื่อส่งหนังสือและบทความไปยังคลังของคุณ", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "วางหนังสือหรือเอกสาร หรือวางลิงก์บทความ ระบบจะซิงค์ไปยังทุกอุปกรณ์ของคุณ", + "Drop a book or document, or tap to choose": "วางหนังสือหรือเอกสาร หรือแตะเพื่อเลือก", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "วาง URL ของบทความ", + "Added to your library — it will sync to your other devices.": "เพิ่มไปยังคลังของคุณแล้ว — ระบบจะซิงค์ไปยังอุปกรณ์อื่นของคุณ", + "Working…": "กำลังดำเนินการ…", + "Could not load Send to Readest settings": "ไม่สามารถโหลดการตั้งค่าส่งไปยัง Readest ได้", + "Address copied": "คัดลอกที่อยู่แล้ว", + "Could not copy address": "ไม่สามารถคัดลอกที่อยู่ได้", + "Enter a name for your address": "ป้อนชื่อสำหรับที่อยู่ของคุณ", + "Address updated": "อัปเดตที่อยู่แล้ว", + "Could not update address": "ไม่สามารถอัปเดตที่อยู่ได้", + "Enter a valid email address": "ป้อนที่อยู่อีเมลที่ถูกต้อง", + "Could not add sender": "ไม่สามารถเพิ่มผู้ส่งได้", + "Could not approve sender": "ไม่สามารถอนุมัติผู้ส่งได้", + "Could not remove sender": "ไม่สามารถลบผู้ส่งได้", + "Email books and articles straight into your library.": "ส่งหนังสือและบทความทางอีเมลเข้าคลังของคุณได้โดยตรง", + "Your inbound address": "ที่อยู่รับเข้าของคุณ", + "Change address name": "เปลี่ยนชื่อที่อยู่", + "Copy address": "คัดลอกที่อยู่", + "Customize your address name": "ปรับแต่งชื่อที่อยู่ของคุณ", + "your-name": "your-name", + "Approved senders": "ผู้ส่งที่อนุมัติแล้ว", + "No approved senders yet. Add an email to let it send to your library.": "ยังไม่มีผู้ส่งที่อนุมัติ เพิ่มอีเมลเพื่อให้สามารถส่งไปยังคลังของคุณได้", + "Pending approval": "รออนุมัติ", + "Approve": "อนุมัติ", + "Add a sender": "เพิ่มผู้ส่ง", + "name@example.com": "name@example.com", + "Recent activity": "กิจกรรมล่าสุด", + "Nothing sent yet. Email a book to your address above.": "ยังไม่มีการส่งอะไร ส่งหนังสือทางอีเมลไปยังที่อยู่ด้านบนของคุณ", + "Process incoming items on this device": "ประมวลผลรายการที่เข้ามาบนอุปกรณ์นี้", + "Email books to your library": "ส่งหนังสือทางอีเมลไปยังคลังของคุณ", + "Email a book or document to this address from an approved sender below.": "ส่งหนังสือหรือเอกสารทางอีเมลไปยังที่อยู่นี้จากผู้ส่งที่อนุมัติด้านล่าง", + "Download and import books emailed to your address.": "ดาวน์โหลดและนำเข้าหนังสือที่ส่งทางอีเมลมายังที่อยู่ของคุณ" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index b3134097..99c445a1 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1454,5 +1454,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Kitaplığınızın ve ayarlarınızın bir yedeğini oluşturun veya önceki bir yedekten geri yükleyin. Geri yükleme, mevcut kitaplığınızla birleştirilir.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Hesap kimlik bilgilerini (eşitleme belirteçleri, parolalar) dahil et. Yedek dosyası şifrelenmemiştir.", "Your library and settings have been saved to the selected location.": "Kitaplığınız ve ayarlarınız seçilen konuma kaydedildi.", - "Settings have been restored.": "Ayarlar geri yüklendi." + "Settings have been restored.": "Ayarlar geri yüklendi.", + "Could not fetch this page": "Bu sayfa getirilemedi", + "Send to Readest": "Readest'e Gönder", + "Sign in to send books and articles to your library.": "Kitap ve makaleleri kitaplığınıza göndermek için giriş yapın.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Bir kitap ya da belge bırakın veya bir makale bağlantısı yapıştırın. Tüm cihazlarınızla senkronize olur.", + "Drop a book or document, or tap to choose": "Bir kitap ya da belge bırakın veya seçmek için dokunun", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Bir makale URL'si yapıştırın", + "Added to your library — it will sync to your other devices.": "Kitaplığınıza eklendi — diğer cihazlarınızla senkronize edilecek.", + "Working…": "Çalışıyor…", + "Could not load Send to Readest settings": "Readest'e Gönder ayarları yüklenemedi", + "Address copied": "Adres kopyalandı", + "Could not copy address": "Adres kopyalanamadı", + "Enter a name for your address": "Adresiniz için bir ad girin", + "Address updated": "Adres güncellendi", + "Could not update address": "Adres güncellenemedi", + "Enter a valid email address": "Geçerli bir e-posta adresi girin", + "Could not add sender": "Gönderen eklenemedi", + "Could not approve sender": "Gönderen onaylanamadı", + "Could not remove sender": "Gönderen kaldırılamadı", + "Email books and articles straight into your library.": "Kitap ve makaleleri doğrudan kitaplığınıza e-postayla gönderin.", + "Your inbound address": "Gelen adresiniz", + "Change address name": "Adres adını değiştir", + "Copy address": "Adresi kopyala", + "Customize your address name": "Adres adınızı özelleştirin", + "your-name": "your-name", + "Approved senders": "Onaylı gönderenler", + "No approved senders yet. Add an email to let it send to your library.": "Henüz onaylı gönderen yok. Kitaplığınıza gönderim yapabilmesi için bir e-posta ekleyin.", + "Pending approval": "Onay bekliyor", + "Approve": "Onayla", + "Add a sender": "Gönderen ekle", + "name@example.com": "name@example.com", + "Recent activity": "Son etkinlik", + "Nothing sent yet. Email a book to your address above.": "Henüz bir şey gönderilmedi. Yukarıdaki adresinize e-postayla bir kitap gönderin.", + "Process incoming items on this device": "Gelen öğeleri bu cihazda işle", + "Email books to your library": "Kitapları kitaplığınıza e-postayla gönderin", + "Email a book or document to this address from an approved sender below.": "Aşağıdaki onaylı bir göndericiden bu adrese bir kitap veya belge e-postayla gönderin.", + "Download and import books emailed to your address.": "Adresinize e-postayla gönderilen kitapları indirip içe aktarın." } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 373973b7..c1cb2adb 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1510,5 +1510,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Створіть резервну копію вашої бібліотеки та налаштувань або відновіть із попередньої резервної копії. Відновлення буде об’єднано з вашою поточною бібліотекою.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Включити облікові дані акаунта (токени синхронізації, паролі). Файл резервної копії не зашифровано.", "Your library and settings have been saved to the selected location.": "Вашу бібліотеку та налаштування збережено у вибраному місці.", - "Settings have been restored.": "Налаштування відновлено." + "Settings have been restored.": "Налаштування відновлено.", + "Could not fetch this page": "Не вдалося завантажити цю сторінку", + "Send to Readest": "Надіслати до Readest", + "Sign in to send books and articles to your library.": "Увійдіть, щоб надсилати книги та статті до своєї бібліотеки.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Перетягніть книгу чи документ або вставте посилання на статтю. Вони синхронізуються на всіх ваших пристроях.", + "Drop a book or document, or tap to choose": "Перетягніть книгу чи документ або торкніться, щоб вибрати", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Вставте URL-адресу статті", + "Added to your library — it will sync to your other devices.": "Додано до вашої бібліотеки — буде синхронізовано з іншими пристроями.", + "Working…": "Виконується…", + "Could not load Send to Readest settings": "Не вдалося завантажити налаштування «Надіслати до Readest»", + "Address copied": "Адресу скопійовано", + "Could not copy address": "Не вдалося скопіювати адресу", + "Enter a name for your address": "Введіть назву для вашої адреси", + "Address updated": "Адресу оновлено", + "Could not update address": "Не вдалося оновити адресу", + "Enter a valid email address": "Введіть дійсну адресу електронної пошти", + "Could not add sender": "Не вдалося додати відправника", + "Could not approve sender": "Не вдалося підтвердити відправника", + "Could not remove sender": "Не вдалося видалити відправника", + "Email books and articles straight into your library.": "Надсилайте книги та статті прямо до своєї бібліотеки електронною поштою.", + "Your inbound address": "Ваша вхідна адреса", + "Change address name": "Змінити назву адреси", + "Copy address": "Копіювати адресу", + "Customize your address name": "Налаштуйте назву вашої адреси", + "your-name": "your-name", + "Approved senders": "Підтверджені відправники", + "No approved senders yet. Add an email to let it send to your library.": "Підтверджених відправників ще немає. Додайте адресу електронної пошти, щоб дозволити надсилання до вашої бібліотеки.", + "Pending approval": "Очікує підтвердження", + "Approve": "Підтвердити", + "Add a sender": "Додати відправника", + "name@example.com": "name@example.com", + "Recent activity": "Нещодавня активність", + "Nothing sent yet. Email a book to your address above.": "Ще нічого не надіслано. Надішліть книгу електронною поштою на свою адресу вище.", + "Process incoming items on this device": "Обробляти вхідні елементи на цьому пристрої", + "Email books to your library": "Надсилайте книги до своєї бібліотеки електронною поштою", + "Email a book or document to this address from an approved sender below.": "Надішліть книгу або документ на цю адресу від схваленого відправника зі списку нижче.", + "Download and import books emailed to your address.": "Завантажуйте та імпортуйте книги, надіслані на вашу адресу електронною поштою." } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index 00d516ee..fffdc0cc 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -1454,5 +1454,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Kutubxonangiz va sozlamalaringizning zaxira nusxasini yarating yoki avvalgi zaxira nusxasidan tiklang. Tiklash joriy kutubxonangiz bilan birlashtiriladi.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Hisob hisob maʼlumotlarini (sinxronlash tokenlari, parollar) kiritish. Zaxira nusxa fayli shifrlanmagan.", "Your library and settings have been saved to the selected location.": "Kutubxonangiz va sozlamalaringiz tanlangan joyga saqlandi.", - "Settings have been restored.": "Sozlamalar tiklandi." + "Settings have been restored.": "Sozlamalar tiklandi.", + "Could not fetch this page": "Bu sahifani yuklab boʻlmadi", + "Send to Readest": "Readest’ga yuborish", + "Sign in to send books and articles to your library.": "Kitoblar va maqolalarni kutubxonangizga yuborish uchun tizimga kiring.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Kitob yoki hujjatni tashlang yoki maqola havolasini joylashtiring. U barcha qurilmalaringizga sinxronlanadi.", + "Drop a book or document, or tap to choose": "Kitob yoki hujjatni tashlang yoki tanlash uchun bosing", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Maqola URL manzilini joylashtiring", + "Added to your library — it will sync to your other devices.": "Kutubxonangizga qoʻshildi — u boshqa qurilmalaringizga sinxronlanadi.", + "Working…": "Bajarilmoqda…", + "Could not load Send to Readest settings": "Readest’ga yuborish sozlamalarini yuklab boʻlmadi", + "Address copied": "Manzil nusxalandi", + "Could not copy address": "Manzilni nusxalab boʻlmadi", + "Enter a name for your address": "Manzilingiz uchun nom kiriting", + "Address updated": "Manzil yangilandi", + "Could not update address": "Manzilni yangilab boʻlmadi", + "Enter a valid email address": "Yaroqli e-pochta manzilini kiriting", + "Could not add sender": "Joʻnatuvchini qoʻshib boʻlmadi", + "Could not approve sender": "Joʻnatuvchini tasdiqlab boʻlmadi", + "Could not remove sender": "Joʻnatuvchini olib tashlab boʻlmadi", + "Email books and articles straight into your library.": "Kitoblar va maqolalarni toʻgʻridan-toʻgʻri kutubxonangizga e-pochta orqali yuboring.", + "Your inbound address": "Kiruvchi manzilingiz", + "Change address name": "Manzil nomini oʻzgartirish", + "Copy address": "Manzilni nusxalash", + "Customize your address name": "Manzilingiz nomini moslashtiring", + "your-name": "your-name", + "Approved senders": "Tasdiqlangan joʻnatuvchilar", + "No approved senders yet. Add an email to let it send to your library.": "Hozircha tasdiqlangan joʻnatuvchilar yoʻq. Kutubxonangizga yuborishga ruxsat berish uchun e-pochta qoʻshing.", + "Pending approval": "Tasdiq kutilmoqda", + "Approve": "Tasdiqlash", + "Add a sender": "Joʻnatuvchi qoʻshish", + "name@example.com": "name@example.com", + "Recent activity": "Soʻnggi faoliyat", + "Nothing sent yet. Email a book to your address above.": "Hozircha hech narsa yuborilmagan. Yuqoridagi manzilingizga kitob e-pochta orqali yuboring.", + "Process incoming items on this device": "Bu qurilmada kiruvchi elementlarni qayta ishlash", + "Email books to your library": "Kitoblarni kutubxonangizga e-pochta orqali yuborish", + "Email a book or document to this address from an approved sender below.": "Quyidagi tasdiqlangan jo‘natuvchidan ushbu manzilga kitob yoki hujjatni e-pochta orqali yuboring.", + "Download and import books emailed to your address.": "Manzilingizga e-pochta orqali yuborilgan kitoblarni yuklab oling va import qiling." } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index b7f42448..7869e985 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1426,5 +1426,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "Tạo bản sao lưu thư viện và cài đặt của bạn hoặc khôi phục từ bản sao lưu trước đó. Việc khôi phục sẽ hợp nhất với thư viện hiện tại của bạn.", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "Bao gồm thông tin đăng nhập tài khoản (mã thông báo đồng bộ, mật khẩu). Tệp sao lưu không được mã hóa.", "Your library and settings have been saved to the selected location.": "Thư viện và cài đặt của bạn đã được lưu vào vị trí đã chọn.", - "Settings have been restored.": "Đã khôi phục cài đặt." + "Settings have been restored.": "Đã khôi phục cài đặt.", + "Could not fetch this page": "Không thể tải trang này", + "Send to Readest": "Gửi đến Readest", + "Sign in to send books and articles to your library.": "Đăng nhập để gửi sách và bài viết vào thư viện của bạn.", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "Thả một cuốn sách hoặc tài liệu, hoặc dán liên kết bài viết. Nó sẽ đồng bộ đến tất cả thiết bị của bạn.", + "Drop a book or document, or tap to choose": "Thả một cuốn sách hoặc tài liệu, hoặc chạm để chọn", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "Dán URL bài viết", + "Added to your library — it will sync to your other devices.": "Đã thêm vào thư viện của bạn — nó sẽ đồng bộ đến các thiết bị khác của bạn.", + "Working…": "Đang xử lý…", + "Could not load Send to Readest settings": "Không thể tải cài đặt Gửi đến Readest", + "Address copied": "Đã sao chép địa chỉ", + "Could not copy address": "Không thể sao chép địa chỉ", + "Enter a name for your address": "Nhập tên cho địa chỉ của bạn", + "Address updated": "Đã cập nhật địa chỉ", + "Could not update address": "Không thể cập nhật địa chỉ", + "Enter a valid email address": "Nhập địa chỉ email hợp lệ", + "Could not add sender": "Không thể thêm người gửi", + "Could not approve sender": "Không thể phê duyệt người gửi", + "Could not remove sender": "Không thể xóa người gửi", + "Email books and articles straight into your library.": "Gửi sách và bài viết qua email thẳng vào thư viện của bạn.", + "Your inbound address": "Địa chỉ nhận của bạn", + "Change address name": "Đổi tên địa chỉ", + "Copy address": "Sao chép địa chỉ", + "Customize your address name": "Tùy chỉnh tên địa chỉ của bạn", + "your-name": "your-name", + "Approved senders": "Người gửi đã phê duyệt", + "No approved senders yet. Add an email to let it send to your library.": "Chưa có người gửi nào được phê duyệt. Thêm một email để cho phép gửi vào thư viện của bạn.", + "Pending approval": "Đang chờ phê duyệt", + "Approve": "Phê duyệt", + "Add a sender": "Thêm người gửi", + "name@example.com": "name@example.com", + "Recent activity": "Hoạt động gần đây", + "Nothing sent yet. Email a book to your address above.": "Chưa gửi gì cả. Gửi một cuốn sách qua email đến địa chỉ ở trên của bạn.", + "Process incoming items on this device": "Xử lý các mục đến trên thiết bị này", + "Email books to your library": "Gửi sách vào thư viện của bạn qua email", + "Email a book or document to this address from an approved sender below.": "Gửi sách hoặc tài liệu qua email đến địa chỉ này từ người gửi đã được phê duyệt bên dưới.", + "Download and import books emailed to your address.": "Tải xuống và nhập sách được gửi qua email đến địa chỉ của bạn." } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index e7a44ca3..0c2a4ac8 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1426,5 +1426,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "创建您的书库和设置的备份,或从之前的备份中恢复。恢复将与您当前的书库合并。", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "包含账户凭据(同步令牌、密码)。备份文件未加密。", "Your library and settings have been saved to the selected location.": "您的书库和设置已保存到所选位置。", - "Settings have been restored.": "设置已恢复。" + "Settings have been restored.": "设置已恢复。", + "Could not fetch this page": "无法获取此页面", + "Send to Readest": "发送到 Readest", + "Sign in to send books and articles to your library.": "登录后即可将图书和文章发送到您的库。", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "拖入一本书或文档,或粘贴文章链接。它将同步到您的所有设备。", + "Drop a book or document, or tap to choose": "拖入一本书或文档,或点按选择", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "粘贴文章网址", + "Added to your library — it will sync to your other devices.": "已添加到您的库 — 它将同步到您的其他设备。", + "Working…": "处理中…", + "Could not load Send to Readest settings": "无法加载“发送到 Readest”设置", + "Address copied": "地址已复制", + "Could not copy address": "无法复制地址", + "Enter a name for your address": "请输入您的地址名称", + "Address updated": "地址已更新", + "Could not update address": "无法更新地址", + "Enter a valid email address": "请输入有效的电子邮件地址", + "Could not add sender": "无法添加发件人", + "Could not approve sender": "无法批准发件人", + "Could not remove sender": "无法移除发件人", + "Email books and articles straight into your library.": "通过电子邮件将图书和文章直接发送到您的库。", + "Your inbound address": "您的接收地址", + "Change address name": "更改地址名称", + "Copy address": "复制地址", + "Customize your address name": "自定义您的地址名称", + "your-name": "your-name", + "Approved senders": "已批准的发件人", + "No approved senders yet. Add an email to let it send to your library.": "尚无已批准的发件人。添加一个电子邮件地址,让它能够发送到您的库。", + "Pending approval": "等待批准", + "Approve": "批准", + "Add a sender": "添加发件人", + "name@example.com": "name@example.com", + "Recent activity": "最近活动", + "Nothing sent yet. Email a book to your address above.": "尚未发送任何内容。请将图书通过电子邮件发送到上面的地址。", + "Process incoming items on this device": "在此设备上处理接收的项目", + "Email books to your library": "通过电子邮件将图书发送到您的书库", + "Email a book or document to this address from an approved sender below.": "从下方已批准的发件人,将图书或文档通过电子邮件发送到此地址。", + "Download and import books emailed to your address.": "下载并导入通过电子邮件发送到您地址的图书。" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 72b1dc29..ca50ebbc 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1426,5 +1426,42 @@ "Create a backup of your library and settings or restore from a previous backup. Restoring will merge with your current library.": "建立您的書庫與設定的備份,或從先前的備份還原。還原將與您目前的書庫合併。", "Include account credentials (sync tokens, passwords). The backup file is not encrypted.": "包含帳戶憑證(同步權杖、密碼)。備份檔案未加密。", "Your library and settings have been saved to the selected location.": "您的書庫與設定已儲存至所選位置。", - "Settings have been restored.": "設定已還原。" + "Settings have been restored.": "設定已還原。", + "Could not fetch this page": "無法取得此頁面", + "Send to Readest": "傳送到 Readest", + "Sign in to send books and articles to your library.": "登入後即可將書籍和文章傳送到您的書庫。", + "Drop a book or document, or paste an article link. It syncs to all your devices.": "拖入一本書或文件,或貼上文章連結。它將同步到您的所有裝置。", + "Drop a book or document, or tap to choose": "拖入一本書或文件,或點按選擇", + "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML": "EPUB, PDF, MOBI, AZW3, FB2, CBZ, TXT, DOCX, RTF, HTML", + "Paste an article URL": "貼上文章網址", + "Added to your library — it will sync to your other devices.": "已新增到您的書庫 — 它將同步到您的其他裝置。", + "Working…": "處理中…", + "Could not load Send to Readest settings": "無法載入「傳送到 Readest」設定", + "Address copied": "位址已複製", + "Could not copy address": "無法複製位址", + "Enter a name for your address": "請輸入您的位址名稱", + "Address updated": "位址已更新", + "Could not update address": "無法更新位址", + "Enter a valid email address": "請輸入有效的電子郵件地址", + "Could not add sender": "無法新增寄件者", + "Could not approve sender": "無法核准寄件者", + "Could not remove sender": "無法移除寄件者", + "Email books and articles straight into your library.": "透過電子郵件將書籍和文章直接傳送到您的書庫。", + "Your inbound address": "您的接收位址", + "Change address name": "變更位址名稱", + "Copy address": "複製位址", + "Customize your address name": "自訂您的位址名稱", + "your-name": "your-name", + "Approved senders": "已核准的寄件者", + "No approved senders yet. Add an email to let it send to your library.": "尚無已核准的寄件者。新增一個電子郵件地址,讓它能夠傳送到您的書庫。", + "Pending approval": "等待核准", + "Approve": "核准", + "Add a sender": "新增寄件者", + "name@example.com": "name@example.com", + "Recent activity": "最近活動", + "Nothing sent yet. Email a book to your address above.": "尚未傳送任何內容。請將書籍透過電子郵件傳送到上面的位址。", + "Process incoming items on this device": "在此裝置上處理接收的項目", + "Email books to your library": "透過電子郵件將書籍傳送到您的書庫", + "Email a book or document to this address from an approved sender below.": "從下方已核准的寄件者,將書籍或文件透過電子郵件傳送到此地址。", + "Download and import books emailed to your address.": "下載並匯入透過電子郵件傳送到您地址的書籍。" } diff --git a/apps/readest-app/src/__tests__/services/ingest-service.test.ts b/apps/readest-app/src/__tests__/services/ingest-service.test.ts new file mode 100644 index 00000000..f18aae42 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/ingest-service.test.ts @@ -0,0 +1,180 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; + +// transferManager is a singleton with heavy dependencies; mock it so the test +// only observes whether ingestFile decided to queue an upload. +vi.mock('@/services/transferManager', () => ({ + transferManager: { queueUpload: vi.fn() }, +})); + +import { ingestFile } from '@/services/ingestService'; +import { transferManager } from '@/services/transferManager'; +import type { Book } from '@/types/book'; +import type { AppService } from '@/types/system'; +import type { SystemSettings } from '@/types/settings'; + +function makeBook(overrides: Partial = {}): Book { + return { + hash: 'hash1', + format: 'EPUB', + title: 'Test Book', + author: 'Author', + createdAt: 1000, + updatedAt: 2000, + ...overrides, + }; +} + +function makeDeps( + over: { importResult?: Book | null; autoUpload?: boolean; isLoggedIn?: boolean } = {}, +) { + const importResult = over.importResult === undefined ? makeBook() : over.importResult; + const importBook = vi.fn().mockResolvedValue(importResult); + const appService = { importBook } as unknown as AppService; + const settings = { autoUpload: over.autoUpload ?? false } as SystemSettings; + return { appService, settings, isLoggedIn: over.isLoggedIn ?? false, importBook }; +} + +describe('ingestFile', () => { + beforeEach(() => { + vi.mocked(transferManager.queueUpload).mockClear(); + }); + + test('returns the imported book', async () => { + const { appService, settings, isLoggedIn } = makeDeps(); + const book = await ingestFile( + { file: 'book.epub', books: [] }, + { appService, settings, isLoggedIn }, + ); + expect(book?.hash).toBe('hash1'); + }); + + test('returns null when importBook returns null', async () => { + const { appService, settings, isLoggedIn } = makeDeps({ importResult: null }); + const book = await ingestFile( + { file: 'book.epub', books: [] }, + { appService, settings, isLoggedIn }, + ); + expect(book).toBeNull(); + }); + + test('passes the lookup index through to importBook', async () => { + const { appService, settings, isLoggedIn, importBook } = makeDeps(); + const lookupIndex = { byHash: new Map(), byMetaHash: new Map() } as never; + await ingestFile( + { file: 'book.epub', books: [], lookupIndex }, + { appService, settings, isLoggedIn }, + ); + expect(importBook).toHaveBeenCalledWith('book.epub', [], { lookupIndex }); + }); + + test('applies groupId and groupName', async () => { + const { appService, settings, isLoggedIn } = makeDeps(); + const book = await ingestFile( + { file: 'book.epub', books: [], groupId: 'g1', groupName: 'Sci-Fi' }, + { appService, settings, isLoggedIn }, + ); + expect(book?.groupId).toBe('g1'); + expect(book?.groupName).toBe('Sci-Fi'); + }); + + test('applies a subject tag and bumps updatedAt', async () => { + const { appService, settings, isLoggedIn } = makeDeps(); + const book = await ingestFile( + { file: 'book.epub', books: [], subjectTag: 'scifi' }, + { appService, settings, isLoggedIn }, + ); + expect(book?.tags).toContain('scifi'); + expect(book?.updatedAt).toBeGreaterThan(2000); + }); + + test('does not duplicate an existing tag or bump updatedAt', async () => { + const { appService, settings, isLoggedIn } = makeDeps({ + importResult: makeBook({ tags: ['scifi'], updatedAt: 2000 }), + }); + const book = await ingestFile( + { file: 'book.epub', books: [], subjectTag: 'scifi' }, + { appService, settings, isLoggedIn }, + ); + expect(book?.tags).toEqual(['scifi']); + expect(book?.updatedAt).toBe(2000); + }); + + test('forceUpload queues an upload even when autoUpload is off', async () => { + const { appService, settings, isLoggedIn } = makeDeps({ + autoUpload: false, + isLoggedIn: true, + }); + await ingestFile( + { file: 'book.epub', books: [], forceUpload: true }, + { appService, settings, isLoggedIn }, + ); + expect(transferManager.queueUpload).toHaveBeenCalledTimes(1); + }); + + test('autoUpload queues an upload without forceUpload', async () => { + const { appService, settings, isLoggedIn } = makeDeps({ + autoUpload: true, + isLoggedIn: true, + }); + await ingestFile({ file: 'book.epub', books: [] }, { appService, settings, isLoggedIn }); + expect(transferManager.queueUpload).toHaveBeenCalledTimes(1); + }); + + test('does not queue an upload when neither forceUpload nor autoUpload is set', async () => { + const { appService, settings, isLoggedIn } = makeDeps({ + autoUpload: false, + isLoggedIn: true, + }); + await ingestFile({ file: 'book.epub', books: [] }, { appService, settings, isLoggedIn }); + expect(transferManager.queueUpload).not.toHaveBeenCalled(); + }); + + test('does not queue an upload when the user is not logged in', async () => { + const { appService, settings, isLoggedIn } = makeDeps({ + autoUpload: true, + isLoggedIn: false, + }); + await ingestFile( + { file: 'book.epub', books: [], forceUpload: true }, + { appService, settings, isLoggedIn }, + ); + expect(transferManager.queueUpload).not.toHaveBeenCalled(); + }); + + test('never queues an upload for a transient import', async () => { + const { appService, settings, isLoggedIn } = makeDeps({ + autoUpload: true, + isLoggedIn: true, + }); + await ingestFile( + { file: 'book.epub', books: [], transient: true, forceUpload: true }, + { appService, settings, isLoggedIn }, + ); + expect(transferManager.queueUpload).not.toHaveBeenCalled(); + }); + + test('passes the transient flag through to importBook', async () => { + const { appService, settings, isLoggedIn, importBook } = makeDeps(); + await ingestFile( + { file: 'book.epub', books: [], transient: true }, + { appService, settings, isLoggedIn }, + ); + expect(importBook).toHaveBeenCalledWith('book.epub', [], { + lookupIndex: undefined, + transient: true, + }); + }); + + test('does not queue an upload when the book is already uploaded', async () => { + const { appService, settings, isLoggedIn } = makeDeps({ + importResult: makeBook({ uploadedAt: 5000 }), + autoUpload: true, + isLoggedIn: true, + }); + await ingestFile( + { file: 'book.epub', books: [], forceUpload: true }, + { appService, settings, isLoggedIn }, + ); + expect(transferManager.queueUpload).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/send-address.test.ts b/apps/readest-app/src/__tests__/services/send-address.test.ts new file mode 100644 index 00000000..4078fd7f --- /dev/null +++ b/apps/readest-app/src/__tests__/services/send-address.test.ts @@ -0,0 +1,111 @@ +import { describe, test, expect } from 'vitest'; +import { + slugFromIdentity, + sanitizeSlug, + isReservedSlug, + generateAddressToken, + generateSendAddress, + buildSendAddress, + isValidSendAddress, + normalizeSenderEmail, + parseSubjectTag, +} from '@/services/send/sendAddress'; + +describe('slugFromIdentity', () => { + test('derives a slug from an email local part', () => { + expect(slugFromIdentity('Jane.Doe@example.com')).toBe('janedoe'); + }); + + test('derives a slug from a display name', () => { + expect(slugFromIdentity('Jane Doe')).toBe('janedoe'); + }); + + test('caps the slug length', () => { + expect(slugFromIdentity('abcdefghijklmnopqrstuvwxyz').length).toBe(12); + }); + + test('falls back to "reader" when nothing usable remains', () => { + expect(slugFromIdentity('!!!@example.com')).toBe('reader'); + }); +}); + +describe('sanitizeSlug', () => { + test('lowercases and strips non-alphanumerics', () => { + expect(sanitizeSlug('Jane.Doe!')).toBe('janedoe'); + }); + + test('caps the slug length at 12', () => { + expect(sanitizeSlug('abcdefghijklmnopqrstuvwxyz').length).toBe(12); + }); + + test('returns empty when nothing usable remains', () => { + expect(sanitizeSlug('!!! ###')).toBe(''); + }); +}); + +describe('isReservedSlug', () => { + test('flags role/system slugs', () => { + expect(isReservedSlug('admin')).toBe(true); + expect(isReservedSlug('info')).toBe(true); + expect(isReservedSlug('support')).toBe(true); + }); + + test('allows ordinary slugs', () => { + expect(isReservedSlug('janedoe')).toBe(false); + }); +}); + +describe('generateAddressToken', () => { + test('produces a 5-char Crockford-base32 token', () => { + const token = generateAddressToken(); + expect(token).toMatch(/^[0-9abcdefghjkmnpqrstvwxyz]{5}$/); + }); + + test('maps random bytes deterministically', () => { + const bytes = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]); + expect(generateAddressToken(bytes)).toBe('01234'); + }); +}); + +describe('generateSendAddress / buildSendAddress / isValidSendAddress', () => { + test('generates a valid {slug}-{token} address from an identity', () => { + const address = generateSendAddress('reader@example.com'); + expect(isValidSendAddress(address)).toBe(true); + expect(address.startsWith('reader-')).toBe(true); + }); + + test('builds an address from an explicit slug', () => { + const address = buildSendAddress('janedoe'); + expect(isValidSendAddress(address)).toBe(true); + expect(address.startsWith('janedoe-')).toBe(true); + }); + + test('rejects malformed addresses', () => { + expect(isValidSendAddress('no-token')).toBe(false); + expect(isValidSendAddress('UPPER-abcde')).toBe(false); + expect(isValidSendAddress('slug-toolongtoken')).toBe(false); + expect(isValidSendAddress('slug-ab12')).toBe(false); // 4 chars, too short + }); +}); + +describe('normalizeSenderEmail', () => { + test('lowercases and trims', () => { + expect(normalizeSenderEmail(' Jane@Example.COM ')).toBe('jane@example.com'); + }); +}); + +describe('parseSubjectTag', () => { + test('extracts the first #tag from a subject', () => { + expect(parseSubjectTag('My new book #scifi')).toBe('scifi'); + }); + + test('returns undefined when no tag is present', () => { + expect(parseSubjectTag('Just a subject')).toBeUndefined(); + expect(parseSubjectTag('')).toBeUndefined(); + expect(parseSubjectTag(null)).toBeUndefined(); + }); + + test('supports unicode tags', () => { + expect(parseSubjectTag('书 #科幻')).toBe('科幻'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/send-conversion.test.ts b/apps/readest-app/src/__tests__/services/send-conversion.test.ts new file mode 100644 index 00000000..e5dba38e --- /dev/null +++ b/apps/readest-app/src/__tests__/services/send-conversion.test.ts @@ -0,0 +1,89 @@ +import { describe, test, expect } from 'vitest'; +import { sanitizeHtml } from '@/services/send/conversion/sanitizeHtml'; +import { convertToEpub, isConvertible, mimeToKind } from '@/services/send/conversion/convertToEpub'; +import { ConversionError } from '@/services/send/conversion/types'; + +const encode = (s: string): ArrayBuffer => new TextEncoder().encode(s).buffer as ArrayBuffer; + +describe('isConvertible / mimeToKind', () => { + test('recognizes convertible MIME types', () => { + expect(isConvertible('text/html')).toBe(true); + expect(isConvertible('application/rtf')).toBe(true); + expect( + isConvertible('application/vnd.openxmlformats-officedocument.wordprocessingml.document'), + ).toBe(true); + expect(isConvertible('application/epub+zip')).toBe(false); + expect(isConvertible('application/pdf')).toBe(false); + }); + + test('maps MIME types to conversion kinds', () => { + expect(mimeToKind('text/html')).toBe('html'); + expect(mimeToKind('text/uri-list')).toBe('article'); + expect(mimeToKind('text/plain')).toBe('txt'); + }); +}); + +describe('sanitizeHtml', () => { + test('strips scripts and event handlers, keeps structural tags', () => { + const dirty = '

Hi

Title

'; + const clean = sanitizeHtml(dirty); + expect(clean).toContain('

Hi

'); + expect(clean).toContain('

Title

'); + expect(clean).not.toContain('script'); + expect(clean).not.toContain('onclick'); + }); +}); + +describe('convertToEpub — html', () => { + test('produces an EPUB file with the document title', async () => { + const html = `My Article +

My Article

Some body text here.

`; + const book = await convertToEpub({ kind: 'html', bytes: encode(html) }); + expect(book.title).toBe('My Article'); + expect(book.file.name).toBe('My Article.epub'); + expect(book.file.type).toBe('application/epub+zip'); + expect(book.file.size).toBeGreaterThan(0); + }); + + test('is deterministic — same input yields byte-identical EPUBs', async () => { + const html = 'Doc

Stable content.

'; + const a = await convertToEpub({ kind: 'html', bytes: encode(html) }); + const b = await convertToEpub({ kind: 'html', bytes: encode(html) }); + const [bufA, bufB] = [await a.file.arrayBuffer(), await b.file.arrayBuffer()]; + expect(new Uint8Array(bufA)).toEqual(new Uint8Array(bufB)); + }); + + test('throws ConversionError on content-free HTML', async () => { + await expect( + convertToEpub({ kind: 'html', bytes: encode('') }), + ).rejects.toBeInstanceOf(ConversionError); + }); +}); + +describe('convertToEpub — rtf', () => { + test('extracts text from a minimal RTF document', async () => { + const rtf = '{\\rtf1\\ansi {\\b Hello} world from RTF.\\par More text.}'; + const book = await convertToEpub({ kind: 'rtf', bytes: encode(rtf), fileName: 'note.rtf' }); + expect(book.title).toBe('note'); + expect(book.file.size).toBeGreaterThan(0); + }); +}); + +describe('convertToEpub — article', () => { + test('extracts the main article content with Readability', async () => { + const paragraph = 'This is a substantial paragraph of article body text. '.repeat(8); + const html = `News Story + +

News Story

+

${paragraph}

${paragraph}

${paragraph}

+
+
footer junk
`; + const book = await convertToEpub({ + kind: 'article', + html, + url: 'https://example.com/news', + }); + expect(book.title).toContain('News Story'); + expect(book.file.size).toBeGreaterThan(0); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/send-fetch-url-guard.test.ts b/apps/readest-app/src/__tests__/services/send-fetch-url-guard.test.ts new file mode 100644 index 00000000..5253409f --- /dev/null +++ b/apps/readest-app/src/__tests__/services/send-fetch-url-guard.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'vitest'; +import { isBlockedHost } from '@/pages/api/send/fetch-url'; + +// `isBlockedHost` receives the host as the WHATWG URL parser serializes it, so +// these helpers mirror that normalization for the test inputs. +const hostOf = (url: string) => new URL(url).hostname; + +describe('isBlockedHost — internal names', () => { + test('blocks localhost and internal suffixes', () => { + for (const h of ['localhost', 'foo.localhost', 'db.internal', 'host.local', 'box.lan']) { + expect(isBlockedHost(h)).toBe(true); + } + }); + + test('blocks bare single-label hostnames', () => { + expect(isBlockedHost('intranet')).toBe(true); + expect(isBlockedHost('metadata')).toBe(true); + }); + + test('allows normal public hostnames', () => { + for (const h of ['example.com', 'www.readest.com', 'sub.domain.co.uk']) { + expect(isBlockedHost(h)).toBe(false); + } + }); +}); + +describe('isBlockedHost — IPv4 (incl. encoded forms normalized by URL)', () => { + test('blocks loopback / private / link-local / CGNAT', () => { + for (const ip of [ + '127.0.0.1', + '10.1.2.3', + '172.16.0.1', + '192.168.1.1', + '169.254.1.1', + '100.64.0.1', + ]) { + expect(isBlockedHost(ip)).toBe(true); + } + }); + + test('blocks decimal / hex / octal IPv4 once URL-normalized', () => { + expect(isBlockedHost(hostOf('http://2130706433/'))).toBe(true); // 127.0.0.1 + expect(isBlockedHost(hostOf('http://0x7f000001/'))).toBe(true); // 127.0.0.1 + }); + + test('allows public IPv4', () => { + expect(isBlockedHost('8.8.8.8')).toBe(false); + expect(isBlockedHost('1.1.1.1')).toBe(false); + }); +}); + +describe('isBlockedHost — IPv6', () => { + test('blocks loopback / unique-local / link-local', () => { + for (const ip of ['[::1]', '::1', 'fc00::1', 'fd12:3456::1', 'fe80::1']) { + expect(isBlockedHost(ip)).toBe(true); + } + }); + + test('blocks IPv4-mapped IPv6 pointing at private space', () => { + expect(isBlockedHost('::ffff:127.0.0.1')).toBe(true); + expect(isBlockedHost('::ffff:10.0.0.1')).toBe(true); + expect(isBlockedHost('::ffff:7f00:1')).toBe(true); // 127.0.0.1 in hex + }); + + test('allows IPv4-mapped IPv6 pointing at public space', () => { + expect(isBlockedHost('::ffff:8.8.8.8')).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/send-inbox-drainer.test.ts b/apps/readest-app/src/__tests__/services/send-inbox-drainer.test.ts new file mode 100644 index 00000000..607fbb7d --- /dev/null +++ b/apps/readest-app/src/__tests__/services/send-inbox-drainer.test.ts @@ -0,0 +1,101 @@ +import { describe, test, expect, vi } from 'vitest'; +import { drainInbox, type InboxDrainerDeps } from '@/services/send/inboxDrainer'; +import type { DBSendInboxItem } from '@/types/sendRecords'; + +function makeItem(overrides: Partial = {}): DBSendInboxItem { + return { + id: 'item1', + user_id: 'user1', + kind: 'file', + source: 'email', + payload_key: 'inbox/user1/item1/book.epub', + url: null, + filename: 'book.epub', + subject_tag: null, + byte_size: 1000, + status: 'claimed', + claimed_by: 'device1', + claimed_at: new Date().toISOString(), + attempts: 0, + error: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +function makeDeps(over: Partial = {}): InboxDrainerDeps { + return { + claimItem: vi.fn().mockResolvedValue(null), + renewClaim: vi.fn().mockResolvedValue(true), + completeItem: vi.fn().mockResolvedValue(true), + failItem: vi.fn().mockResolvedValue(true), + resolvePayload: vi.fn().mockResolvedValue(new File(['x'], 'book.epub')), + importItem: vi.fn().mockResolvedValue(undefined), + ...over, + }; +} + +describe('drainInbox', () => { + test('does nothing when the inbox is empty', async () => { + const deps = makeDeps(); + const result = await drainInbox(deps); + expect(result).toEqual({ processed: 0, failed: 0 }); + expect(deps.importItem).not.toHaveBeenCalled(); + }); + + test('claims, imports, and completes one item', async () => { + const item = makeItem(); + const deps = makeDeps({ + claimItem: vi.fn().mockResolvedValueOnce(item).mockResolvedValue(null), + }); + const result = await drainInbox(deps); + expect(result).toEqual({ processed: 1, failed: 0 }); + expect(deps.importItem).toHaveBeenCalledOnce(); + expect(deps.completeItem).toHaveBeenCalledWith('item1'); + expect(deps.failItem).not.toHaveBeenCalled(); + }); + + test('marks an item failed when the import throws', async () => { + const deps = makeDeps({ + claimItem: vi.fn().mockResolvedValueOnce(makeItem()).mockResolvedValue(null), + importItem: vi.fn().mockRejectedValue(new Error('conversion failed')), + }); + const result = await drainInbox(deps); + expect(result).toEqual({ processed: 0, failed: 1 }); + expect(deps.failItem).toHaveBeenCalledWith('item1', 'conversion failed'); + expect(deps.completeItem).not.toHaveBeenCalled(); + }); + + test('stops at maxItems even when more items remain', async () => { + const deps = makeDeps({ + claimItem: vi.fn().mockResolvedValue(makeItem()), + }); + const result = await drainInbox(deps, 3); + expect(result.processed).toBe(3); + expect(deps.claimItem).toHaveBeenCalledTimes(3); + }); + + test('a failed payload cleanup does not fail the item', async () => { + const deps = makeDeps({ + claimItem: vi.fn().mockResolvedValueOnce(makeItem()).mockResolvedValue(null), + deletePayload: vi.fn().mockRejectedValue(new Error('R2 down')), + }); + const result = await drainInbox(deps); + expect(result).toEqual({ processed: 1, failed: 0 }); + expect(deps.completeItem).toHaveBeenCalledOnce(); + }); + + test('drains a mix of successes and failures', async () => { + const deps = makeDeps({ + claimItem: vi + .fn() + .mockResolvedValueOnce(makeItem({ id: 'a' })) + .mockResolvedValueOnce(makeItem({ id: 'b' })) + .mockResolvedValue(null), + importItem: vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('bad')), + }); + const result = await drainInbox(deps); + expect(result).toEqual({ processed: 1, failed: 1 }); + }); +}); diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index a7e52680..6ea9640f 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -12,6 +12,7 @@ import { buildBookLookupIndex } from '@/services/bookService'; import { navigateToLibrary, navigateToReader } from '@/utils/nav'; import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/utils/book'; import { getImportErrorMessage } from '@/services/errors'; +import { ingestFile } from '@/services/ingestService'; import { eventDispatcher } from '@/utils/event'; import { ProgressPayload } from '@/utils/transfer'; import { throttle } from '@/utils/throttle'; @@ -35,6 +36,7 @@ import { useTheme } from '@/hooks/useTheme'; import { useUICSS } from '@/hooks/useUICSS'; import { useDemoBooks } from './hooks/useDemoBooks'; import { useBooksSync } from './hooks/useBooksSync'; +import { useInboxDrainer } from '@/hooks/useInboxDrainer'; import { useOPDSSubscriptions } from '@/hooks/useOPDSSubscriptions'; import { useBookDataStore } from '@/store/bookDataStore'; import { useTransferStore } from '@/store/transferStore'; @@ -186,6 +188,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP const { pullLibrary, pushLibrary } = useBooksSync(); const { checkOPDSSubscriptions } = useOPDSSubscriptions(); + useInboxDrainer(); const { isDragging } = useDragDropImport(); usePullToRefresh( @@ -380,17 +383,21 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP console.log('Open with book:', file); try { const temp = appService.isMobile ? false : !settings.autoImportBooksOnOpen; - const book = await appService.importBook(file, libraryBooks, { transient: temp }); + // A file shared into Readest on mobile (the OS share-sheet) is a + // "Send to Readest" capture — force it to the cloud so it syncs to + // every device. Desktop "open with" keeps the autoUpload setting. + const book = await ingestFile( + { + file, + books: libraryBooks, + transient: temp, + forceUpload: !!appService.isMobile && !!user, + }, + { appService, settings, isLoggedIn: !!user }, + ); if (book) { bookIds.push(book.hash); } - if (user && book && !temp && !book.uploadedAt && settings.autoUpload) { - setTimeout(() => { - console.log('Queueing upload for book:', book.title); - transferManager.queueUpload(book); - // wait for the initialization of the transfer manager and opening of the book - }, 3000); - } } catch (error) { console.log('Failed to import book:', file, error); } @@ -585,24 +592,27 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP const processFile = async (selectedFile: SelectedFile): Promise => { const file = selectedFile.file || selectedFile.path; if (!file) return null; + if (!appService) return null; try { - const book = await appService?.importBook(file, library, { lookupIndex }); - if (!book) return null; const { path, basePath } = selectedFile; - if (groupId) { - book.groupId = groupId; - book.groupName = getGroupName(groupId); - } else if (path && basePath) { + let resolvedGroupId = groupId; + let resolvedGroupName = groupId ? getGroupName(groupId) : undefined; + if (!resolvedGroupId && path && basePath) { const rootPath = getDirPath(basePath); - const groupName = getDirPath(path).replace(rootPath, '').replace(/^\//, ''); - book.groupName = groupName; - book.groupId = getGroupId(groupName); - } - - if (user && !book.uploadedAt && settings.autoUpload) { - console.log('Queueing upload for book:', book.title); - transferManager.queueUpload(book); + resolvedGroupName = getDirPath(path).replace(rootPath, '').replace(/^\//, ''); + resolvedGroupId = getGroupId(resolvedGroupName); } + const book = await ingestFile( + { + file, + books: library, + lookupIndex, + groupId: resolvedGroupId, + groupName: resolvedGroupName, + }, + { appService, settings, isLoggedIn: !!user }, + ); + if (!book) return null; successfulImports.push(book.title); return book; } catch (error) { diff --git a/apps/readest-app/src/app/send/page.tsx b/apps/readest-app/src/app/send/page.tsx new file mode 100644 index 00000000..6b4b6fd5 --- /dev/null +++ b/apps/readest-app/src/app/send/page.tsx @@ -0,0 +1,210 @@ +'use client'; + +import { useCallback, useRef, useState } from 'react'; +import { MdUploadFile, MdCheckCircle, MdError, MdLink } from 'react-icons/md'; +import { useEnv } from '@/context/EnvContext'; +import { useAuth } from '@/context/AuthContext'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useLibraryStore } from '@/store/libraryStore'; +import { useTranslation } from '@/hooks/useTranslation'; +import { fetchWithAuth } from '@/utils/fetch'; +import { getAPIBaseUrl, isTauriAppPlatform } from '@/services/environment'; +import { ingestFile } from '@/services/ingestService'; +import { + convertFileIfNeeded, + convertToEpubWithWorker, +} from '@/services/send/conversion/conversionWorker'; + +type ItemStatus = 'working' | 'done' | 'error'; + +interface SendItem { + id: string; + label: string; + status: ItemStatus; + detail?: string; +} + +/** + * The Send to Readest web page. Being a Readest client itself, it runs the + * shared import pipeline directly (no inbox round-trip): drop a file or paste + * an article URL and it lands in the cloud library, syncing to every device. + */ +export default function SendPage() { + const _ = useTranslation(); + const { envConfig, appService } = useEnv(); + const { user } = useAuth(); + const { settings } = useSettingsStore(); + + const [items, setItems] = useState([]); + const [dragging, setDragging] = useState(false); + const [url, setUrl] = useState(''); + const fileInputRef = useRef(null); + + const setItem = useCallback((id: string, patch: Partial) => { + setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it))); + }, []); + + const importResolvedFile = useCallback( + async (file: File, id: string, label: string) => { + if (!appService) throw new Error('App not ready'); + const { library } = useLibraryStore.getState(); + const book = await ingestFile( + { file, books: library, forceUpload: true }, + { appService, settings, isLoggedIn: !!user }, + ); + if (!book) throw new Error('Import produced no book'); + await useLibraryStore.getState().updateBooks(envConfig, [book]); + setItem(id, { status: 'done', label: book.title || label }); + }, + [appService, settings, user, envConfig, setItem], + ); + + const handleFiles = useCallback( + async (files: File[]) => { + for (const file of files) { + const id = crypto.randomUUID(); + setItems((prev) => [...prev, { id, label: file.name, status: 'working' }]); + try { + const resolved = await convertFileIfNeeded(file); + await importResolvedFile(resolved, id, file.name); + } catch (err) { + setItem(id, { + status: 'error', + detail: err instanceof Error ? err.message : _('Import failed'), + }); + } + } + }, + [importResolvedFile, setItem, _], + ); + + const handleUrl = useCallback(async () => { + const target = url.trim(); + if (!/^https?:\/\//i.test(target)) return; + setUrl(''); + const id = crypto.randomUUID(); + setItems((prev) => [...prev, { id, label: target, status: 'working' }]); + try { + let html: string; + if (isTauriAppPlatform()) { + const res = await fetch(target); + if (!res.ok) throw new Error(`Could not fetch URL (${res.status})`); + html = await res.text(); + } else { + const res = await fetchWithAuth( + `${getAPIBaseUrl()}/send/fetch-url?url=${encodeURIComponent(target)}`, + { method: 'GET' }, + ); + html = ((await res.json()) as { html: string }).html; + } + const book = await convertToEpubWithWorker({ kind: 'article', html, url: target }); + await importResolvedFile(book.file, id, book.title); + } catch (err) { + setItem(id, { + status: 'error', + detail: err instanceof Error ? err.message : _('Could not fetch this page'), + }); + } + }, [url, importResolvedFile, setItem, _]); + + if (!user) { + return ( +
+

{_('Send to Readest')}

+

+ {_('Sign in to send books and articles to your library.')} +

+
+ ); + } + + return ( +
+
+

{_('Send to Readest')}

+

+ {_('Drop a book or document, or paste an article link. It syncs to all your devices.')} +

+
+ + + { + if (e.target.files) void handleFiles(Array.from(e.target.files)); + e.target.value = ''; + }} + /> + +
+ setUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleUrl(); + }} + /> + +
+ + {items.length > 0 && ( +
    + {items.map((item) => ( +
  • + {item.status === 'working' && ( + + )} + {item.status === 'done' && ( + + )} + {item.status === 'error' && } +
    + {item.label} + + {item.status === 'done' + ? _('Added to your library — it will sync to your other devices.') + : item.status === 'error' + ? item.detail + : _('Working…')} + +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/readest-app/src/components/settings/IntegrationsPanel.tsx b/apps/readest-app/src/components/settings/IntegrationsPanel.tsx index f26edf86..1c64e883 100644 --- a/apps/readest-app/src/components/settings/IntegrationsPanel.tsx +++ b/apps/readest-app/src/components/settings/IntegrationsPanel.tsx @@ -8,6 +8,7 @@ import { RiBookReadLine, RiBook3Line, RiDiscordLine, + RiSendPlaneLine, } from 'react-icons/ri'; import { useEnv } from '@/context/EnvContext'; import { useAuth } from '@/context/AuthContext'; @@ -20,10 +21,11 @@ import { navigateToLogin } from '@/utils/nav'; import KOSyncForm from './integrations/KOSyncForm'; import ReadwiseForm from './integrations/ReadwiseForm'; import HardcoverForm from './integrations/HardcoverForm'; +import SendToReadestForm from './integrations/SendToReadestForm'; import SubPageHeader from './SubPageHeader'; import { SectionTitle, SettingLabel } from './primitives'; -type SubPage = 'kosync' | 'readwise' | 'hardcover' | 'opds' | null; +type SubPage = 'kosync' | 'readwise' | 'hardcover' | 'opds' | 'send' | null; /** * Integrations panel — single point of discovery for external service config: @@ -66,7 +68,8 @@ const IntegrationsPanel: React.FC = () => { requestedSubPage === 'kosync' || requestedSubPage === 'readwise' || requestedSubPage === 'hardcover' || - requestedSubPage === 'opds' + requestedSubPage === 'opds' || + requestedSubPage === 'send' ) { setSubPage(requestedSubPage); } @@ -107,6 +110,12 @@ const IntegrationsPanel: React.FC = () => { ); + if (subPage === 'send') + return ( +
+ setSubPage(null)} /> +
+ ); const koSyncStatus = settings.kosync?.enabled ? settings.kosync.username @@ -164,6 +173,12 @@ const IntegrationsPanel: React.FC = () => { status={opdsStatus} onClick={() => setSubPage('opds')} /> + setSubPage('send')} + /> diff --git a/apps/readest-app/src/components/settings/integrations/SendToReadestForm.tsx b/apps/readest-app/src/components/settings/integrations/SendToReadestForm.tsx new file mode 100644 index 00000000..5c4915a0 --- /dev/null +++ b/apps/readest-app/src/components/settings/integrations/SendToReadestForm.tsx @@ -0,0 +1,389 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { MdContentCopy, MdRefresh, MdCheck, MdClose, MdAdd } from 'react-icons/md'; +import { RiSendPlaneLine } from 'react-icons/ri'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useAuth } from '@/context/AuthContext'; +import { fetchWithAuth } from '@/utils/fetch'; +import { getAPIBaseUrl } from '@/services/environment'; +import { isInboxDrainEnabled, setInboxDrainEnabled } from '@/services/send/devicePrefs'; +import { navigateToLogin } from '@/utils/nav'; +import { eventDispatcher } from '@/utils/event'; +import type { DBSendAllowedSender, DBSendInboxItem } from '@/types/sendRecords'; +import SubPageHeader from '../SubPageHeader'; +import { BoxedList, SectionTitle, SettingLabel, SettingsSwitchRow } from '../primitives'; + +interface SendToReadestFormProps { + onBack: () => void; +} + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Pull the editable slug out of a `{slug}-{token}@domain` address. */ +function slugOf(address: string): string { + const local = address.split('@')[0] ?? ''; + const dash = local.lastIndexOf('-'); + return dash > 0 ? local.slice(0, dash) : local; +} + +/** The fixed `-{token}@domain` part shown after the editable slug. */ +function suffixOf(address: string): string { + return address.slice(slugOf(address).length); +} + +const SendToReadestForm: React.FC = ({ onBack }) => { + const _ = useTranslation(); + const router = useRouter(); + const { user } = useAuth(); + const apiBase = getAPIBaseUrl(); + + const [address, setAddress] = useState(''); + const [senders, setSenders] = useState([]); + const [activity, setActivity] = useState([]); + const [newEmail, setNewEmail] = useState(''); + const [slugInput, setSlugInput] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [drainEnabled, setDrainEnabled] = useState(() => isInboxDrainEnabled()); + // Editing affordances stay collapsed once configured, keeping the panel + // minimal; the refresh / plus icons reveal the input rows. + const [editingAddress, setEditingAddress] = useState(false); + const [addingSender, setAddingSender] = useState(false); + + const toast = (message: string, type: 'info' | 'error' | 'success' = 'info') => + eventDispatcher.dispatch('toast', { message, type, timeout: 2500 }); + + const toggleDrain = () => { + const next = !drainEnabled; + setDrainEnabled(next); + setInboxDrainEnabled(next); + }; + + const load = useCallback(async () => { + try { + const [addrRes, sendersRes, inboxRes] = await Promise.all([ + fetchWithAuth(`${apiBase}/send/address`, { method: 'GET' }), + fetchWithAuth(`${apiBase}/send/senders`, { method: 'GET' }), + fetchWithAuth(`${apiBase}/send/inbox`, { method: 'GET' }), + ]); + const addrData = (await addrRes.json()) as { address: string }; + const sendersData = (await sendersRes.json()) as { senders: DBSendAllowedSender[] }; + const inboxData = (await inboxRes.json()) as { items: DBSendInboxItem[] }; + setAddress(addrData.address); + setSlugInput(slugOf(addrData.address)); + setSenders(sendersData.senders); + setActivity(inboxData.items); + } catch { + toast(_('Could not load Send to Readest settings'), 'error'); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [apiBase]); + + useEffect(() => { + if (user) void load(); + }, [user, load]); + + const copyAddress = async () => { + try { + await navigator.clipboard.writeText(address); + toast(_('Address copied'), 'success'); + } catch { + toast(_('Could not copy address'), 'error'); + } + }; + + // Saving issues a fresh address: the chosen slug + a new token suffix. + // (Leaving the slug unchanged and saving is also how you rotate a leaked + // address — the token is always regenerated.) + const saveAddress = async () => { + const slug = slugInput.trim(); + if (!slug) { + toast(_('Enter a name for your address'), 'error'); + return; + } + setSaving(true); + try { + const res = await fetchWithAuth(`${apiBase}/send/address`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug }), + }); + const data = (await res.json()) as { address: string }; + setAddress(data.address); + setSlugInput(slugOf(data.address)); + setEditingAddress(false); + toast(_('Address updated'), 'success'); + } catch (err) { + toast(err instanceof Error ? err.message : _('Could not update address'), 'error'); + } finally { + setSaving(false); + } + }; + + const addSender = async () => { + const email = newEmail.trim().toLowerCase(); + if (!EMAIL_RE.test(email)) { + toast(_('Enter a valid email address'), 'error'); + return; + } + try { + const res = await fetchWithAuth(`${apiBase}/send/senders`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + const data = (await res.json()) as { sender: DBSendAllowedSender }; + setSenders((prev) => [...prev.filter((s) => s.id !== data.sender.id), data.sender]); + setNewEmail(''); + setAddingSender(false); + } catch { + toast(_('Could not add sender'), 'error'); + } + }; + + const approveSender = async (id: string) => { + try { + await fetchWithAuth(`${apiBase}/send/senders`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + setSenders((prev) => prev.map((s) => (s.id === id ? { ...s, status: 'approved' } : s))); + } catch { + toast(_('Could not approve sender'), 'error'); + } + }; + + const removeSender = async (id: string) => { + try { + await fetchWithAuth(`${apiBase}/send/senders`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + setSenders((prev) => prev.filter((s) => s.id !== id)); + } catch { + toast(_('Could not remove sender'), 'error'); + } + }; + + return ( +
+ + + {!user ? ( +
+ + + +

+ {_('Sign in to send books and articles to your library.')} +

+ +
+ ) : loading ? ( +
+ {[ + { key: 'address', card: 'h-28' }, + { key: 'senders', card: 'h-32' }, + { key: 'activity', card: 'h-24' }, + ].map((section) => ( +
+
+
+
+ ))} +
+ ) : ( +
+
+ {_('Your inbound address')} +
+
+ {address} + {address && ( + + )} + +
+ {(editingAddress || !address) && ( +
+ setSlugInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void saveAddress(); + }} + aria-label={_('Customize your address name')} + placeholder={_('your-name')} + /> + {suffixOf(address)} + +
+ )} +
+

+ {_('Email a book or document to this address from an approved sender below.')} +

+
+ +
+ {_('Approved senders')} +
+
+ {senders.length === 0 && ( +
+ {_('No approved senders yet. Add an email to let it send to your library.')} +
+ )} + {senders.map((sender) => ( +
+
+ {sender.email} + {sender.status === 'pending' && ( + {_('Pending approval')} + )} +
+ {sender.status === 'pending' && ( + + )} + + +
+ ))} +
+ {(addingSender || senders.length === 0) && ( +
+ setNewEmail(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void addSender(); + }} + /> + +
+ )} +
+
+ +
+ {_('Recent activity')} +
+
+ {activity.length === 0 && ( +
+ {_('Nothing sent yet. Email a book to your address above.')} +
+ )} + {activity.map((item) => ( +
+
+ + {item.filename || item.url || _('Untitled')} + + + {item.status === 'failed' + ? item.error || _('Failed') + : _(activityStatusLabel(item.status))} + +
+
+ ))} +
+
+
+ + + + +
+ )} +
+ ); +}; + +function activityStatusLabel(status: DBSendInboxItem['status']): string { + switch (status) { + case 'done': + return 'Added to your library'; + case 'pending': + return 'Waiting to be processed'; + case 'claimed': + return 'Processing…'; + default: + return 'Failed'; + } +} + +export default SendToReadestForm; diff --git a/apps/readest-app/src/hooks/useInboxDrainer.ts b/apps/readest-app/src/hooks/useInboxDrainer.ts new file mode 100644 index 00000000..78616d54 --- /dev/null +++ b/apps/readest-app/src/hooks/useInboxDrainer.ts @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useEnv } from '@/context/EnvContext'; +import { useAuth } from '@/context/AuthContext'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useLibraryStore } from '@/store/libraryStore'; +import { fetchWithAuth } from '@/utils/fetch'; +import { getAPIBaseUrl } from '@/services/environment'; +import { isTauriAppPlatform } from '@/services/environment'; +import { ingestFile } from '@/services/ingestService'; +import { drainInbox, DEFAULT_MAX_ITEMS_PER_PASS } from '@/services/send/inboxDrainer'; +import { isInboxDrainEnabled } from '@/services/send/devicePrefs'; +import { + convertToEpubWithWorker, + convertFileIfNeeded, +} from '@/services/send/conversion/conversionWorker'; +import type { DBSendInboxItem } from '@/types/sendRecords'; + +const DRAIN_INTERVAL_MS = 60_000; +const DEVICE_ID_KEY = 'readest-send-device-id'; + +function getDeviceId(): string { + try { + let id = localStorage.getItem(DEVICE_ID_KEY); + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem(DEVICE_ID_KEY, id); + } + return id; + } catch { + return crypto.randomUUID(); + } +} + +/** + * Background controller that drains the Send to Readest inbox. Mounted once in + * the library/app shell — runs on app focus and on a 60s interval whenever a + * user is signed in. Not part of useBooksSync: this does network downloads and + * CPU-bound conversion that must stay off the throttled cover-sync path. + */ +export function useInboxDrainer(): void { + const { envConfig, appService } = useEnv(); + const { user } = useAuth(); + const { settings } = useSettingsStore(); + const runningRef = useRef(false); + const lastDrainAtRef = useRef(0); + + const runDrain = useCallback(async () => { + if (!user || !appService || runningRef.current) return; + // Per-device opt-out: this device does not claim/process inbox items. + if (!isInboxDrainEnabled()) return; + // Throttle: drain (and so claim_inbox_item) at most once per interval, so + // the timer and focus events together never poll faster than that. + if (Date.now() - lastDrainAtRef.current < DRAIN_INTERVAL_MS) return; + lastDrainAtRef.current = Date.now(); + runningRef.current = true; + try { + const device = getDeviceId(); + const apiBase = getAPIBaseUrl(); + + // All inbox state changes route through /api/send/* rather than calling + // Supabase directly. + const postJSON = async (path: string, body: object) => { + const res = await fetchWithAuth(`${apiBase}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return res.json(); + }; + + const claimRow = async (): Promise => { + const { item } = (await postJSON('/send/inbox/claim', { device })) as { + item: DBSendInboxItem | null; + }; + return item; + }; + + const resolvePayload = async (item: DBSendInboxItem): Promise => { + if (item.kind === 'url') { + const html = await fetchUrlHtml(item.url!, apiBase); + const book = await convertToEpubWithWorker({ + kind: 'article', + html, + url: item.url!, + }); + return book.file; + } + // file / html: download the R2 payload via the signed-URL endpoint. + const res = await fetchWithAuth(`${apiBase}/send/inbox/${item.id}/payload`, { + method: 'GET', + }); + if (!res.ok) throw new Error(`Payload download failed (${res.status})`); + const { downloadUrl } = (await res.json()) as { downloadUrl: string }; + const fileRes = await fetch(downloadUrl); + if (!fileRes.ok) throw new Error(`Payload fetch failed (${fileRes.status})`); + const bytes = await fileRes.arrayBuffer(); + const filename = item.filename ?? 'document'; + + if (item.kind === 'html') { + const book = await convertToEpubWithWorker({ + kind: 'html', + bytes, + fileName: filename, + }); + return book.file; + } + // file kind: convert documents to EPUB, import native formats as-is. + return convertFileIfNeeded(new File([bytes], filename)); + }; + + const importItem = async (file: File, item: DBSendInboxItem): Promise => { + const { library } = useLibraryStore.getState(); + const book = await ingestFile( + { + file, + books: library, + subjectTag: item.subject_tag ?? undefined, + forceUpload: true, + }, + { appService, settings, isLoggedIn: true }, + ); + if (!book) throw new Error('Import produced no book'); + // updateBooks persists the library; useBooksSync then pushes it. + await useLibraryStore.getState().updateBooks(envConfig, [book]); + }; + + await drainInbox( + { + claimItem: claimRow, + renewClaim: async (id) => { + const { ok } = (await postJSON(`/send/inbox/${id}/transition`, { + action: 'renew', + device, + })) as { ok: boolean }; + return ok; + }, + completeItem: async (id) => { + const { ok } = (await postJSON(`/send/inbox/${id}/transition`, { + action: 'complete', + device, + })) as { ok: boolean }; + return ok; + }, + failItem: async (id, error) => { + const { ok } = (await postJSON(`/send/inbox/${id}/transition`, { + action: 'fail', + device, + error: error.slice(0, 500), + })) as { ok: boolean }; + return ok; + }, + resolvePayload, + importItem, + }, + DEFAULT_MAX_ITEMS_PER_PASS, + ); + } catch (err) { + console.warn('Inbox drain pass failed:', err); + } finally { + runningRef.current = false; + } + }, [user, appService, settings, envConfig]); + + useEffect(() => { + if (!user) return; + void runDrain(); + const interval = setInterval(() => void runDrain(), DRAIN_INTERVAL_MS); + const onFocus = () => void runDrain(); + window.addEventListener('focus', onFocus); + return () => { + clearInterval(interval); + window.removeEventListener('focus', onFocus); + }; + }, [user, runDrain]); +} + +/** + * Fetch a page's HTML for article extraction. The browser cannot fetch + * cross-origin pages, so web goes through the SSRF-guarded proxy; the Tauri + * apps fetch directly. + */ +async function fetchUrlHtml(url: string, apiBase: string): Promise { + if (isTauriAppPlatform()) { + const res = await fetch(url); + if (!res.ok) throw new Error(`Could not fetch URL (${res.status})`); + return res.text(); + } + const res = await fetchWithAuth(`${apiBase}/send/fetch-url?url=${encodeURIComponent(url)}`, { + method: 'GET', + }); + if (!res.ok) throw new Error(`Could not fetch URL (${res.status})`); + const { html } = (await res.json()) as { html: string }; + return html; +} diff --git a/apps/readest-app/src/pages/api/send/address.ts b/apps/readest-app/src/pages/api/send/address.ts new file mode 100644 index 00000000..850ac69d --- /dev/null +++ b/apps/readest-app/src/pages/api/send/address.ts @@ -0,0 +1,119 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { createSupabaseAdminClient } from '@/utils/supabase'; +import { corsAllMethods, runMiddleware } from '@/utils/cors'; +import { validateUserAndToken } from '@/utils/access'; +import { + generateSendAddress, + buildSendAddress, + sanitizeSlug, + isReservedSlug, +} from '@/services/send/sendAddress'; +import { SEND_EMAIL_DOMAIN } from '@/services/constants'; +import type { DBSendAddress } from '@/types/sendRecords'; + +const MAX_COLLISION_RETRIES = 5; + +/** Build the full inbound email address from a stored local part. */ +const fullAddress = (localPart: string) => `${localPart}@${SEND_EMAIL_DOMAIN}`; + +/** + * GET — return the caller's inbound address, lazily creating one on first call. + * POST — rotate the address (issue a fresh random local part). + * + * The address is the local part only in the DB; the `@send.readest.com` host + * is appended here so the domain can change without a migration. + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await runMiddleware(req, res, corsAllMethods); + + const { user } = await validateUserAndToken(req.headers['authorization']); + if (!user) { + return res.status(403).json({ error: 'Not authenticated' }); + } + + const supabase = createSupabaseAdminClient(); + + if (req.method === 'GET') { + const { data, error } = await supabase + .from('send_addresses') + .select('*') + .eq('user_id', user.id) + .maybeSingle(); + if (error) { + return res.status(500).json({ error: error.message }); + } + if (data) { + return res.status(200).json({ address: fullAddress(data.address), enabled: data.enabled }); + } + // Lazily create on first access. + const created = await insertWithRetry(supabase, user.id, user.email ?? user.id); + if (!created) { + return res.status(500).json({ error: 'Could not allocate an address' }); + } + return res.status(200).json({ address: fullAddress(created), enabled: true }); + } + + if (req.method === 'POST') { + // Optional custom slug; the token suffix is always regenerated. Without a + // slug this is a plain rotation with an identity-derived slug. + let customSlug: string | undefined; + if (req.body?.slug !== undefined) { + customSlug = sanitizeSlug(String(req.body.slug)); + if (!customSlug) { + return res.status(400).json({ error: 'Name must contain letters or digits' }); + } + if (isReservedSlug(customSlug)) { + return res.status(400).json({ error: 'That name is reserved' }); + } + } + // Rotation: overwrite with a fresh local part. PK is user_id, so upsert. + for (let attempt = 0; attempt < MAX_COLLISION_RETRIES; attempt++) { + const localPart = customSlug + ? buildSendAddress(customSlug) + : generateSendAddress(user.email ?? user.id); + const { error } = await supabase.from('send_addresses').upsert( + { + user_id: user.id, + address: localPart, + enabled: true, + rotated_at: new Date().toISOString(), + }, + { onConflict: 'user_id' }, + ); + if (!error) { + return res.status(200).json({ address: fullAddress(localPart), enabled: true }); + } + // 23505 = unique_violation on the address column; retry with a new token. + if (error.code !== '23505') { + return res.status(500).json({ error: error.message }); + } + } + return res.status(500).json({ error: 'Could not allocate an address' }); + } + + return res.status(405).json({ error: 'Method not allowed' }); +} + +async function insertWithRetry( + supabase: ReturnType, + userId: string, + identity: string, +): Promise { + for (let attempt = 0; attempt < MAX_COLLISION_RETRIES; attempt++) { + const localPart = generateSendAddress(identity); + const { error } = await supabase + .from('send_addresses') + .insert({ user_id: userId, address: localPart, enabled: true }); + if (!error) return localPart; + // Another request created the row first — read it back. + if (error.code === '23505') { + const { data } = await supabase + .from('send_addresses') + .select('address') + .eq('user_id', userId) + .maybeSingle>(); + if (data) return data.address; + } + } + return null; +} diff --git a/apps/readest-app/src/pages/api/send/fetch-url.ts b/apps/readest-app/src/pages/api/send/fetch-url.ts new file mode 100644 index 00000000..80240b0d --- /dev/null +++ b/apps/readest-app/src/pages/api/send/fetch-url.ts @@ -0,0 +1,155 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { corsAllMethods, runMiddleware } from '@/utils/cors'; +import { validateUserAndToken } from '@/utils/access'; + +const FETCH_TIMEOUT_MS = 10_000; +const MAX_HTML_BYTES = 5 * 1024 * 1024; +const MAX_REDIRECTS = 3; + +/** Whether a dotted-decimal IPv4 address falls in a private / reserved range. */ +function isBlockedV4(a: number, b: number, c: number, _d: number): boolean { + if (a === 0 || a === 10 || a === 127) return true; // this-network, private, loopback + if (a === 169 && b === 254) return true; // link-local + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + if (a === 192 && b === 0 && c === 0) return true; // IETF protocol assignments + if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking + if (a >= 224) return true; // multicast + reserved + return false; +} + +/** + * Block obviously-internal hosts. A browser cannot fetch arbitrary cross-origin + * pages (CORS), so the web `/send` page routes article URLs through this proxy + * — which means the server makes the request, so an SSRF guard is mandatory. + * The Tauri apps fetch directly and never hit this route. + * + * Note: this is a string check on the URL host. A hostname that DNS-resolves to + * a private address (DNS rebinding) is a documented residual risk — the web + * build runs on the Cloudflare Workers edge, which has no reachable internal + * network or metadata endpoint. + */ +export function isBlockedHost(hostname: string): boolean { + const h = hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if (!h) return true; + if (h === 'localhost' || h.endsWith('.localhost')) return true; + if (h.endsWith('.local') || h.endsWith('.internal') || h.endsWith('.lan')) return true; + // Bare single-label hostnames (e.g. `intranet`, `metadata`) — never public. + if (!h.includes('.') && !h.includes(':')) return true; + + // IPv4 — the WHATWG URL parser already normalized decimal/hex/octal forms. + const v4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (v4) { + return isBlockedV4(Number(v4[1]), Number(v4[2]), Number(v4[3]), Number(v4[4])); + } + + // IPv6, including IPv4-mapped / -compatible forms. + if (h.includes(':')) { + if (h === '::' || h === '::1') return true; // unspecified, loopback + if (/^(fc|fd)/.test(h)) return true; // unique-local + if (/^fe[89ab]/.test(h)) return true; // link-local + const mapped = h.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (mapped) { + return isBlockedV4( + Number(mapped[1]), + Number(mapped[2]), + Number(mapped[3]), + Number(mapped[4]), + ); + } + const hexMapped = h.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (hexMapped) { + const hi = parseInt(hexMapped[1] ?? '0', 16); + const lo = parseInt(hexMapped[2] ?? '0', 16); + return isBlockedV4((hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff); + } + return false; + } + return false; +} + +/** GET ?url=... — fetch a remote page's HTML for client-side article extraction. */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await runMiddleware(req, res, corsAllMethods); + + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const { user } = await validateUserAndToken(req.headers['authorization']); + if (!user) { + return res.status(403).json({ error: 'Not authenticated' }); + } + + const target = String(req.query['url'] ?? ''); + let parsed: URL; + try { + parsed = new URL(target); + } catch { + return res.status(400).json({ error: 'Invalid URL' }); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return res.status(400).json({ error: 'Only http(s) URLs are supported' }); + } + if (isBlockedHost(parsed.hostname)) { + return res.status(400).json({ error: 'This URL is not allowed' }); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + // Follow redirects manually so the SSRF host check runs on EVERY hop — + // `redirect: 'follow'` would let a public URL 302 to an internal address. + let currentUrl = parsed.toString(); + let upstream: Response | null = null; + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + const hopHost = new URL(currentUrl).hostname; + if (isBlockedHost(hopHost)) { + return res.status(400).json({ error: 'This URL is not allowed' }); + } + const response = await fetch(currentUrl, { + signal: controller.signal, + redirect: 'manual', + headers: { 'User-Agent': 'ReadestBot/1.0 (+https://readest.com)' }, + }); + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + if (!location) { + return res.status(502).json({ error: 'Redirect without a location' }); + } + currentUrl = new URL(location, currentUrl).toString(); + const proto = new URL(currentUrl).protocol; + if (proto !== 'http:' && proto !== 'https:') { + return res.status(400).json({ error: 'Redirect to an unsupported scheme' }); + } + continue; + } + upstream = response; + break; + } + if (!upstream) { + return res.status(502).json({ error: 'Too many redirects' }); + } + if (!upstream.ok) { + return res.status(502).json({ error: `Upstream returned ${upstream.status}` }); + } + const contentType = upstream.headers.get('content-type') ?? ''; + if (!/text\/html|application\/xhtml/i.test(contentType)) { + return res.status(415).json({ error: 'URL did not return an HTML page' }); + } + const buffer = await upstream.arrayBuffer(); + if (buffer.byteLength > MAX_HTML_BYTES) { + return res.status(413).json({ error: 'Page is too large' }); + } + const html = new TextDecoder('utf-8').decode(buffer); + return res.status(200).json({ html, finalUrl: upstream.url }); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + return res.status(504).json({ error: 'Fetching the URL timed out' }); + } + return res.status(502).json({ error: 'Could not fetch the URL' }); + } finally { + clearTimeout(timer); + } +} diff --git a/apps/readest-app/src/pages/api/send/inbox.ts b/apps/readest-app/src/pages/api/send/inbox.ts new file mode 100644 index 00000000..9eda5b9d --- /dev/null +++ b/apps/readest-app/src/pages/api/send/inbox.ts @@ -0,0 +1,84 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { createSupabaseAdminClient } from '@/utils/supabase'; +import { corsAllMethods, runMiddleware } from '@/utils/cors'; +import { validateUserAndToken } from '@/utils/access'; +import { parseSubjectTag } from '@/services/send/sendAddress'; +import { SEND_INBOX_PENDING_LIMIT } from '@/services/constants'; +import type { DBSendInboxItem } from '@/types/sendRecords'; + +const RECENT_LIMIT = 20; + +/** + * Inbox endpoint — clients route through here instead of querying Supabase + * directly. + * GET — list the caller's recent inbox items (the "Recent activity" list). + * POST — authenticated producer (browser extension): drop a captured URL in. + * (The email channel writes `send_inbox` from the email Worker.) + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await runMiddleware(req, res, corsAllMethods); + + const { user } = await validateUserAndToken(req.headers['authorization']); + if (!user) { + return res.status(403).json({ error: 'Not authenticated' }); + } + + const supabase = createSupabaseAdminClient(); + + if (req.method === 'GET') { + const { data, error } = await supabase + .from('send_inbox') + .select('*') + .eq('user_id', user.id) + .order('created_at', { ascending: false }) + .limit(RECENT_LIMIT) + .returns(); + if (error) { + return res.status(500).json({ error: error.message }); + } + return res.status(200).json({ items: data ?? [] }); + } + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const url = String(req.body?.url ?? '').trim(); + if (!url || !/^https?:\/\//i.test(url)) { + return res.status(400).json({ error: 'A valid http(s) URL is required' }); + } + const title = req.body?.title ? String(req.body.title) : null; + + // Anti-abuse: cap undrained items so a leaked address/token can't flood R2 + // or the user's library. Count claimed items too — a crashed drainer can + // leave items stuck in `claimed` until the lease expires. + const { count, error: countError } = await supabase + .from('send_inbox') + .select('id', { count: 'exact', head: true }) + .eq('user_id', user.id) + .in('status', ['pending', 'claimed']); + if (countError) { + return res.status(500).json({ error: countError.message }); + } + if ((count ?? 0) >= SEND_INBOX_PENDING_LIMIT) { + return res.status(429).json({ error: 'Inbox is full — open Readest to process pending items' }); + } + + const { data, error } = await supabase + .from('send_inbox') + .insert({ + user_id: user.id, + kind: 'url', + source: 'extension', + url, + filename: title, + subject_tag: parseSubjectTag(title) ?? null, + }) + .select('id') + .single<{ id: string }>(); + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ id: data.id }); +} diff --git a/apps/readest-app/src/pages/api/send/inbox/[id]/payload.ts b/apps/readest-app/src/pages/api/send/inbox/[id]/payload.ts new file mode 100644 index 00000000..8106afb8 --- /dev/null +++ b/apps/readest-app/src/pages/api/send/inbox/[id]/payload.ts @@ -0,0 +1,62 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { createSupabaseAdminClient } from '@/utils/supabase'; +import { corsAllMethods, runMiddleware } from '@/utils/cors'; +import { validateUserAndToken } from '@/utils/access'; +import { getDownloadSignedUrl } from '@/utils/object'; +import { SEND_INBOX_BUCKET } from '@/services/constants'; +import type { DBSendInboxItem } from '@/types/sendRecords'; + +const DOWNLOAD_TTL_SECONDS = 600; + +/** + * Signed-download URL for an inbox payload. Authorizes against `send_inbox` + * ownership — a separate path from `storage/download`, which checks the + * `files` table (inbox payloads are not `files` rows). + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await runMiddleware(req, res, corsAllMethods); + + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const { user } = await validateUserAndToken(req.headers['authorization']); + if (!user) { + return res.status(403).json({ error: 'Not authenticated' }); + } + + const id = String(req.query['id'] ?? ''); + if (!id) { + return res.status(400).json({ error: 'Missing inbox item id' }); + } + + const supabase = createSupabaseAdminClient(); + const { data, error } = await supabase + .from('send_inbox') + .select('user_id, payload_key') + .eq('id', id) + .maybeSingle>(); + if (error) { + return res.status(500).json({ error: error.message }); + } + if (!data || data.user_id !== user.id) { + return res.status(404).json({ error: 'Inbox item not found' }); + } + if (!data.payload_key) { + return res.status(409).json({ error: 'Inbox item has no file payload' }); + } + + try { + // Inbox payloads live in their own bucket, separate from the books bucket + // that getDownloadSignedUrl defaults to. + const downloadUrl = await getDownloadSignedUrl( + data.payload_key, + DOWNLOAD_TTL_SECONDS, + SEND_INBOX_BUCKET, + ); + return res.status(200).json({ downloadUrl }); + } catch (err) { + console.error('Inbox payload sign failed:', err); + return res.status(500).json({ error: 'Could not sign payload URL' }); + } +} diff --git a/apps/readest-app/src/pages/api/send/inbox/[id]/transition.ts b/apps/readest-app/src/pages/api/send/inbox/[id]/transition.ts new file mode 100644 index 00000000..fe0e498d --- /dev/null +++ b/apps/readest-app/src/pages/api/send/inbox/[id]/transition.ts @@ -0,0 +1,48 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { createSupabaseClient } from '@/utils/supabase'; +import { corsAllMethods, runMiddleware } from '@/utils/cors'; +import { validateUserAndToken } from '@/utils/access'; + +/** + * Drainer state transitions for a claimed inbox item — `renew` the lease, + * `complete`, or `fail`. Wraps the renew/complete/fail RPCs so the drainer + * routes through the API rather than calling Supabase directly. The RPCs + * self-scope to `auth.uid()`, so a user-scoped Supabase client is used. + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await runMiddleware(req, res, corsAllMethods); + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const { user, token } = await validateUserAndToken(req.headers['authorization']); + if (!user || !token) { + return res.status(403).json({ error: 'Not authenticated' }); + } + + const id = String(req.query['id'] ?? ''); + const action = String(req.body?.action ?? ''); + const device = String(req.body?.device ?? '').slice(0, 100); + if (!id || !device) { + return res.status(400).json({ error: 'Missing item id or device' }); + } + + const supabase = createSupabaseClient(token); + let result; + if (action === 'renew') { + result = await supabase.rpc('renew_inbox_claim', { p_id: id, p_device: device }); + } else if (action === 'complete') { + result = await supabase.rpc('complete_inbox_item', { p_id: id, p_device: device }); + } else if (action === 'fail') { + const error = String(req.body?.error ?? '').slice(0, 500); + result = await supabase.rpc('fail_inbox_item', { p_id: id, p_device: device, p_error: error }); + } else { + return res.status(400).json({ error: 'Unknown action' }); + } + + if (result.error) { + return res.status(500).json({ error: result.error.message }); + } + return res.status(200).json({ ok: Boolean(result.data) }); +} diff --git a/apps/readest-app/src/pages/api/send/inbox/claim.ts b/apps/readest-app/src/pages/api/send/inbox/claim.ts new file mode 100644 index 00000000..25425b0b --- /dev/null +++ b/apps/readest-app/src/pages/api/send/inbox/claim.ts @@ -0,0 +1,39 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { createSupabaseClient } from '@/utils/supabase'; +import { corsAllMethods, runMiddleware } from '@/utils/cors'; +import { validateUserAndToken } from '@/utils/access'; +import type { DBSendInboxItem } from '@/types/sendRecords'; + +/** + * Claim the oldest drainable inbox item for the caller, via the + * `claim_inbox_item` RPC. Clients route through here instead of calling + * Supabase directly. The RPC self-scopes to `auth.uid()`, so a user-scoped + * Supabase client (carrying the caller's JWT) is used. + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await runMiddleware(req, res, corsAllMethods); + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const { user, token } = await validateUserAndToken(req.headers['authorization']); + if (!user || !token) { + return res.status(403).json({ error: 'Not authenticated' }); + } + + const device = String(req.body?.device ?? '').slice(0, 100); + if (!device) { + return res.status(400).json({ error: 'Missing device id' }); + } + + const supabase = createSupabaseClient(token); + const { data, error } = await supabase.rpc('claim_inbox_item', { p_device: device }); + if (error) { + return res.status(500).json({ error: error.message }); + } + + // The RPC yields null (or a NULL-filled row) when nothing was claimable. + const item = data && data.id ? (data as DBSendInboxItem) : null; + return res.status(200).json({ item }); +} diff --git a/apps/readest-app/src/pages/api/send/senders.ts b/apps/readest-app/src/pages/api/send/senders.ts new file mode 100644 index 00000000..cf06cd54 --- /dev/null +++ b/apps/readest-app/src/pages/api/send/senders.ts @@ -0,0 +1,83 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { createSupabaseAdminClient } from '@/utils/supabase'; +import { corsAllMethods, runMiddleware } from '@/utils/cors'; +import { validateUserAndToken } from '@/utils/access'; +import { normalizeSenderEmail } from '@/services/send/sendAddress'; +import type { DBSendAllowedSender } from '@/types/sendRecords'; + +// Linear-time email check: domain labels exclude '.' so there is no +// quantifier ambiguity (a polynomial-backtracking ReDoS would need it). +const EMAIL_RE = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/; +const MAX_EMAIL_LENGTH = 254; + +/** + * The approved-sender allowlist. + * GET — list the caller's senders (approved + pending). + * POST — add an approved sender `{ email }`. + * PATCH — approve a pending sender `{ id }`. + * DELETE — remove a sender `{ id }`. + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await runMiddleware(req, res, corsAllMethods); + + const { user } = await validateUserAndToken(req.headers['authorization']); + if (!user) { + return res.status(403).json({ error: 'Not authenticated' }); + } + + const supabase = createSupabaseAdminClient(); + + if (req.method === 'GET') { + const { data, error } = await supabase + .from('send_allowed_senders') + .select('*') + .eq('user_id', user.id) + .order('created_at', { ascending: true }) + .returns(); + if (error) return res.status(500).json({ error: error.message }); + return res.status(200).json({ senders: data ?? [] }); + } + + if (req.method === 'POST') { + const email = normalizeSenderEmail(String(req.body?.email ?? '')); + if (email.length > MAX_EMAIL_LENGTH || !EMAIL_RE.test(email)) { + return res.status(400).json({ error: 'Invalid email address' }); + } + const { data, error } = await supabase + .from('send_allowed_senders') + .upsert({ user_id: user.id, email, status: 'approved' }, { onConflict: 'user_id,email' }) + .select() + .single(); + if (error) return res.status(500).json({ error: error.message }); + return res.status(200).json({ sender: data }); + } + + if (req.method === 'PATCH') { + const id = String(req.body?.id ?? ''); + if (!id) return res.status(400).json({ error: 'Missing sender id' }); + const { data, error } = await supabase + .from('send_allowed_senders') + .update({ status: 'approved' }) + .eq('id', id) + .eq('user_id', user.id) + .select() + .maybeSingle(); + if (error) return res.status(500).json({ error: error.message }); + if (!data) return res.status(404).json({ error: 'Sender not found' }); + return res.status(200).json({ sender: data }); + } + + if (req.method === 'DELETE') { + const id = String(req.body?.id ?? req.query['id'] ?? ''); + if (!id) return res.status(400).json({ error: 'Missing sender id' }); + const { error } = await supabase + .from('send_allowed_senders') + .delete() + .eq('id', id) + .eq('user_id', user.id); + if (error) return res.status(500).json({ error: error.message }); + return res.status(200).json({ ok: true }); + } + + return res.status(405).json({ error: 'Method not allowed' }); +} diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 127bb473..fd37a5f8 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -746,6 +746,13 @@ export const READEST_NODE_BASE_URL = 'https://node.readest.com'; export const SHARE_BASE_URL = `${READEST_WEB_BASE_URL}/s`; export const SHARE_EXPIRATION_DAYS = [1, 3, 7] as const; + +// Send to Readest — the domain inbound capture emails are addressed to, the +// R2 bucket holding raw inbound payloads, and the per-user cap on undrained +// inbox items (defense against a leaked address). +export const SEND_EMAIL_DOMAIN = 'readest.com'; +export const SEND_INBOX_BUCKET = 'readest-send-inbox'; +export const SEND_INBOX_PENDING_LIMIT = 50; export const SHARE_DEFAULT_EXPIRATION_DAYS = 3; export const SHARE_MAX_PER_USER = 50; export const SHARE_TOKEN_LENGTH = 22; diff --git a/apps/readest-app/src/services/ingestService.ts b/apps/readest-app/src/services/ingestService.ts new file mode 100644 index 00000000..fec854e4 --- /dev/null +++ b/apps/readest-app/src/services/ingestService.ts @@ -0,0 +1,79 @@ +import type { Book, BookLookupIndex } from '@/types/book'; +import type { AppService } from '@/types/system'; +import type { SystemSettings } from '@/types/settings'; +import { transferManager } from '@/services/transferManager'; + +export interface IngestFileDeps { + appService: AppService; + settings: SystemSettings; + isLoggedIn: boolean; +} + +export interface IngestFileOptions { + /** A file path (desktop/mobile) or a File object (web). */ + file: File | string; + /** Current library, used by importBook for dedup. */ + books: Book[]; + /** Pre-built lookup index for O(1) dedup during batch imports. */ + lookupIndex?: BookLookupIndex; + /** Collection to place the book in. */ + groupId?: string; + groupName?: string; + /** Tag parsed from a Send-to-Readest email subject (`#scifi`). */ + subjectTag?: string; + /** Upload to the cloud even when the user has disabled autoUpload. */ + forceUpload?: boolean; + /** Transient import (not stored long-term) — never uploaded. */ + transient?: boolean; +} + +/** + * Channel-agnostic single-file ingestion. Every capture channel — local library + * import, the /send page, the inbox drainer — calls this so a sent book behaves + * exactly like a locally-imported one. + * + * Persistence (`updateBooks` / `saveLibraryBooks`) and the sync push stay with + * the caller on purpose: batch importers save once per batch, single-item + * callers save per item. The shared logic that must NOT diverge — importing, + * group/tag metadata, the upload decision — lives here. + */ +export async function ingestFile( + opts: IngestFileOptions, + deps: IngestFileDeps, +): Promise { + const { appService, settings, isLoggedIn } = deps; + + const book = await appService.importBook(opts.file, opts.books, { + lookupIndex: opts.lookupIndex, + transient: opts.transient, + }); + if (!book) return null; + + if (opts.groupId) { + book.groupId = opts.groupId; + book.groupName = opts.groupName; + } + + const tag = opts.subjectTag?.trim(); + if (tag) { + const tags = book.tags ?? []; + if (!tags.includes(tag)) { + book.tags = [...tags, tag]; + book.updatedAt = Date.now(); + } + } + + // Sent books force the upload so they reach the user's other devices even + // when autoUpload is off; normal library imports honor the setting. + // Transient imports are never uploaded. + if ( + !opts.transient && + isLoggedIn && + !book.uploadedAt && + (opts.forceUpload || settings.autoUpload) + ) { + transferManager.queueUpload(book); + } + + return book; +} diff --git a/apps/readest-app/src/services/send/conversion/buildEpub.ts b/apps/readest-app/src/services/send/conversion/buildEpub.ts new file mode 100644 index 00000000..00c6a8e0 --- /dev/null +++ b/apps/readest-app/src/services/send/conversion/buildEpub.ts @@ -0,0 +1,128 @@ +import { configureZip } from '@/utils/zip'; +import type { EpubChapter, EpubBuildMetadata } from './types'; + +// Zero the zip timestamps so converting the same source twice yields +// byte-identical EPUBs — that keeps the import-time hash stable and dedups +// a book a user sends more than once. +const zipWriteOptions = { + lastAccessDate: new Date(0), + lastModDate: new Date(0), +}; + +const escapeXml = (str: string): string => { + if (!str) return ''; + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; + +const CSS = ` +body { line-height: 1.6; font-size: 1em; text-align: justify; } +h1, h2, h3 { line-height: 1.3; } +p { margin: 0.6em 0; } +img { max-width: 100%; height: auto; } +`; + +/** + * Build a minimal, valid EPUB 2.0 from sanitized HTML chapters. Shares the + * `@zip.js/zip.js` assembly pattern with `TxtToEpubConverter` — mimetype first + * and uncompressed, `container.xml`, `content.opf`, `toc.ncx`, zeroed + * timestamps. + */ +export async function buildEpub( + chapters: EpubChapter[], + metadata: EpubBuildMetadata, +): Promise { + if (chapters.length === 0) { + throw new Error('buildEpub: no chapters'); + } + await configureZip(); + const { BlobWriter, TextReader, ZipWriter } = await import('@zip.js/zip.js'); + const { title, author, language, identifier } = metadata; + + const zipWriter = new ZipWriter(new BlobWriter('application/epub+zip'), { + extendedTimestamp: false, + }); + await zipWriter.add('mimetype', new TextReader('application/epub+zip'), zipWriteOptions); + + const containerXml = ` + + + + +`; + await zipWriter.add('META-INF/container.xml', new TextReader(containerXml), zipWriteOptions); + + const navPoints = chapters + .map((chapter, i) => { + const id = `chapter${i + 1}`; + return ( + `` + + `${escapeXml(chapter.title)}` + + `` + ); + }) + .join('\n'); + + const tocNcx = ` + + + + + + + + ${escapeXml(title)} + ${escapeXml(author)} + ${navPoints} +`; + await zipWriter.add('toc.ncx', new TextReader(tocNcx), zipWriteOptions); + + await zipWriter.add('style.css', new TextReader(CSS), zipWriteOptions); + + for (let i = 0; i < chapters.length; i++) { + const chapter = chapters[i]!; + const xhtml = ` + + + + ${escapeXml(chapter.title)} + + + ${chapter.html} +`; + await zipWriter.add(`OEBPS/chapter${i + 1}.xhtml`, new TextReader(xhtml), zipWriteOptions); + } + + const manifest = chapters + .map( + (_, i) => + ``, + ) + .join('\n '); + const spine = chapters.map((_, i) => ``).join('\n '); + + const contentOpf = ` + + + ${escapeXml(title)} + ${escapeXml(language)} + ${escapeXml(author)} + ${escapeXml(identifier)} + + + ${manifest} + + + + + ${spine} + +`; + await zipWriter.add('content.opf', new TextReader(contentOpf), zipWriteOptions); + + return (await zipWriter.close()) as Blob; +} diff --git a/apps/readest-app/src/services/send/conversion/conversion-worker-protocol.ts b/apps/readest-app/src/services/send/conversion/conversion-worker-protocol.ts new file mode 100644 index 00000000..27e108d7 --- /dev/null +++ b/apps/readest-app/src/services/send/conversion/conversion-worker-protocol.ts @@ -0,0 +1,26 @@ +import type { ConvertInput } from './convertToEpub'; + +export interface ConversionWorkerRequest { + type: 'convert'; + payload: ConvertInput; +} + +export interface ConversionWorkerSuccess { + type: 'success'; + payload: { + epubBuffer: ArrayBuffer; + name: string; + title: string; + author: string; + }; +} + +export interface ConversionWorkerError { + type: 'error'; + payload: { + message: string; + code?: string; + }; +} + +export type ConversionWorkerResponse = ConversionWorkerSuccess | ConversionWorkerError; diff --git a/apps/readest-app/src/services/send/conversion/conversionWorker.ts b/apps/readest-app/src/services/send/conversion/conversionWorker.ts new file mode 100644 index 00000000..5e1b8a05 --- /dev/null +++ b/apps/readest-app/src/services/send/conversion/conversionWorker.ts @@ -0,0 +1,104 @@ +import { convertToEpub, type ConvertInput } from './convertToEpub'; +import type { ConvertedBook } from './types'; +import type { + ConversionWorkerRequest, + ConversionWorkerResponse, +} from './conversion-worker-protocol'; + +const DEFAULT_TIMEOUT_MS = 120_000; + +async function convertInWorker(input: ConvertInput, timeoutMs: number): Promise { + return await new Promise((resolve, reject) => { + const worker = new Worker( + new URL('../../../workers/send-conversion.worker.ts', import.meta.url), + { + type: 'module', + }, + ); + + const cleanup = () => { + worker.onmessage = null; + worker.onerror = null; + worker.onmessageerror = null; + worker.terminate(); + }; + + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Conversion worker timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + worker.onmessage = (event: MessageEvent) => { + clearTimeout(timer); + if (event.data.type === 'error') { + cleanup(); + reject(new Error(event.data.payload.message)); + return; + } + const { epubBuffer, name, title, author } = event.data.payload; + cleanup(); + resolve({ + file: new File([epubBuffer], name, { type: 'application/epub+zip' }), + title, + author, + }); + }; + + worker.onerror = () => { + clearTimeout(timer); + cleanup(); + reject(new Error('Conversion worker failed')); + }; + worker.onmessageerror = () => { + clearTimeout(timer); + cleanup(); + reject(new Error('Conversion worker message deserialization failed')); + }; + + const request: ConversionWorkerRequest = { type: 'convert', payload: input }; + worker.postMessage(request); + }); +} + +/** + * Convert a document to EPUB off the main thread, falling back to in-thread + * conversion when Web Workers are unavailable or the worker fails. + */ +export async function convertToEpubWithWorker( + input: ConvertInput, + timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise { + if (typeof Worker === 'undefined') { + return convertToEpub(input); + } + try { + return await convertInWorker(input, timeoutMs); + } catch (error) { + console.warn('Conversion worker failed, falling back to main thread:', error); + return convertToEpub(input); + } +} + +const CONVERTIBLE_EXT: Record = { + docx: 'docx', + rtf: 'rtf', + html: 'html', + htm: 'html', + txt: 'txt', +}; + +/** + * If a file is a document Readest can't read natively, convert it to EPUB; + * otherwise return it unchanged. Shared by the `/send` page and the inbox + * drainer so both channels convert identically. + */ +export async function convertFileIfNeeded(file: File): Promise { + const ext = file.name.includes('.') ? file.name.split('.').pop()!.toLowerCase() : ''; + const kind = CONVERTIBLE_EXT[ext]; + if (!kind) return file; + if (kind === 'txt') { + return (await convertToEpubWithWorker({ kind: 'txt', file })).file; + } + const bytes = await file.arrayBuffer(); + return (await convertToEpubWithWorker({ kind, bytes, fileName: file.name })).file; +} diff --git a/apps/readest-app/src/services/send/conversion/convertToEpub.ts b/apps/readest-app/src/services/send/conversion/convertToEpub.ts new file mode 100644 index 00000000..8d0ae259 --- /dev/null +++ b/apps/readest-app/src/services/send/conversion/convertToEpub.ts @@ -0,0 +1,173 @@ +import mammoth from 'mammoth'; +import { Readability } from '@mozilla/readability'; +import { TxtToEpubConverter } from '@/utils/txt'; +import { detectLanguage } from '@/utils/lang'; +import { sanitizeHtml, sanitizeForParsing } from './sanitizeHtml'; +import { buildEpub } from './buildEpub'; +import { ConversionError } from './types'; +import type { ConvertibleMime, ConvertedBook, EpubChapter } from './types'; + +/** Discriminated input — the caller resolves the kind from the MIME type. */ +export type ConvertInput = + | { kind: 'docx'; bytes: ArrayBuffer; fileName?: string } + | { kind: 'rtf'; bytes: ArrayBuffer; fileName?: string } + | { kind: 'html'; bytes: ArrayBuffer; fileName?: string } + | { kind: 'txt'; file: File } + | { kind: 'article'; html: string; url: string }; + +const MIME_TO_KIND: Record = { + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', + 'application/rtf': 'rtf', + 'text/rtf': 'rtf', + 'text/html': 'html', + 'application/xhtml+xml': 'html', + 'text/plain': 'txt', + 'text/uri-list': 'article', +}; + +/** Whether a MIME type needs conversion before the normal import pipeline. */ +export function isConvertible(mime: string): mime is ConvertibleMime { + return mime in MIME_TO_KIND; +} + +export function mimeToKind(mime: ConvertibleMime): ConvertInput['kind'] { + return MIME_TO_KIND[mime]; +} + +// djb2 — a deterministic content hash so re-converting the same source yields +// the same EPUB identifier (and, with zeroed zip timestamps, identical bytes). +function stableIdentifier(content: string): string { + let h = 5381; + for (let i = 0; i < content.length; i++) { + h = ((h << 5) + h + content.charCodeAt(i)) >>> 0; + } + return `send-to-readest:${h.toString(16)}`; +} + +function stripTags(html: string): string { + return html + .replace(/<[^>]+>/g, ' ') + .replace(/&[a-z]+;/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function safeFileName(title: string): string { + const base = title.replace(/[\\/:*?"<>|]+/g, '_').trim() || 'document'; + return base.slice(0, 120); +} + +/** Assemble an EPUB from a single block of sanitized HTML. */ +async function htmlToBook(rawHtml: string, title: string, author: string): Promise { + const html = sanitizeHtml(rawHtml); + const text = stripTags(html); + if (!text) { + throw new ConversionError('Document has no readable content', 'empty_input'); + } + const language = detectLanguage(text.slice(0, 2048)) || 'en'; + const chapter: EpubChapter = { title, html }; + const blob = await buildEpub([chapter], { + title, + author, + language, + identifier: stableIdentifier(html), + }); + const file = new File([blob], `${safeFileName(title)}.epub`, { + type: 'application/epub+zip', + }); + return { file, title, author }; +} + +/** Pull `` / first `<h1>` out of a full HTML document. */ +function extractHtmlTitle(html: string, fallback: string): string { + try { + const doc = new DOMParser().parseFromString(sanitizeForParsing(html), 'text/html'); + const t = doc.querySelector('title')?.textContent?.trim(); + if (t) return t; + const h1 = doc.querySelector('h1')?.textContent?.trim(); + if (h1) return h1; + } catch { + /* fall through */ + } + return fallback; +} + +// Best-effort RTF → plain text: drop control words, unescape hex, strip groups. +function rtfToText(rtf: string): string { + return rtf + .replace(/\\'[0-9a-fA-F]{2}/g, ' ') + .replace(/\\par[d]?/g, '\n') + .replace(/\\[a-zA-Z]+-?\d* ?/g, '') + .replace(/[{}]/g, '') + .replace(/\r/g, '') + .trim(); +} + +function baseName(fileName: string | undefined, fallback: string): string { + if (!fileName) return fallback; + return fileName.replace(/\.[^.]+$/, '').trim() || fallback; +} + +/** + * Convert a document Readest cannot open natively into an EPUB. Runs entirely + * client-side (browser or Tauri webview) — meant to be called inside a Web + * Worker so the heavy parsing never blocks the UI thread. + */ +export async function convertToEpub(input: ConvertInput): Promise<ConvertedBook> { + switch (input.kind) { + case 'txt': { + const result = await new TxtToEpubConverter().convert({ file: input.file }); + return { file: result.file, title: result.bookTitle, author: '' }; + } + case 'docx': { + if (input.bytes.byteLength === 0) { + throw new ConversionError('Empty .docx file', 'empty_input'); + } + let html: string; + try { + const result = await mammoth.convertToHtml({ arrayBuffer: input.bytes }); + html = result.value; + } catch (err) { + throw new ConversionError(`Could not parse .docx: ${String(err)}`, 'parse_failed'); + } + return htmlToBook(html, baseName(input.fileName, 'Document'), ''); + } + case 'html': { + const raw = new TextDecoder('utf-8').decode(input.bytes); + const title = extractHtmlTitle(raw, baseName(input.fileName, 'Document')); + return htmlToBook(raw, title, ''); + } + case 'rtf': { + const rtf = new TextDecoder('utf-8').decode(input.bytes); + const text = rtfToText(rtf); + if (!text) { + throw new ConversionError('Could not extract text from .rtf', 'parse_failed'); + } + const html = text + .split(/\n+/) + .map((line) => `<p>${line.replace(/[<>&]/g, ' ').trim()}</p>`) + .join(''); + return htmlToBook(html, baseName(input.fileName, 'Document'), ''); + } + case 'article': { + let parsed: { title?: string | null; content?: string | null; byline?: string | null } | null; + try { + const doc = new DOMParser().parseFromString(sanitizeForParsing(input.html), 'text/html'); + parsed = new Readability(doc).parse(); + } catch (err) { + throw new ConversionError(`Could not extract article: ${String(err)}`, 'parse_failed'); + } + if (!parsed?.content) { + throw new ConversionError('No readable article found at the URL', 'parse_failed'); + } + const title = parsed.title?.trim() || extractHtmlTitle(input.html, input.url); + return htmlToBook(parsed.content, title, parsed.byline?.trim() || ''); + } + default: { + throw new ConversionError( + `Unsupported conversion input: ${(input as { kind: string }).kind}`, + 'unsupported_type', + ); + } + } +} diff --git a/apps/readest-app/src/services/send/conversion/sanitizeHtml.ts b/apps/readest-app/src/services/send/conversion/sanitizeHtml.ts new file mode 100644 index 00000000..becbb2bc --- /dev/null +++ b/apps/readest-app/src/services/send/conversion/sanitizeHtml.ts @@ -0,0 +1,69 @@ +import DOMPurify from 'dompurify'; + +/** + * Strip untrusted HTML (from email bodies, web pages, DOCX conversion) down to + * safe, EPUB-appropriate structural markup. Removes scripts, event handlers, + * styles, iframes, and form controls; keeps headings, text, lists, tables, + * links and images. + * + * Runs against the real DOM — Send to Readest converts on the client (browser + * or Tauri webview), both of which provide `window`/`DOMParser`. + */ +export function sanitizeHtml(html: string): string { + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'p', + 'br', + 'hr', + 'blockquote', + 'pre', + 'code', + 'strong', + 'em', + 'b', + 'i', + 'u', + 's', + 'sup', + 'sub', + 'span', + 'ul', + 'ol', + 'li', + 'dl', + 'dt', + 'dd', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + 'a', + 'img', + 'figure', + 'figcaption', + ], + ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'colspan', 'rowspan'], + // Drop anything that would load or run remote code. + FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input'], + FORBID_ATTR: ['srcset'], + ALLOW_DATA_ATTR: false, + }); +} + +/** + * Strip scripts and event handlers from an untrusted *document* before it is + * handed to `DOMParser`. Unlike `sanitizeHtml`, this keeps the document + * structure (`<head>`, `<title>`, sectioning elements) so title extraction and + * Readability still work — it only removes anything executable. + */ +export function sanitizeForParsing(html: string): string { + return DOMPurify.sanitize(html, { WHOLE_DOCUMENT: true }); +} diff --git a/apps/readest-app/src/services/send/conversion/types.ts b/apps/readest-app/src/services/send/conversion/types.ts new file mode 100644 index 00000000..c87f92f2 --- /dev/null +++ b/apps/readest-app/src/services/send/conversion/types.ts @@ -0,0 +1,41 @@ +/** MIME types that Send to Readest converts to EPUB before import. */ +export type ConvertibleMime = + | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' // .docx + | 'application/rtf' + | 'text/rtf' + | 'text/html' + | 'application/xhtml+xml' + | 'text/plain' + | 'text/uri-list'; // a web article URL + +export interface ConvertedBook { + /** The generated `.epub` file, ready for the normal import pipeline. */ + file: File; + title: string; + author: string; +} + +/** One chapter of body-inner HTML (already sanitized). */ +export interface EpubChapter { + title: string; + html: string; +} + +export interface EpubBuildMetadata { + title: string; + author: string; + language: string; + /** Stable EPUB `dc:identifier` — derive deterministically so re-converting + * the same source yields the same bytes and dedups on import. */ + identifier: string; +} + +export class ConversionError extends Error { + constructor( + message: string, + readonly code: 'unsupported_type' | 'empty_input' | 'parse_failed' | 'fetch_failed', + ) { + super(message); + this.name = 'ConversionError'; + } +} diff --git a/apps/readest-app/src/services/send/devicePrefs.ts b/apps/readest-app/src/services/send/devicePrefs.ts new file mode 100644 index 00000000..e083cb44 --- /dev/null +++ b/apps/readest-app/src/services/send/devicePrefs.ts @@ -0,0 +1,26 @@ +// Per-device Send to Readest preferences, stored in localStorage (not synced +// across devices). + +const DRAIN_ENABLED_KEY = 'readest-send-drain-enabled'; + +/** + * Whether this device should drain the Send to Readest inbox — download, + * convert, and import items emailed to the user's address. Defaults to true; + * a user with several devices can turn it off on the ones that should not do + * the work. + */ +export function isInboxDrainEnabled(): boolean { + try { + return localStorage.getItem(DRAIN_ENABLED_KEY) !== 'false'; + } catch { + return true; + } +} + +export function setInboxDrainEnabled(enabled: boolean): void { + try { + localStorage.setItem(DRAIN_ENABLED_KEY, enabled ? 'true' : 'false'); + } catch { + /* localStorage unavailable — the default (enabled) stands */ + } +} diff --git a/apps/readest-app/src/services/send/inboxDrainer.ts b/apps/readest-app/src/services/send/inboxDrainer.ts new file mode 100644 index 00000000..7821b234 --- /dev/null +++ b/apps/readest-app/src/services/send/inboxDrainer.ts @@ -0,0 +1,84 @@ +import type { DBSendInboxItem } from '@/types/sendRecords'; + +/** + * Side-effect ports the drainer needs. The Supabase RPC calls, the payload + * download, and the import pipeline are injected so the orchestration here is + * unit-testable and free of React/network coupling. The `useInboxDrainer` hook + * builds the real adapter from app context. + */ +export interface InboxDrainerDeps { + /** `claim_inbox_item` RPC — claims the oldest drainable row, or null. */ + claimItem: () => Promise<DBSendInboxItem | null>; + /** `renew_inbox_claim` RPC — refreshes the lease mid-job. */ + renewClaim: (id: string) => Promise<boolean>; + /** `complete_inbox_item` RPC — terminal success. */ + completeItem: (id: string) => Promise<boolean>; + /** `fail_inbox_item` RPC — increments attempts; retries or fails terminally. */ + failItem: (id: string, error: string) => Promise<boolean>; + /** Resolve a claimed item into an EPUB-or-native File ready for import. */ + resolvePayload: (item: DBSendInboxItem) => Promise<File>; + /** Run the shared import pipeline (wraps ingestFile + persistence + push). */ + importItem: (file: File, item: DBSendInboxItem) => Promise<void>; + /** Best-effort R2 payload cleanup after a terminal success. */ + deletePayload?: (item: DBSendInboxItem) => Promise<void>; +} + +export interface DrainResult { + processed: number; + failed: number; +} + +/** How often to refresh a 15-minute lease during a long conversion/upload. */ +export const LEASE_RENEW_INTERVAL_MS = 5 * 60 * 1000; + +/** Max items drained per pass, so a large backlog never freezes a sync cycle. */ +export const DEFAULT_MAX_ITEMS_PER_PASS = 5; + +/** + * Drain pending inbox items one at a time. Each item is claimed via the + * lease RPC (so only one device processes it), kept alive with a heartbeat, + * imported through the shared pipeline, then marked done — or failed, which + * the RPC turns into a retry or a terminal failure after three attempts. + * + * importItem is expected to be idempotent (importBook dedups by hash), so a + * retry after a partial failure never produces a duplicate book. + */ +export async function drainInbox( + deps: InboxDrainerDeps, + maxItems: number = DEFAULT_MAX_ITEMS_PER_PASS, +): Promise<DrainResult> { + let processed = 0; + let failed = 0; + + for (let i = 0; i < maxItems; i++) { + const item = await deps.claimItem(); + if (!item) break; + + const heartbeat = setInterval(() => { + void deps.renewClaim(item.id); + }, LEASE_RENEW_INTERVAL_MS); + + try { + const file = await deps.resolvePayload(item); + await deps.importItem(file, item); + clearInterval(heartbeat); + await deps.completeItem(item.id); + if (deps.deletePayload) { + // The book is already imported; a failed cleanup only leaves an + // orphan R2 object, so never let it fail the item. + try { + await deps.deletePayload(item); + } catch (err) { + console.warn('Inbox payload cleanup failed:', err); + } + } + processed++; + } catch (err) { + clearInterval(heartbeat); + await deps.failItem(item.id, err instanceof Error ? err.message : String(err)); + failed++; + } + } + + return { processed, failed }; +} diff --git a/apps/readest-app/src/services/send/sendAddress.ts b/apps/readest-app/src/services/send/sendAddress.ts new file mode 100644 index 00000000..f7412ccd --- /dev/null +++ b/apps/readest-app/src/services/send/sendAddress.ts @@ -0,0 +1,98 @@ +// Crockford base32 — omits I, L, O, U to avoid look-alike confusion. Lowercased +// here because the value is the local part of an email address. +const CROCKFORD = '0123456789abcdefghjkmnpqrstvwxyz'; +// 5 chars ≈ 25 bits. Following Send to Kindle's model: the suffix is for +// address uniqueness, not secrecy — the approved-sender allowlist is the +// security gate. It also keeps `{slug}-{token}` from ever colliding with a +// plain role address like `info@` / `admin@`. +const TOKEN_LENGTH = 5; +const SLUG_MAX = 12; + +// Role / system addresses a user-chosen slug must not impersonate. The token +// suffix already prevents an exact routing collision; this is for clarity so +// nobody gets an address that reads as official. +const RESERVED_SLUGS = new Set([ + 'admin', + 'info', + 'support', + 'legal', + 'privacy', + 'mailrobot', + 'help', + 'contact', + 'abuse', + 'postmaster', + 'noreply', + 'sales', + 'readest', + 'root', + 'webmaster', + 'security', + 'billing', +]); + +/** Derive a short, sanitized slug from a display name or email local part. */ +export function slugFromIdentity(identity: string): string { + const localPart = identity.includes('@') ? identity.split('@')[0]! : identity; + return sanitizeSlug(localPart) || 'reader'; +} + +/** Normalize a user-supplied slug to the allowed shape (`[a-z0-9]`, ≤12). */ +export function sanitizeSlug(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, '') + .slice(0, SLUG_MAX); +} + +/** Whether a slug impersonates a role/system address. */ +export function isReservedSlug(slug: string): boolean { + return RESERVED_SLUGS.has(slug); +} + +/** Generate the random, high-entropy half of an inbound address. */ +export function generateAddressToken( + randomBytes: Uint8Array = crypto.getRandomValues(new Uint8Array(TOKEN_LENGTH)), +): string { + let token = ''; + for (let i = 0; i < TOKEN_LENGTH; i++) { + token += CROCKFORD[randomBytes[i]! % 32]; + } + return token; +} + +/** + * Build an inbound address local part `{slug}-{token}` from an explicit slug. + * The caller must retry on a UNIQUE-constraint collision (a fresh token). + */ +export function buildSendAddress(slug: string): string { + return `${slug}-${generateAddressToken()}`; +} + +/** + * Build an inbound address local part with a slug auto-derived from the user's + * identity — used for the lazily-created default address. + */ +export function generateSendAddress(identity: string): string { + return buildSendAddress(slugFromIdentity(identity)); +} + +/** Validate an address local part has the expected `{slug}-{token}` shape. */ +export function isValidSendAddress(address: string): boolean { + return /^[a-z0-9]{1,12}-[0-9abcdefghjkmnpqrstvwxyz]{5}$/.test(address); +} + +/** Normalize a sender email for allowlist comparison. */ +export function normalizeSenderEmail(email: string): string { + return email.trim().toLowerCase(); +} + +/** + * Pull a routing tag out of an email subject: the first `#word` token, e.g. + * `Re: my book #scifi` → `scifi`. Returns undefined when absent. + */ +export function parseSubjectTag(subject: string | null | undefined): string | undefined { + if (!subject) return undefined; + const match = subject.match(/#([\p{L}\p{N}_-]{1,40})/u); + return match ? match[1] : undefined; +} diff --git a/apps/readest-app/src/styles/globals.css b/apps/readest-app/src/styles/globals.css index 5bae3351..d68934cb 100644 --- a/apps/readest-app/src/styles/globals.css +++ b/apps/readest-app/src/styles/globals.css @@ -553,12 +553,25 @@ input[type='range'].slider-input { } [data-eink='true'] button, +/* btn-contrast — a solid, high-contrast CTA: base-content background with a + base-100 label. Theme-neutral (unlike the colored btn-primary); fits the + minimalist themes and is already e-ink-correct. */ +.btn-contrast { + background-color: theme('colors.base-content') !important; + border: 1px solid theme('colors.base-content') !important; + color: theme('colors.base-100') !important; +} +.btn-contrast:hover { + opacity: 0.9; +} + [data-eink='true'] .btn { color: theme('colors.base-content') !important; } [data-eink='true'] .btn-primary, -[data-eink='true'] .btn-outline { +[data-eink='true'] .btn-outline, +[data-eink='true'] .btn-contrast { background-color: theme('colors.base-content') !important; border: 1px solid theme('colors.base-content') !important; color: theme('colors.base-100') !important; diff --git a/apps/readest-app/src/types/sendRecords.ts b/apps/readest-app/src/types/sendRecords.ts new file mode 100644 index 00000000..fcf504fe --- /dev/null +++ b/apps/readest-app/src/types/sendRecords.ts @@ -0,0 +1,43 @@ +// TypeScript mirrors of the Send to Readest tables in +// docker/volumes/db/init/schema.sql. + +export interface DBSendAddress { + user_id: string; + address: string; + enabled: boolean; + created_at: string; + rotated_at: string | null; +} + +export type SendSenderStatus = 'approved' | 'pending'; + +export interface DBSendAllowedSender { + id: string; + user_id: string; + email: string; + status: SendSenderStatus; + created_at: string; +} + +export type SendInboxKind = 'file' | 'url' | 'html'; +export type SendInboxSource = 'email' | 'extension'; +export type SendInboxStatus = 'pending' | 'claimed' | 'done' | 'failed'; + +export interface DBSendInboxItem { + id: string; + user_id: string; + kind: SendInboxKind; + source: SendInboxSource; + payload_key: string | null; + url: string | null; + filename: string | null; + subject_tag: string | null; + byte_size: number; + status: SendInboxStatus; + claimed_by: string | null; + claimed_at: string | null; + attempts: number; + error: string | null; + created_at: string; + updated_at: string; +} diff --git a/apps/readest-app/src/workers/send-conversion.worker.ts b/apps/readest-app/src/workers/send-conversion.worker.ts new file mode 100644 index 00000000..508cbac9 --- /dev/null +++ b/apps/readest-app/src/workers/send-conversion.worker.ts @@ -0,0 +1,38 @@ +import { convertToEpub } from '../services/send/conversion/convertToEpub'; +import { ConversionError } from '../services/send/conversion/types'; +import { + ConversionWorkerRequest, + ConversionWorkerResponse, +} from '../services/send/conversion/conversion-worker-protocol'; + +// Document conversion (mammoth DOCX parsing, Readability, EPUB zip assembly) is +// CPU-heavy; running it here keeps the UI thread responsive. +const workerContext: DedicatedWorkerGlobalScope = self as unknown as DedicatedWorkerGlobalScope; + +workerContext.onmessage = async (event: MessageEvent<ConversionWorkerRequest>) => { + if (event.data.type !== 'convert') return; + + try { + const book = await convertToEpub(event.data.payload); + const epubBuffer = await book.file.arrayBuffer(); + const response: ConversionWorkerResponse = { + type: 'success', + payload: { + epubBuffer, + name: book.file.name, + title: book.title, + author: book.author, + }, + }; + workerContext.postMessage(response, [epubBuffer]); + } catch (error) { + const response: ConversionWorkerResponse = { + type: 'error', + payload: { + message: error instanceof Error ? error.message : String(error), + code: error instanceof ConversionError ? error.code : undefined, + }, + }; + workerContext.postMessage(response); + } +}; diff --git a/apps/readest-app/workers/send-email/.gitignore b/apps/readest-app/workers/send-email/.gitignore new file mode 100644 index 00000000..3c3629e6 --- /dev/null +++ b/apps/readest-app/workers/send-email/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/apps/readest-app/workers/send-email/package.json b/apps/readest-app/workers/send-email/package.json new file mode 100644 index 00000000..f6102349 --- /dev/null +++ b/apps/readest-app/workers/send-email/package.json @@ -0,0 +1,20 @@ +{ + "name": "readest-send-email-worker", + "version": "0.1.0", + "private": true, + "description": "Cloudflare Email Worker that ingests Send to Readest inbound mail", + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@supabase/supabase-js": "^2.45.0", + "postal-mime": "^2.2.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.0.0", + "typescript": "^5.0.0", + "wrangler": "^4.0.0" + } +} diff --git a/apps/readest-app/workers/send-email/src/index.ts b/apps/readest-app/workers/send-email/src/index.ts new file mode 100644 index 00000000..8357b748 --- /dev/null +++ b/apps/readest-app/workers/send-email/src/index.ts @@ -0,0 +1,167 @@ +import { createClient } from '@supabase/supabase-js'; +import PostalMime from 'postal-mime'; + +interface Env { + SEND_EMAIL_DOMAIN: string; + MAX_MESSAGE_BYTES: string; + INBOX_PENDING_LIMIT: string; + SUPABASE_URL: string; + SUPABASE_SERVICE_ROLE_KEY: string; + INBOX_BUCKET: R2Bucket; +} + +// Extensions Readest reads natively or converts client-side after import. +const ACCEPTED_EXTS = new Set([ + 'epub', + 'mobi', + 'azw', + 'azw3', + 'fb2', + 'fbz', + 'zip', + 'cbz', + 'pdf', + 'txt', + 'docx', + 'rtf', + 'html', + 'htm', +]); + +const normalizeEmail = (email: string): string => email.trim().toLowerCase(); + +const extensionOf = (filename: string): string => + filename.includes('.') ? filename.split('.').pop()!.toLowerCase() : ''; + +/** First `#tag` token in an email subject (`my book #scifi` -> `scifi`). */ +const parseSubjectTag = (subject: string | undefined): string | null => { + if (!subject) return null; + const match = subject.match(/#([\p{L}\p{N}_-]{1,40})/u); + return match ? match[1]! : null; +}; + +export default { + async email(message: ForwardableEmailMessage, env: Env): Promise<void> { + const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false }, + }); + + // 1. Resolve the recipient local part to a user. + const localPart = message.to.split('@')[0]!.toLowerCase(); + const { data: addressRow } = await supabase + .from('send_addresses') + .select('user_id, enabled') + .eq('address', localPart) + .maybeSingle(); + if (!addressRow || !addressRow.enabled) { + // Unknown address: reject silently — no backscatter to a guessed address. + message.setReject('Unknown address'); + return; + } + const userId = addressRow.user_id as string; + + // 2. Anti-spoofing is enforced UPSTREAM by Cloudflare Email Routing, in + // the SMTP session before this Worker ever runs: it requires SPF or DKIM + // to pass, rejects per the sender's DMARC policy, and applies RBL + spam + // scoring. The Worker therefore cannot (and need not) re-verify — Cloudflare + // exposes no verdict header, and the `From` header is already trustworthy + // by the time we see it. The approved-sender allowlist below is the gate. + + // 3. Size guard (Cloudflare's own ceiling is ~25-30 MB). + const maxBytes = Number(env.MAX_MESSAGE_BYTES) || 26_214_400; + if (message.rawSize > maxBytes) { + message.setReject('Message too large — use the Send page for large files'); + return; + } + + // 4. Parse the MIME message. + const rawBuffer = await new Response(message.raw).arrayBuffer(); + const parsed = await PostalMime.parse(rawBuffer); + const fromEmail = normalizeEmail(parsed.from?.address ?? message.from); + + // 5. Approved-sender allowlist. + const { data: senderRow } = await supabase + .from('send_allowed_senders') + .select('status') + .eq('user_id', userId) + .eq('email', fromEmail) + .maybeSingle(); + if (!senderRow || senderRow.status !== 'approved') { + if (!senderRow) { + // Record the sender as pending so the user can approve it in settings. + await supabase + .from('send_allowed_senders') + .insert({ user_id: userId, email: fromEmail, status: 'pending' }); + } + message.setReject('Sender not approved — approve it in Readest settings'); + return; + } + + // 6. Inbox quota — blunt a leaked-address flood. Count both pending and + // claimed items: a crashed drainer can leave items stuck in `claimed` + // until the lease expires, and those still occupy the inbox. + const limit = Number(env.INBOX_PENDING_LIMIT) || 50; + const { count } = await supabase + .from('send_inbox') + .select('id', { count: 'exact', head: true }) + .eq('user_id', userId) + .in('status', ['pending', 'claimed']); + if ((count ?? 0) >= limit) { + message.setReject('Inbox is full — open Readest to process pending items'); + return; + } + + const subjectTag = parseSubjectTag(parsed.subject); + + // 7. Pick the first accepted attachment. + const attachment = (parsed.attachments ?? []).find((a) => + ACCEPTED_EXTS.has(extensionOf(a.filename ?? '')), + ); + + if (attachment) { + const inboxId = crypto.randomUUID(); + const filename = attachment.filename ?? 'document'; + const payloadKey = `inbox/${userId}/${inboxId}/${filename}`; + const body = + typeof attachment.content === 'string' + ? new TextEncoder().encode(attachment.content) + : new Uint8Array(attachment.content); + await env.INBOX_BUCKET.put(payloadKey, body); + const { error: insertError } = await supabase.from('send_inbox').insert({ + id: inboxId, + user_id: userId, + kind: 'file', + source: 'email', + payload_key: payloadKey, + filename, + subject_tag: subjectTag, + byte_size: body.byteLength, + }); + if (insertError) { + // The inbox row is the source of truth; without it the R2 object is + // an unreachable orphan. Delete it and reject so the sender retries. + await env.INBOX_BUCKET.delete(payloadKey).catch(() => {}); + message.setReject('Could not queue the message — please retry'); + } + return; + } + + // 8. No attachment: treat a URL in the body as a read-later capture. + const urlMatch = (parsed.text ?? '').match(/https?:\/\/\S+/); + if (urlMatch) { + const { error: insertError } = await supabase.from('send_inbox').insert({ + user_id: userId, + kind: 'url', + source: 'email', + url: urlMatch[0], + subject_tag: subjectTag, + }); + if (insertError) { + message.setReject('Could not queue the message — please retry'); + } + return; + } + + message.setReject('No supported attachment or link found'); + }, +}; diff --git a/apps/readest-app/workers/send-email/tsconfig.json b/apps/readest-app/workers/send-email/tsconfig.json new file mode 100644 index 00000000..5d0fd6bf --- /dev/null +++ b/apps/readest-app/workers/send-email/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/readest-app/workers/send-email/wrangler.toml b/apps/readest-app/workers/send-email/wrangler.toml new file mode 100644 index 00000000..76881a30 --- /dev/null +++ b/apps/readest-app/workers/send-email/wrangler.toml @@ -0,0 +1,27 @@ +name = "readest-send-email" +main = "src/index.ts" +compatibility_date = "2026-04-10" +compatibility_flags = ["nodejs_compat"] + +# Bound to Cloudflare Email Routing on the readest.com zone: the catch-all +# rule routes to this Worker, while the existing role addresses (info@, +# admin@, …) keep their own forward rules. readest.com MX must point to +# Cloudflare. + +[vars] +SEND_EMAIL_DOMAIN = "readest.com" +# Reject inbound messages larger than this (Cloudflare's own ceiling is +# ~25-30 MB); senders are pointed at the web /send page instead. +MAX_MESSAGE_BYTES = "26214400" +# Per-user cap on undrained inbox items. +INBOX_PENDING_LIMIT = "50" + +# R2 bucket for raw inbound payloads. Drained, then deleted by the client. +[[r2_buckets]] +binding = "INBOX_BUCKET" +bucket_name = "readest-send-inbox" + +# Secrets (set with `wrangler secret put`): +# SUPABASE_URL +# SUPABASE_SERVICE_ROLE_KEY +# APPROVE_LINK_BASE e.g. https://web.readest.com/settings/send diff --git a/docker/volumes/db/migrations/012_send_to_readest.sql b/docker/volumes/db/migrations/012_send_to_readest.sql new file mode 100644 index 00000000..5cff57c8 --- /dev/null +++ b/docker/volumes/db/migrations/012_send_to_readest.sql @@ -0,0 +1,180 @@ +-- Migration 012: Send to Readest — inbound capture (email / web / extension). +-- +-- Out-of-app channels (email, browser extension) drop a raw payload into +-- `send_inbox`; any Readest client drains it through the normal import +-- pipeline. `send_inbox` state transitions go ONLY through the SECURITY +-- DEFINER RPCs below — clients have SELECT but no write policy, so a client +-- cannot forge `done`/`failed` or reset another device's claim. + +-- Per-user inbound email address (the local part of `<address>@send.readest.com`). +CREATE TABLE IF NOT EXISTS public.send_addresses ( + user_id uuid NOT NULL, + address text NOT NULL, + enabled boolean NOT NULL DEFAULT true, + created_at timestamp with time zone NOT NULL DEFAULT now(), + rotated_at timestamp with time zone NULL, + CONSTRAINT send_addresses_pkey PRIMARY KEY (user_id), + CONSTRAINT send_addresses_address_key UNIQUE (address), + CONSTRAINT send_addresses_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_send_addresses_address ON public.send_addresses (address); + +ALTER TABLE public.send_addresses ENABLE ROW LEVEL SECURITY; +CREATE POLICY send_addresses_select ON public.send_addresses FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id); +CREATE POLICY send_addresses_insert ON public.send_addresses FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id); +CREATE POLICY send_addresses_update ON public.send_addresses FOR UPDATE TO authenticated USING ((SELECT auth.uid()) = user_id); +CREATE POLICY send_addresses_delete ON public.send_addresses FOR DELETE TO authenticated USING ((SELECT auth.uid()) = user_id); + +-- Approved-sender allowlist. `pending` rows are senders awaiting one-click approval. +CREATE TABLE IF NOT EXISTS public.send_allowed_senders ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, + email text NOT NULL, + status text NOT NULL DEFAULT 'approved', + created_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT send_allowed_senders_pkey PRIMARY KEY (id), + CONSTRAINT send_allowed_senders_user_email_key UNIQUE (user_id, email), + CONSTRAINT send_allowed_senders_status_check CHECK (status IN ('approved', 'pending')), + CONSTRAINT send_allowed_senders_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_send_allowed_senders_user ON public.send_allowed_senders (user_id); + +ALTER TABLE public.send_allowed_senders ENABLE ROW LEVEL SECURITY; +CREATE POLICY send_allowed_senders_select ON public.send_allowed_senders FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id); +CREATE POLICY send_allowed_senders_insert ON public.send_allowed_senders FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id); +CREATE POLICY send_allowed_senders_update ON public.send_allowed_senders FOR UPDATE TO authenticated USING ((SELECT auth.uid()) = user_id); +CREATE POLICY send_allowed_senders_delete ON public.send_allowed_senders FOR DELETE TO authenticated USING ((SELECT auth.uid()) = user_id); + +-- The capture inbox. Rows are kept after `done`/`failed` as the user's +-- "Recent activity" log; the R2 payload is deleted once a row reaches `done`. +CREATE TABLE IF NOT EXISTS public.send_inbox ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, + kind text NOT NULL, + source text NOT NULL, + payload_key text NULL, + url text NULL, + filename text NULL, + subject_tag text NULL, + byte_size bigint NOT NULL DEFAULT 0, + status text NOT NULL DEFAULT 'pending', + claimed_by text NULL, + claimed_at timestamp with time zone NULL, + attempts integer NOT NULL DEFAULT 0, + error text NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT send_inbox_pkey PRIMARY KEY (id), + CONSTRAINT send_inbox_kind_check CHECK (kind IN ('file', 'url', 'html')), + CONSTRAINT send_inbox_source_check CHECK (source IN ('email', 'extension')), + CONSTRAINT send_inbox_status_check CHECK (status IN ('pending', 'claimed', 'done', 'failed')), + CONSTRAINT send_inbox_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_send_inbox_user_status ON public.send_inbox (user_id, status, created_at); + +ALTER TABLE public.send_inbox ENABLE ROW LEVEL SECURITY; +-- SELECT only: the Recent-activity UI reads rows. Writes go through the RPCs. +CREATE POLICY send_inbox_select ON public.send_inbox FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id); + +-- Claim the oldest drainable row (pending, or a claim whose 15-minute lease +-- expired). FOR UPDATE SKIP LOCKED makes concurrent drainers pick distinct rows. +CREATE OR REPLACE FUNCTION public.claim_inbox_item(p_device text) +RETURNS public.send_inbox +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_row public.send_inbox; +BEGIN + UPDATE public.send_inbox + SET status = 'claimed', claimed_by = p_device, claimed_at = now(), updated_at = now() + WHERE id = ( + SELECT id FROM public.send_inbox + WHERE user_id = auth.uid() + AND ( + status = 'pending' + OR (status = 'claimed' AND claimed_at < now() - interval '15 minutes') + ) + ORDER BY created_at + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING * INTO v_row; + RETURN v_row; +END; +$$; + +-- Refresh the lease for a long-running job; succeeds only if still owned. +CREATE OR REPLACE FUNCTION public.renew_inbox_claim(p_id uuid, p_device text) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_count integer; +BEGIN + UPDATE public.send_inbox + SET claimed_at = now(), updated_at = now() + WHERE id = p_id AND user_id = auth.uid() + AND status = 'claimed' AND claimed_by = p_device; + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count > 0; +END; +$$; + +-- Terminal success. +CREATE OR REPLACE FUNCTION public.complete_inbox_item(p_id uuid, p_device text) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_count integer; +BEGIN + UPDATE public.send_inbox + SET status = 'done', error = NULL, updated_at = now() + WHERE id = p_id AND user_id = auth.uid() + AND status = 'claimed' AND claimed_by = p_device; + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count > 0; +END; +$$; + +-- Failure: increment attempts atomically; back to `pending` for retry, or +-- terminal `failed` after the third attempt. +CREATE OR REPLACE FUNCTION public.fail_inbox_item(p_id uuid, p_device text, p_error text) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_count integer; +BEGIN + UPDATE public.send_inbox + SET attempts = attempts + 1, + status = CASE WHEN attempts + 1 >= 3 THEN 'failed' ELSE 'pending' END, + error = p_error, + claimed_by = NULL, + claimed_at = NULL, + updated_at = now() + WHERE id = p_id AND user_id = auth.uid() + AND status = 'claimed' AND claimed_by = p_device; + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count > 0; +END; +$$; + +GRANT ALL ON public.send_addresses TO authenticated; +GRANT ALL ON public.send_allowed_senders TO authenticated; +GRANT SELECT ON public.send_inbox TO authenticated; +GRANT EXECUTE ON FUNCTION public.claim_inbox_item(text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.renew_inbox_claim(uuid, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.complete_inbox_item(uuid, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.fail_inbox_item(uuid, text, text) TO authenticated; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 449bc67b..d610b69f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,12 +99,15 @@ importers: '@fabianlars/tauri-plugin-oauth': specifier: '2' version: 2.0.0 + '@mozilla/readability': + specifier: ^0.6.0 + version: 0.6.0 '@napi-rs/wasm-runtime': specifier: ^1.1.1 version: 1.1.4(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1) '@opennextjs/cloudflare': specifier: ^1.19.4 - version: 1.19.9(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(wrangler@4.90.0) + version: 1.19.9(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(wrangler@4.90.0(@cloudflare/workers-types@4.20260518.1)) '@radix-ui/react-collapsible': specifier: ^1.1.12 version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -303,6 +306,9 @@ importers: lunr: specifier: ^2.3.9 version: 2.3.9 + mammoth: + specifier: ^1.12.0 + version: 1.12.0 marked: specifier: ^15.0.12 version: 15.0.12 @@ -555,7 +561,26 @@ importers: version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(@vitest/browser-playwright@4.1.6)(@vitest/browser-webdriverio@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) wrangler: specifier: ^4.85.0 - version: 4.90.0 + version: 4.90.0(@cloudflare/workers-types@4.20260518.1) + + apps/readest-app/workers/send-email: + dependencies: + '@supabase/supabase-js': + specifier: ^2.45.0 + version: 2.105.4 + postal-mime: + specifier: ^2.2.0 + version: 2.7.4 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.0.0 + version: 4.20260518.1 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + wrangler: + specifier: ^4.0.0 + version: 4.90.0(@cloudflare/workers-types@4.20260518.1) packages/foliate-js: dependencies: @@ -1192,6 +1217,9 @@ packages: cpu: [x64] os: [win32] + '@cloudflare/workers-types@4.20260518.1': + resolution: {integrity: sha512-xXzGrbRi8RHRBNQFgXYkzrB4DgF0RXvmp8E1vCxoBmINpeitM/ZjVDd1CNC+N3uXjgcNjacoz4OgTa0rxgig1A==} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -2125,6 +2153,10 @@ packages: '@mermaid-js/parser@1.1.1': resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + '@mozilla/readability@0.6.0': + resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} + engines: {node: '>=14.0.0'} + '@napi-rs/canvas-android-arm64@0.1.100': resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} engines: {node: '>= 10'} @@ -4113,6 +4145,10 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -4290,6 +4326,9 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -4444,6 +4483,9 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -5093,6 +5135,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dingbat-to-unicode@1.0.1: + resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -5135,6 +5180,9 @@ packages: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} + duck@0.1.12: + resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -6285,6 +6333,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lop@0.4.2: + resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -6326,6 +6377,11 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mammoth@1.12.0: + resolution: {integrity: sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==} + engines: {node: '>=12.0.0'} + hasBin: true + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -6791,6 +6847,9 @@ packages: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true + option@0.2.4: + resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} + overlayscrollbars-react@0.5.6: resolution: {integrity: sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==} peerDependencies: @@ -6900,6 +6959,10 @@ packages: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -6970,6 +7033,9 @@ packages: points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postal-mime@2.7.4: + resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==} + postcss-cli@11.0.1: resolution: {integrity: sha512-0UnkNPSayHKRe/tc2YGW6XnSqqOA9eqpiRMgRlV1S6HdGi16vwJBx7lviARzbV1HpQHqLLRH3o8vTcB0cLc+5g==} engines: {node: '>=18'} @@ -7683,6 +7749,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + srvx@0.11.15: resolution: {integrity: sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg==} engines: {node: '>=20.16.0'} @@ -8078,6 +8147,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -8546,6 +8618,10 @@ packages: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} + xmlbuilder@10.1.1: + resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} + engines: {node: '>=4.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -9796,6 +9872,8 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260507.1': optional: true + '@cloudflare/workers-types@4.20260518.1': {} + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -10437,6 +10515,8 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@mozilla/readability@0.6.0': {} + '@napi-rs/canvas-android-arm64@0.1.100': optional: true @@ -10591,7 +10671,7 @@ snapshots: - aws-crt - supports-color - '@opennextjs/cloudflare@1.19.9(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(wrangler@4.90.0)': + '@opennextjs/cloudflare@1.19.9(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(wrangler@4.90.0(@cloudflare/workers-types@4.20260518.1))': dependencies: '@ast-grep/napi': 0.40.5 '@dotenvx/dotenvx': 1.31.0 @@ -10603,7 +10683,7 @@ snapshots: glob: 12.0.0 next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-tqdm: 0.8.6 - wrangler: 4.90.0 + wrangler: 4.90.0(@cloudflare/workers-types@4.20260518.1) yargs: 18.0.0 transitivePeerDependencies: - aws-crt @@ -12614,6 +12694,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@xmldom/xmldom@0.8.13': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -12791,6 +12873,10 @@ snapshots: arg@5.0.2: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -12918,6 +13004,8 @@ snapshots: blake3-wasm@2.1.5: {} + bluebird@3.4.7: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -13595,6 +13683,8 @@ snapshots: diff@8.0.4: {} + dingbat-to-unicode@1.0.1: {} + dlv@1.1.3: {} dom-accessibility-api@0.5.16: {} @@ -13636,6 +13726,10 @@ snapshots: dotenv@8.6.0: {} + duck@0.1.12: + dependencies: + underscore: 1.13.8 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -15001,6 +15095,12 @@ snapshots: dependencies: js-tokens: 4.0.0 + lop@0.4.2: + dependencies: + duck: 0.1.12 + option: 0.2.4 + underscore: 1.13.8 + lru-cache@10.4.3: {} lru-cache@11.3.6: {} @@ -15037,6 +15137,19 @@ snapshots: dependencies: semver: 7.8.0 + mammoth@1.12.0: + dependencies: + '@xmldom/xmldom': 0.8.13 + argparse: 1.0.10 + base64-js: 1.5.1 + bluebird: 3.4.7 + dingbat-to-unicode: 1.0.1 + jszip: 3.10.1 + lop: 0.4.2 + path-is-absolute: 1.0.1 + underscore: 1.13.8 + xmlbuilder: 10.1.1 + markdown-table@3.0.4: {} marked@15.0.12: {} @@ -15736,6 +15849,8 @@ snapshots: opener@1.5.2: {} + option@0.2.4: {} + overlayscrollbars-react@0.5.6(overlayscrollbars@2.16.0)(react@19.2.5): dependencies: overlayscrollbars: 2.16.0 @@ -15851,6 +15966,8 @@ snapshots: path-expression-matcher@1.5.0: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -15901,6 +16018,8 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 + postal-mime@2.7.4: {} + postcss-cli@11.0.1(jiti@1.21.7)(postcss@8.5.14)(tsx@4.21.0): dependencies: chokidar: 3.6.0 @@ -16776,6 +16895,8 @@ snapshots: split2@4.2.0: {} + sprintf-js@1.0.3: {} + srvx@0.11.15: {} stack-utils@2.0.6: @@ -17173,6 +17294,8 @@ snapshots: typescript@5.9.3: {} + underscore@1.13.8: {} + undici-types@5.26.5: {} undici-types@6.21.0: {} @@ -17691,7 +17814,7 @@ snapshots: workerpool@6.5.1: {} - wrangler@4.90.0: + wrangler@4.90.0(@cloudflare/workers-types@4.20260518.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260507.1) @@ -17702,6 +17825,7 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260507.1 optionalDependencies: + '@cloudflare/workers-types': 4.20260518.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil @@ -17733,6 +17857,8 @@ snapshots: xml-naming@0.1.0: {} + xmlbuilder@10.1.1: {} + xmlchars@2.2.0: {} xtend@4.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e147f8ef..503014f9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - apps/* + - apps/readest-app/workers/send-email - packages/foliate-js allowBuilds: