diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index d4bf547a..f51014b5 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -19,6 +19,7 @@ ## Feature Notes - [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls - [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes) +- [Share-a-Book Feature (in progress)](share-feature.md) — locked decisions for the /s/{token} share-link feature; plan at ~/.claude/plans/ok-we-will-learn-cosmic-acorn.md ## Patterns - [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews @@ -39,3 +40,4 @@ - [Upgrade gstack locally](feedback_gstack_upgrade.md) — always upgrade from the project's .claude/skills/gstack, not global - [No lookbehind regex](feedback_no_lookbehind_regex.md) — never use `(?<=)` or `(?_one`, `_other`, and locale-specific `_few`/`_many`/etc. as needed by CLDR). i18next MUST find these to know which form to render — without them, `count: 1` falls back to the bare key like `{{count}} days` and renders "1 days" instead of "1 day". +2. **Proper-noun overrides** (e.g., font names like `LXGW WenKai GB Screen`) where the en value differs from the key. + +Everything else — ordinary translatable strings like `Sign in to share books` — does NOT belong in en/translation.json. The translation hook calls `t(key, { defaultValue: key, ...options })`, so for any string not in en/translation.json, i18next renders the key itself. That's why a 51-key en file works for a codebase with thousands of `_()` callsites. + +**Why en is hand-curated:** the project's `i18next-scanner.config.cjs` lists every locale EXCEPT `en` in its `lngs` array. The scanner generates `__STRING_NOT_TRANSLATED__` placeholders only for the listed locales; `en` is never touched. + +**Workflow when adding `_(...)` calls:** + +- **Non-plural string** (e.g. `_('Sign in to share books')`): add the `_(...)` call, run `pnpm run i18n:extract`, translate the new placeholders in non-en locales. **Do NOT touch `en/translation.json`** — the key itself is the en value. +- **Plural string** (e.g. `_('{{count}} days', { count: n })`): same as above, PLUS hand-add `_one` and `_other` to `en/translation.json` (and `_few`/`_many`/etc. only if the source language ever needs them, which English doesn't). Convention from existing entries (e.g., `Are you sure to delete {{count}} selected book(s)?_one` → `Are you sure to delete {{count}} selected book?`): keep `{{count}}` interpolated even in `_one`, and swap any `(s)` placeholder to the proper singular/plural noun. + +**Audit script:** walk `src/`, regex-match `_('...', { ..., count: ... })`, for each base key verify both `_one` and `_other` exist in `en/translation.json`. (See conversation history for an implementation.) + +**Where this bit us:** + +- Initial bug: `_('{{count}} days', { count: n })` rendered "1 days" because en had no `_one` form. +- Audit found 16 missing en plural keys from earlier PRs (OPDS / TTS / dictionary import) silently rendering wrong. +- Then overcorrected and added non-plural keys like `Sign in to share books` to en/translation.json — wrong, breaks the project's convention. en stays clean for non-plural strings. diff --git a/apps/readest-app/.claude/memory/share-feature.md b/apps/readest-app/.claude/memory/share-feature.md new file mode 100644 index 00000000..af177d5f --- /dev/null +++ b/apps/readest-app/.claude/memory/share-feature.md @@ -0,0 +1,53 @@ +--- +name: Share-a-Book Feature (in progress) +description: Active implementation of /s/{token} share links + cherry-picks (CFI auto-include, 1-tap library import, branded OG images). All locked decisions and the plan file path. +type: project +originSessionId: e4ddc690-b1a9-4557-855f-d4e67055824f +--- +Active feature on branch `dev` as of 2026-05-02. Plan file: `/Users/chrox/.claude/plans/ok-we-will-learn-cosmic-acorn.md` (always read for source of truth before continuing). + +**Why:** complement just-shipped annotation deep-links (PRs #4018, #4019) with whole-book sharing. Cloud-sync infra exists; this layers public time-limited links on top. + +**How to apply:** these decisions are locked across CEO + eng + design reviews. Do NOT re-litigate when implementing. + +## Locked decisions (authoritative) + +- **Universal 7-day cap** on share expiry, no tier differentiation, no "Never". All users pick `[1, 3, 7]` days. DMCA-risk reduction. +- **App Router** for all share routes (mirrors `/o` precedent + recent Stripe / AI / IAP / OPDS). Pages API `storage/*` neighbors stay where they are. +- **Single non-dynamic landing page** at `src/app/s/page.tsx` + rewrite `'/s/:token' → '/s?token=:token'` in `next.config.mjs`. Mirrors `/o`'s pattern exactly. Avoids `[token]` dynamic-segment trap under `output: 'export'`. +- **R2 server-side byte-copy** for `/import` (recipient-side library import). NOT a reference. Preserves invariant that every `files` row's `file_key` starts with that row's `user_id` — keeps stats / purge / delete / download routes working unchanged. +- **`token_hash` (sha256) in DB**, never the raw token. Raw token shown to user once at create. +- **Live `(user_id, book_hash)` resolution** at every access (no FK to `files`). Re-uploads of the same hash follow the share automatically. +- **GET `/download` is a 302 redirect with NO DB writes**. Count via separate `POST /download/confirm` so unfurlers / prefetchers can't inflate counts. +- **Atomic SQL `download_count = download_count + 1`**, not read-modify-write. +- **Per-user 50-share cap**, enforced at create-time. +- **Auth detection on /s landing**: server-render via Supabase auth cookie in `app/s/layout.tsx`. No layout shift. Falls back to anonymous flow + post-hydration upgrade if SSR fails. +- **Cover-less `og.png` fallback**: text-only display-type card. NO placeholder rectangle, NO procedural pattern. +- **Universal Links / App Links**: handle `https://web.readest.com/s/...` in v1 via `useOpenShareLink`. Tauri's `applinks` config already covers the host. + +## Cherry-picks accepted (CEO review SELECTIVE EXPANSION) + +1. **Position-aware share** — every share initiated from the reader auto-includes `cfi`; recipient lands at sharer's paragraph. Toggle in dialog (default on, only visible when reader-context). +3. **1-tap "Add to my library"** — logged-in recipient on `/s` gets primary action that calls `/import` (R2 byte-copy) and navigates to `/reader?ids=...&cfi=...`. +4. **Branded OG image** — `/api/share/[token]/og.png` server-renders via `@vercel/og`. Spec includes anti-slop checklist. + +Deferred to TODOS.md: QR code on landing, notify-on-download toggle. + +## Open implementation questions (resolve mid-build) + +1. Upload-confirmation: HEAD R2 in `/create`, or add `uploaded_at` column to `files`? — recommend HEAD. +2. Migration directory: project has no `apps/readest-app/supabase/migrations/`. Confirm where SQL actually lands before writing the file. +3. `@vercel/og` runtime compat with CloudFlare Workers (OpenNextJS). Verify before relying on it; fallback to Satori + sharp if incompatible. +4. App Router route handlers under `src/app/api/share/...` should be silently dropped by `output: 'export'` in Next 16.2.3. Confirm during first Tauri build. + +## Critical files for implementation + +See "Critical Files (modify or create)" table in the plan. Key starting points: +- `src/libs/storage-server.ts` — extract `getDownloadSignedUrl` from `pages/api/storage/download.ts` +- `src/libs/share-server.ts` (new) — `generateShareToken()`, `hashShareToken(raw)` +- `src/libs/share.ts` (new) — typed client used by dialog, manage section, deeplink hook, landing page +- `src/utils/share.ts` (new) — `buildShareUrl(token)`, `parseShareDeepLink(url)` +- Dialog reference: `src/components/Dialog.tsx`, `BookDetailModal.tsx` +- Landing reference: `src/app/o/page.tsx` (lift `Card`, `BrandHeader`, `PageFooter` to `src/components/landing/`) +- Deeplink hook reference: `src/hooks/useOpenAnnotationLink.ts`, `useOpenWithBooks.ts` +- App-level upload entry: `appService.uploadBook(book)` at `src/services/appService.ts:269` (NOT `cloudService.uploadBook` — lower-level fn) diff --git a/apps/readest-app/next.config.mjs b/apps/readest-app/next.config.mjs index df118f45..eda4b3c7 100644 --- a/apps/readest-app/next.config.mjs +++ b/apps/readest-app/next.config.mjs @@ -84,6 +84,10 @@ const nextConfig = { source: '/o/book/:hash/annotation/:id', destination: '/o?book=:hash¬e=:id', }, + { + source: '/s/:token', + destination: '/s?token=:token', + }, ]; }, async headers() { diff --git a/apps/readest-app/public/.well-known/apple-app-site-association b/apps/readest-app/public/.well-known/apple-app-site-association index fc851695..e58baabd 100644 --- a/apps/readest-app/public/.well-known/apple-app-site-association +++ b/apps/readest-app/public/.well-known/apple-app-site-association @@ -13,6 +13,10 @@ { "/": "/o/*", "comment": "Matches annotation deeplinks" + }, + { + "/": "/s/*", + "comment": "Matches book share links" } ] } diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 26b6acdf..7ecd37ef 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1327,5 +1327,84 @@ "Open in Readest app": "فتح في تطبيق Readest", "Continue in browser": "متابعة في المتصفح", "Don't have Readest?": "ليس لديك Readest؟", - "Book not in your library": "الكتاب غير موجود في مكتبتك" + "Book not in your library": "الكتاب غير موجود في مكتبتك", + "Share Book": "مشاركة الكتاب", + "Could not create share link": "تعذر إنشاء رابط المشاركة", + "Link copied": "تم نسخ الرابط", + "Could not copy link": "تعذر نسخ الرابط", + "Share revoked": "تم إلغاء المشاركة", + "Could not revoke share": "تعذر إلغاء المشاركة", + "Uploading book…": "جاري رفع الكتاب…", + "Generating…": "جاري الإنشاء…", + "Generate share link": "إنشاء رابط مشاركة", + "Includes your current page": "يشمل صفحتك الحالية", + "Share URL": "رابط المشاركة", + "Copy link": "نسخ الرابط", + "Copied": "تم النسخ", + "Share via…": "مشاركة عبر…", + "Expires {{date}}": "تنتهي في {{date}}", + "Revoking…": "جاري الإلغاء…", + "Revoke share": "إلغاء المشاركة", + "Sign in to share books": "سجّل الدخول لمشاركة الكتب", + "Expiring soon": "تنتهي صلاحيته قريبًا", + "Missing share token": "رمز المشاركة مفقود", + "Could not add to your library": "تعذرت الإضافة إلى مكتبتك", + "This share link is no longer available": "لم يعد رابط المشاركة هذا متاحًا", + "The original link may have expired or been revoked.": "ربما انتهت صلاحية الرابط الأصلي أو تم إلغاؤه.", + "Get Readest": "احصل على Readest", + "Loading shared book…": "جاري تحميل الكتاب المشترك…", + "Adding…": "جاري الإضافة…", + "Add to my library": "إضافة إلى مكتبتي", + "Could not load your shares": "تعذر تحميل مشاركاتك", + "Revoked": "ملغى", + "Expired": "منتهي الصلاحية", + "Shared books": "الكتب المشتركة", + "You haven't shared any books yet": "لم تشارك أي كتب بعد", + "Open a book and tap Share to send it to a friend.": "افتح كتابًا واضغط على مشاركة لإرساله إلى صديق.", + "{{count}} active_zero": "لا توجد روابط نشطة", + "{{count}} active_one": "رابط نشط واحد", + "{{count}} active_two": "رابطان نشطان", + "{{count}} active_few": "{{count}} روابط نشطة", + "{{count}} active_many": "{{count}} رابطًا نشطًا", + "{{count}} active_other": "{{count}} رابط نشط", + "{{count}} downloads_zero": "لا توجد تنزيلات", + "{{count}} downloads_one": "تنزيل واحد", + "{{count}} downloads_two": "تنزيلان", + "{{count}} downloads_few": "{{count}} تنزيلات", + "{{count}} downloads_many": "{{count}} تنزيلًا", + "{{count}} downloads_other": "{{count}} تنزيل", + "starts at saved page": "يبدأ من الصفحة المحفوظة", + "More actions": "المزيد من الإجراءات", + "Loading…": "جاري التحميل…", + "Load more": "تحميل المزيد", + "Could not load shared book": "تعذر تحميل الكتاب المشترك", + "The share link is missing required information.": "يفتقد رابط المشاركة معلومات مطلوبة.", + "Please check your connection and try again.": "يرجى التحقق من اتصالك والمحاولة مرة أخرى.", + "Sign in to import shared books": "سجّل الدخول لاستيراد الكتب المشتركة", + "Already in your library": "موجود بالفعل في مكتبتك", + "Added to your library": "تمت الإضافة إلى مكتبتك", + "Could not import shared book": "تعذر استيراد الكتاب المشترك", + "Manage Shared Links": "إدارة روابط المشاركة", + "Share current page": "مشاركة الصفحة الحالية", + "Expires in": "تنتهي خلال", + "{{count}} days_zero": "لا توجد أيام", + "{{count}} days_one": "يوم واحد", + "{{count}} days_two": "يومان", + "{{count}} days_few": "{{count}} أيام", + "{{count}} days_many": "{{count}} يومًا", + "{{count}} days_other": "{{count}} يوم", + "Expires in {{count}} days_zero": "تنتهي خلال أقل من يوم", + "Expires in {{count}} days_one": "تنتهي خلال يوم واحد", + "Expires in {{count}} days_two": "تنتهي خلال يومين", + "Expires in {{count}} days_few": "تنتهي خلال {{count}} أيام", + "Expires in {{count}} days_many": "تنتهي خلال {{count}} يومًا", + "Expires in {{count}} days_other": "تنتهي خلال {{count}} يوم", + "Expires in {{count}} hours_zero": "تنتهي خلال أقل من ساعة", + "Expires in {{count}} hours_one": "تنتهي خلال ساعة واحدة", + "Expires in {{count}} hours_two": "تنتهي خلال ساعتين", + "Expires in {{count}} hours_few": "تنتهي خلال {{count}} ساعات", + "Expires in {{count}} hours_many": "تنتهي خلال {{count}} ساعة", + "Expires in {{count}} hours_other": "تنتهي خلال {{count}} ساعة", + "Open in app": "فتح في التطبيق", + "Shared with you": "تمت مشاركته معك" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index ec5697e1..3ad88e8a 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1255,5 +1255,64 @@ "Open in Readest app": "Readest অ্যাপে খুলুন", "Continue in browser": "ব্রাউজারে চালিয়ে যান", "Don't have Readest?": "Readest নেই?", - "Book not in your library": "এই বইটি আপনার লাইব্রেরিতে নেই" + "Book not in your library": "এই বইটি আপনার লাইব্রেরিতে নেই", + "Share Book": "বই শেয়ার করুন", + "Could not create share link": "শেয়ার লিঙ্ক তৈরি করা যায়নি", + "Link copied": "লিঙ্ক কপি হয়েছে", + "Could not copy link": "লিঙ্ক কপি করা যায়নি", + "Share revoked": "শেয়ার বাতিল হয়েছে", + "Could not revoke share": "শেয়ার বাতিল করা যায়নি", + "Uploading book…": "বই আপলোড হচ্ছে…", + "Generating…": "তৈরি হচ্ছে…", + "Generate share link": "শেয়ার লিঙ্ক তৈরি করুন", + "Includes your current page": "আপনার বর্তমান পৃষ্ঠা সহ", + "Share URL": "শেয়ার URL", + "Copy link": "লিঙ্ক কপি করুন", + "Copied": "কপি হয়েছে", + "Share via…": "মাধ্যমে শেয়ার করুন…", + "Expires {{date}}": "{{date}} এ মেয়াদ শেষ", + "Revoking…": "বাতিল হচ্ছে…", + "Revoke share": "শেয়ার বাতিল করুন", + "Sign in to share books": "বই শেয়ার করতে সাইন ইন করুন", + "Expiring soon": "শীঘ্রই মেয়াদ শেষ হবে", + "Missing share token": "শেয়ার টোকেন অনুপস্থিত", + "Could not add to your library": "আপনার লাইব্রেরিতে যোগ করা যায়নি", + "This share link is no longer available": "এই শেয়ার লিঙ্কটি আর উপলব্ধ নেই", + "The original link may have expired or been revoked.": "মূল লিঙ্কটির মেয়াদ শেষ হয়ে থাকতে পারে বা বাতিল হয়েছে।", + "Get Readest": "Readest পান", + "Loading shared book…": "শেয়ার করা বই লোড হচ্ছে…", + "Adding…": "যোগ করা হচ্ছে…", + "Add to my library": "আমার লাইব্রেরিতে যোগ করুন", + "Could not load your shares": "আপনার শেয়ারগুলো লোড করা যায়নি", + "Revoked": "বাতিল", + "Expired": "মেয়াদোত্তীর্ণ", + "Shared books": "শেয়ার করা বই", + "You haven't shared any books yet": "আপনি এখনও কোনো বই শেয়ার করেননি", + "Open a book and tap Share to send it to a friend.": "একটি বই খুলুন এবং বন্ধুকে পাঠাতে শেয়ার ট্যাপ করুন।", + "{{count}} active_one": "{{count}}টি সক্রিয়", + "{{count}} active_other": "{{count}}টি সক্রিয়", + "{{count}} downloads_one": "{{count}}টি ডাউনলোড", + "{{count}} downloads_other": "{{count}}টি ডাউনলোড", + "starts at saved page": "সংরক্ষিত পৃষ্ঠা থেকে শুরু হয়", + "More actions": "আরও ক্রিয়াকলাপ", + "Loading…": "লোড হচ্ছে…", + "Load more": "আরও লোড করুন", + "Could not load shared book": "শেয়ার করা বই লোড করা যায়নি", + "The share link is missing required information.": "শেয়ার লিঙ্কে প্রয়োজনীয় তথ্য নেই।", + "Please check your connection and try again.": "অনুগ্রহ করে আপনার সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন।", + "Sign in to import shared books": "শেয়ার করা বই আমদানি করতে সাইন ইন করুন", + "Already in your library": "ইতিমধ্যে আপনার লাইব্রেরিতে রয়েছে", + "Added to your library": "আপনার লাইব্রেরিতে যোগ করা হয়েছে", + "Could not import shared book": "শেয়ার করা বই আমদানি করা যায়নি", + "Manage Shared Links": "শেয়ার লিঙ্ক পরিচালনা করুন", + "Share current page": "বর্তমান পৃষ্ঠা শেয়ার করুন", + "Expires in": "মেয়াদ শেষ হবে", + "{{count}} days_one": "১ দিন", + "{{count}} days_other": "{{count}} দিন", + "Expires in {{count}} days_one": "১ দিনে মেয়াদ শেষ", + "Expires in {{count}} days_other": "{{count}} দিনে মেয়াদ শেষ", + "Expires in {{count}} hours_one": "১ ঘণ্টায় মেয়াদ শেষ", + "Expires in {{count}} hours_other": "{{count}} ঘণ্টায় মেয়াদ শেষ", + "Open in app": "অ্যাপে খুলুন", + "Shared with you": "আপনার সাথে শেয়ার করা" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 87066e62..55d16c4b 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1237,5 +1237,59 @@ "Open in Readest app": "Readest ཉེར་སྤྱོད་ནང་ཁ་ཕྱེ", "Continue in browser": "བརྡར་ཞིབ་ཆས་ནང་མུ་མཐུད", "Don't have Readest?": "Readest མེད་དམ།", - "Book not in your library": "དེབ་འདི་ཁྱེད་ཀྱི་དཔེ་མཛོད་ནང་མི་འདུག" + "Book not in your library": "དེབ་འདི་ཁྱེད་ཀྱི་དཔེ་མཛོད་ནང་མི་འདུག", + "Share Book": "དཔེ་ཆ་མཉམ་སྤྱོད།", + "Could not create share link": "མཉམ་སྤྱོད་འབྲེལ་མཐུད་བཟོ་མ་ཐུབ།", + "Link copied": "འབྲེལ་མཐུད་པར་སློག་གྲུབ།", + "Could not copy link": "འབྲེལ་མཐུད་པར་སློག་མ་ཐུབ།", + "Share revoked": "མཉམ་སྤྱོད་ཕྱིར་འཐེན།", + "Could not revoke share": "མཉམ་སྤྱོད་ཕྱིར་འཐེན་མ་ཐུབ།", + "Uploading book…": "དཔེ་ཆ་ཡར་སྐྱེལ་བཞིན་པ…", + "Generating…": "བཟོ་བཞིན་པ…", + "Generate share link": "མཉམ་སྤྱོད་འབྲེལ་མཐུད་བཟོ།", + "Includes your current page": "ཁྱེད་ཀྱི་ད་ལྟའི་ཤོག་ངོས་འདུས།", + "Share URL": "མཉམ་སྤྱོད་གནས་སྡོད།", + "Copy link": "འབྲེལ་མཐུད་པར་སློག", + "Copied": "པར་སློག་གྲུབ།", + "Share via…": "བརྒྱུད་ནས་མཉམ་སྤྱོད…", + "Expires {{date}}": "{{date}} ལ་དུས་ཟད།", + "Revoking…": "ཕྱིར་འཐེན་བཞིན་པ…", + "Revoke share": "མཉམ་སྤྱོད་ཕྱིར་འཐེན།", + "Sign in to share books": "དཔེ་ཆ་མཉམ་སྤྱོད་པར་ཐོ་འགོད་གནང་རོགས།", + "Expiring soon": "མྱུར་དུ་ཟད་ངེས།", + "Missing share token": "མཉམ་སྤྱོད་ཐམ་ག་མེད།", + "Could not add to your library": "ཁྱེད་ཀྱི་དཔེ་མཛོད་ལ་སྣོན་མ་ཐུབ།", + "This share link is no longer available": "མཉམ་སྤྱོད་འབྲེལ་མཐུད་འདི་མི་འཐོབ།", + "The original link may have expired or been revoked.": "གདོད་མའི་འབྲེལ་མཐུད་དུས་ཟད་པའམ་ཕྱིར་འཐེན་སྲིད།", + "Get Readest": "Readest བཞེས་པ།", + "Loading shared book…": "མཉམ་སྤྱོད་དཔེ་ཆ་མར་སྐྱེལ་བཞིན་པ…", + "Adding…": "སྣོན་བཞིན་པ…", + "Add to my library": "ངའི་དཔེ་མཛོད་ལ་སྣོན།", + "Could not load your shares": "ཁྱེད་ཀྱི་མཉམ་སྤྱོད་མར་སྐྱེལ་མ་ཐུབ།", + "Revoked": "ཕྱིར་འཐེན།", + "Expired": "དུས་ཟད།", + "Shared books": "མཉམ་སྤྱོད་དཔེ་ཆ།", + "You haven't shared any books yet": "ཁྱོས་ད་དུང་དཔེ་ཆ་གང་ཡང་མཉམ་སྤྱོད་མ་བྱས།", + "Open a book and tap Share to send it to a friend.": "དཔེ་ཆ་ཞིག་ཁ་ཕྱེ་ནས་གྲོགས་པོ་ལ་སྐུར་ཆེད་མཉམ་སྤྱོད་གཏུགས།", + "{{count}} active_other": "{{count}} ལས་འཛིན་བཞིན་པ།", + "{{count}} downloads_other": "{{count}} ཕབ་ལེན།", + "starts at saved page": "ཉར་ཚགས་ཤོག་ངོས་ནས་འགོ་འཛུགས།", + "More actions": "བྱ་འགུལ་མང་བ།", + "Loading…": "མར་སྐྱེལ་བཞིན་པ…", + "Load more": "མང་བ་མར་སྐྱེལ།", + "Could not load shared book": "མཉམ་སྤྱོད་དཔེ་ཆ་མར་སྐྱེལ་མ་ཐུབ།", + "The share link is missing required information.": "མཉམ་སྤྱོད་འབྲེལ་མཐུད་ལ་ཆ་རྐྱེན་མེད།", + "Please check your connection and try again.": "ཁྱེད་ཀྱི་འབྲེལ་མཐུད་ཞིབ་འཇུག་གནང་ནས་ཡང་བསྐྱར་ཐེངས་གཅིག་འབད་རོགས།", + "Sign in to import shared books": "མཉམ་སྤྱོད་དཔེ་ཆ་འདྲེན་པར་ཐོ་འགོད་གནང་རོགས།", + "Already in your library": "ཁྱེད་ཀྱི་དཔེ་མཛོད་ནང་ཡོད་ཟིན་པ།", + "Added to your library": "ཁྱེད་ཀྱི་དཔེ་མཛོད་ལ་སྣོན་ཟིན།", + "Could not import shared book": "མཉམ་སྤྱོད་དཔེ་ཆ་འདྲེན་མ་ཐུབ།", + "Manage Shared Links": "མཉམ་སྤྱོད་འབྲེལ་མཐུད་དོ་དམ།", + "Share current page": "ད་ལྟའི་ཤོག་ངོས་མཉམ་སྤྱོད།", + "Expires in": "དུས་ཟད།", + "{{count}} days_other": "ཉིན་{{count}}", + "Expires in {{count}} days_other": "ཉིན་{{count}}ནང་ཟད།", + "Expires in {{count}} hours_other": "ཆུ་ཚོད་{{count}}ནང་ཟད།", + "Open in app": "ཉེར་སྤྱོད་ནང་ཁ་ཕྱེ།", + "Shared with you": "ཁྱེད་དང་མཉམ་སྤྱོད་བྱས།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index eea5a407..0a3fb8f8 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1255,5 +1255,64 @@ "Open in Readest app": "In der Readest-App öffnen", "Continue in browser": "Im Browser fortfahren", "Don't have Readest?": "Du hast Readest noch nicht?", - "Book not in your library": "Buch nicht in deiner Bibliothek" + "Book not in your library": "Buch nicht in deiner Bibliothek", + "Share Book": "Buch teilen", + "Could not create share link": "Freigabelink konnte nicht erstellt werden", + "Link copied": "Link kopiert", + "Could not copy link": "Link konnte nicht kopiert werden", + "Share revoked": "Freigabe widerrufen", + "Could not revoke share": "Freigabe konnte nicht widerrufen werden", + "Uploading book…": "Buch wird hochgeladen…", + "Generating…": "Wird erstellt…", + "Generate share link": "Freigabelink erstellen", + "Includes your current page": "Enthält Ihre aktuelle Seite", + "Share URL": "Freigabelink", + "Copy link": "Link kopieren", + "Copied": "Kopiert", + "Share via…": "Teilen über…", + "Expires {{date}}": "Läuft ab am {{date}}", + "Revoking…": "Wird widerrufen…", + "Revoke share": "Freigabe widerrufen", + "Sign in to share books": "Melde dich an, um Bücher zu teilen", + "Expiring soon": "Läuft bald ab", + "Missing share token": "Freigabe-Token fehlt", + "Could not add to your library": "Konnte nicht zu deiner Bibliothek hinzugefügt werden", + "This share link is no longer available": "Dieser Freigabelink ist nicht mehr verfügbar", + "The original link may have expired or been revoked.": "Der ursprüngliche Link ist möglicherweise abgelaufen oder wurde widerrufen.", + "Get Readest": "Readest holen", + "Loading shared book…": "Geteiltes Buch wird geladen…", + "Adding…": "Wird hinzugefügt…", + "Add to my library": "Zu meiner Bibliothek hinzufügen", + "Could not load your shares": "Deine Freigaben konnten nicht geladen werden", + "Revoked": "Widerrufen", + "Expired": "Abgelaufen", + "Shared books": "Geteilte Bücher", + "You haven't shared any books yet": "Du hast noch keine Bücher geteilt", + "Open a book and tap Share to send it to a friend.": "Öffne ein Buch und tippe auf „Teilen“, um es an einen Freund zu senden.", + "{{count}} active_one": "{{count}} aktiv", + "{{count}} active_other": "{{count}} aktiv", + "{{count}} downloads_one": "{{count}} Download", + "{{count}} downloads_other": "{{count}} Downloads", + "starts at saved page": "beginnt auf gespeicherter Seite", + "More actions": "Weitere Aktionen", + "Loading…": "Wird geladen…", + "Load more": "Mehr laden", + "Could not load shared book": "Geteiltes Buch konnte nicht geladen werden", + "The share link is missing required information.": "Dem Freigabelink fehlen erforderliche Informationen.", + "Please check your connection and try again.": "Bitte überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.", + "Sign in to import shared books": "Melde dich an, um geteilte Bücher zu importieren", + "Already in your library": "Bereits in deiner Bibliothek", + "Added to your library": "Zu deiner Bibliothek hinzugefügt", + "Could not import shared book": "Geteiltes Buch konnte nicht importiert werden", + "Manage Shared Links": "Freigabelinks verwalten", + "Share current page": "Aktuelle Seite teilen", + "Expires in": "Läuft ab in", + "{{count}} days_one": "1 Tag", + "{{count}} days_other": "{{count}} Tage", + "Expires in {{count}} days_one": "Läuft in 1 Tag ab", + "Expires in {{count}} days_other": "Läuft in {{count}} Tagen ab", + "Expires in {{count}} hours_one": "Läuft in 1 Stunde ab", + "Expires in {{count}} hours_other": "Läuft in {{count}} Stunden ab", + "Open in app": "In der App öffnen", + "Shared with you": "Mit dir geteilt" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 20864a54..d2273b40 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1255,5 +1255,64 @@ "Open in Readest app": "Άνοιγμα στην εφαρμογή Readest", "Continue in browser": "Συνέχεια στο πρόγραμμα περιήγησης", "Don't have Readest?": "Δεν έχετε το Readest;", - "Book not in your library": "Το βιβλίο δεν βρίσκεται στη βιβλιοθήκη σας" + "Book not in your library": "Το βιβλίο δεν βρίσκεται στη βιβλιοθήκη σας", + "Share Book": "Κοινή χρήση βιβλίου", + "Could not create share link": "Δεν ήταν δυνατή η δημιουργία συνδέσμου κοινής χρήσης", + "Link copied": "Ο σύνδεσμος αντιγράφηκε", + "Could not copy link": "Δεν ήταν δυνατή η αντιγραφή του συνδέσμου", + "Share revoked": "Η κοινή χρήση ανακλήθηκε", + "Could not revoke share": "Δεν ήταν δυνατή η ανάκληση της κοινής χρήσης", + "Uploading book…": "Μεταφόρτωση βιβλίου…", + "Generating…": "Δημιουργία…", + "Generate share link": "Δημιουργία συνδέσμου κοινής χρήσης", + "Includes your current page": "Περιλαμβάνει την τρέχουσα σελίδα σας", + "Share URL": "URL κοινής χρήσης", + "Copy link": "Αντιγραφή συνδέσμου", + "Copied": "Αντιγράφηκε", + "Share via…": "Κοινή χρήση μέσω…", + "Expires {{date}}": "Λήγει στις {{date}}", + "Revoking…": "Ανάκληση…", + "Revoke share": "Ανάκληση κοινής χρήσης", + "Sign in to share books": "Συνδεθείτε για να μοιραστείτε βιβλία", + "Expiring soon": "Λήγει σύντομα", + "Missing share token": "Λείπει το διακριτικό κοινής χρήσης", + "Could not add to your library": "Δεν ήταν δυνατή η προσθήκη στη βιβλιοθήκη σας", + "This share link is no longer available": "Αυτός ο σύνδεσμος κοινής χρήσης δεν είναι πλέον διαθέσιμος", + "The original link may have expired or been revoked.": "Ο αρχικός σύνδεσμος ενδέχεται να έχει λήξει ή να έχει ανακληθεί.", + "Get Readest": "Αποκτήστε το Readest", + "Loading shared book…": "Φόρτωση κοινόχρηστου βιβλίου…", + "Adding…": "Προσθήκη…", + "Add to my library": "Προσθήκη στη βιβλιοθήκη μου", + "Could not load your shares": "Δεν ήταν δυνατή η φόρτωση των κοινοποιήσεών σας", + "Revoked": "Ανακλήθηκε", + "Expired": "Έληξε", + "Shared books": "Κοινόχρηστα βιβλία", + "You haven't shared any books yet": "Δεν έχετε μοιραστεί κανένα βιβλίο ακόμη", + "Open a book and tap Share to send it to a friend.": "Ανοίξτε ένα βιβλίο και πατήστε «Κοινή χρήση» για να το στείλετε σε έναν φίλο.", + "{{count}} active_one": "{{count}} ενεργό", + "{{count}} active_other": "{{count}} ενεργά", + "{{count}} downloads_one": "{{count}} λήψη", + "{{count}} downloads_other": "{{count}} λήψεις", + "starts at saved page": "ξεκινά από την αποθηκευμένη σελίδα", + "More actions": "Περισσότερες ενέργειες", + "Loading…": "Φόρτωση…", + "Load more": "Φόρτωση περισσότερων", + "Could not load shared book": "Δεν ήταν δυνατή η φόρτωση του κοινόχρηστου βιβλίου", + "The share link is missing required information.": "Από τον σύνδεσμο κοινής χρήσης λείπουν απαιτούμενες πληροφορίες.", + "Please check your connection and try again.": "Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.", + "Sign in to import shared books": "Συνδεθείτε για να εισαγάγετε κοινόχρηστα βιβλία", + "Already in your library": "Υπάρχει ήδη στη βιβλιοθήκη σας", + "Added to your library": "Προστέθηκε στη βιβλιοθήκη σας", + "Could not import shared book": "Δεν ήταν δυνατή η εισαγωγή του κοινόχρηστου βιβλίου", + "Manage Shared Links": "Διαχείριση συνδέσμων κοινής χρήσης", + "Share current page": "Κοινή χρήση τρέχουσας σελίδας", + "Expires in": "Λήγει σε", + "{{count}} days_one": "1 ημέρα", + "{{count}} days_other": "{{count}} ημέρες", + "Expires in {{count}} days_one": "Λήγει σε 1 ημέρα", + "Expires in {{count}} days_other": "Λήγει σε {{count}} ημέρες", + "Expires in {{count}} hours_one": "Λήγει σε 1 ώρα", + "Expires in {{count}} hours_other": "Λήγει σε {{count}} ώρες", + "Open in app": "Άνοιγμα στην εφαρμογή", + "Shared with you": "Κοινοποιήθηκε σε εσάς" } diff --git a/apps/readest-app/public/locales/en/translation.json b/apps/readest-app/public/locales/en/translation.json index 0f7b8b9e..ebd1e5bb 100644 --- a/apps/readest-app/public/locales/en/translation.json +++ b/apps/readest-app/public/locales/en/translation.json @@ -23,5 +23,31 @@ "Set status for {{count}} book(s)_one": "Set status for {{count}} book", "Set status for {{count}} book(s)_other": "Set status for {{count}} books", "{{count}} book(s) synced_one": "{{count}} book synced", - "{{count}} book(s) synced_other": "{{count}} books synced" + "{{count}} book(s) synced_other": "{{count}} books synced", + "{{count}} active_one": "{{count}} active", + "{{count}} active_other": "{{count}} active", + "{{count}} downloads_one": "{{count}} download", + "{{count}} downloads_other": "{{count}} downloads", + "{{count}} days_one": "{{count}} day", + "{{count}} days_other": "{{count}} days", + "Expires in {{count}} days_one": "Expires in {{count}} day", + "Expires in {{count}} days_other": "Expires in {{count}} days", + "Expires in {{count}} hours_one": "Expires in {{count}} hour", + "Expires in {{count}} hours_other": "Expires in {{count}} hours", + "Attempts: {{count}}_one": "Attempts: {{count}}", + "Attempts: {{count}}_other": "Attempts: {{count}}", + "Failed to sync {{count}} OPDS catalog(s)_one": "Failed to sync {{count}} OPDS catalog", + "Failed to sync {{count}} OPDS catalog(s)_other": "Failed to sync {{count}} OPDS catalogs", + "Imported {{count}} dictionary_one": "Imported {{count}} dictionary", + "Imported {{count}} dictionary_other": "Imported {{count}} dictionaries", + "{{count}} books refreshed_one": "{{count}} book refreshed", + "{{count}} books refreshed_other": "{{count}} books refreshed", + "{{count}} failed_one": "{{count}} failed", + "{{count}} failed_other": "{{count}} failed", + "{{count}} items_one": "{{count}} item", + "{{count}} items_other": "{{count}} items", + "{{count}} new item(s) downloaded from OPDS_one": "{{count}} new item downloaded from OPDS", + "{{count}} new item(s) downloaded from OPDS_other": "{{count}} new items downloaded from OPDS", + "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} voice", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} voices" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index a122992b..012a809f 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1273,5 +1273,69 @@ "Open in Readest app": "Abrir en la app de Readest", "Continue in browser": "Continuar en el navegador", "Don't have Readest?": "¿No tienes Readest?", - "Book not in your library": "El libro no está en tu biblioteca" + "Book not in your library": "El libro no está en tu biblioteca", + "Share Book": "Compartir libro", + "Could not create share link": "No se pudo crear el enlace de compartir", + "Link copied": "Enlace copiado", + "Could not copy link": "No se pudo copiar el enlace", + "Share revoked": "Compartir revocado", + "Could not revoke share": "No se pudo revocar el compartir", + "Uploading book…": "Subiendo libro…", + "Generating…": "Generando…", + "Generate share link": "Generar enlace de compartir", + "Includes your current page": "Incluye tu página actual", + "Share URL": "URL de compartir", + "Copy link": "Copiar enlace", + "Copied": "Copiado", + "Share via…": "Compartir vía…", + "Expires {{date}}": "Caduca el {{date}}", + "Revoking…": "Revocando…", + "Revoke share": "Revocar compartir", + "Sign in to share books": "Inicia sesión para compartir libros", + "Expiring soon": "Caduca pronto", + "Missing share token": "Falta el token de compartir", + "Could not add to your library": "No se pudo añadir a tu biblioteca", + "This share link is no longer available": "Este enlace ya no está disponible", + "The original link may have expired or been revoked.": "Es posible que el enlace original haya caducado o se haya revocado.", + "Get Readest": "Obtener Readest", + "Loading shared book…": "Cargando libro compartido…", + "Adding…": "Añadiendo…", + "Add to my library": "Añadir a mi biblioteca", + "Could not load your shares": "No se pudieron cargar tus compartidos", + "Revoked": "Revocado", + "Expired": "Caducado", + "Shared books": "Libros compartidos", + "You haven't shared any books yet": "Aún no has compartido ningún libro", + "Open a book and tap Share to send it to a friend.": "Abre un libro y pulsa Compartir para enviárselo a un amigo.", + "{{count}} active_one": "{{count}} activo", + "{{count}} active_many": "{{count}} activos", + "{{count}} active_other": "{{count}} activos", + "{{count}} downloads_one": "{{count}} descarga", + "{{count}} downloads_many": "{{count}} descargas", + "{{count}} downloads_other": "{{count}} descargas", + "starts at saved page": "comienza en la página guardada", + "More actions": "Más acciones", + "Loading…": "Cargando…", + "Load more": "Cargar más", + "Could not load shared book": "No se pudo cargar el libro compartido", + "The share link is missing required information.": "Al enlace de compartir le faltan datos necesarios.", + "Please check your connection and try again.": "Comprueba tu conexión y vuelve a intentarlo.", + "Sign in to import shared books": "Inicia sesión para importar libros compartidos", + "Already in your library": "Ya está en tu biblioteca", + "Added to your library": "Añadido a tu biblioteca", + "Could not import shared book": "No se pudo importar el libro compartido", + "Manage Shared Links": "Gestionar enlaces compartidos", + "Share current page": "Compartir página actual", + "Expires in": "Caduca en", + "{{count}} days_one": "1 día", + "{{count}} days_many": "{{count}} días", + "{{count}} days_other": "{{count}} días", + "Expires in {{count}} days_one": "Caduca en 1 día", + "Expires in {{count}} days_many": "Caduca en {{count}} días", + "Expires in {{count}} days_other": "Caduca en {{count}} días", + "Expires in {{count}} hours_one": "Caduca en 1 hora", + "Expires in {{count}} hours_many": "Caduca en {{count}} horas", + "Expires in {{count}} hours_other": "Caduca en {{count}} horas", + "Open in app": "Abrir en la app", + "Shared with you": "Compartido contigo" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index b7b4ab9a..5ada8034 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1255,5 +1255,64 @@ "Open in Readest app": "باز کردن در برنامهٔ Readest", "Continue in browser": "ادامه در مرورگر", "Don't have Readest?": "Readest ندارید؟", - "Book not in your library": "این کتاب در کتابخانهٔ شما نیست" + "Book not in your library": "این کتاب در کتابخانهٔ شما نیست", + "Share Book": "اشتراک‌گذاری کتاب", + "Could not create share link": "ایجاد لینک اشتراک‌گذاری امکان‌پذیر نبود", + "Link copied": "لینک کپی شد", + "Could not copy link": "کپی کردن لینک امکان‌پذیر نبود", + "Share revoked": "اشتراک‌گذاری لغو شد", + "Could not revoke share": "لغو اشتراک‌گذاری امکان‌پذیر نبود", + "Uploading book…": "در حال آپلود کتاب…", + "Generating…": "در حال ایجاد…", + "Generate share link": "ایجاد لینک اشتراک‌گذاری", + "Includes your current page": "شامل صفحه فعلی شما", + "Share URL": "آدرس اشتراک‌گذاری", + "Copy link": "کپی لینک", + "Copied": "کپی شد", + "Share via…": "اشتراک‌گذاری از طریق…", + "Expires {{date}}": "منقضی می‌شود در {{date}}", + "Revoking…": "در حال لغو…", + "Revoke share": "لغو اشتراک‌گذاری", + "Sign in to share books": "برای اشتراک‌گذاری کتاب‌ها وارد شوید", + "Expiring soon": "به زودی منقضی می‌شود", + "Missing share token": "توکن اشتراک‌گذاری وجود ندارد", + "Could not add to your library": "افزودن به کتابخانه شما امکان‌پذیر نبود", + "This share link is no longer available": "این لینک اشتراک‌گذاری دیگر در دسترس نیست", + "The original link may have expired or been revoked.": "لینک اصلی ممکن است منقضی شده یا لغو شده باشد.", + "Get Readest": "دریافت Readest", + "Loading shared book…": "در حال بارگذاری کتاب اشتراک‌گذاری شده…", + "Adding…": "در حال افزودن…", + "Add to my library": "افزودن به کتابخانه من", + "Could not load your shares": "بارگذاری اشتراک‌گذاری‌های شما امکان‌پذیر نبود", + "Revoked": "لغو شده", + "Expired": "منقضی شده", + "Shared books": "کتاب‌های اشتراک‌گذاری شده", + "You haven't shared any books yet": "هنوز کتابی به اشتراک نگذاشته‌اید", + "Open a book and tap Share to send it to a friend.": "یک کتاب باز کنید و روی اشتراک‌گذاری ضربه بزنید تا برای دوستتان بفرستید.", + "{{count}} active_one": "{{count}} فعال", + "{{count}} active_other": "{{count}} فعال", + "{{count}} downloads_one": "{{count}} دانلود", + "{{count}} downloads_other": "{{count}} دانلود", + "starts at saved page": "از صفحه ذخیره‌شده شروع می‌شود", + "More actions": "اقدامات بیشتر", + "Loading…": "در حال بارگذاری…", + "Load more": "بارگذاری بیشتر", + "Could not load shared book": "بارگذاری کتاب اشتراک‌گذاری شده امکان‌پذیر نبود", + "The share link is missing required information.": "لینک اشتراک‌گذاری اطلاعات لازم را ندارد.", + "Please check your connection and try again.": "لطفاً اتصال خود را بررسی کرده و دوباره تلاش کنید.", + "Sign in to import shared books": "برای وارد کردن کتاب‌های اشتراک‌گذاری شده وارد شوید", + "Already in your library": "از قبل در کتابخانه شما موجود است", + "Added to your library": "به کتابخانه شما افزوده شد", + "Could not import shared book": "وارد کردن کتاب اشتراک‌گذاری شده امکان‌پذیر نبود", + "Manage Shared Links": "مدیریت لینک‌های اشتراک‌گذاری", + "Share current page": "اشتراک صفحه فعلی", + "Expires in": "منقضی می‌شود در", + "{{count}} days_one": "۱ روز", + "{{count}} days_other": "{{count}} روز", + "Expires in {{count}} days_one": "در ۱ روز منقضی می‌شود", + "Expires in {{count}} days_other": "در {{count}} روز منقضی می‌شود", + "Expires in {{count}} hours_one": "در ۱ ساعت منقضی می‌شود", + "Expires in {{count}} hours_other": "در {{count}} ساعت منقضی می‌شود", + "Open in app": "باز کردن در برنامه", + "Shared with you": "با شما به اشتراک گذاشته شده" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index d5c6d17a..46220585 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1273,5 +1273,69 @@ "Open in Readest app": "Ouvrir dans l'application Readest", "Continue in browser": "Continuer dans le navigateur", "Don't have Readest?": "Vous n'avez pas Readest ?", - "Book not in your library": "Livre absent de votre bibliothèque" + "Book not in your library": "Livre absent de votre bibliothèque", + "Share Book": "Partager le livre", + "Could not create share link": "Impossible de créer le lien de partage", + "Link copied": "Lien copié", + "Could not copy link": "Impossible de copier le lien", + "Share revoked": "Partage révoqué", + "Could not revoke share": "Impossible de révoquer le partage", + "Uploading book…": "Téléchargement du livre…", + "Generating…": "Génération…", + "Generate share link": "Créer un lien de partage", + "Includes your current page": "Inclut votre page actuelle", + "Share URL": "URL de partage", + "Copy link": "Copier le lien", + "Copied": "Copié", + "Share via…": "Partager via…", + "Expires {{date}}": "Expire le {{date}}", + "Revoking…": "Révocation…", + "Revoke share": "Révoquer le partage", + "Sign in to share books": "Connectez-vous pour partager des livres", + "Expiring soon": "Expire bientôt", + "Missing share token": "Jeton de partage manquant", + "Could not add to your library": "Impossible d'ajouter à votre bibliothèque", + "This share link is no longer available": "Ce lien de partage n'est plus disponible", + "The original link may have expired or been revoked.": "Le lien original a peut-être expiré ou été révoqué.", + "Get Readest": "Obtenir Readest", + "Loading shared book…": "Chargement du livre partagé…", + "Adding…": "Ajout…", + "Add to my library": "Ajouter à ma bibliothèque", + "Could not load your shares": "Impossible de charger vos partages", + "Revoked": "Révoqué", + "Expired": "Expiré", + "Shared books": "Livres partagés", + "You haven't shared any books yet": "Vous n’avez encore partagé aucun livre", + "Open a book and tap Share to send it to a friend.": "Ouvrez un livre et appuyez sur Partager pour l’envoyer à un ami.", + "{{count}} active_one": "{{count}} actif", + "{{count}} active_many": "{{count}} actifs", + "{{count}} active_other": "{{count}} actifs", + "{{count}} downloads_one": "{{count}} téléchargement", + "{{count}} downloads_many": "{{count}} téléchargements", + "{{count}} downloads_other": "{{count}} téléchargements", + "starts at saved page": "commence à la page enregistrée", + "More actions": "Plus d’actions", + "Loading…": "Chargement…", + "Load more": "Charger plus", + "Could not load shared book": "Impossible de charger le livre partagé", + "The share link is missing required information.": "Il manque des informations nécessaires au lien de partage.", + "Please check your connection and try again.": "Vérifiez votre connexion et réessayez.", + "Sign in to import shared books": "Connectez-vous pour importer des livres partagés", + "Already in your library": "Déjà dans votre bibliothèque", + "Added to your library": "Ajouté à votre bibliothèque", + "Could not import shared book": "Impossible d'importer le livre partagé", + "Manage Shared Links": "Gérer les liens de partage", + "Share current page": "Partager la page actuelle", + "Expires in": "Expire dans", + "{{count}} days_one": "1 jour", + "{{count}} days_many": "{{count}} jours", + "{{count}} days_other": "{{count}} jours", + "Expires in {{count}} days_one": "Expire dans 1 jour", + "Expires in {{count}} days_many": "Expire dans {{count}} jours", + "Expires in {{count}} days_other": "Expire dans {{count}} jours", + "Expires in {{count}} hours_one": "Expire dans 1 heure", + "Expires in {{count}} hours_many": "Expire dans {{count}} heures", + "Expires in {{count}} hours_other": "Expire dans {{count}} heures", + "Open in app": "Ouvrir dans l'app", + "Shared with you": "Partagé avec vous" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index 7a247fa7..78d44ad8 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1273,5 +1273,69 @@ "Open in Readest app": "פתיחה באפליקציית Readest", "Continue in browser": "המשך בדפדפן", "Don't have Readest?": "אין לך את Readest?", - "Book not in your library": "הספר אינו בספרייה שלך" + "Book not in your library": "הספר אינו בספרייה שלך", + "Share Book": "שיתוף ספר", + "Could not create share link": "לא ניתן היה ליצור קישור שיתוף", + "Link copied": "הקישור הועתק", + "Could not copy link": "לא ניתן היה להעתיק את הקישור", + "Share revoked": "השיתוף בוטל", + "Could not revoke share": "לא ניתן היה לבטל את השיתוף", + "Uploading book…": "מעלה ספר…", + "Generating…": "יוצר…", + "Generate share link": "צור קישור שיתוף", + "Includes your current page": "כולל את העמוד הנוכחי שלך", + "Share URL": "כתובת שיתוף", + "Copy link": "העתק קישור", + "Copied": "הועתק", + "Share via…": "שתף באמצעות…", + "Expires {{date}}": "יפוג ב-{{date}}", + "Revoking…": "מבטל…", + "Revoke share": "בטל שיתוף", + "Sign in to share books": "היכנס כדי לשתף ספרים", + "Expiring soon": "יפוג בקרוב", + "Missing share token": "אסימון שיתוף חסר", + "Could not add to your library": "לא ניתן היה להוסיף לספרייה שלך", + "This share link is no longer available": "קישור השיתוף הזה כבר אינו זמין", + "The original link may have expired or been revoked.": "הקישור המקורי אולי פג תוקפו או בוטל.", + "Get Readest": "הורד את Readest", + "Loading shared book…": "טוען ספר משותף…", + "Adding…": "מוסיף…", + "Add to my library": "הוסף לספרייה שלי", + "Could not load your shares": "לא ניתן היה לטעון את השיתופים שלך", + "Revoked": "בוטל", + "Expired": "פג תוקף", + "Shared books": "ספרים משותפים", + "You haven't shared any books yet": "עדיין לא שיתפת ספרים", + "Open a book and tap Share to send it to a friend.": "פתח ספר והקש על שיתוף כדי לשלוח אותו לחבר.", + "{{count}} active_one": "{{count}} פעיל", + "{{count}} active_two": "{{count}} פעילים", + "{{count}} active_other": "{{count}} פעילים", + "{{count}} downloads_one": "הורדה אחת", + "{{count}} downloads_two": "{{count}} הורדות", + "{{count}} downloads_other": "{{count}} הורדות", + "starts at saved page": "מתחיל בעמוד השמור", + "More actions": "פעולות נוספות", + "Loading…": "טוען…", + "Load more": "טען עוד", + "Could not load shared book": "לא ניתן היה לטעון את הספר המשותף", + "The share link is missing required information.": "בקישור השיתוף חסר מידע נדרש.", + "Please check your connection and try again.": "בדוק את החיבור שלך ונסה שוב.", + "Sign in to import shared books": "היכנס כדי לייבא ספרים משותפים", + "Already in your library": "כבר בספרייה שלך", + "Added to your library": "נוסף לספרייה שלך", + "Could not import shared book": "לא ניתן היה לייבא את הספר המשותף", + "Manage Shared Links": "ניהול קישורי שיתוף", + "Share current page": "שתף את העמוד הנוכחי", + "Expires in": "יפוג בעוד", + "{{count}} days_one": "יום אחד", + "{{count}} days_two": "יומיים", + "{{count}} days_other": "{{count}} ימים", + "Expires in {{count}} days_one": "יפוג בעוד יום", + "Expires in {{count}} days_two": "יפוג בעוד יומיים", + "Expires in {{count}} days_other": "יפוג בעוד {{count}} ימים", + "Expires in {{count}} hours_one": "יפוג בעוד שעה", + "Expires in {{count}} hours_two": "יפוג בעוד שעתיים", + "Expires in {{count}} hours_other": "יפוג בעוד {{count}} שעות", + "Open in app": "פתח באפליקציה", + "Shared with you": "שותף איתך" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 9a0d59a3..d0d2def2 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1255,5 +1255,64 @@ "Open in Readest app": "Readest ऐप में खोलें", "Continue in browser": "ब्राउज़र में जारी रखें", "Don't have Readest?": "Readest नहीं है?", - "Book not in your library": "यह किताब आपकी लाइब्रेरी में नहीं है" + "Book not in your library": "यह किताब आपकी लाइब्रेरी में नहीं है", + "Share Book": "पुस्तक साझा करें", + "Could not create share link": "साझा लिंक नहीं बनाया जा सका", + "Link copied": "लिंक कॉपी किया गया", + "Could not copy link": "लिंक कॉपी नहीं किया जा सका", + "Share revoked": "साझा रद्द किया गया", + "Could not revoke share": "साझा रद्द नहीं किया जा सका", + "Uploading book…": "पुस्तक अपलोड हो रही है…", + "Generating…": "बना रहा है…", + "Generate share link": "साझा लिंक बनाएँ", + "Includes your current page": "आपका वर्तमान पृष्ठ शामिल है", + "Share URL": "साझा URL", + "Copy link": "लिंक कॉपी करें", + "Copied": "कॉपी किया गया", + "Share via…": "के माध्यम से साझा करें…", + "Expires {{date}}": "{{date}} को समाप्त", + "Revoking…": "रद्द कर रहा है…", + "Revoke share": "साझा रद्द करें", + "Sign in to share books": "पुस्तकें साझा करने के लिए साइन इन करें", + "Expiring soon": "जल्द ही समाप्त होगा", + "Missing share token": "साझा टोकन गायब है", + "Could not add to your library": "आपकी लाइब्रेरी में जोड़ा नहीं जा सका", + "This share link is no longer available": "यह साझा लिंक अब उपलब्ध नहीं है", + "The original link may have expired or been revoked.": "मूल लिंक समाप्त हो गया हो सकता है या रद्द कर दिया गया हो।", + "Get Readest": "Readest प्राप्त करें", + "Loading shared book…": "साझा पुस्तक लोड हो रही है…", + "Adding…": "जोड़ रहा है…", + "Add to my library": "मेरी लाइब्रेरी में जोड़ें", + "Could not load your shares": "आपके साझा लोड नहीं किए जा सके", + "Revoked": "रद्द", + "Expired": "समाप्त", + "Shared books": "साझा पुस्तकें", + "You haven't shared any books yet": "आपने अभी तक कोई पुस्तक साझा नहीं की है", + "Open a book and tap Share to send it to a friend.": "किसी पुस्तक को खोलें और मित्र को भेजने के लिए साझा पर टैप करें।", + "{{count}} active_one": "{{count}} सक्रिय", + "{{count}} active_other": "{{count}} सक्रिय", + "{{count}} downloads_one": "{{count}} डाउनलोड", + "{{count}} downloads_other": "{{count}} डाउनलोड", + "starts at saved page": "सहेजे गए पृष्ठ से शुरू", + "More actions": "अधिक क्रियाएँ", + "Loading…": "लोड हो रहा है…", + "Load more": "और लोड करें", + "Could not load shared book": "साझा पुस्तक लोड नहीं की जा सकी", + "The share link is missing required information.": "साझा लिंक में आवश्यक जानकारी नहीं है।", + "Please check your connection and try again.": "कृपया अपना कनेक्शन जांचें और पुनः प्रयास करें।", + "Sign in to import shared books": "साझा पुस्तकें आयात करने के लिए साइन इन करें", + "Already in your library": "पहले से ही आपकी लाइब्रेरी में है", + "Added to your library": "आपकी लाइब्रेरी में जोड़ा गया", + "Could not import shared book": "साझा पुस्तक आयात नहीं की जा सकी", + "Manage Shared Links": "साझा लिंक प्रबंधित करें", + "Share current page": "वर्तमान पृष्ठ साझा करें", + "Expires in": "समाप्त होगा", + "{{count}} days_one": "1 दिन", + "{{count}} days_other": "{{count}} दिन", + "Expires in {{count}} days_one": "1 दिन में समाप्त", + "Expires in {{count}} days_other": "{{count}} दिनों में समाप्त", + "Expires in {{count}} hours_one": "1 घंटे में समाप्त", + "Expires in {{count}} hours_other": "{{count}} घंटों में समाप्त", + "Open in app": "ऐप में खोलें", + "Shared with you": "आपके साथ साझा किया गया" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 38473f5b..1967b02c 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -1255,5 +1255,64 @@ "Open in Readest app": "Megnyitás a Readest alkalmazásban", "Continue in browser": "Folytatás a böngészőben", "Don't have Readest?": "Még nincs Readested?", - "Book not in your library": "A könyv nincs a könyvtáradban" + "Book not in your library": "A könyv nincs a könyvtáradban", + "Share Book": "Könyv megosztása", + "Could not create share link": "A megosztási link létrehozása nem sikerült", + "Link copied": "Link másolva", + "Could not copy link": "A link másolása nem sikerült", + "Share revoked": "Megosztás visszavonva", + "Could not revoke share": "A megosztás visszavonása nem sikerült", + "Uploading book…": "Könyv feltöltése…", + "Generating…": "Létrehozás…", + "Generate share link": "Megosztási link létrehozása", + "Includes your current page": "Tartalmazza az aktuális oldalt", + "Share URL": "Megosztási URL", + "Copy link": "Link másolása", + "Copied": "Másolva", + "Share via…": "Megosztás ezzel…", + "Expires {{date}}": "Lejár: {{date}}", + "Revoking…": "Visszavonás…", + "Revoke share": "Megosztás visszavonása", + "Sign in to share books": "Jelentkezz be a könyvek megosztásához", + "Expiring soon": "Hamarosan lejár", + "Missing share token": "Hiányzó megosztási token", + "Could not add to your library": "Nem sikerült hozzáadni a könyvtáradhoz", + "This share link is no longer available": "Ez a megosztási link már nem érhető el", + "The original link may have expired or been revoked.": "Az eredeti link lehet, hogy lejárt vagy visszavonták.", + "Get Readest": "Readest letöltése", + "Loading shared book…": "Megosztott könyv betöltése…", + "Adding…": "Hozzáadás…", + "Add to my library": "Hozzáadás a könyvtáramhoz", + "Could not load your shares": "Nem sikerült betölteni a megosztásaidat", + "Revoked": "Visszavonva", + "Expired": "Lejárt", + "Shared books": "Megosztott könyvek", + "You haven't shared any books yet": "Még nem osztottál meg könyvet", + "Open a book and tap Share to send it to a friend.": "Nyiss meg egy könyvet, és koppints a Megosztásra, hogy elküldd egy barátodnak.", + "{{count}} active_one": "{{count}} aktív", + "{{count}} active_other": "{{count}} aktív", + "{{count}} downloads_one": "{{count}} letöltés", + "{{count}} downloads_other": "{{count}} letöltés", + "starts at saved page": "mentett oldaltól indul", + "More actions": "További műveletek", + "Loading…": "Betöltés…", + "Load more": "Több betöltése", + "Could not load shared book": "A megosztott könyv betöltése nem sikerült", + "The share link is missing required information.": "A megosztási linkről hiányoznak szükséges adatok.", + "Please check your connection and try again.": "Ellenőrizd a kapcsolatot, majd próbáld újra.", + "Sign in to import shared books": "Jelentkezz be a megosztott könyvek importálásához", + "Already in your library": "Már a könyvtáradban van", + "Added to your library": "Hozzáadva a könyvtáradhoz", + "Could not import shared book": "A megosztott könyv importálása nem sikerült", + "Manage Shared Links": "Megosztási linkek kezelése", + "Share current page": "Aktuális oldal megosztása", + "Expires in": "Lejár", + "{{count}} days_one": "1 nap", + "{{count}} days_other": "{{count}} nap", + "Expires in {{count}} days_one": "Lejár 1 nap múlva", + "Expires in {{count}} days_other": "Lejár {{count}} nap múlva", + "Expires in {{count}} hours_one": "Lejár 1 óra múlva", + "Expires in {{count}} hours_other": "Lejár {{count}} óra múlva", + "Open in app": "Megnyitás az alkalmazásban", + "Shared with you": "Megosztva veled" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 8b4a834f..ac3b94e9 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1237,5 +1237,59 @@ "Open in Readest app": "Buka di aplikasi Readest", "Continue in browser": "Lanjutkan di peramban", "Don't have Readest?": "Belum punya Readest?", - "Book not in your library": "Buku tidak ada di pustaka Anda" + "Book not in your library": "Buku tidak ada di pustaka Anda", + "Share Book": "Bagikan Buku", + "Could not create share link": "Tidak dapat membuat tautan berbagi", + "Link copied": "Tautan disalin", + "Could not copy link": "Tidak dapat menyalin tautan", + "Share revoked": "Berbagi dibatalkan", + "Could not revoke share": "Tidak dapat membatalkan berbagi", + "Uploading book…": "Mengunggah buku…", + "Generating…": "Membuat…", + "Generate share link": "Buat tautan berbagi", + "Includes your current page": "Termasuk halaman Anda saat ini", + "Share URL": "URL Berbagi", + "Copy link": "Salin tautan", + "Copied": "Disalin", + "Share via…": "Bagikan melalui…", + "Expires {{date}}": "Kedaluwarsa pada {{date}}", + "Revoking…": "Membatalkan…", + "Revoke share": "Batalkan berbagi", + "Sign in to share books": "Masuk untuk berbagi buku", + "Expiring soon": "Segera kedaluwarsa", + "Missing share token": "Token berbagi hilang", + "Could not add to your library": "Tidak dapat ditambahkan ke pustaka Anda", + "This share link is no longer available": "Tautan berbagi ini tidak lagi tersedia", + "The original link may have expired or been revoked.": "Tautan asli mungkin telah kedaluwarsa atau dibatalkan.", + "Get Readest": "Dapatkan Readest", + "Loading shared book…": "Memuat buku yang dibagikan…", + "Adding…": "Menambahkan…", + "Add to my library": "Tambahkan ke pustaka saya", + "Could not load your shares": "Tidak dapat memuat berbagi Anda", + "Revoked": "Dibatalkan", + "Expired": "Kedaluwarsa", + "Shared books": "Buku yang dibagikan", + "You haven't shared any books yet": "Anda belum membagikan buku apa pun", + "Open a book and tap Share to send it to a friend.": "Buka buku dan ketuk Bagikan untuk mengirimnya ke teman.", + "{{count}} active_other": "{{count}} aktif", + "{{count}} downloads_other": "{{count}} unduhan", + "starts at saved page": "mulai dari halaman tersimpan", + "More actions": "Tindakan lainnya", + "Loading…": "Memuat…", + "Load more": "Muat lainnya", + "Could not load shared book": "Tidak dapat memuat buku yang dibagikan", + "The share link is missing required information.": "Tautan berbagi tidak memiliki informasi yang diperlukan.", + "Please check your connection and try again.": "Periksa koneksi Anda dan coba lagi.", + "Sign in to import shared books": "Masuk untuk mengimpor buku yang dibagikan", + "Already in your library": "Sudah ada di pustaka Anda", + "Added to your library": "Ditambahkan ke pustaka Anda", + "Could not import shared book": "Tidak dapat mengimpor buku yang dibagikan", + "Manage Shared Links": "Kelola tautan berbagi", + "Share current page": "Bagikan halaman saat ini", + "Expires in": "Kedaluwarsa dalam", + "{{count}} days_other": "{{count}} hari", + "Expires in {{count}} days_other": "Kedaluwarsa dalam {{count}} hari", + "Expires in {{count}} hours_other": "Kedaluwarsa dalam {{count}} jam", + "Open in app": "Buka di aplikasi", + "Shared with you": "Dibagikan dengan Anda" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 1922a8d7..70c43b01 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1273,5 +1273,69 @@ "Open in Readest app": "Apri nell'app Readest", "Continue in browser": "Continua nel browser", "Don't have Readest?": "Non hai Readest?", - "Book not in your library": "Libro non presente nella tua libreria" + "Book not in your library": "Libro non presente nella tua libreria", + "Share Book": "Condividi libro", + "Could not create share link": "Impossibile creare il link di condivisione", + "Link copied": "Link copiato", + "Could not copy link": "Impossibile copiare il link", + "Share revoked": "Condivisione revocata", + "Could not revoke share": "Impossibile revocare la condivisione", + "Uploading book…": "Caricamento libro…", + "Generating…": "Generazione…", + "Generate share link": "Genera link di condivisione", + "Includes your current page": "Include la tua pagina attuale", + "Share URL": "URL di condivisione", + "Copy link": "Copia link", + "Copied": "Copiato", + "Share via…": "Condividi tramite…", + "Expires {{date}}": "Scade il {{date}}", + "Revoking…": "Revoca in corso…", + "Revoke share": "Revoca condivisione", + "Sign in to share books": "Accedi per condividere libri", + "Expiring soon": "Scade presto", + "Missing share token": "Token di condivisione mancante", + "Could not add to your library": "Impossibile aggiungere alla tua libreria", + "This share link is no longer available": "Questo link di condivisione non è più disponibile", + "The original link may have expired or been revoked.": "Il link originale potrebbe essere scaduto o revocato.", + "Get Readest": "Ottieni Readest", + "Loading shared book…": "Caricamento libro condiviso…", + "Adding…": "Aggiunta in corso…", + "Add to my library": "Aggiungi alla mia libreria", + "Could not load your shares": "Impossibile caricare le tue condivisioni", + "Revoked": "Revocato", + "Expired": "Scaduto", + "Shared books": "Libri condivisi", + "You haven't shared any books yet": "Non hai ancora condiviso alcun libro", + "Open a book and tap Share to send it to a friend.": "Apri un libro e tocca Condividi per inviarlo a un amico.", + "{{count}} active_one": "{{count}} attivo", + "{{count}} active_many": "{{count}} attivi", + "{{count}} active_other": "{{count}} attivi", + "{{count}} downloads_one": "{{count}} download", + "{{count}} downloads_many": "{{count}} download", + "{{count}} downloads_other": "{{count}} download", + "starts at saved page": "inizia dalla pagina salvata", + "More actions": "Altre azioni", + "Loading…": "Caricamento…", + "Load more": "Carica altro", + "Could not load shared book": "Impossibile caricare il libro condiviso", + "The share link is missing required information.": "Al link di condivisione mancano informazioni necessarie.", + "Please check your connection and try again.": "Controlla la connessione e riprova.", + "Sign in to import shared books": "Accedi per importare libri condivisi", + "Already in your library": "Già nella tua libreria", + "Added to your library": "Aggiunto alla tua libreria", + "Could not import shared book": "Impossibile importare il libro condiviso", + "Manage Shared Links": "Gestisci link di condivisione", + "Share current page": "Condividi pagina attuale", + "Expires in": "Scade tra", + "{{count}} days_one": "1 giorno", + "{{count}} days_many": "{{count}} giorni", + "{{count}} days_other": "{{count}} giorni", + "Expires in {{count}} days_one": "Scade tra 1 giorno", + "Expires in {{count}} days_many": "Scade tra {{count}} giorni", + "Expires in {{count}} days_other": "Scade tra {{count}} giorni", + "Expires in {{count}} hours_one": "Scade tra 1 ora", + "Expires in {{count}} hours_many": "Scade tra {{count}} ore", + "Expires in {{count}} hours_other": "Scade tra {{count}} ore", + "Open in app": "Apri nell'app", + "Shared with you": "Condiviso con te" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index d8de0548..a3ccd99f 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1237,5 +1237,59 @@ "Open in Readest app": "Readest アプリで開く", "Continue in browser": "ブラウザーで続ける", "Don't have Readest?": "Readest をお持ちでない方は", - "Book not in your library": "この本はライブラリにありません" + "Book not in your library": "この本はライブラリにありません", + "Share Book": "本を共有", + "Could not create share link": "共有リンクを作成できませんでした", + "Link copied": "リンクをコピーしました", + "Could not copy link": "リンクをコピーできませんでした", + "Share revoked": "共有を取り消しました", + "Could not revoke share": "共有を取り消せませんでした", + "Uploading book…": "本をアップロード中…", + "Generating…": "生成中…", + "Generate share link": "共有リンクを作成", + "Includes your current page": "現在のページを含む", + "Share URL": "共有URL", + "Copy link": "リンクをコピー", + "Copied": "コピーしました", + "Share via…": "共有…", + "Expires {{date}}": "{{date}}に期限切れ", + "Revoking…": "取り消し中…", + "Revoke share": "共有を取り消す", + "Sign in to share books": "本を共有するにはサインインしてください", + "Expiring soon": "まもなく期限切れ", + "Missing share token": "共有トークンが見つかりません", + "Could not add to your library": "ライブラリに追加できませんでした", + "This share link is no longer available": "この共有リンクはもう利用できません", + "The original link may have expired or been revoked.": "元のリンクは期限切れか取り消された可能性があります。", + "Get Readest": "Readest を入手", + "Loading shared book…": "共有された本を読み込み中…", + "Adding…": "追加中…", + "Add to my library": "ライブラリに追加", + "Could not load your shares": "共有を読み込めませんでした", + "Revoked": "取り消し済み", + "Expired": "期限切れ", + "Shared books": "共有された本", + "You haven't shared any books yet": "まだ本を共有していません", + "Open a book and tap Share to send it to a friend.": "本を開き、共有をタップして友達に送りましょう。", + "{{count}} active_other": "{{count}} 件アクティブ", + "{{count}} downloads_other": "{{count}} 件のダウンロード", + "starts at saved page": "保存されたページから開始", + "More actions": "その他の操作", + "Loading…": "読み込み中…", + "Load more": "さらに読み込む", + "Could not load shared book": "共有された本を読み込めませんでした", + "The share link is missing required information.": "共有リンクに必要な情報が含まれていません。", + "Please check your connection and try again.": "接続を確認してもう一度お試しください。", + "Sign in to import shared books": "共有された本を取り込むにはサインインしてください", + "Already in your library": "すでにライブラリにあります", + "Added to your library": "ライブラリに追加しました", + "Could not import shared book": "共有された本を取り込めませんでした", + "Manage Shared Links": "共有リンクを管理", + "Share current page": "現在のページを共有", + "Expires in": "有効期限", + "{{count}} days_other": "{{count}}日", + "Expires in {{count}} days_other": "{{count}}日で期限切れ", + "Expires in {{count}} hours_other": "{{count}}時間で期限切れ", + "Open in app": "アプリで開く", + "Shared with you": "あなたへの共有" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 7695930f..2f4ccf01 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1237,5 +1237,59 @@ "Open in Readest app": "Readest 앱에서 열기", "Continue in browser": "브라우저에서 계속", "Don't have Readest?": "Readest가 없으신가요?", - "Book not in your library": "책이 라이브러리에 없습니다" + "Book not in your library": "책이 라이브러리에 없습니다", + "Share Book": "책 공유", + "Could not create share link": "공유 링크를 만들 수 없습니다", + "Link copied": "링크가 복사되었습니다", + "Could not copy link": "링크를 복사할 수 없습니다", + "Share revoked": "공유가 취소되었습니다", + "Could not revoke share": "공유를 취소할 수 없습니다", + "Uploading book…": "책 업로드 중…", + "Generating…": "생성 중…", + "Generate share link": "공유 링크 만들기", + "Includes your current page": "현재 페이지 포함", + "Share URL": "공유 URL", + "Copy link": "링크 복사", + "Copied": "복사됨", + "Share via…": "공유…", + "Expires {{date}}": "{{date}}에 만료", + "Revoking…": "취소 중…", + "Revoke share": "공유 취소", + "Sign in to share books": "책을 공유하려면 로그인하세요", + "Expiring soon": "곧 만료", + "Missing share token": "공유 토큰이 없습니다", + "Could not add to your library": "내 라이브러리에 추가할 수 없습니다", + "This share link is no longer available": "이 공유 링크는 더 이상 사용할 수 없습니다", + "The original link may have expired or been revoked.": "원래 링크가 만료되었거나 취소되었을 수 있습니다.", + "Get Readest": "Readest 받기", + "Loading shared book…": "공유된 책을 불러오는 중…", + "Adding…": "추가 중…", + "Add to my library": "내 라이브러리에 추가", + "Could not load your shares": "공유를 불러올 수 없습니다", + "Revoked": "취소됨", + "Expired": "만료됨", + "Shared books": "공유된 책", + "You haven't shared any books yet": "아직 책을 공유하지 않았습니다", + "Open a book and tap Share to send it to a friend.": "책을 열고 공유를 눌러 친구에게 보내세요.", + "{{count}} active_other": "활성 {{count}}개", + "{{count}} downloads_other": "{{count}}회 다운로드", + "starts at saved page": "저장된 페이지에서 시작", + "More actions": "추가 작업", + "Loading…": "불러오는 중…", + "Load more": "더 보기", + "Could not load shared book": "공유된 책을 불러올 수 없습니다", + "The share link is missing required information.": "공유 링크에 필요한 정보가 없습니다.", + "Please check your connection and try again.": "연결을 확인한 후 다시 시도하세요.", + "Sign in to import shared books": "공유된 책을 가져오려면 로그인하세요", + "Already in your library": "이미 라이브러리에 있습니다", + "Added to your library": "라이브러리에 추가되었습니다", + "Could not import shared book": "공유된 책을 가져올 수 없습니다", + "Manage Shared Links": "공유 링크 관리", + "Share current page": "현재 페이지 공유", + "Expires in": "만료", + "{{count}} days_other": "{{count}}일", + "Expires in {{count}} days_other": "{{count}}일 후 만료", + "Expires in {{count}} hours_other": "{{count}}시간 후 만료", + "Open in app": "앱에서 열기", + "Shared with you": "회원님과 공유됨" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index d8b5d436..5bfb85da 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1237,5 +1237,59 @@ "Open in Readest app": "Buka dalam aplikasi Readest", "Continue in browser": "Teruskan dalam pelayar", "Don't have Readest?": "Tiada Readest?", - "Book not in your library": "Buku tiada dalam pustaka anda" + "Book not in your library": "Buku tiada dalam pustaka anda", + "Share Book": "Kongsi Buku", + "Could not create share link": "Tidak dapat mencipta pautan kongsi", + "Link copied": "Pautan disalin", + "Could not copy link": "Tidak dapat menyalin pautan", + "Share revoked": "Perkongsian dibatalkan", + "Could not revoke share": "Tidak dapat membatalkan perkongsian", + "Uploading book…": "Memuat naik buku…", + "Generating…": "Menjana…", + "Generate share link": "Jana pautan kongsi", + "Includes your current page": "Termasuk halaman semasa anda", + "Share URL": "URL Kongsi", + "Copy link": "Salin pautan", + "Copied": "Disalin", + "Share via…": "Kongsi melalui…", + "Expires {{date}}": "Tamat pada {{date}}", + "Revoking…": "Membatalkan…", + "Revoke share": "Batalkan perkongsian", + "Sign in to share books": "Log masuk untuk berkongsi buku", + "Expiring soon": "Akan tamat tidak lama lagi", + "Missing share token": "Token kongsi hilang", + "Could not add to your library": "Tidak dapat ditambah ke pustaka anda", + "This share link is no longer available": "Pautan kongsi ini tidak lagi tersedia", + "The original link may have expired or been revoked.": "Pautan asal mungkin telah tamat tempoh atau dibatalkan.", + "Get Readest": "Dapatkan Readest", + "Loading shared book…": "Memuat buku yang dikongsi…", + "Adding…": "Menambah…", + "Add to my library": "Tambah ke pustaka saya", + "Could not load your shares": "Tidak dapat memuat perkongsian anda", + "Revoked": "Dibatalkan", + "Expired": "Tamat tempoh", + "Shared books": "Buku yang dikongsi", + "You haven't shared any books yet": "Anda belum berkongsi sebarang buku", + "Open a book and tap Share to send it to a friend.": "Buka buku dan ketik Kongsi untuk menghantarnya kepada rakan.", + "{{count}} active_other": "{{count}} aktif", + "{{count}} downloads_other": "{{count}} muat turun", + "starts at saved page": "mula dari halaman tersimpan", + "More actions": "Tindakan lain", + "Loading…": "Memuat…", + "Load more": "Muat lagi", + "Could not load shared book": "Tidak dapat memuat buku yang dikongsi", + "The share link is missing required information.": "Pautan kongsi tidak mempunyai maklumat yang diperlukan.", + "Please check your connection and try again.": "Sila semak sambungan anda dan cuba lagi.", + "Sign in to import shared books": "Log masuk untuk mengimport buku yang dikongsi", + "Already in your library": "Sudah ada dalam pustaka anda", + "Added to your library": "Ditambah ke pustaka anda", + "Could not import shared book": "Tidak dapat mengimport buku yang dikongsi", + "Manage Shared Links": "Urus pautan kongsi", + "Share current page": "Kongsi halaman semasa", + "Expires in": "Tamat dalam", + "{{count}} days_other": "{{count}} hari", + "Expires in {{count}} days_other": "Tamat dalam {{count}} hari", + "Expires in {{count}} hours_other": "Tamat dalam {{count}} jam", + "Open in app": "Buka dalam app", + "Shared with you": "Dikongsi dengan anda" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index f2fd7146..cacf302e 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1255,5 +1255,64 @@ "Open in Readest app": "Openen in de Readest-app", "Continue in browser": "Doorgaan in de browser", "Don't have Readest?": "Heb je Readest nog niet?", - "Book not in your library": "Boek staat niet in je bibliotheek" + "Book not in your library": "Boek staat niet in je bibliotheek", + "Share Book": "Boek delen", + "Could not create share link": "Kan deelkoppeling niet maken", + "Link copied": "Koppeling gekopieerd", + "Could not copy link": "Kan koppeling niet kopiëren", + "Share revoked": "Delen ingetrokken", + "Could not revoke share": "Kan delen niet intrekken", + "Uploading book…": "Boek uploaden…", + "Generating…": "Genereren…", + "Generate share link": "Deellink maken", + "Includes your current page": "Bevat uw huidige pagina", + "Share URL": "Deel-URL", + "Copy link": "Koppeling kopiëren", + "Copied": "Gekopieerd", + "Share via…": "Delen via…", + "Expires {{date}}": "Verloopt op {{date}}", + "Revoking…": "Intrekken…", + "Revoke share": "Delen intrekken", + "Sign in to share books": "Meld u aan om boeken te delen", + "Expiring soon": "Verloopt binnenkort", + "Missing share token": "Deeltoken ontbreekt", + "Could not add to your library": "Kon niet aan je bibliotheek worden toegevoegd", + "This share link is no longer available": "Deze deelkoppeling is niet meer beschikbaar", + "The original link may have expired or been revoked.": "De oorspronkelijke koppeling is mogelijk verlopen of ingetrokken.", + "Get Readest": "Readest downloaden", + "Loading shared book…": "Gedeeld boek laden…", + "Adding…": "Toevoegen…", + "Add to my library": "Aan mijn bibliotheek toevoegen", + "Could not load your shares": "Kan uw delingen niet laden", + "Revoked": "Ingetrokken", + "Expired": "Verlopen", + "Shared books": "Gedeelde boeken", + "You haven't shared any books yet": "U heeft nog geen boeken gedeeld", + "Open a book and tap Share to send it to a friend.": "Open een boek en tik op Delen om het naar een vriend te sturen.", + "{{count}} active_one": "{{count}} actief", + "{{count}} active_other": "{{count}} actief", + "{{count}} downloads_one": "{{count}} download", + "{{count}} downloads_other": "{{count}} downloads", + "starts at saved page": "begint op opgeslagen pagina", + "More actions": "Meer acties", + "Loading…": "Laden…", + "Load more": "Meer laden", + "Could not load shared book": "Kan gedeeld boek niet laden", + "The share link is missing required information.": "In de deellink ontbreekt vereiste informatie.", + "Please check your connection and try again.": "Controleer uw verbinding en probeer het opnieuw.", + "Sign in to import shared books": "Meld u aan om gedeelde boeken te importeren", + "Already in your library": "Al in uw bibliotheek", + "Added to your library": "Toegevoegd aan uw bibliotheek", + "Could not import shared book": "Kan gedeeld boek niet importeren", + "Manage Shared Links": "Deelkoppelingen beheren", + "Share current page": "Huidige pagina delen", + "Expires in": "Verloopt over", + "{{count}} days_one": "1 dag", + "{{count}} days_other": "{{count}} dagen", + "Expires in {{count}} days_one": "Verloopt over 1 dag", + "Expires in {{count}} days_other": "Verloopt over {{count}} dagen", + "Expires in {{count}} hours_one": "Verloopt over 1 uur", + "Expires in {{count}} hours_other": "Verloopt over {{count}} uur", + "Open in app": "Openen in app", + "Shared with you": "Met u gedeeld" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 9957881f..f57ed1a2 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1291,5 +1291,74 @@ "Open in Readest app": "Otwórz w aplikacji Readest", "Continue in browser": "Kontynuuj w przeglądarce", "Don't have Readest?": "Nie masz jeszcze Readest?", - "Book not in your library": "Książki nie ma w Twojej bibliotece" + "Book not in your library": "Książki nie ma w Twojej bibliotece", + "Share Book": "Udostępnij książkę", + "Could not create share link": "Nie można utworzyć linku udostępniania", + "Link copied": "Link skopiowany", + "Could not copy link": "Nie można skopiować linku", + "Share revoked": "Udostępnianie odwołane", + "Could not revoke share": "Nie można odwołać udostępniania", + "Uploading book…": "Przesyłanie książki…", + "Generating…": "Generowanie…", + "Generate share link": "Utwórz link udostępniania", + "Includes your current page": "Zawiera Twoją bieżącą stronę", + "Share URL": "Adres udostępniania", + "Copy link": "Kopiuj link", + "Copied": "Skopiowano", + "Share via…": "Udostępnij przez…", + "Expires {{date}}": "Wygasa {{date}}", + "Revoking…": "Odwoływanie…", + "Revoke share": "Odwołaj udostępnianie", + "Sign in to share books": "Zaloguj się, aby udostępniać książki", + "Expiring soon": "Wkrótce wygaśnie", + "Missing share token": "Brakuje tokenu udostępniania", + "Could not add to your library": "Nie można dodać do biblioteki", + "This share link is no longer available": "Ten link udostępniania jest już niedostępny", + "The original link may have expired or been revoked.": "Pierwotny link mógł wygasnąć lub został odwołany.", + "Get Readest": "Pobierz Readest", + "Loading shared book…": "Wczytywanie udostępnionej książki…", + "Adding…": "Dodawanie…", + "Add to my library": "Dodaj do mojej biblioteki", + "Could not load your shares": "Nie można wczytać udostępnień", + "Revoked": "Odwołany", + "Expired": "Wygasł", + "Shared books": "Udostępnione książki", + "You haven't shared any books yet": "Nie udostępniłeś jeszcze żadnych książek", + "Open a book and tap Share to send it to a friend.": "Otwórz książkę i dotknij Udostępnij, aby wysłać ją znajomemu.", + "{{count}} active_one": "{{count}} aktywny", + "{{count}} active_few": "{{count}} aktywne", + "{{count}} active_many": "{{count}} aktywnych", + "{{count}} active_other": "{{count}} aktywnych", + "{{count}} downloads_one": "{{count}} pobranie", + "{{count}} downloads_few": "{{count}} pobrania", + "{{count}} downloads_many": "{{count}} pobrań", + "{{count}} downloads_other": "{{count}} pobrań", + "starts at saved page": "zaczyna się od zapisanej strony", + "More actions": "Więcej akcji", + "Loading…": "Wczytywanie…", + "Load more": "Wczytaj więcej", + "Could not load shared book": "Nie można wczytać udostępnionej książki", + "The share link is missing required information.": "W linku udostępniania brakuje wymaganych informacji.", + "Please check your connection and try again.": "Sprawdź połączenie i spróbuj ponownie.", + "Sign in to import shared books": "Zaloguj się, aby importować udostępnione książki", + "Already in your library": "Już w Twojej bibliotece", + "Added to your library": "Dodano do biblioteki", + "Could not import shared book": "Nie można zaimportować udostępnionej książki", + "Manage Shared Links": "Zarządzaj linkami udostępniania", + "Share current page": "Udostępnij bieżącą stronę", + "Expires in": "Wygasa za", + "{{count}} days_one": "1 dzień", + "{{count}} days_few": "{{count}} dni", + "{{count}} days_many": "{{count}} dni", + "{{count}} days_other": "{{count}} dni", + "Expires in {{count}} days_one": "Wygasa za 1 dzień", + "Expires in {{count}} days_few": "Wygasa za {{count}} dni", + "Expires in {{count}} days_many": "Wygasa za {{count}} dni", + "Expires in {{count}} days_other": "Wygasa za {{count}} dni", + "Expires in {{count}} hours_one": "Wygasa za 1 godz.", + "Expires in {{count}} hours_few": "Wygasa za {{count}} godz.", + "Expires in {{count}} hours_many": "Wygasa za {{count}} godz.", + "Expires in {{count}} hours_other": "Wygasa za {{count}} godz.", + "Open in app": "Otwórz w aplikacji", + "Shared with you": "Udostępnione Tobie" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 08fc8350..7a482713 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1273,5 +1273,69 @@ "Open in Readest app": "Abrir no app Readest", "Continue in browser": "Continuar no navegador", "Don't have Readest?": "Ainda não tem o Readest?", - "Book not in your library": "O livro não está na sua biblioteca" + "Book not in your library": "O livro não está na sua biblioteca", + "Share Book": "Compartilhar livro", + "Could not create share link": "Não foi possível criar o link de compartilhamento", + "Link copied": "Link copiado", + "Could not copy link": "Não foi possível copiar o link", + "Share revoked": "Compartilhamento revogado", + "Could not revoke share": "Não foi possível revogar o compartilhamento", + "Uploading book…": "Enviando livro…", + "Generating…": "Gerando…", + "Generate share link": "Gerar link de compartilhamento", + "Includes your current page": "Inclui sua página atual", + "Share URL": "URL de compartilhamento", + "Copy link": "Copiar link", + "Copied": "Copiado", + "Share via…": "Compartilhar via…", + "Expires {{date}}": "Expira em {{date}}", + "Revoking…": "Revogando…", + "Revoke share": "Revogar compartilhamento", + "Sign in to share books": "Entre para compartilhar livros", + "Expiring soon": "Expira em breve", + "Missing share token": "Token de compartilhamento ausente", + "Could not add to your library": "Não foi possível adicionar à sua biblioteca", + "This share link is no longer available": "Este link de compartilhamento não está mais disponível", + "The original link may have expired or been revoked.": "O link original pode ter expirado ou sido revogado.", + "Get Readest": "Obter Readest", + "Loading shared book…": "Carregando livro compartilhado…", + "Adding…": "Adicionando…", + "Add to my library": "Adicionar à minha biblioteca", + "Could not load your shares": "Não foi possível carregar seus compartilhamentos", + "Revoked": "Revogado", + "Expired": "Expirado", + "Shared books": "Livros compartilhados", + "You haven't shared any books yet": "Você ainda não compartilhou nenhum livro", + "Open a book and tap Share to send it to a friend.": "Abra um livro e toque em Compartilhar para enviá-lo a um amigo.", + "{{count}} active_one": "{{count}} ativo", + "{{count}} active_many": "{{count}} ativos", + "{{count}} active_other": "{{count}} ativos", + "{{count}} downloads_one": "{{count}} download", + "{{count}} downloads_many": "{{count}} downloads", + "{{count}} downloads_other": "{{count}} downloads", + "starts at saved page": "começa na página salva", + "More actions": "Mais ações", + "Loading…": "Carregando…", + "Load more": "Carregar mais", + "Could not load shared book": "Não foi possível carregar o livro compartilhado", + "The share link is missing required information.": "Faltam informações necessárias no link de compartilhamento.", + "Please check your connection and try again.": "Verifique sua conexão e tente novamente.", + "Sign in to import shared books": "Entre para importar livros compartilhados", + "Already in your library": "Já está na sua biblioteca", + "Added to your library": "Adicionado à sua biblioteca", + "Could not import shared book": "Não foi possível importar o livro compartilhado", + "Manage Shared Links": "Gerenciar links de compartilhamento", + "Share current page": "Compartilhar página atual", + "Expires in": "Expira em", + "{{count}} days_one": "1 dia", + "{{count}} days_many": "{{count}} dias", + "{{count}} days_other": "{{count}} dias", + "Expires in {{count}} days_one": "Expira em 1 dia", + "Expires in {{count}} days_many": "Expira em {{count}} dias", + "Expires in {{count}} days_other": "Expira em {{count}} dias", + "Expires in {{count}} hours_one": "Expira em 1 hora", + "Expires in {{count}} hours_many": "Expira em {{count}} horas", + "Expires in {{count}} hours_other": "Expira em {{count}} horas", + "Open in app": "Abrir no app", + "Shared with you": "Compartilhado com você" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index f030f56a..ddf4196c 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -1273,5 +1273,69 @@ "Open in Readest app": "Deschide în aplicația Readest", "Continue in browser": "Continuă în browser", "Don't have Readest?": "Nu ai Readest?", - "Book not in your library": "Cartea nu este în biblioteca ta" + "Book not in your library": "Cartea nu este în biblioteca ta", + "Share Book": "Partajează cartea", + "Could not create share link": "Nu s-a putut crea linkul de partajare", + "Link copied": "Link copiat", + "Could not copy link": "Nu s-a putut copia linkul", + "Share revoked": "Partajare revocată", + "Could not revoke share": "Nu s-a putut revoca partajarea", + "Uploading book…": "Se încarcă cartea…", + "Generating…": "Se generează…", + "Generate share link": "Generează link de partajare", + "Includes your current page": "Include pagina ta curentă", + "Share URL": "URL de partajare", + "Copy link": "Copiază linkul", + "Copied": "Copiat", + "Share via…": "Partajează prin…", + "Expires {{date}}": "Expiră pe {{date}}", + "Revoking…": "Se revocă…", + "Revoke share": "Revocă partajarea", + "Sign in to share books": "Conectează-te pentru a partaja cărți", + "Expiring soon": "Expiră în curând", + "Missing share token": "Lipsește jetonul de partajare", + "Could not add to your library": "Nu s-a putut adăuga în biblioteca ta", + "This share link is no longer available": "Acest link de partajare nu mai este disponibil", + "The original link may have expired or been revoked.": "Linkul original poate fi expirat sau revocat.", + "Get Readest": "Obține Readest", + "Loading shared book…": "Se încarcă cartea partajată…", + "Adding…": "Se adaugă…", + "Add to my library": "Adaugă în biblioteca mea", + "Could not load your shares": "Nu s-au putut încărca partajările tale", + "Revoked": "Revocat", + "Expired": "Expirat", + "Shared books": "Cărți partajate", + "You haven't shared any books yet": "Nu ai partajat încă nicio carte", + "Open a book and tap Share to send it to a friend.": "Deschide o carte și apasă pe Partajează pentru a o trimite unui prieten.", + "{{count}} active_one": "{{count}} activ", + "{{count}} active_few": "{{count}} active", + "{{count}} active_other": "{{count}} de active", + "{{count}} downloads_one": "{{count}} descărcare", + "{{count}} downloads_few": "{{count}} descărcări", + "{{count}} downloads_other": "{{count}} de descărcări", + "starts at saved page": "începe de la pagina salvată", + "More actions": "Mai multe acțiuni", + "Loading…": "Se încarcă…", + "Load more": "Încarcă mai multe", + "Could not load shared book": "Nu s-a putut încărca cartea partajată", + "The share link is missing required information.": "Lipsesc informații necesare din linkul de partajare.", + "Please check your connection and try again.": "Verifică conexiunea și încearcă din nou.", + "Sign in to import shared books": "Conectează-te pentru a importa cărți partajate", + "Already in your library": "Deja în biblioteca ta", + "Added to your library": "Adăugat în biblioteca ta", + "Could not import shared book": "Nu s-a putut importa cartea partajată", + "Manage Shared Links": "Gestionează linkurile de partajare", + "Share current page": "Partajează pagina curentă", + "Expires in": "Expiră în", + "{{count}} days_one": "1 zi", + "{{count}} days_few": "{{count}} zile", + "{{count}} days_other": "{{count}} de zile", + "Expires in {{count}} days_one": "Expiră într-o zi", + "Expires in {{count}} days_few": "Expiră în {{count}} zile", + "Expires in {{count}} days_other": "Expiră în {{count}} de zile", + "Expires in {{count}} hours_one": "Expiră într-o oră", + "Expires in {{count}} hours_few": "Expiră în {{count}} ore", + "Expires in {{count}} hours_other": "Expiră în {{count}} de ore", + "Open in app": "Deschide în aplicație", + "Shared with you": "Partajat cu tine" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 0e7a4a57..91c9cbe4 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1291,5 +1291,74 @@ "Open in Readest app": "Открыть в приложении Readest", "Continue in browser": "Продолжить в браузере", "Don't have Readest?": "Ещё нет Readest?", - "Book not in your library": "Книги нет в вашей библиотеке" + "Book not in your library": "Книги нет в вашей библиотеке", + "Share Book": "Поделиться книгой", + "Could not create share link": "Не удалось создать ссылку для общего доступа", + "Link copied": "Ссылка скопирована", + "Could not copy link": "Не удалось скопировать ссылку", + "Share revoked": "Доступ отозван", + "Could not revoke share": "Не удалось отозвать доступ", + "Uploading book…": "Загрузка книги…", + "Generating…": "Создание…", + "Generate share link": "Создать ссылку", + "Includes your current page": "Включает вашу текущую страницу", + "Share URL": "URL для общего доступа", + "Copy link": "Копировать ссылку", + "Copied": "Скопировано", + "Share via…": "Поделиться через…", + "Expires {{date}}": "Истекает {{date}}", + "Revoking…": "Отзыв…", + "Revoke share": "Отозвать доступ", + "Sign in to share books": "Войдите, чтобы делиться книгами", + "Expiring soon": "Скоро истекает", + "Missing share token": "Отсутствует токен общего доступа", + "Could not add to your library": "Не удалось добавить в вашу библиотеку", + "This share link is no longer available": "Эта ссылка больше не доступна", + "The original link may have expired or been revoked.": "Срок действия исходной ссылки мог истечь или её отозвали.", + "Get Readest": "Скачать Readest", + "Loading shared book…": "Загрузка книги…", + "Adding…": "Добавление…", + "Add to my library": "Добавить в мою библиотеку", + "Could not load your shares": "Не удалось загрузить ваши ссылки", + "Revoked": "Отозвано", + "Expired": "Истекло", + "Shared books": "Поделённые книги", + "You haven't shared any books yet": "Вы ещё не поделились ни одной книгой", + "Open a book and tap Share to send it to a friend.": "Откройте книгу и нажмите «Поделиться», чтобы отправить её другу.", + "{{count}} active_one": "{{count}} активная", + "{{count}} active_few": "{{count}} активные", + "{{count}} active_many": "{{count}} активных", + "{{count}} active_other": "{{count}} активных", + "{{count}} downloads_one": "{{count}} загрузка", + "{{count}} downloads_few": "{{count}} загрузки", + "{{count}} downloads_many": "{{count}} загрузок", + "{{count}} downloads_other": "{{count}} загрузок", + "starts at saved page": "начинается с сохранённой страницы", + "More actions": "Другие действия", + "Loading…": "Загрузка…", + "Load more": "Загрузить ещё", + "Could not load shared book": "Не удалось загрузить книгу", + "The share link is missing required information.": "В ссылке для общего доступа не хватает необходимых данных.", + "Please check your connection and try again.": "Проверьте подключение и повторите попытку.", + "Sign in to import shared books": "Войдите, чтобы импортировать поделённые книги", + "Already in your library": "Уже в вашей библиотеке", + "Added to your library": "Добавлено в вашу библиотеку", + "Could not import shared book": "Не удалось импортировать книгу", + "Manage Shared Links": "Управление ссылками", + "Share current page": "Поделиться текущей страницей", + "Expires in": "Истекает через", + "{{count}} days_one": "{{count}} день", + "{{count}} days_few": "{{count}} дня", + "{{count}} days_many": "{{count}} дней", + "{{count}} days_other": "{{count}} дней", + "Expires in {{count}} days_one": "Истекает через {{count}} день", + "Expires in {{count}} days_few": "Истекает через {{count}} дня", + "Expires in {{count}} days_many": "Истекает через {{count}} дней", + "Expires in {{count}} days_other": "Истекает через {{count}} дней", + "Expires in {{count}} hours_one": "Истекает через {{count}} час", + "Expires in {{count}} hours_few": "Истекает через {{count}} часа", + "Expires in {{count}} hours_many": "Истекает через {{count}} часов", + "Expires in {{count}} hours_other": "Истекает через {{count}} часов", + "Open in app": "Открыть в приложении", + "Shared with you": "Поделились с вами" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 7b860f75..bec40b43 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1255,5 +1255,64 @@ "Open in Readest app": "Readest යෙදුමෙහි විවෘත කරන්න", "Continue in browser": "බ්‍රවුසරයේ දිගටම යන්න", "Don't have Readest?": "Readest නැද්ද?", - "Book not in your library": "පොත ඔබේ පුස්තකාලයේ නැත" + "Book not in your library": "පොත ඔබේ පුස්තකාලයේ නැත", + "Share Book": "පොත බෙදාගන්න", + "Could not create share link": "බෙදාගැනීමේ සබැඳිය සෑදිය නොහැක", + "Link copied": "සබැඳිය පිටපත් කළා", + "Could not copy link": "සබැඳිය පිටපත් කළ නොහැක", + "Share revoked": "බෙදාගැනීම අවලංගු කළා", + "Could not revoke share": "බෙදාගැනීම අවලංගු කළ නොහැක", + "Uploading book…": "පොත උඩුගත කරමින්…", + "Generating…": "සාදමින්…", + "Generate share link": "බෙදාගැනීමේ සබැඳිය සාදන්න", + "Includes your current page": "ඔබගේ වර්තමාන පිටුව ඇතුළත්", + "Share URL": "බෙදාගැනීමේ URL", + "Copy link": "සබැඳිය පිටපත් කරන්න", + "Copied": "පිටපත් කළා", + "Share via…": "හරහා බෙදාගන්න…", + "Expires {{date}}": "{{date}} දින කල් ඉකුත් වේ", + "Revoking…": "අවලංගු කරමින්…", + "Revoke share": "බෙදාගැනීම අවලංගු කරන්න", + "Sign in to share books": "පොත් බෙදාගැනීමට පුරනය වන්න", + "Expiring soon": "ඉක්මනින් කල් ඉකුත් වේ", + "Missing share token": "බෙදාගැනීමේ ටෝකනය නොමැත", + "Could not add to your library": "ඔබගේ පුස්තකාලයට එක් කළ නොහැක", + "This share link is no longer available": "මෙම බෙදාගැනීමේ සබැඳිය තවදුරටත් නොමැත", + "The original link may have expired or been revoked.": "මුල් සබැඳිය කල් ඉකුත් වී හෝ අවලංගු වී ඇත.", + "Get Readest": "Readest ලබාගන්න", + "Loading shared book…": "බෙදාගත් පොත පූරණය වෙමින්…", + "Adding…": "එක් කරමින්…", + "Add to my library": "මගේ පුස්තකාලයට එක් කරන්න", + "Could not load your shares": "ඔබගේ බෙදාගැනීම් පූරණය කළ නොහැක", + "Revoked": "අවලංගු", + "Expired": "කල් ඉකුත්", + "Shared books": "බෙදාගත් පොත්", + "You haven't shared any books yet": "ඔබ තවම පොත් කිසිවක් බෙදාගෙන නැත", + "Open a book and tap Share to send it to a friend.": "පොතක් විවෘත කර එය මිතුරෙකුට යවන්න බෙදාගන්න ටැප් කරන්න.", + "{{count}} active_one": "සක්‍රිය {{count}}ක්", + "{{count}} active_other": "සක්‍රිය {{count}}ක්", + "{{count}} downloads_one": "බාගත {{count}}ක්", + "{{count}} downloads_other": "බාගත {{count}}ක්", + "starts at saved page": "සුරකින ලද පිටුවෙන් ආරම්භ වේ", + "More actions": "තවත් ක්‍රියා", + "Loading…": "පූරණය වෙමින්…", + "Load more": "තවත් පූරණය කරන්න", + "Could not load shared book": "බෙදාගත් පොත පූරණය කළ නොහැක", + "The share link is missing required information.": "බෙදාගැනීමේ සබැඳියෙහි අවශ්‍ය තොරතුරු නොමැත.", + "Please check your connection and try again.": "කරුණාකර ඔබගේ සම්බන්ධතාවය පරීක්ෂා කර නැවත උත්සාහ කරන්න.", + "Sign in to import shared books": "බෙදාගත් පොත් ආයාත කිරීමට පුරනය වන්න", + "Already in your library": "දැනටමත් ඔබගේ පුස්තකාලයේ ඇත", + "Added to your library": "ඔබගේ පුස්තකාලයට එක් කළා", + "Could not import shared book": "බෙදාගත් පොත ආයාත කළ නොහැක", + "Manage Shared Links": "බෙදාගැනීමේ සබැඳි කළමනාකරණය", + "Share current page": "වර්තමාන පිටුව බෙදාගන්න", + "Expires in": "කල් ඉකුත් වේ", + "{{count}} days_one": "දින 1", + "{{count}} days_other": "දින {{count}}", + "Expires in {{count}} days_one": "දින 1කින් කල් ඉකුත් වේ", + "Expires in {{count}} days_other": "දින {{count}}කින් කල් ඉකුත් වේ", + "Expires in {{count}} hours_one": "පැය 1කින් කල් ඉකුත් වේ", + "Expires in {{count}} hours_other": "පැය {{count}}කින් කල් ඉකුත් වේ", + "Open in app": "යෙදුමෙන් විවෘත කරන්න", + "Shared with you": "ඔබ සමඟ බෙදාගත්" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index 0532f7ce..8b9e4ae2 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -1291,5 +1291,74 @@ "Open in Readest app": "Odpri v aplikaciji Readest", "Continue in browser": "Nadaljuj v brskalniku", "Don't have Readest?": "Še nimaš programa Readest?", - "Book not in your library": "Knjiga ni v tvoji knjižnici" + "Book not in your library": "Knjiga ni v tvoji knjižnici", + "Share Book": "Deli knjigo", + "Could not create share link": "Povezave za deljenje ni bilo mogoče ustvariti", + "Link copied": "Povezava kopirana", + "Could not copy link": "Povezave ni bilo mogoče kopirati", + "Share revoked": "Deljenje preklicano", + "Could not revoke share": "Deljenja ni bilo mogoče preklicati", + "Uploading book…": "Prenašanje knjige…", + "Generating…": "Ustvarjanje…", + "Generate share link": "Ustvari povezavo za deljenje", + "Includes your current page": "Vključuje vašo trenutno stran", + "Share URL": "URL za deljenje", + "Copy link": "Kopiraj povezavo", + "Copied": "Kopirano", + "Share via…": "Deli prek…", + "Expires {{date}}": "Poteče {{date}}", + "Revoking…": "Preklicovanje…", + "Revoke share": "Prekliči deljenje", + "Sign in to share books": "Prijavite se za deljenje knjig", + "Expiring soon": "Kmalu poteče", + "Missing share token": "Manjka žeton za deljenje", + "Could not add to your library": "Knjige ni bilo mogoče dodati v knjižnico", + "This share link is no longer available": "Ta povezava za deljenje ni več na voljo", + "The original link may have expired or been revoked.": "Prvotna povezava je morda potekla ali bila preklicana.", + "Get Readest": "Prenesi Readest", + "Loading shared book…": "Nalaganje deljene knjige…", + "Adding…": "Dodajanje…", + "Add to my library": "Dodaj v mojo knjižnico", + "Could not load your shares": "Vaših delitev ni bilo mogoče naložiti", + "Revoked": "Preklicano", + "Expired": "Poteklo", + "Shared books": "Deljene knjige", + "You haven't shared any books yet": "Še niste delili nobenih knjig", + "Open a book and tap Share to send it to a friend.": "Odprite knjigo in tapnite Deli, da jo pošljete prijatelju.", + "{{count}} active_one": "{{count}} aktivna", + "{{count}} active_two": "{{count}} aktivni", + "{{count}} active_few": "{{count}} aktivne", + "{{count}} active_other": "{{count}} aktivnih", + "{{count}} downloads_one": "{{count}} prenos", + "{{count}} downloads_two": "{{count}} prenosa", + "{{count}} downloads_few": "{{count}} prenosi", + "{{count}} downloads_other": "{{count}} prenosov", + "starts at saved page": "začne pri shranjeni strani", + "More actions": "Več dejanj", + "Loading…": "Nalaganje…", + "Load more": "Naloži več", + "Could not load shared book": "Deljene knjige ni bilo mogoče naložiti", + "The share link is missing required information.": "V povezavi za deljenje manjkajo zahtevani podatki.", + "Please check your connection and try again.": "Preverite povezavo in poskusite znova.", + "Sign in to import shared books": "Prijavite se za uvoz deljenih knjig", + "Already in your library": "Že je v vaši knjižnici", + "Added to your library": "Dodano v vašo knjižnico", + "Could not import shared book": "Deljene knjige ni bilo mogoče uvoziti", + "Manage Shared Links": "Upravljanje povezav za deljenje", + "Share current page": "Deli trenutno stran", + "Expires in": "Poteče čez", + "{{count}} days_one": "{{count}} dan", + "{{count}} days_two": "{{count}} dneva", + "{{count}} days_few": "{{count}} dnevi", + "{{count}} days_other": "{{count}} dni", + "Expires in {{count}} days_one": "Poteče čez {{count}} dan", + "Expires in {{count}} days_two": "Poteče čez {{count}} dni", + "Expires in {{count}} days_few": "Poteče čez {{count}} dni", + "Expires in {{count}} days_other": "Poteče čez {{count}} dni", + "Expires in {{count}} hours_one": "Poteče čez {{count}} uro", + "Expires in {{count}} hours_two": "Poteče čez {{count}} uri", + "Expires in {{count}} hours_few": "Poteče čez {{count}} ure", + "Expires in {{count}} hours_other": "Poteče čez {{count}} ur", + "Open in app": "Odpri v aplikaciji", + "Shared with you": "Deljeno z vami" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index c5e7b741..6bbf2569 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1255,5 +1255,64 @@ "Open in Readest app": "Öppna i Readest-appen", "Continue in browser": "Fortsätt i webbläsaren", "Don't have Readest?": "Saknar du Readest?", - "Book not in your library": "Boken finns inte i ditt bibliotek" + "Book not in your library": "Boken finns inte i ditt bibliotek", + "Share Book": "Dela bok", + "Could not create share link": "Det gick inte att skapa delningslänken", + "Link copied": "Länk kopierad", + "Could not copy link": "Det gick inte att kopiera länken", + "Share revoked": "Delning återkallad", + "Could not revoke share": "Det gick inte att återkalla delningen", + "Uploading book…": "Laddar upp bok…", + "Generating…": "Genererar…", + "Generate share link": "Skapa delningslänk", + "Includes your current page": "Inkluderar din nuvarande sida", + "Share URL": "Delnings-URL", + "Copy link": "Kopiera länk", + "Copied": "Kopierad", + "Share via…": "Dela via…", + "Expires {{date}}": "Upphör {{date}}", + "Revoking…": "Återkallar…", + "Revoke share": "Återkalla delning", + "Sign in to share books": "Logga in för att dela böcker", + "Expiring soon": "Upphör snart", + "Missing share token": "Delningstoken saknas", + "Could not add to your library": "Det gick inte att lägga till i ditt bibliotek", + "This share link is no longer available": "Den här delningslänken är inte längre tillgänglig", + "The original link may have expired or been revoked.": "Den ursprungliga länken kan ha löpt ut eller återkallats.", + "Get Readest": "Hämta Readest", + "Loading shared book…": "Laddar delad bok…", + "Adding…": "Lägger till…", + "Add to my library": "Lägg till i mitt bibliotek", + "Could not load your shares": "Det gick inte att ladda dina delningar", + "Revoked": "Återkallad", + "Expired": "Upphörd", + "Shared books": "Delade böcker", + "You haven't shared any books yet": "Du har inte delat några böcker än", + "Open a book and tap Share to send it to a friend.": "Öppna en bok och tryck på Dela för att skicka den till en vän.", + "{{count}} active_one": "{{count}} aktiv", + "{{count}} active_other": "{{count}} aktiva", + "{{count}} downloads_one": "{{count}} nedladdning", + "{{count}} downloads_other": "{{count}} nedladdningar", + "starts at saved page": "börjar på sparad sida", + "More actions": "Fler åtgärder", + "Loading…": "Laddar…", + "Load more": "Ladda mer", + "Could not load shared book": "Det gick inte att ladda den delade boken", + "The share link is missing required information.": "Delningslänken saknar nödvändig information.", + "Please check your connection and try again.": "Kontrollera din anslutning och försök igen.", + "Sign in to import shared books": "Logga in för att importera delade böcker", + "Already in your library": "Finns redan i ditt bibliotek", + "Added to your library": "Tillagd i ditt bibliotek", + "Could not import shared book": "Det gick inte att importera den delade boken", + "Manage Shared Links": "Hantera delningslänkar", + "Share current page": "Dela nuvarande sida", + "Expires in": "Upphör om", + "{{count}} days_one": "1 dag", + "{{count}} days_other": "{{count}} dagar", + "Expires in {{count}} days_one": "Upphör om 1 dag", + "Expires in {{count}} days_other": "Upphör om {{count}} dagar", + "Expires in {{count}} hours_one": "Upphör om 1 timme", + "Expires in {{count}} hours_other": "Upphör om {{count}} timmar", + "Open in app": "Öppna i appen", + "Shared with you": "Delat med dig" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 02efdbb8..53ee6e0e 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1255,5 +1255,64 @@ "Open in Readest app": "Readest பயன்பாட்டில் திற", "Continue in browser": "உலாவியில் தொடரவும்", "Don't have Readest?": "Readest இல்லையா?", - "Book not in your library": "புத்தகம் உங்கள் நூலகத்தில் இல்லை" + "Book not in your library": "புத்தகம் உங்கள் நூலகத்தில் இல்லை", + "Share Book": "புத்தகத்தைப் பகிர்", + "Could not create share link": "பகிர்வுக் இணைப்பை உருவாக்க முடியவில்லை", + "Link copied": "இணைப்பு நகலெடுக்கப்பட்டது", + "Could not copy link": "இணைப்பை நகலெடுக்க முடியவில்லை", + "Share revoked": "பகிர்வு ரத்து செய்யப்பட்டது", + "Could not revoke share": "பகிர்வை ரத்து செய்ய முடியவில்லை", + "Uploading book…": "புத்தகத்தை பதிவேற்றுகிறது…", + "Generating…": "உருவாக்குகிறது…", + "Generate share link": "பகிர்வு இணைப்பை உருவாக்கு", + "Includes your current page": "உங்கள் தற்போதைய பக்கத்தை உள்ளடக்கியது", + "Share URL": "பகிர்வு URL", + "Copy link": "இணைப்பை நகலெடு", + "Copied": "நகலெடுக்கப்பட்டது", + "Share via…": "மூலம் பகிர்…", + "Expires {{date}}": "{{date}} அன்று காலாவதியாகும்", + "Revoking…": "ரத்து செய்கிறது…", + "Revoke share": "பகிர்வை ரத்து செய்", + "Sign in to share books": "புத்தகங்களைப் பகிர உள்நுழையவும்", + "Expiring soon": "விரைவில் காலாவதியாகும்", + "Missing share token": "பகிர்வு டோக்கன் இல்லை", + "Could not add to your library": "உங்கள் நூலகத்தில் சேர்க்க முடியவில்லை", + "This share link is no longer available": "இந்தப் பகிர்வு இணைப்பு இனி கிடைக்காது", + "The original link may have expired or been revoked.": "அசல் இணைப்பு காலாவதியாகியிருக்கலாம் அல்லது ரத்து செய்யப்பட்டிருக்கலாம்.", + "Get Readest": "Readest பெறுக", + "Loading shared book…": "பகிரப்பட்ட புத்தகத்தை ஏற்றுகிறது…", + "Adding…": "சேர்க்கிறது…", + "Add to my library": "எனது நூலகத்தில் சேர்", + "Could not load your shares": "உங்கள் பகிர்வுகளை ஏற்ற முடியவில்லை", + "Revoked": "ரத்து செய்யப்பட்டது", + "Expired": "காலாவதியானது", + "Shared books": "பகிரப்பட்ட புத்தகங்கள்", + "You haven't shared any books yet": "நீங்கள் இதுவரை எந்தப் புத்தகத்தையும் பகிரவில்லை", + "Open a book and tap Share to send it to a friend.": "புத்தகத்தைத் திறந்து நண்பருக்கு அனுப்ப பகிர் என்பதைத் தட்டவும்.", + "{{count}} active_one": "{{count}} செயலில்", + "{{count}} active_other": "{{count}} செயலில்", + "{{count}} downloads_one": "{{count}} பதிவிறக்கம்", + "{{count}} downloads_other": "{{count}} பதிவிறக்கங்கள்", + "starts at saved page": "சேமிக்கப்பட்ட பக்கத்திலிருந்து தொடங்குகிறது", + "More actions": "மேலும் செயல்கள்", + "Loading…": "ஏற்றுகிறது…", + "Load more": "மேலும் ஏற்று", + "Could not load shared book": "பகிரப்பட்ட புத்தகத்தை ஏற்ற முடியவில்லை", + "The share link is missing required information.": "பகிர்வு இணைப்பில் தேவையான தகவல் இல்லை.", + "Please check your connection and try again.": "உங்கள் இணைப்பை சரிபார்த்து மீண்டும் முயற்சிக்கவும்.", + "Sign in to import shared books": "பகிரப்பட்ட புத்தகங்களை இறக்குமதி செய்ய உள்நுழையவும்", + "Already in your library": "ஏற்கனவே உங்கள் நூலகத்தில் உள்ளது", + "Added to your library": "உங்கள் நூலகத்தில் சேர்க்கப்பட்டது", + "Could not import shared book": "பகிரப்பட்ட புத்தகத்தை இறக்குமதி செய்ய முடியவில்லை", + "Manage Shared Links": "பகிர்வு இணைப்புகளை நிர்வகி", + "Share current page": "தற்போதைய பக்கத்தைப் பகிர்", + "Expires in": "காலாவதியாகும்", + "{{count}} days_one": "1 நாள்", + "{{count}} days_other": "{{count}} நாட்கள்", + "Expires in {{count}} days_one": "1 நாளில் காலாவதியாகும்", + "Expires in {{count}} days_other": "{{count}} நாட்களில் காலாவதியாகும்", + "Expires in {{count}} hours_one": "1 மணிநேரத்தில் காலாவதியாகும்", + "Expires in {{count}} hours_other": "{{count}} மணிநேரத்தில் காலாவதியாகும்", + "Open in app": "பயன்பாட்டில் திற", + "Shared with you": "உங்களுடன் பகிரப்பட்டது" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index f3eaf8de..81ed5ee5 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1237,5 +1237,59 @@ "Open in Readest app": "เปิดในแอป Readest", "Continue in browser": "ทำต่อในเบราว์เซอร์", "Don't have Readest?": "ยังไม่มี Readest?", - "Book not in your library": "ไม่พบหนังสือนี้ในคลังของคุณ" + "Book not in your library": "ไม่พบหนังสือนี้ในคลังของคุณ", + "Share Book": "แชร์หนังสือ", + "Could not create share link": "ไม่สามารถสร้างลิงก์แชร์ได้", + "Link copied": "คัดลอกลิงก์แล้ว", + "Could not copy link": "ไม่สามารถคัดลอกลิงก์ได้", + "Share revoked": "ยกเลิกการแชร์แล้ว", + "Could not revoke share": "ไม่สามารถยกเลิกการแชร์ได้", + "Uploading book…": "กำลังอัปโหลดหนังสือ…", + "Generating…": "กำลังสร้าง…", + "Generate share link": "สร้างลิงก์แชร์", + "Includes your current page": "รวมหน้าปัจจุบันของคุณ", + "Share URL": "URL แชร์", + "Copy link": "คัดลอกลิงก์", + "Copied": "คัดลอกแล้ว", + "Share via…": "แชร์ผ่าน…", + "Expires {{date}}": "หมดอายุ {{date}}", + "Revoking…": "กำลังยกเลิก…", + "Revoke share": "ยกเลิกการแชร์", + "Sign in to share books": "เข้าสู่ระบบเพื่อแชร์หนังสือ", + "Expiring soon": "จะหมดอายุเร็วๆ นี้", + "Missing share token": "ไม่มีโทเค็นแชร์", + "Could not add to your library": "ไม่สามารถเพิ่มลงในคลังของคุณได้", + "This share link is no longer available": "ลิงก์แชร์นี้ไม่สามารถใช้งานได้อีก", + "The original link may have expired or been revoked.": "ลิงก์ต้นฉบับอาจหมดอายุหรือถูกยกเลิกแล้ว", + "Get Readest": "รับ Readest", + "Loading shared book…": "กำลังโหลดหนังสือที่แชร์…", + "Adding…": "กำลังเพิ่ม…", + "Add to my library": "เพิ่มในคลังของฉัน", + "Could not load your shares": "ไม่สามารถโหลดการแชร์ของคุณได้", + "Revoked": "ยกเลิกแล้ว", + "Expired": "หมดอายุ", + "Shared books": "หนังสือที่แชร์", + "You haven't shared any books yet": "คุณยังไม่ได้แชร์หนังสือใดๆ", + "Open a book and tap Share to send it to a friend.": "เปิดหนังสือแล้วแตะ แชร์ เพื่อส่งให้เพื่อน", + "{{count}} active_other": "ใช้งานอยู่ {{count}} รายการ", + "{{count}} downloads_other": "ดาวน์โหลด {{count}} ครั้ง", + "starts at saved page": "เริ่มที่หน้าที่บันทึกไว้", + "More actions": "การดำเนินการเพิ่มเติม", + "Loading…": "กำลังโหลด…", + "Load more": "โหลดเพิ่ม", + "Could not load shared book": "ไม่สามารถโหลดหนังสือที่แชร์ได้", + "The share link is missing required information.": "ลิงก์แชร์ขาดข้อมูลที่จำเป็น", + "Please check your connection and try again.": "โปรดตรวจสอบการเชื่อมต่อของคุณและลองอีกครั้ง", + "Sign in to import shared books": "เข้าสู่ระบบเพื่อนำเข้าหนังสือที่แชร์", + "Already in your library": "อยู่ในคลังของคุณแล้ว", + "Added to your library": "เพิ่มลงในคลังของคุณแล้ว", + "Could not import shared book": "ไม่สามารถนำเข้าหนังสือที่แชร์ได้", + "Manage Shared Links": "จัดการลิงก์แชร์", + "Share current page": "แชร์หน้าปัจจุบัน", + "Expires in": "หมดอายุใน", + "{{count}} days_other": "{{count}} วัน", + "Expires in {{count}} days_other": "หมดอายุใน {{count}} วัน", + "Expires in {{count}} hours_other": "หมดอายุใน {{count}} ชั่วโมง", + "Open in app": "เปิดในแอป", + "Shared with you": "แชร์กับคุณ" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 3db349d7..a4575624 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1255,5 +1255,64 @@ "Open in Readest app": "Readest uygulamasında aç", "Continue in browser": "Tarayıcıda devam et", "Don't have Readest?": "Readest'iniz yok mu?", - "Book not in your library": "Kitap, kitaplığınızda yok" + "Book not in your library": "Kitap, kitaplığınızda yok", + "Share Book": "Kitabı paylaş", + "Could not create share link": "Paylaşım bağlantısı oluşturulamadı", + "Link copied": "Bağlantı kopyalandı", + "Could not copy link": "Bağlantı kopyalanamadı", + "Share revoked": "Paylaşım iptal edildi", + "Could not revoke share": "Paylaşım iptal edilemedi", + "Uploading book…": "Kitap yükleniyor…", + "Generating…": "Oluşturuluyor…", + "Generate share link": "Paylaşım bağlantısı oluştur", + "Includes your current page": "Mevcut sayfanızı içerir", + "Share URL": "Paylaşım URL’si", + "Copy link": "Bağlantıyı kopyala", + "Copied": "Kopyalandı", + "Share via…": "Şununla paylaş…", + "Expires {{date}}": "{{date}} tarihinde sona erer", + "Revoking…": "İptal ediliyor…", + "Revoke share": "Paylaşımı iptal et", + "Sign in to share books": "Kitapları paylaşmak için oturum açın", + "Expiring soon": "Yakında sona eriyor", + "Missing share token": "Paylaşım belirteci eksik", + "Could not add to your library": "Kütüphanenize eklenemedi", + "This share link is no longer available": "Bu paylaşım bağlantısı artık kullanılamıyor", + "The original link may have expired or been revoked.": "Orijinal bağlantının süresi dolmuş veya iptal edilmiş olabilir.", + "Get Readest": "Readest'i edinin", + "Loading shared book…": "Paylaşılan kitap yükleniyor…", + "Adding…": "Ekleniyor…", + "Add to my library": "Kütüphaneme ekle", + "Could not load your shares": "Paylaşımlarınız yüklenemedi", + "Revoked": "İptal edildi", + "Expired": "Süresi doldu", + "Shared books": "Paylaşılan kitaplar", + "You haven't shared any books yet": "Henüz hiç kitap paylaşmadınız", + "Open a book and tap Share to send it to a friend.": "Bir kitap açın ve arkadaşınıza göndermek için Paylaş’a dokunun.", + "{{count}} active_one": "{{count}} etkin", + "{{count}} active_other": "{{count}} etkin", + "{{count}} downloads_one": "{{count}} indirme", + "{{count}} downloads_other": "{{count}} indirme", + "starts at saved page": "kayıtlı sayfadan başlar", + "More actions": "Diğer işlemler", + "Loading…": "Yükleniyor…", + "Load more": "Daha fazla yükle", + "Could not load shared book": "Paylaşılan kitap yüklenemedi", + "The share link is missing required information.": "Paylaşım bağlantısında gerekli bilgiler eksik.", + "Please check your connection and try again.": "Bağlantınızı kontrol edip tekrar deneyin.", + "Sign in to import shared books": "Paylaşılan kitapları içe aktarmak için oturum açın", + "Already in your library": "Zaten kütüphanenizde", + "Added to your library": "Kütüphanenize eklendi", + "Could not import shared book": "Paylaşılan kitap içe aktarılamadı", + "Manage Shared Links": "Paylaşım bağlantılarını yönet", + "Share current page": "Mevcut sayfayı paylaş", + "Expires in": "Sona erer", + "{{count}} days_one": "1 gün", + "{{count}} days_other": "{{count}} gün", + "Expires in {{count}} days_one": "1 gün içinde sona erer", + "Expires in {{count}} days_other": "{{count}} gün içinde sona erer", + "Expires in {{count}} hours_one": "1 saat içinde sona erer", + "Expires in {{count}} hours_other": "{{count}} saat içinde sona erer", + "Open in app": "Uygulamada aç", + "Shared with you": "Sizinle paylaşıldı" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index c80dea66..3a15d2cd 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1291,5 +1291,74 @@ "Open in Readest app": "Відкрити в застосунку Readest", "Continue in browser": "Продовжити у браузері", "Don't have Readest?": "Не маєте Readest?", - "Book not in your library": "Книги немає у вашій бібліотеці" + "Book not in your library": "Книги немає у вашій бібліотеці", + "Share Book": "Поділитися книгою", + "Could not create share link": "Не вдалося створити посилання для поширення", + "Link copied": "Посилання скопійовано", + "Could not copy link": "Не вдалося скопіювати посилання", + "Share revoked": "Доступ скасовано", + "Could not revoke share": "Не вдалося скасувати доступ", + "Uploading book…": "Завантаження книги…", + "Generating…": "Створення…", + "Generate share link": "Створити посилання", + "Includes your current page": "Включає вашу поточну сторінку", + "Share URL": "URL для поширення", + "Copy link": "Копіювати посилання", + "Copied": "Скопійовано", + "Share via…": "Поділитися через…", + "Expires {{date}}": "Дійсна до {{date}}", + "Revoking…": "Скасування…", + "Revoke share": "Скасувати доступ", + "Sign in to share books": "Увійдіть, щоб ділитися книгами", + "Expiring soon": "Скоро закінчиться", + "Missing share token": "Відсутній маркер доступу", + "Could not add to your library": "Не вдалося додати до вашої бібліотеки", + "This share link is no longer available": "Це посилання більше не доступне", + "The original link may have expired or been revoked.": "Первинне посилання могло втратити чинність або бути скасоване.", + "Get Readest": "Отримати Readest", + "Loading shared book…": "Завантаження поширеної книги…", + "Adding…": "Додавання…", + "Add to my library": "Додати до моєї бібліотеки", + "Could not load your shares": "Не вдалося завантажити ваші посилання", + "Revoked": "Скасовано", + "Expired": "Минув термін", + "Shared books": "Поширені книги", + "You haven't shared any books yet": "Ви ще не поділилися жодною книгою", + "Open a book and tap Share to send it to a friend.": "Відкрийте книгу й торкніться «Поділитися», щоб надіслати її другові.", + "{{count}} active_one": "{{count}} активне", + "{{count}} active_few": "{{count}} активні", + "{{count}} active_many": "{{count}} активних", + "{{count}} active_other": "{{count}} активних", + "{{count}} downloads_one": "{{count}} завантаження", + "{{count}} downloads_few": "{{count}} завантаження", + "{{count}} downloads_many": "{{count}} завантажень", + "{{count}} downloads_other": "{{count}} завантажень", + "starts at saved page": "починається зі збереженої сторінки", + "More actions": "Більше дій", + "Loading…": "Завантаження…", + "Load more": "Завантажити більше", + "Could not load shared book": "Не вдалося завантажити поширену книгу", + "The share link is missing required information.": "У посиланні бракує необхідної інформації.", + "Please check your connection and try again.": "Перевірте підключення та спробуйте ще раз.", + "Sign in to import shared books": "Увійдіть, щоб імпортувати поширені книги", + "Already in your library": "Уже у вашій бібліотеці", + "Added to your library": "Додано до вашої бібліотеки", + "Could not import shared book": "Не вдалося імпортувати поширену книгу", + "Manage Shared Links": "Керування посиланнями", + "Share current page": "Поділитися поточною сторінкою", + "Expires in": "Закінчується через", + "{{count}} days_one": "{{count}} день", + "{{count}} days_few": "{{count}} дні", + "{{count}} days_many": "{{count}} днів", + "{{count}} days_other": "{{count}} днів", + "Expires in {{count}} days_one": "Дійсне ще {{count}} день", + "Expires in {{count}} days_few": "Дійсне ще {{count}} дні", + "Expires in {{count}} days_many": "Дійсне ще {{count}} днів", + "Expires in {{count}} days_other": "Дійсне ще {{count}} днів", + "Expires in {{count}} hours_one": "Дійсне ще {{count}} годину", + "Expires in {{count}} hours_few": "Дійсне ще {{count}} години", + "Expires in {{count}} hours_many": "Дійсне ще {{count}} годин", + "Expires in {{count}} hours_other": "Дійсне ще {{count}} годин", + "Open in app": "Відкрити в застосунку", + "Shared with you": "Поділилися з вами" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 45e5015d..0d3bf299 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1237,5 +1237,59 @@ "Open in Readest app": "Mở trong ứng dụng Readest", "Continue in browser": "Tiếp tục trong trình duyệt", "Don't have Readest?": "Bạn chưa có Readest?", - "Book not in your library": "Sách không có trong thư viện của bạn" + "Book not in your library": "Sách không có trong thư viện của bạn", + "Share Book": "Chia sẻ sách", + "Could not create share link": "Không thể tạo liên kết chia sẻ", + "Link copied": "Đã sao chép liên kết", + "Could not copy link": "Không thể sao chép liên kết", + "Share revoked": "Đã thu hồi chia sẻ", + "Could not revoke share": "Không thể thu hồi chia sẻ", + "Uploading book…": "Đang tải lên sách…", + "Generating…": "Đang tạo…", + "Generate share link": "Tạo liên kết chia sẻ", + "Includes your current page": "Bao gồm trang hiện tại của bạn", + "Share URL": "URL chia sẻ", + "Copy link": "Sao chép liên kết", + "Copied": "Đã sao chép", + "Share via…": "Chia sẻ qua…", + "Expires {{date}}": "Hết hạn vào {{date}}", + "Revoking…": "Đang thu hồi…", + "Revoke share": "Thu hồi chia sẻ", + "Sign in to share books": "Đăng nhập để chia sẻ sách", + "Expiring soon": "Sắp hết hạn", + "Missing share token": "Thiếu mã chia sẻ", + "Could not add to your library": "Không thể thêm vào thư viện của bạn", + "This share link is no longer available": "Liên kết chia sẻ này không còn khả dụng", + "The original link may have expired or been revoked.": "Liên kết gốc có thể đã hết hạn hoặc bị thu hồi.", + "Get Readest": "Tải Readest", + "Loading shared book…": "Đang tải sách được chia sẻ…", + "Adding…": "Đang thêm…", + "Add to my library": "Thêm vào thư viện của tôi", + "Could not load your shares": "Không thể tải danh sách chia sẻ của bạn", + "Revoked": "Đã thu hồi", + "Expired": "Đã hết hạn", + "Shared books": "Sách đã chia sẻ", + "You haven't shared any books yet": "Bạn chưa chia sẻ cuốn sách nào", + "Open a book and tap Share to send it to a friend.": "Mở một cuốn sách và nhấn Chia sẻ để gửi cho bạn bè.", + "{{count}} active_other": "{{count}} đang hoạt động", + "{{count}} downloads_other": "{{count}} lượt tải", + "starts at saved page": "bắt đầu tại trang đã lưu", + "More actions": "Thao tác khác", + "Loading…": "Đang tải…", + "Load more": "Tải thêm", + "Could not load shared book": "Không thể tải sách được chia sẻ", + "The share link is missing required information.": "Liên kết chia sẻ thiếu thông tin cần thiết.", + "Please check your connection and try again.": "Vui lòng kiểm tra kết nối và thử lại.", + "Sign in to import shared books": "Đăng nhập để nhập sách được chia sẻ", + "Already in your library": "Đã có trong thư viện của bạn", + "Added to your library": "Đã thêm vào thư viện của bạn", + "Could not import shared book": "Không thể nhập sách được chia sẻ", + "Manage Shared Links": "Quản lý liên kết chia sẻ", + "Share current page": "Chia sẻ trang hiện tại", + "Expires in": "Hết hạn sau", + "{{count}} days_other": "{{count}} ngày", + "Expires in {{count}} days_other": "Hết hạn sau {{count}} ngày", + "Expires in {{count}} hours_other": "Hết hạn sau {{count}} giờ", + "Open in app": "Mở trong ứng dụng", + "Shared with you": "Được chia sẻ với 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 02c0b708..ded9c263 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1237,5 +1237,59 @@ "Open in Readest app": "在 Readest 应用中打开", "Continue in browser": "在浏览器中继续", "Don't have Readest?": "还没有 Readest?", - "Book not in your library": "书库中没有这本书" + "Book not in your library": "书库中没有这本书", + "Share Book": "分享书籍", + "Could not create share link": "无法创建分享链接", + "Link copied": "链接已复制", + "Could not copy link": "无法复制链接", + "Share revoked": "已撤销分享", + "Could not revoke share": "无法撤销分享", + "Uploading book…": "正在上传书籍…", + "Generating…": "正在生成…", + "Generate share link": "生成分享链接", + "Includes your current page": "包含你当前的页面", + "Share URL": "分享 URL", + "Copy link": "复制链接", + "Copied": "已复制", + "Share via…": "通过…分享", + "Expires {{date}}": "到期日 {{date}}", + "Revoking…": "正在撤销…", + "Revoke share": "撤销分享", + "Sign in to share books": "登录以分享书籍", + "Expiring soon": "即将到期", + "Missing share token": "缺少分享令牌", + "Could not add to your library": "无法添加到你的书库", + "This share link is no longer available": "此分享链接已不可用", + "The original link may have expired or been revoked.": "原始链接可能已过期或被撤销。", + "Get Readest": "获取 Readest", + "Loading shared book…": "正在加载共享书籍…", + "Adding…": "正在添加…", + "Add to my library": "加入我的书库", + "Could not load your shares": "无法加载你的分享", + "Revoked": "已撤销", + "Expired": "已过期", + "Shared books": "已分享的书籍", + "You haven't shared any books yet": "你还没有分享过任何书籍", + "Open a book and tap Share to send it to a friend.": "打开一本书,点按\"分享\"发送给朋友。", + "{{count}} active_other": "{{count}} 个活动中", + "{{count}} downloads_other": "{{count}} 次下载", + "starts at saved page": "从保存的页面开始", + "More actions": "更多操作", + "Loading…": "加载中…", + "Load more": "加载更多", + "Could not load shared book": "无法加载共享书籍", + "The share link is missing required information.": "分享链接缺少必要信息。", + "Please check your connection and try again.": "请检查你的网络连接后重试。", + "Sign in to import shared books": "登录以导入共享书籍", + "Already in your library": "已在你的书库中", + "Added to your library": "已加入你的书库", + "Could not import shared book": "无法导入共享书籍", + "Manage Shared Links": "管理分享链接", + "Share current page": "分享当前页", + "Expires in": "有效期", + "{{count}} days_other": "{{count}} 天", + "Expires in {{count}} days_other": "{{count}} 天后到期", + "Expires in {{count}} hours_other": "{{count}} 小时后到期", + "Open in app": "在应用中打开", + "Shared with you": "与你分享" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index db112917..aadf9fb7 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1237,5 +1237,59 @@ "Open in Readest app": "在 Readest 應用程式中開啟", "Continue in browser": "在瀏覽器中繼續", "Don't have Readest?": "還沒有 Readest?", - "Book not in your library": "書庫中沒有這本書" + "Book not in your library": "書庫中沒有這本書", + "Share Book": "分享書籍", + "Could not create share link": "無法建立分享連結", + "Link copied": "已複製連結", + "Could not copy link": "無法複製連結", + "Share revoked": "已撤銷分享", + "Could not revoke share": "無法撤銷分享", + "Uploading book…": "正在上傳書籍…", + "Generating…": "正在產生…", + "Generate share link": "產生分享連結", + "Includes your current page": "包含您目前的頁面", + "Share URL": "分享 URL", + "Copy link": "複製連結", + "Copied": "已複製", + "Share via…": "透過…分享", + "Expires {{date}}": "到期日 {{date}}", + "Revoking…": "正在撤銷…", + "Revoke share": "撤銷分享", + "Sign in to share books": "登入以分享書籍", + "Expiring soon": "即將到期", + "Missing share token": "缺少分享權杖", + "Could not add to your library": "無法加入您的書庫", + "This share link is no longer available": "此分享連結已不可用", + "The original link may have expired or been revoked.": "原始連結可能已過期或被撤銷。", + "Get Readest": "取得 Readest", + "Loading shared book…": "正在載入分享書籍…", + "Adding…": "正在新增…", + "Add to my library": "加入我的書庫", + "Could not load your shares": "無法載入您的分享", + "Revoked": "已撤銷", + "Expired": "已過期", + "Shared books": "已分享的書籍", + "You haven't shared any books yet": "您尚未分享任何書籍", + "Open a book and tap Share to send it to a friend.": "開啟一本書,點按「分享」傳送給朋友。", + "{{count}} active_other": "{{count}} 個有效", + "{{count}} downloads_other": "{{count}} 次下載", + "starts at saved page": "從保存的頁面開始", + "More actions": "更多操作", + "Loading…": "載入中…", + "Load more": "載入更多", + "Could not load shared book": "無法載入分享書籍", + "The share link is missing required information.": "分享連結缺少必要資訊。", + "Please check your connection and try again.": "請檢查您的網路連線後再試一次。", + "Sign in to import shared books": "登入以匯入分享書籍", + "Already in your library": "已在您的書庫中", + "Added to your library": "已加入您的書庫", + "Could not import shared book": "無法匯入分享書籍", + "Manage Shared Links": "管理分享連結", + "Share current page": "分享目前頁面", + "Expires in": "有效期", + "{{count}} days_other": "{{count}} 天", + "Expires in {{count}} days_other": "{{count}} 天後到期", + "Expires in {{count}} hours_other": "{{count}} 小時後到期", + "Open in app": "在應用中開啟", + "Shared with you": "與您分享" } diff --git a/apps/readest-app/src/__tests__/libs/share-server.test.ts b/apps/readest-app/src/__tests__/libs/share-server.test.ts new file mode 100644 index 00000000..32e38095 --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/share-server.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { generateShareToken, hashShareToken, isValidShareToken } from '@/libs/share-server'; + +describe('share-server', () => { + describe('generateShareToken', () => { + it('produces a 22-char alphanumeric raw token', async () => { + const { raw, hash } = await generateShareToken(); + expect(raw).toMatch(/^[A-Za-z0-9]{22}$/); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it('produces unique tokens across calls', async () => { + const tokens = new Set(); + for (let i = 0; i < 50; i++) { + const { raw } = await generateShareToken(); + tokens.add(raw); + } + expect(tokens.size).toBe(50); + }); + + it('hash is deterministic for the same raw input', async () => { + const { raw, hash } = await generateShareToken(); + const again = await hashShareToken(raw); + expect(again).toBe(hash); + }); + }); + + describe('isValidShareToken', () => { + it('accepts well-formed 22-char alphanumeric tokens', () => { + expect(isValidShareToken('aBcDeFgHiJkLmNoPqRsTuV')).toBe(true); + expect(isValidShareToken('0123456789abcdefABCDEF')).toBe(true); + }); + + it('rejects wrong-length tokens', () => { + expect(isValidShareToken('short')).toBe(false); + expect(isValidShareToken('aBcDeFgHiJkLmNoPqRsTuVextra')).toBe(false); + expect(isValidShareToken('')).toBe(false); + }); + + it('rejects tokens with non-alphanumeric characters', () => { + expect(isValidShareToken('aBcDeFgHiJkLmNoPqRsTu-')).toBe(false); + expect(isValidShareToken('aBcDeFgHiJkLmNoPqRsTu_')).toBe(false); + expect(isValidShareToken('aBcDeFgHiJkLmNoPqRsTu.')).toBe(false); + expect(isValidShareToken('aBcDeFgHiJkLmNoPqRs Tuv')).toBe(false); + }); + + it('rejects non-string input', () => { + expect(isValidShareToken(undefined)).toBe(false); + expect(isValidShareToken(null)).toBe(false); + expect(isValidShareToken(42)).toBe(false); + expect(isValidShareToken({})).toBe(false); + }); + }); + + describe('hashShareToken', () => { + it('produces a 64-char lowercase hex SHA-256 digest', async () => { + const hash = await hashShareToken('aBcDeFgHiJkLmNoPqRsTuV'); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it('different inputs produce different hashes', async () => { + const a = await hashShareToken('aBcDeFgHiJkLmNoPqRsTuV'); + const b = await hashShareToken('aBcDeFgHiJkLmNoPqRsTuW'); + expect(a).not.toBe(b); + }); + + it('matches a known SHA-256 vector for sanity', async () => { + // Known test vector: sha256("abc") = ba7816bf... + const hash = await hashShareToken('abc'); + expect(hash).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/share.test.ts b/apps/readest-app/src/__tests__/utils/share.test.ts new file mode 100644 index 00000000..7f86f22f --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/share.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { buildShareUrl, parseShareDeepLink } from '@/utils/share'; + +describe('buildShareUrl', () => { + it('builds the canonical https URL for a token', () => { + expect(buildShareUrl('aBcDeFgHiJkLmNoPqRsTuV')).toBe( + 'https://web.readest.com/s/aBcDeFgHiJkLmNoPqRsTuV', + ); + }); +}); + +describe('parseShareDeepLink', () => { + const VALID_TOKEN = 'aBcDeFgHiJkLmNoPqRsTuV'; + + it('parses readest://share/{token}', () => { + expect(parseShareDeepLink(`readest://share/${VALID_TOKEN}`)).toEqual({ token: VALID_TOKEN }); + }); + + it('parses https://web.readest.com/s/{token}', () => { + expect(parseShareDeepLink(`https://web.readest.com/s/${VALID_TOKEN}`)).toEqual({ + token: VALID_TOKEN, + }); + }); + + it('parses *.readest.com subdomains for preview deploys', () => { + expect(parseShareDeepLink(`https://staging.readest.com/s/${VALID_TOKEN}`)).toEqual({ + token: VALID_TOKEN, + }); + }); + + it('rejects tokens of the wrong length', () => { + expect(parseShareDeepLink('readest://share/short')).toBeNull(); + expect(parseShareDeepLink(`readest://share/${VALID_TOKEN}extra`)).toBeNull(); + }); + + it('rejects tokens with disallowed characters', () => { + // Underscore and hyphen are explicitly NOT in the alphabet. + const bad = 'aBcDeFgHiJkLmNoPqRsTu-'; + expect(parseShareDeepLink(`readest://share/${bad}`)).toBeNull(); + }); + + it('rejects URLs from third-party hosts', () => { + expect(parseShareDeepLink(`https://evil.example.com/s/${VALID_TOKEN}`)).toBeNull(); + }); + + it('rejects readest:// URLs whose host is not "share"', () => { + expect(parseShareDeepLink(`readest://book/${VALID_TOKEN}`)).toBeNull(); + expect(parseShareDeepLink(`readest://annotation/${VALID_TOKEN}`)).toBeNull(); + }); + + it('rejects nested or extra path segments', () => { + expect(parseShareDeepLink(`https://web.readest.com/s/${VALID_TOKEN}/extra`)).toBeNull(); + expect(parseShareDeepLink(`https://web.readest.com/extra/s/${VALID_TOKEN}`)).toBeNull(); + }); + + it('returns null for malformed input', () => { + expect(parseShareDeepLink('')).toBeNull(); + expect(parseShareDeepLink('not-a-url')).toBeNull(); + expect(parseShareDeepLink('ftp://web.readest.com/s/' + VALID_TOKEN)).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/app/api/share/[token]/cover/route.ts b/apps/readest-app/src/app/api/share/[token]/cover/route.ts new file mode 100644 index 00000000..075071fe --- /dev/null +++ b/apps/readest-app/src/app/api/share/[token]/cover/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; +import { getDownloadSignedUrl } from '@/utils/object'; +import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server'; +import { SHARE_PRESIGN_TTL_SECONDS } from '@/services/constants'; + +interface RouteParams { + params: Promise<{ token: string }>; +} + +// GET /api/share/[token]/cover — public 302 redirect to a presigned cover URL. +// Cached briefly so chat-app preview crawlers don't re-fetch the same image +// for every recipient. Covers aren't sensitive; max-age is intentional. +export async function GET(_request: Request, { params }: RouteParams) { + const { token } = await params; + + const result = await resolveActiveShare(token); + if (!result.ok) { + const { status, body } = rejectionToHttp(result.reason); + return NextResponse.json(body, { status }); + } + const { share } = result; + + if (!share.coverFileKey) { + return NextResponse.json({ error: 'No cover for this share' }, { status: 404 }); + } + + let url: string; + try { + url = await getDownloadSignedUrl(share.coverFileKey, SHARE_PRESIGN_TTL_SECONDS); + } catch (err) { + console.error('Share cover presign failed:', err); + return NextResponse.json({ error: 'Could not sign cover URL' }, { status: 500 }); + } + + return NextResponse.redirect(url, { + status: 302, + headers: { 'Cache-Control': 'public, max-age=300' }, + }); +} diff --git a/apps/readest-app/src/app/api/share/[token]/download/confirm/route.ts b/apps/readest-app/src/app/api/share/[token]/download/confirm/route.ts new file mode 100644 index 00000000..82635ac9 --- /dev/null +++ b/apps/readest-app/src/app/api/share/[token]/download/confirm/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; +import { createSupabaseAdminClient } from '@/utils/supabase'; +import { hashShareToken, isValidShareToken } from '@/libs/share-server'; + +interface RouteParams { + params: Promise<{ token: string }>; +} + +// POST /api/share/[token]/download/confirm — analytics ping fired by the +// landing-page Download button (post-click) and the in-app deeplink hook on +// successful import. Best-effort: the user-facing action does not depend on +// this returning 2xx. Lookup is by token_hash so the row stays cheap to find. +// +// Increments are done in a single SQL UPDATE so concurrent requests cannot +// race a read-modify-write. We also accept the small risk that an increment +// lands shortly after a revoke — that's harmless, the counter doesn't grant +// access. The validity check below skips obviously dead shares so crawlers +// hitting expired links don't pollute the count after the fact. +export async function POST(_request: Request, { params }: RouteParams) { + const { token } = await params; + + if (!isValidShareToken(token)) { + // Silently OK — this is a best-effort beacon, not an enforcement point. + return new NextResponse(null, { status: 204 }); + } + + const supabase = createSupabaseAdminClient(); + const tokenHash = await hashShareToken(token); + + // Atomic conditional update via the SQL function defined alongside the + // table. Only bumps rows that are still active so late-firing pings on + // expired/revoked shares don't pollute the count. + const nowIso = new Date().toISOString(); + const { error } = await supabase.rpc('increment_book_share_download', { + p_token_hash: tokenHash, + p_now: nowIso, + }); + if (error) { + // Best-effort beacon — log but never surface to the caller. + console.error('download confirm rpc failed:', error); + } + + return new NextResponse(null, { + status: 204, + headers: { 'Cache-Control': 'private, no-store' }, + }); +} diff --git a/apps/readest-app/src/app/api/share/[token]/download/route.ts b/apps/readest-app/src/app/api/share/[token]/download/route.ts new file mode 100644 index 00000000..7855f120 --- /dev/null +++ b/apps/readest-app/src/app/api/share/[token]/download/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server'; +import { getDownloadSignedUrl } from '@/utils/object'; +import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server'; +import { SHARE_PRESIGN_TTL_SECONDS } from '@/services/constants'; + +interface RouteParams { + params: Promise<{ token: string }>; +} + +// GET /api/share/[token]/download — public, 302 to a short-lived presigned URL. +// IMPORTANT: this endpoint MUST NOT write to the database. iMessage / WhatsApp / +// Slack / Twitter unfurlers and browser prefetchers will hit this URL just by +// previewing a link. Counting them would inflate `download_count` to garbage. +// Real downloads ping POST /download/confirm separately so the count tracks +// user intent, not crawler curiosity. +export async function GET(_request: Request, { params }: RouteParams) { + const { token } = await params; + + const result = await resolveActiveShare(token); + if (!result.ok) { + const { status, body } = rejectionToHttp(result.reason); + return NextResponse.json(body, { status }); + } + const { share } = result; + + let url: string; + try { + url = await getDownloadSignedUrl(share.bookFileKey, SHARE_PRESIGN_TTL_SECONDS); + } catch (err) { + console.error('Share download presign failed:', err); + return NextResponse.json({ error: 'Could not sign download URL' }, { status: 500 }); + } + + return NextResponse.redirect(url, { + status: 302, + // Don't let intermediaries cache the redirect target itself; the presign + // expires fast but caching the 302 would point future requests at a + // soon-to-be-dead URL. + headers: { 'Cache-Control': 'private, no-store' }, + }); +} diff --git a/apps/readest-app/src/app/api/share/[token]/import/route.ts b/apps/readest-app/src/app/api/share/[token]/import/route.ts new file mode 100644 index 00000000..ae2d4c40 --- /dev/null +++ b/apps/readest-app/src/app/api/share/[token]/import/route.ts @@ -0,0 +1,217 @@ +import { NextResponse } from 'next/server'; +import { createSupabaseAdminClient } from '@/utils/supabase'; +import { copyObject, objectExists } from '@/utils/object'; +import { + STORAGE_QUOTA_GRACE_BYTES, + getStoragePlanData, + validateUserAndToken, +} from '@/utils/access'; +import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server'; + +interface RouteParams { + params: Promise<{ token: string }>; +} + +// POST /api/share/[token]/import — recipient-side library import. Auth required. +// +// Strategy: R2 server-side byte-copy. +// The existing `files` table consumers (stats / purge / delete / download) +// all assume `file_key` starts with the row's `user_id`. A reference-based +// import would silently break those invariants, so we copy the bytes into +// the recipient's namespace instead. R2 server-side copy is one API call +// and incurs no egress. +// +// Idempotent: if the recipient already has a non-deleted `files` row for the +// same `book_hash`, we return their existing fileId with `alreadyOwned: true` +// and skip the copy. Saves egress on repeated imports. +export async function POST(request: Request, { params }: RouteParams) { + const { token: shareToken } = await params; + + const { user, token: jwt } = await validateUserAndToken(request.headers.get('authorization')); + if (!user || !jwt) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const result = await resolveActiveShare(shareToken); + if (!result.ok) { + const { status, body } = rejectionToHttp(result.reason); + return NextResponse.json(body, { status }); + } + const { share } = result; + + // Self-imports are no-ops; redirect the user to their own copy without + // burning a copy operation. + if (share.userId === user.id) { + const supabase = createSupabaseAdminClient(); + const { data: own } = await supabase + .from('files') + .select('id, book_hash') + .eq('user_id', user.id) + .eq('book_hash', share.bookHash) + .is('deleted_at', null) + .not('file_key', 'ilike', '%.png') + .not('file_key', 'ilike', '%.jpg') + .not('file_key', 'ilike', '%.jpeg') + .not('file_key', 'ilike', '%.webp') + .not('file_key', 'ilike', '%.gif') + .limit(1) + .maybeSingle(); + if (own) { + return NextResponse.json({ + fileId: own.id, + alreadyOwned: true, + bookHash: share.bookHash, + cfi: share.cfi, + }); + } + } + + const supabase = createSupabaseAdminClient(); + + // Idempotency: look up existing rows for the same (user_id, book_hash), + // INCLUDING soft-deleted ones. file_key is unique globally, so an active + // import that the user later deleted leaves a row that would collide with + // a fresh insert below — we restore it instead of failing. + const { data: existing, error: existingError } = await supabase + .from('files') + .select('id, file_key, deleted_at') + .eq('user_id', user.id) + .eq('book_hash', share.bookHash); + if (existingError) { + console.error('Share import existing-row lookup failed:', existingError); + return NextResponse.json({ error: 'Could not check library' }, { status: 500 }); + } + const existingRows = (existing ?? []).filter((f) => !/\.(png|jpe?g|webp|gif)$/i.test(f.file_key)); + const liveRow = existingRows.find((f) => f.deleted_at === null); + if (liveRow) { + return NextResponse.json({ + fileId: liveRow.id, + alreadyOwned: true, + bookHash: share.bookHash, + cfi: share.cfi, + }); + } + const deletedRow = existingRows.find((f) => f.deleted_at !== null); + if (deletedRow) { + // Restore the soft-deleted row so the unique file_key constraint isn't + // hit by a fresh insert. The bytes may also still be in storage; if the + // copy below succeeds it overwrites them, if it doesn't we leave the + // row in its restored state so the user can re-attempt later. + const { error: restoreError } = await supabase + .from('files') + .update({ deleted_at: null, updated_at: new Date().toISOString() }) + .eq('id', deletedRow.id); + if (restoreError) { + console.error('Share import restore-deleted-row failed:', restoreError); + return NextResponse.json({ error: 'Could not restore book' }, { status: 500 }); + } + return NextResponse.json({ + fileId: deletedRow.id, + alreadyOwned: true, + bookHash: share.bookHash, + cfi: share.cfi, + }); + } + + // Quota check before doing any byte-copy work. JWT-based but consistent + // with how the existing upload endpoint enforces it. + const { usage, quota } = getStoragePlanData(jwt); + if (usage + share.bookSize > quota + STORAGE_QUOTA_GRACE_BYTES) { + return NextResponse.json( + { error: 'Insufficient storage quota', code: 'quota_exceeded', usage, quota }, + { status: 402 }, + ); + } + + // Translate the sharer's file_keys into the recipient's namespace by + // swapping the leading user-id prefix. Existing convention: file_key looks + // like `${userId}/Readest/Book/{hash}/{filename}`. + const sharerPrefix = `${share.userId}/`; + const recipientPrefix = `${user.id}/`; + + const remap = (sourceKey: string): string | null => { + if (!sourceKey.startsWith(sharerPrefix)) return null; + return recipientPrefix + sourceKey.slice(sharerPrefix.length); + }; + + const destBookKey = remap(share.bookFileKey); + if (!destBookKey) { + console.error('Share import: source key does not start with sharer user id', share.bookFileKey); + return NextResponse.json({ error: 'Cannot remap shared file' }, { status: 500 }); + } + + // Verify source bytes still exist before allocating a destination row. + const sourceExists = await objectExists(share.bookFileKey); + if (!sourceExists) { + return NextResponse.json( + { error: 'Shared book is no longer available', code: 'source_deleted' }, + { status: 410 }, + ); + } + + // Insert destination row first (to grab a stable id), then copy bytes, + // then mark the row clean. On copy failure we soft-delete the row so the + // user's library doesn't show a phantom book. + const { data: insertedBook, error: insertBookError } = await supabase + .from('files') + .insert({ + user_id: user.id, + book_hash: share.bookHash, + file_key: destBookKey, + file_size: share.bookSize, + }) + .select('id') + .single(); + if (insertBookError || !insertedBook) { + console.error('Share import insert book row failed:', insertBookError); + return NextResponse.json({ error: 'Could not import book' }, { status: 500 }); + } + + try { + const copyResp = await copyObject(share.bookFileKey, destBookKey); + // R2 (aws4fetch) returns a Response; S3 SDK returns a structured object. + // Both throw on hard failures; treat any non-ok HTTP response as a fail. + if (copyResp && typeof (copyResp as Response).ok === 'boolean' && !(copyResp as Response).ok) { + throw new Error(`R2 copy failed: ${(copyResp as Response).status}`); + } + } catch (err) { + console.error('Share import book copy failed:', err); + // Soft-delete the orphaned row so it doesn't count against quota or appear + // in the library list. + await supabase + .from('files') + .update({ deleted_at: new Date().toISOString() }) + .eq('id', insertedBook.id); + return NextResponse.json({ error: 'Could not import book' }, { status: 500 }); + } + + // Cover is best-effort. A failure here doesn't fail the import — the + // recipient still gets the book; the cover will simply be missing in + // their library until they refresh from elsewhere. + if (share.coverFileKey) { + const destCoverKey = remap(share.coverFileKey); + if (destCoverKey) { + try { + const coverExists = await objectExists(share.coverFileKey); + if (coverExists) { + await copyObject(share.coverFileKey, destCoverKey); + await supabase.from('files').insert({ + user_id: user.id, + book_hash: share.bookHash, + file_key: destCoverKey, + file_size: 0, // unknown; not material — covers don't bill + }); + } + } catch (err) { + console.error('Share import cover copy failed (non-fatal):', err); + } + } + } + + return NextResponse.json({ + fileId: insertedBook.id, + alreadyOwned: false, + bookHash: share.bookHash, + cfi: share.cfi, + }); +} diff --git a/apps/readest-app/src/app/api/share/[token]/og.png/route.tsx b/apps/readest-app/src/app/api/share/[token]/og.png/route.tsx new file mode 100644 index 00000000..59853aaf --- /dev/null +++ b/apps/readest-app/src/app/api/share/[token]/og.png/route.tsx @@ -0,0 +1,172 @@ +import { ImageResponse } from 'next/og'; +import { NextResponse } from 'next/server'; +import { getDownloadSignedUrl } from '@/utils/object'; +import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server'; +import { SHARE_PRESIGN_TTL_SECONDS } from '@/services/constants'; + +// Edge runtime is required for `next/og` (uses Satori + WASM under the hood +// and doesn't run on Node-style serverless). Keeps cold start fast on +// CloudFlare Workers via OpenNext. +export const runtime = 'edge'; + +interface RouteParams { + params: Promise<{ token: string }>; +} + +const WIDTH = 1200; +const HEIGHT = 630; + +// GET /api/share/[token]/og.png — server-rendered branded card for chat +// unfurls. Stable URL, cached for an hour: unfurlers (iMessage, WhatsApp, +// Twitter, Slack) cache aggressively, so a short-lived signed cover URL would +// break previews after expiry. By proxying through this route we get a stable +// URL even though the underlying R2 object is presigned per-fetch. +export async function GET(_request: Request, { params }: RouteParams) { + const { token } = await params; + + const result = await resolveActiveShare(token); + if (!result.ok) { + const { status, body } = rejectionToHttp(result.reason); + return NextResponse.json(body, { status }); + } + const { share } = result; + + let coverDataUrl: string | null = null; + if (share.coverFileKey) { + try { + const signedUrl = await getDownloadSignedUrl(share.coverFileKey, SHARE_PRESIGN_TTL_SECONDS); + const response = await fetch(signedUrl); + if (response.ok) { + const buffer = await response.arrayBuffer(); + const contentType = response.headers.get('content-type') ?? 'image/jpeg'; + coverDataUrl = `data:${contentType};base64,${arrayBufferToBase64(buffer)}`; + } + } catch (err) { + console.error('Share og.png cover fetch failed:', err); + // Fall through to text-only card. + } + } + + // JSX form is XSS-safe by construction: ImageResponse escapes text content. + // No raw HTML strings cross the boundary. + return new ImageResponse( + coverDataUrl + ? withCoverCard(coverDataUrl, share.bookTitle, share.bookAuthor) + : textOnlyCard(share.bookTitle, share.bookAuthor), + { + width: WIDTH, + height: HEIGHT, + headers: { + 'Cache-Control': 'public, max-age=3600, s-maxage=3600', + }, + }, + ); +} + +const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]!); + } + return btoa(binary); +}; + +// Cover-on-left composition. Asymmetric (anti-slop). Cover is the visual +// anchor; metadata sits to the right with strong vertical hierarchy. +const withCoverCard = (cover: string, title: string, author: string | null) => ( +
+ +
+
+ {clamp(title, 90)} +
+ {author && ( +
{clamp(author, 60)}
+ )} +
+
+
Shared via Readest
+
readest.com
+
+
+
+); + +// Cover-less fallback (eng-review locked option A). Title becomes the visual +// anchor at display size. No placeholder rectangle, no procedural pattern. +const textOnlyCard = (title: string, author: string | null) => ( +
+
+
+ {clamp(title, 80)} +
+ {author && ( +
{clamp(author, 60)}
+ )} +
+
+
Shared via Readest
+
readest.com
+
+
+); + +const clamp = (s: string, max: number): string => (s.length > max ? `${s.slice(0, max - 1)}…` : s); diff --git a/apps/readest-app/src/app/api/share/[token]/revoke/route.ts b/apps/readest-app/src/app/api/share/[token]/revoke/route.ts new file mode 100644 index 00000000..dc80b3f4 --- /dev/null +++ b/apps/readest-app/src/app/api/share/[token]/revoke/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server'; +import { createSupabaseAdminClient } from '@/utils/supabase'; +import { validateUserAndToken } from '@/utils/access'; +import { hashShareToken, isValidShareToken } from '@/libs/share-server'; + +interface RouteParams { + params: Promise<{ token: string }>; +} + +// POST /api/share/[token]/revoke — owner-only. Sets revoked_at = now() so +// future landing-page visits and downloads return 410. Note: presigned URLs +// already minted (max ~5 min TTL) cannot be canceled — this is a documented +// soft-revocation grace, not a hard guarantee. +export async function POST(request: Request, { params }: RouteParams) { + const { token } = await params; + + if (!isValidShareToken(token)) { + return NextResponse.json({ error: 'Invalid share token' }, { status: 400 }); + } + + const { user, token: jwt } = await validateUserAndToken(request.headers.get('authorization')); + if (!user || !jwt) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const supabase = createSupabaseAdminClient(); + const tokenHash = await hashShareToken(token); + + // RLS would suffice, but we use the admin client elsewhere; gate explicitly + // on user_id to keep the contract obvious to readers. + const { data: share, error: lookupError } = await supabase + .from('book_shares') + .select('id, user_id, revoked_at') + .eq('token_hash', tokenHash) + .maybeSingle(); + + if (lookupError) { + console.error('book_shares lookup failed:', lookupError); + return NextResponse.json({ error: 'Could not look up share' }, { status: 500 }); + } + if (!share) { + return NextResponse.json({ error: 'Share not found' }, { status: 404 }); + } + if (share.user_id !== user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + // Idempotent: re-revoking returns success without churning the timestamp. + if (share.revoked_at) { + return new NextResponse(null, { status: 204 }); + } + + const { error: updateError } = await supabase + .from('book_shares') + .update({ revoked_at: new Date().toISOString() }) + .eq('id', share.id); + if (updateError) { + console.error('book_shares revoke failed:', updateError); + return NextResponse.json({ error: 'Could not revoke share' }, { status: 500 }); + } + + return new NextResponse(null, { status: 204 }); +} diff --git a/apps/readest-app/src/app/api/share/[token]/route.ts b/apps/readest-app/src/app/api/share/[token]/route.ts new file mode 100644 index 00000000..20032019 --- /dev/null +++ b/apps/readest-app/src/app/api/share/[token]/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; +import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server'; + +interface RouteParams { + params: Promise<{ token: string }>; +} + +// GET /api/share/[token] — public metadata used by the /s landing page. +// Returns 410 if the share is revoked, expired, or its source file no longer +// exists. Never returns presigned URLs in this body — covers and downloads +// are fetched from dedicated endpoints with their own caching semantics. +export async function GET(_request: Request, { params }: RouteParams) { + const { token } = await params; + + const result = await resolveActiveShare(token); + if (!result.ok) { + const { status, body } = rejectionToHttp(result.reason); + return NextResponse.json(body, { status }); + } + const { share } = result; + + return NextResponse.json( + { + title: share.bookTitle, + author: share.bookAuthor, + format: share.bookFormat, + size: share.bookSize, + expiresAt: share.expiresAt, + hasCover: !!share.coverFileKey, + hasCfi: !!share.cfi, + downloadCount: share.downloadCount, + }, + { headers: { 'Cache-Control': 'private, no-store' } }, + ); +} diff --git a/apps/readest-app/src/app/api/share/create/route.ts b/apps/readest-app/src/app/api/share/create/route.ts new file mode 100644 index 00000000..98d1077b --- /dev/null +++ b/apps/readest-app/src/app/api/share/create/route.ts @@ -0,0 +1,178 @@ +import { NextResponse } from 'next/server'; +import { createSupabaseAdminClient } from '@/utils/supabase'; +import { validateUserAndToken } from '@/utils/access'; +import { generateShareToken } from '@/libs/share-server'; +import { objectExists } from '@/utils/object'; +import { + SHARE_BASE_URL, + SHARE_CFI_MAX_LENGTH, + SHARE_EXPIRATION_DAYS, + SHARE_MAX_PER_USER, +} from '@/services/constants'; + +interface CreateShareBody { + bookHash?: unknown; + expirationDays?: unknown; + title?: unknown; + author?: unknown; + format?: unknown; + cfi?: unknown; +} + +const isAllowedExpiration = (value: unknown): value is number => + typeof value === 'number' && + Number.isInteger(value) && + (SHARE_EXPIRATION_DAYS as readonly number[]).includes(value); + +// Bounds the snapshotted text fields the client can pass through. The metadata +// is rendered on a public landing page and embedded in the OG image, so a +// hostile client could try to abuse very long values for layout disruption. +const trimText = (value: unknown, max: number): string | null => { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + return trimmed.length > max ? trimmed.slice(0, max) : trimmed; +}; + +// Reject the C0 control range (U+0000-U+001F) and DEL (U+007F). The cfi +// is round-tripped into URLs and rendered into HTML; a control byte in +// either path would do bad things. +const isControlChar = (s: string): boolean => /[\u0000-\u001f\u007f]/.test(s); + +export async function POST(request: Request) { + const { user, token } = await validateUserAndToken(request.headers.get('authorization')); + if (!user || !token) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + let body: CreateShareBody; + try { + body = (await request.json()) as CreateShareBody; + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const bookHash = trimText(body.bookHash, 64); + if (!bookHash) { + return NextResponse.json({ error: 'Missing or invalid bookHash' }, { status: 400 }); + } + + if (!isAllowedExpiration(body.expirationDays)) { + return NextResponse.json( + { + error: `expirationDays must be one of ${SHARE_EXPIRATION_DAYS.join(', ')}`, + code: 'invalid_expiration', + }, + { status: 400 }, + ); + } + const expirationDays = body.expirationDays; + + const title = trimText(body.title, 512); + if (!title) { + return NextResponse.json({ error: 'Missing or invalid title' }, { status: 400 }); + } + const author = trimText(body.author, 256); + const format = trimText(body.format, 16); + if (!format) { + return NextResponse.json({ error: 'Missing or invalid format' }, { status: 400 }); + } + + let cfi: string | null = null; + if (body.cfi != null) { + cfi = trimText(body.cfi, SHARE_CFI_MAX_LENGTH); + if (cfi && isControlChar(cfi)) { + return NextResponse.json({ error: 'cfi contains invalid characters' }, { status: 400 }); + } + } + + const supabase = createSupabaseAdminClient(); + + // Active-share cap — silently enforced. Counts only non-revoked, non-expired rows. + const { count: activeCount, error: countError } = await supabase + .from('book_shares') + .select('id', { count: 'exact', head: true }) + .eq('user_id', user.id) + .is('revoked_at', null) + .gt('expires_at', new Date().toISOString()); + if (countError) { + console.error('book_shares cap query failed:', countError); + return NextResponse.json({ error: 'Could not check share quota' }, { status: 500 }); + } + if ((activeCount ?? 0) >= SHARE_MAX_PER_USER) { + return NextResponse.json( + { + error: `You have reached the maximum of ${SHARE_MAX_PER_USER} active shares.`, + code: 'share_limit_reached', + }, + { status: 429 }, + ); + } + + // Look up the live `files` row for this user's book. Re-uploads of the same + // hash follow the share automatically because we resolve at every access. + const { data: bookFiles, error: filesError } = await supabase + .from('files') + .select('file_key, file_size') + .eq('user_id', user.id) + .eq('book_hash', bookHash) + .is('deleted_at', null); + if (filesError) { + console.error('book_shares files lookup failed:', filesError); + return NextResponse.json({ error: 'Could not look up book' }, { status: 500 }); + } + if (!bookFiles || bookFiles.length === 0) { + return NextResponse.json( + { error: 'Book is not uploaded yet', code: 'book_not_uploaded' }, + { status: 409 }, + ); + } + + // Pick the book file (not the cover) by extension. Covers are PNG/JPG; + // book files are EPUB/PDF/MOBI/etc. The widest filter is "is not an image". + const bookFile = bookFiles.find((f) => !/\.(png|jpe?g|webp|gif)$/i.test(f.file_key)); + if (!bookFile) { + return NextResponse.json( + { error: 'Book file row not found', code: 'book_not_uploaded' }, + { status: 409 }, + ); + } + const size = bookFile.file_size; + + // The `files` row is inserted before bytes upload (storage/upload.ts:74), so + // a ghost row can exist if the client aborted. HEAD R2 to confirm bytes are + // really there before we make the share publicly resolvable. + const exists = await objectExists(bookFile.file_key); + if (!exists) { + return NextResponse.json( + { error: 'Book upload is incomplete; please retry', code: 'upload_incomplete' }, + { status: 409 }, + ); + } + + const { raw, hash } = await generateShareToken(); + const expiresAt = new Date(Date.now() + expirationDays * 24 * 60 * 60 * 1000); + + const { error: insertError } = await supabase.from('book_shares').insert({ + token_hash: hash, + token: raw, + user_id: user.id, + book_hash: bookHash, + book_title: title, + book_author: author, + book_format: format, + book_size: size, + cfi, + expires_at: expiresAt.toISOString(), + }); + if (insertError) { + console.error('book_shares insert failed:', insertError); + return NextResponse.json({ error: 'Could not create share' }, { status: 500 }); + } + + return NextResponse.json({ + token: raw, + url: `${SHARE_BASE_URL}/${raw}`, + expiresAt: expiresAt.toISOString(), + }); +} diff --git a/apps/readest-app/src/app/api/share/list/route.ts b/apps/readest-app/src/app/api/share/list/route.ts new file mode 100644 index 00000000..300d094a --- /dev/null +++ b/apps/readest-app/src/app/api/share/list/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server'; +import { createSupabaseAdminClient } from '@/utils/supabase'; +import { validateUserAndToken } from '@/utils/access'; +import { SHARE_BASE_URL } from '@/services/constants'; + +const PAGE_SIZE = 25; + +// GET /api/share/list?cursor=: +// Owner-only. Cursor-paginated list of the caller's shares (active + expired). +// Cursor format mirrors the (created_at DESC, id DESC) order so duplicates and +// drops are impossible across pages even when rows are added concurrently. +export async function GET(request: Request) { + const { user, token } = await validateUserAndToken(request.headers.get('authorization')); + if (!user || !token) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const url = new URL(request.url); + const rawCursor = url.searchParams.get('cursor'); + let cursorCreatedAt: string | null = null; + let cursorId: string | null = null; + if (rawCursor) { + const sep = rawCursor.indexOf('|'); + if (sep > 0) { + cursorCreatedAt = rawCursor.slice(0, sep); + cursorId = rawCursor.slice(sep + 1); + } + } + + const supabase = createSupabaseAdminClient(); + let query = supabase + .from('book_shares') + .select( + 'id, user_id, token, book_hash, book_title, book_author, book_format, book_size, cfi, expires_at, revoked_at, download_count, created_at', + ) + .eq('user_id', user.id) + .order('created_at', { ascending: false }) + .order('id', { ascending: false }) + .limit(PAGE_SIZE + 1); + + if (cursorCreatedAt && cursorId) { + // Strict less-than on (created_at, id) lexicographic to avoid skipping ties. + query = query.or( + `created_at.lt.${cursorCreatedAt},and(created_at.eq.${cursorCreatedAt},id.lt.${cursorId})`, + ); + } + + const { data, error } = await query; + if (error) { + console.error('book_shares list failed:', error); + return NextResponse.json({ error: 'Could not list shares' }, { status: 500 }); + } + + const rows = data ?? []; + const hasMore = rows.length > PAGE_SIZE; + const page = hasMore ? rows.slice(0, PAGE_SIZE) : rows; + const last = page.length > 0 ? page[page.length - 1] : null; + const nextCursor = hasMore && last ? `${last.created_at}|${last.id}` : null; + + return NextResponse.json({ + shares: page.map((row) => ({ + id: row.id, + // Plaintext token surfaced to the OWNER only. RLS ensures other users + // cannot read this row; this endpoint is auth-gated and queried by + // user_id so a token never leaves the sharer's session. + token: row.token, + bookHash: row.book_hash, + title: row.book_title, + author: row.book_author, + format: row.book_format, + size: row.book_size, + hasCfi: !!row.cfi, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + downloadCount: row.download_count, + createdAt: row.created_at, + })), + nextCursor, + shareUrlBase: SHARE_BASE_URL, + }); +} diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index 403d8ab3..9ff07e2b 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -48,6 +48,8 @@ import Spinner from '@/components/Spinner'; import ModalPortal from '@/components/ModalPortal'; import BookshelfItem, { generateBookshelfItems } from './BookshelfItem'; import SelectModeActions from './SelectModeActions'; +import ShareBookDialog from './ShareBookDialog'; +import { useAuth } from '@/context/AuthContext'; import GroupingModal from './GroupingModal'; import SetStatusAlert from './SetStatusAlert'; @@ -462,6 +464,31 @@ const Bookshelf: React.FC = ({ }; }, []); + const { user } = useAuth(); + const [shareDialogBook, setShareDialogBook] = useState(null); + + useEffect(() => { + const handleShareIntent = (event: CustomEvent) => { + const book = (event.detail as { book?: Book } | undefined)?.book; + if (!book) return; + if (!user) { + // Logged-out users can't share their own files; route through the + // login flow instead. The /auth route preserves a return path. + eventDispatcher.dispatch('toast', { + type: 'info', + message: _('Sign in to share books'), + timeout: 2500, + }); + return; + } + setShareDialogBook(book); + }; + eventDispatcher.on('show-share-dialog', handleShareIntent); + return () => { + eventDispatcher.off('show-share-dialog', handleShareIntent); + }; + }, [user, _]); + // OverlayScrollbars + Virtuoso integration: Virtuoso manages its own // scroller; OverlayScrollbars wraps it for overlay scrollbar rendering. const osRootRef = useRef(null); @@ -698,6 +725,11 @@ const Bookshelf: React.FC = ({ onUpdateStatus={updateBooksStatus} /> )} + setShareDialogBook(null)} + />
); }; diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index 6fe7ba7b..a19cf191 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -252,6 +252,14 @@ const BookshelfItem: React.FC = ({ handleBookUpload(book); }, }); + const shareBookMenuItem = await MenuItem.new({ + text: _('Share Book'), + action: async () => { + // Bookshelf.tsx hosts the dialog; we dispatch and let it route + // unauthenticated users into the login flow first. + eventDispatcher.dispatch('show-share-dialog', { book }); + }, + }); const deleteBookMenuItem = await MenuItem.new({ text: _('Delete'), action: async () => { @@ -278,6 +286,11 @@ const BookshelfItem: React.FC = ({ if (!book.uploadedAt && book.downloadedAt) { menu.append(uploadBookMenuItem); } + // Share is offered for any local-or-uploaded book; the dialog will trigger + // an upload first if the book hasn't been pushed yet. + if (book.downloadedAt || book.uploadedAt) { + menu.append(shareBookMenuItem); + } menu.append(deleteBookMenuItem); menu.popup(); }; diff --git a/apps/readest-app/src/app/library/components/ShareBookDialog.tsx b/apps/readest-app/src/app/library/components/ShareBookDialog.tsx new file mode 100644 index 00000000..143da9c8 --- /dev/null +++ b/apps/readest-app/src/app/library/components/ShareBookDialog.tsx @@ -0,0 +1,392 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import { + IoCheckmarkCircle, + IoCopyOutline, + IoLinkOutline, + IoShareSocialOutline, +} from 'react-icons/io5'; +import Dialog from '@/components/Dialog'; +import SegmentedControl from '@/components/SegmentedControl'; +import { useEnv } from '@/context/EnvContext'; +import { useTranslation } from '@/hooks/useTranslation'; +import { eventDispatcher } from '@/utils/event'; +import { Book } from '@/types/book'; +import { SHARE_DEFAULT_EXPIRATION_DAYS, SHARE_EXPIRATION_DAYS } from '@/services/constants'; +import { ShareApiError, createShare, revokeShare } from '@/libs/share'; +import { formatBytes } from '@/utils/book'; + +interface ShareBookDialogProps { + isOpen: boolean; + book: Book | null; + // Optional starting position when launched from the reader top bar. + // When present, the dialog shows the "Share current page" toggle and + // includes the cfi in the create payload if the toggle is checked. + cfi?: string | null; + onClose: () => void; +} + +interface CreatedShare { + url: string; + expiresAt: string; + hasCfi: boolean; +} + +const ShareBookDialog: React.FC = ({ isOpen, book, cfi, onClose }) => { + const _ = useTranslation(); + const { appService } = useEnv(); + + const [expirationDays, setExpirationDays] = useState(SHARE_DEFAULT_EXPIRATION_DAYS); + // Off by default — sharing the current page reveals where the user is in + // the book, which is mild privacy data. Recipient still gets the book; the + // toggle only adds the cfi to the link. Mirrors the dialog's reset effect. + const [includeCfi, setIncludeCfi] = useState(false); + const [generating, setGenerating] = useState(false); + const [uploadProgress, setUploadProgress] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [created, setCreated] = useState(null); + const [revoking, setRevoking] = useState(false); + const [copied, setCopied] = useState(false); + const [fileSize, setFileSize] = useState(null); + + // Reset transient state every time the dialog opens for a new book. + useEffect(() => { + if (!isOpen) return; + setExpirationDays(SHARE_DEFAULT_EXPIRATION_DAYS); + setIncludeCfi(false); + setGenerating(false); + setUploadProgress(null); + setErrorMessage(null); + setCreated(null); + setRevoking(false); + setCopied(false); + setFileSize(null); + }, [isOpen, book?.hash]); + + // Look up the book's file size for the Hero metadata line. Mirrors the + // BookDetailModal pattern: ask appService once per (open, book) pair and + // tolerate failures silently — file size is decorative, not required. + useEffect(() => { + if (!isOpen || !book || !appService) return; + let cancelled = false; + (async () => { + try { + const size = await appService.getBookFileSize(book); + if (!cancelled) setFileSize(size); + } catch { + // Local file may be unavailable (cloud-only book); leave size null. + } + })(); + return () => { + cancelled = true; + }; + }, [isOpen, book?.hash, appService]); // eslint-disable-line react-hooks/exhaustive-deps + + const expiryLabel = useMemo(() => { + if (!created) return null; + const date = new Date(created.expiresAt); + return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); + }, [created]); + + if (!book) return null; + + const handleGenerate = async () => { + if (!book || generating) return; + setGenerating(true); + setErrorMessage(null); + try { + // Upload first if the book lives only locally. + if (!book.uploadedAt && appService) { + try { + await appService.uploadBook(book, (progress) => { + setUploadProgress((progress.progress / progress.total) * 100); + }); + } finally { + setUploadProgress(null); + } + } + + const response = await createShare({ + bookHash: book.hash, + expirationDays, + title: book.title, + author: book.author ?? null, + format: book.format, + cfi: cfi && includeCfi ? cfi : null, + }); + + setCreated({ + url: response.url, + expiresAt: response.expiresAt, + hasCfi: !!(cfi && includeCfi), + }); + } catch (err) { + const message = + err instanceof ShareApiError + ? err.message + : err instanceof Error + ? err.message + : _('Could not create share link'); + setErrorMessage(message); + } finally { + setGenerating(false); + } + }; + + const handleCopy = async () => { + if (!created) return; + try { + await navigator.clipboard.writeText(created.url); + setCopied(true); + eventDispatcher.dispatch('toast', { + type: 'success', + message: _('Link copied'), + timeout: 2000, + }); + window.setTimeout(() => setCopied(false), 2000); + } catch { + eventDispatcher.dispatch('toast', { + type: 'error', + message: _('Could not copy link'), + timeout: 2000, + }); + } + }; + + const handleNativeShare = async () => { + if (!created) return; + const title = book.title; + const url = created.url; + + // Tauri (mobile + desktop windowed): try sharekit. If the import or + // call throws, the plugin isn't usable on this platform — fall through + // to web/copy. If it resolves (success OR user dismissed the sheet + // without picking), we're done; do NOT silently copy on top of that. + if (appService?.isMobileApp || appService?.hasWindow) { + let sharekitWorked = false; + try { + const { shareText } = await import('@choochmeque/tauri-plugin-sharekit-api'); + await shareText(`${title}\n${url}`); + sharekitWorked = true; + } catch (err) { + console.error('shareText failed; falling back:', err); + } + if (sharekitWorked) return; + } + + // Web: navigator.share rejects with AbortError when the user dismisses + // the share sheet — that's an explicit "don't share" choice, not a + // signal to silently copy as a "helpful" fallback. Return on either + // resolve or reject; only fall through if the API isn't supported at all. + if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') { + try { + await navigator.share({ title, url }); + } catch { + // User dismissed or share-time error; respect the choice. + } + return; + } + + // Last resort: clipboard copy. Reached only when no native share method + // is available on this platform (e.g., Linux desktop without sharekit). + await handleCopy(); + }; + + const handleRevoke = async () => { + if (!created || revoking) return; + if (!created.url) return; + // Extract token from the canonical URL we received from the server. + const segments = created.url.split('/'); + const token = segments[segments.length - 1]; + if (!token) return; + setRevoking(true); + try { + await revokeShare(token); + eventDispatcher.dispatch('toast', { + type: 'success', + message: _('Share revoked'), + timeout: 2000, + }); + onClose(); + } catch (err) { + const message = err instanceof Error ? err.message : _('Could not revoke share'); + setErrorMessage(message); + } finally { + setRevoking(false); + } + }; + + return ( + +
+ {/* Hero: cover + metadata. Cover gets a real shadow so it reads as a + physical book rather than a bordered thumbnail. Author + format + collapse into a single muted line for cleaner hierarchy. */} +
+ {book.coverImageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+
+ )} +
+
+ {book.title} +
+
+ {[book.author, book.format, formatBytes(fileSize)].filter(Boolean).join(' · ')} +
+
+
+ + {!created ? ( + <> + {/* Settings-card group: each row is a label + control, separated + by a hairline divider. Mirrors the iOS native settings idiom. */} +
+ {/* Both rows share min-h-12 so the segmented control's internal + pill height doesn't make this row visibly taller than the + toggle row below. */} +
+ {_('Expires in')} + + ariaLabel={_('Expires in')} + value={expirationDays} + onChange={setExpirationDays} + disabled={generating} + options={SHARE_EXPIRATION_DAYS.map((n) => ({ + value: n, + label: _('{{count}} days', { count: n }), + }))} + /> +
+ + {cfi && ( + + )} +
+ + {uploadProgress !== null && ( +
+
+ {_('Uploading book…')} {Math.round(uploadProgress)}% +
+ +
+ )} + + {errorMessage && ( +

+ {errorMessage} +

+ )} + + + + ) : ( + <> + {created.hasCfi && ( +
+
+ )} + + {/* URL pill: softer than a bordered input, with the copy button + as an inline action chip. */} +
+ e.currentTarget.select()} + /> + +
+ + + +

+ {_('Expires {{date}}', { date: expiryLabel ?? '' })} + · + +

+ + {errorMessage && ( +

+ {errorMessage} +

+ )} + + )} +
+
+ ); +}; + +export default ShareBookDialog; diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 08f66ca8..5f192301 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -42,6 +42,7 @@ import { useScreenWakeLock } from '@/hooks/useScreenWakeLock'; import { useAppUrlIngress } from '@/hooks/useAppUrlIngress'; import { useOpenWithBooks } from '@/hooks/useOpenWithBooks'; import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink'; +import { useOpenShareLink } from '@/hooks/useOpenShareLink'; import { useKeyDownActions } from '@/hooks/useKeyDownActions'; import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector'; import { lockScreenOrientation, selectDirectory } from '@/utils/bridge'; @@ -164,6 +165,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP useAppUrlIngress(); useOpenWithBooks(); useOpenAnnotationLink(); + useOpenShareLink(); useTransferQueue(libraryLoaded); const { pullLibrary, pushLibrary } = useBooksSync(); diff --git a/apps/readest-app/src/app/o/page.tsx b/apps/readest-app/src/app/o/page.tsx index 8a8ded12..a98d8aa1 100644 --- a/apps/readest-app/src/app/o/page.tsx +++ b/apps/readest-app/src/app/o/page.tsx @@ -1,12 +1,14 @@ 'use client'; import { Suspense, useEffect, useState } from 'react'; -import Image from 'next/image'; import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { IoAlertCircleOutline, IoBookOutline, IoOpenOutline } from 'react-icons/io5'; import { DOWNLOAD_READEST_URL, READEST_WEB_BASE_URL } from '@/services/constants'; import { useTranslation } from '@/hooks/useTranslation'; import { buildAnnotationAppUrl } from '@/utils/deeplink'; +import { BrandHeader } from '@/components/landing/BrandHeader'; +import { Card } from '@/components/landing/Card'; +import { PageFooter } from '@/components/landing/PageFooter'; type Platform = 'android-chromium' | 'android-other' | 'ios' | 'desktop' | 'unknown'; @@ -37,39 +39,6 @@ const buildWebReaderUrl = (bookHash: string, cfi: string | null): string => { return `/reader/${bookHash}${query}`; }; -const Card: React.FC<{ children: React.ReactNode }> = ({ children }) => ( -
- {children} -
-); - -const BrandHeader: React.FC<{ title: string; subtitle: string; alt: string }> = ({ - title, - subtitle, - alt, -}) => ( -
- {alt} -

{title}

-

{subtitle}

-
-); - -const PageFooter: React.FC<{ tagline: string }> = ({ tagline }) => ( -

- - Readest - - · - {tagline} -

-); - const OpenAnnotationLanding = () => { const _ = useTranslation(); const router = useRouter(); diff --git a/apps/readest-app/src/app/reader/components/HeaderBar.tsx b/apps/readest-app/src/app/reader/components/HeaderBar.tsx index 8f714dd1..d5f4700a 100644 --- a/apps/readest-app/src/app/reader/components/HeaderBar.tsx +++ b/apps/readest-app/src/app/reader/components/HeaderBar.tsx @@ -26,6 +26,7 @@ import WindowButtons from '@/components/WindowButtons'; import QuickActionMenu from './annotator/QuickActionMenu'; import SidebarToggler from './SidebarToggler'; import BookmarkToggler from './BookmarkToggler'; +import ShareToggler from './ShareToggler'; import NotebookToggler from './NotebookToggler'; import SettingsToggler from './SettingsToggler'; import TranslationToggler from './TranslationToggler'; @@ -219,6 +220,7 @@ const HeaderBar: React.FC = ({ + {enableAnnotationQuickActions && ( diff --git a/apps/readest-app/src/app/reader/components/ReaderContent.tsx b/apps/readest-app/src/app/reader/components/ReaderContent.tsx index 3f479573..e9d1b501 100644 --- a/apps/readest-app/src/app/reader/components/ReaderContent.tsx +++ b/apps/readest-app/src/app/reader/components/ReaderContent.tsx @@ -28,6 +28,8 @@ import { import { clearDiscordPresence } from '@/utils/discord'; import { BOOK_IDS_SEPARATOR } from '@/services/constants'; import { BookDetailModal } from '@/components/metadata'; +import ShareBookDialog from '@/app/library/components/ShareBookDialog'; +import { useAuth } from '@/context/AuthContext'; import useBooksManager from '../hooks/useBooksManager'; import useBookShortcuts from '../hooks/useBookShortcuts'; @@ -50,6 +52,11 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i const { initViewState, getViewState, clearViewState } = useReaderStore(); const { isSettingsDialogOpen, settingsDialogBookKey } = useSettingsStore(); const [showDetailsBook, setShowDetailsBook] = useState(null); + const [shareDialogState, setShareDialogState] = useState<{ + book: Book; + cfi: string | null; + } | null>(null); + const { user } = useAuth(); const isInitiating = useRef(false); const [loading, setLoading] = useState(false); const [errorLoading, setErrorLoading] = useState(false); @@ -104,6 +111,29 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i }; }, []); + useEffect(() => { + const handleShareIntent = (event: CustomEvent) => { + const detail = event.detail as { book: Book; cfi?: string | null } | undefined; + if (!detail?.book) return; + if (!user) { + eventDispatcher.dispatch('toast', { + type: 'info', + message: _('Sign in to share books'), + timeout: 2500, + }); + return; + } + setShareDialogState({ + book: detail.book, + cfi: detail.cfi ?? null, + }); + }; + eventDispatcher.on('show-share-dialog', handleShareIntent); + return () => { + eventDispatcher.off('show-share-dialog', handleShareIntent); + }; + }, [user, _]); + useEffect(() => { if (bookKeys && bookKeys.length > 0) { const settings = useSettingsStore.getState().settings; @@ -249,6 +279,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i onClose={() => setShowDetailsBook(null)} /> )} + setShareDialogState(null)} + /> ); }; diff --git a/apps/readest-app/src/app/reader/components/ShareToggler.tsx b/apps/readest-app/src/app/reader/components/ShareToggler.tsx new file mode 100644 index 00000000..32cc0ad8 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/ShareToggler.tsx @@ -0,0 +1,44 @@ +import React, { useCallback } from 'react'; +import { IoShareOutline } from 'react-icons/io5'; +import { useReaderStore } from '@/store/readerStore'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useResponsiveSize } from '@/hooks/useResponsiveSize'; +import { eventDispatcher } from '@/utils/event'; + +interface ShareTogglerProps { + bookKey: string; +} + +// Reader top-bar Share button. Matches BookmarkToggler/TranslationToggler +// shape so it slots into the existing header rhythm. +const ShareToggler: React.FC = ({ bookKey }) => { + const _ = useTranslation(); + const iconSize18 = useResponsiveSize(18); + const { getProgress } = useReaderStore(); + const { getBookData } = useBookDataStore(); + + const handleShare = useCallback(() => { + const bookData = getBookData(bookKey); + if (!bookData?.book) return; + const progress = getProgress(bookKey); + eventDispatcher.dispatch('show-share-dialog', { + book: bookData.book, + cfi: progress?.location ?? null, + }); + }, [bookKey, getBookData, getProgress]); + + return ( + + ); +}; + +export default ShareToggler; diff --git a/apps/readest-app/src/app/reader/page.tsx b/apps/readest-app/src/app/reader/page.tsx index 014bdb8f..500ca740 100644 --- a/apps/readest-app/src/app/reader/page.tsx +++ b/apps/readest-app/src/app/reader/page.tsx @@ -6,6 +6,7 @@ import { useTranslation } from '@/hooks/useTranslation'; import { useAppUrlIngress } from '@/hooks/useAppUrlIngress'; import { useOpenWithBooks } from '@/hooks/useOpenWithBooks'; import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink'; +import { useOpenShareLink } from '@/hooks/useOpenShareLink'; import { useSettingsStore } from '@/store/settingsStore'; import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater'; import { tauriHandleSetAlwaysOnTop } from '@/utils/window'; @@ -20,6 +21,7 @@ export default function Page() { useAppUrlIngress(); useOpenWithBooks(); useOpenAnnotationLink(); + useOpenShareLink(); useEffect(() => { const doCheckAppUpdates = async () => { diff --git a/apps/readest-app/src/app/s/layout.tsx b/apps/readest-app/src/app/s/layout.tsx new file mode 100644 index 00000000..8159a367 --- /dev/null +++ b/apps/readest-app/src/app/s/layout.tsx @@ -0,0 +1,75 @@ +import type { Metadata, ResolvingMetadata } from 'next'; +import { READEST_WEB_BASE_URL, SHARE_BASE_URL } from '@/services/constants'; +import { resolveActiveShare } from '@/libs/share-server'; + +// Server-rendered metadata for chat unfurls. The /s page itself is a client +// component that mirrors /o/page.tsx, but the OG/Twitter tags must be in the +// initial HTML so iMessage / WhatsApp / Twitter / Slack crawlers can read +// them without executing JS. +// +// In the Tauri build (output: 'export'), this whole route is dropped because +// rewrites and dynamic metadata require a server. Tauri intercepts the +// readest://share/{token} deep link before /s ever loads. +interface LayoutProps { + children: React.ReactNode; + searchParams?: Promise>; +} + +interface MetadataProps { + searchParams: Promise>; +} + +export async function generateMetadata( + { searchParams }: MetadataProps, + _parent: ResolvingMetadata, +): Promise { + const params = (await searchParams) ?? {}; + const tokenParam = params['token']; + const token = Array.isArray(tokenParam) ? tokenParam[0] : tokenParam; + + if (!token) { + return { + title: 'Open in Readest', + description: 'Open-source ebook reader for everyone, on every device.', + }; + } + + const result = await resolveActiveShare(token); + if (!result.ok) { + return { + title: 'Share link unavailable · Readest', + description: 'This share link is no longer available.', + }; + } + const { share } = result; + const shareUrl = `${SHARE_BASE_URL}/${token}`; + const ogImage = `${READEST_WEB_BASE_URL}/api/share/${token}/og.png`; + + return { + title: `${share.bookTitle} · Shared via Readest`, + description: share.bookAuthor + ? `${share.bookAuthor} · Shared via Readest` + : 'Shared via Readest', + openGraph: { + type: 'book', + url: shareUrl, + title: share.bookTitle, + description: share.bookAuthor + ? `${share.bookAuthor} · Shared via Readest` + : 'Shared via Readest', + images: [{ url: ogImage, width: 1200, height: 630 }], + }, + twitter: { + card: 'summary_large_image', + title: share.bookTitle, + description: share.bookAuthor + ? `${share.bookAuthor} · Shared via Readest` + : 'Shared via Readest', + images: [ogImage], + }, + }; +} + +export default function ShareLandingLayout({ children }: LayoutProps) { + return <>{children}; +} diff --git a/apps/readest-app/src/app/s/page.tsx b/apps/readest-app/src/app/s/page.tsx new file mode 100644 index 00000000..a3c23b71 --- /dev/null +++ b/apps/readest-app/src/app/s/page.tsx @@ -0,0 +1,321 @@ +'use client'; + +import { Suspense, useEffect, useState } from 'react'; +import Image from 'next/image'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { + IoAlertCircleOutline, + IoBookOutline, + IoCloudDownloadOutline, + IoLibraryOutline, + IoOpenOutline, +} from 'react-icons/io5'; +import { DOWNLOAD_READEST_URL } from '@/services/constants'; +import { useTranslation, type TranslationFunc } from '@/hooks/useTranslation'; +import { useAuth } from '@/context/AuthContext'; +import { BrandHeader } from '@/components/landing/BrandHeader'; +import { Card } from '@/components/landing/Card'; +import { PageFooter } from '@/components/landing/PageFooter'; +import { confirmDownload, getShare, importShare, type ShareMetadata } from '@/libs/share'; +import { formatBytes } from '@/utils/book'; + +const formatExpiry = (iso: string, _: TranslationFunc): string => { + const ms = new Date(iso).getTime() - Date.now(); + const days = Math.round(ms / (24 * 60 * 60 * 1000)); + const hours = Math.round(ms / (60 * 60 * 1000)); + if (days >= 1) return _('Expires in {{count}} days', { count: days }); + if (hours > 0) return _('Expires in {{count}} hours', { count: hours }); + return _('Expiring soon'); +}; + +const ShareLanding = () => { + const _ = useTranslation(); + const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + const { user } = useAuth(); + + // Resolve the token from either the rewritten query (?token=) or the pretty + // path (/s/{token}). The next.config.mjs rewrite handles the web build; the + // pathname fallback handles Tauri (output: 'export', no rewrites), dev + // sessions where the rewrite isn't picked up without a server restart, and + // any deploy where the rewrite gets misconfigured. Mirrors src/app/o/page.tsx. + let token = searchParams?.get('token') ?? ''; + if (!token && pathname) { + const segments = pathname.split('/').filter(Boolean); + if (segments[0] === 's' && segments[1]) { + token = segments[1]; + } + } + + const [meta, setMeta] = useState(null); + const [loadError, setLoadError] = useState<{ status: number; message: string } | null>(null); + const [importing, setImporting] = useState(false); + const [importError, setImportError] = useState(null); + + useEffect(() => { + if (!token) { + setLoadError({ status: 400, message: _('Missing share token') }); + return; + } + let cancelled = false; + (async () => { + try { + const data = await getShare(token); + if (!cancelled) setMeta(data); + } catch (err) { + if (!cancelled) { + const status = + err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' + ? err.status + : 500; + setLoadError({ status, message: err instanceof Error ? err.message : 'Unknown error' }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [token, _]); + + const downloadHref = `/api/share/${encodeURIComponent(token)}/download`; + const appHref = `readest://share/${encodeURIComponent(token)}`; + + const handleDownloadClick = () => { + // Best-effort analytics ping. Doesn't block the navigation. + if (token) confirmDownload(token); + }; + + const handleAddToLibrary = async () => { + if (!token || importing) return; + setImporting(true); + setImportError(null); + try { + const result = await importShare(token); + // The reader resolves `ids` via getBookByHash, so pass the book hash — + // not the `files.id` UUID. Same identifier used by /reader/:hash links. + const params = new URLSearchParams(); + params.set('ids', result.bookHash); + if (result.cfi) params.set('cfi', result.cfi); + router.push(`/reader?${params.toString()}`); + } catch (err) { + setImporting(false); + const message = err instanceof Error ? err.message : _('Could not add to your library'); + setImportError(message); + } + }; + + if (loadError) { + // Pick a body copy that reflects the actual failure mode. Network / + // unknown failures get the generic "try again" message; only confirmed + // expired/revoked/not-found responses get the "no longer available" copy. + // This makes misconfigurations debuggable without inspecting devtools. + const isUnavailable = loadError.status === 410 || loadError.status === 404; + const isInvalidToken = loadError.status === 400; + const heading = isUnavailable + ? _('This share link is no longer available') + : isInvalidToken + ? _("This link can't be opened") + : _('Could not load shared book'); + const body = isUnavailable + ? _('The original link may have expired or been revoked.') + : isInvalidToken + ? _('The share link is missing required information.') + : _('Please check your connection and try again.'); + + return ( +
+ +
+
+ +
+

{heading}

+

{body}

+ + {_('Get Readest')} + +
+
+ +
+ ); + } + + if (!meta) { + return ( +
+ + +
+
+
+ +
+ ); + } + + const coverSrc = meta.hasCover ? `/api/share/${encodeURIComponent(token)}/cover` : null; + const expiryLabel = formatExpiry(meta.expiresAt, _); + + return ( +
+ {/* Inline card instead of : this surface needs a wider container + on desktop (sm:max-w-2xl) and a horizontal cover+content layout + that the shared Card primitive doesn't support. The /o landing + stays on the narrow Card; only /s gets the wider treatment. */} +
+ {/* Branded header — small Readest mark + headline. Stays compact so + the card still fits on common viewports without scroll, but + gives the page identity at a glance. */} +
+ {_('Readest + {_('Shared with you')} +
+ +
+ {/* Cover: dominant visual anchor. aspect-[2/3] keeps the box the + right shape whether or not the image loaded — stable layout + while the cover fetches. */} +
+ {coverSrc ? ( + // Plain : source is a presigned URL that varies per + // request, so next/image's loader gives no win. + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+
+ )} +
+ + {/* Content column: title, author, meta line, then actions. + Centered on mobile, left-aligned on desktop where it sits to + the right of the cover. */} +
+

+ {meta.title} +

+ {meta.author && ( +

{meta.author}

+ )} +

+ {meta.format.toUpperCase()} · {formatBytes(meta.size)} · {expiryLabel} +

+ +
+ {user ? ( + <> + + {/* Secondary actions side-by-side. flex-nowrap + + whitespace-nowrap override daisyUI's default + `flex-wrap: wrap` on .btn so the icon and label stay + on one line in narrow viewports. */} + + {importError && ( +

+ {importError} +

+ )} + + ) : ( + <> + {/* Anonymous flow: only 2 actions, so they sit side-by-side + on one row. The 3-action logged-in flow above keeps its + stacked design (primary + 2 secondary). Download stays + `btn-primary` to keep the "main thing" hierarchy clear, + Open in app is ghost. Labels match the logged-in flow's + shorter forms to avoid wrap in the tighter row layout. */} + +

+ {_("Don't have Readest?")}{' '} + + {_('Download Readest')} + +

+ + )} +
+
+
+
+ + +
+ ); +}; + +const Page = () => { + // useSearchParams must be wrapped in Suspense per Next 16 client-component + // contract, mirrors src/app/o/page.tsx. + return ( + + + + ); +}; + +export default Page; diff --git a/apps/readest-app/src/app/user/components/AccountActions.tsx b/apps/readest-app/src/app/user/components/AccountActions.tsx index fdeecedb..f0dd438f 100644 --- a/apps/readest-app/src/app/user/components/AccountActions.tsx +++ b/apps/readest-app/src/app/user/components/AccountActions.tsx @@ -55,6 +55,7 @@ interface AccountActionsProps { onRestorePurchase?: () => void; onManageSubscription?: () => void; onManageStorage?: () => void; + onManageSharedLinks?: () => void; } const AccountActions: React.FC = ({ @@ -67,6 +68,7 @@ const AccountActions: React.FC = ({ onRestorePurchase, onManageSubscription, onManageStorage, + onManageSharedLinks, }) => { const _ = useTranslation(); const { appService } = useEnv(); @@ -116,6 +118,14 @@ const AccountActions: React.FC = ({ {_('Manage Storage')} )} + {onManageSharedLinks && ( + + )} + + {/* + * Use the project's Dropdown component (Headless-UI-style with + * an Overlay backdrop) instead of daisyUI's
/ + * pattern. The bare daisyUI version doesn't position correctly + * inside this scrollable settings layout — it gets clipped by + * the rounded-2xl border on the surrounding
    . + */} + + + )} + + ); + })} +
+ {cursor && ( + + )} + + ); +}; + +export default SharedLinksSection; diff --git a/apps/readest-app/src/app/user/page.tsx b/apps/readest-app/src/app/user/page.tsx index a45347cc..5fd4b223 100644 --- a/apps/readest-app/src/app/user/page.tsx +++ b/apps/readest-app/src/app/user/page.tsx @@ -40,6 +40,7 @@ import UsageStats from './components/UsageStats'; import PlansComparison from './components/PlansComparison'; import AccountActions from './components/AccountActions'; import StorageManager from './components/StorageManager'; +import SharedLinksSection from './components/SharedLinksSection'; import Checkout from './components/Checkout'; type CheckoutState = { @@ -58,6 +59,7 @@ const ProfilePage = () => { const [loading, setLoading] = useState(false); const [showEmbeddedCheckout, setShowEmbeddedCheckout] = useState(false); const [showStorageManager, setShowStorageManager] = useState(false); + const [showSharedLinksManager, setShowSharedLinksManager] = useState(false); const [checkoutState, setCheckoutState] = useState({ clientSecret: '', sessionId: '', @@ -105,6 +107,8 @@ const ProfilePage = () => { } else if (showStorageManager) { setShowStorageManager(false); refresh(); + } else if (showSharedLinksManager) { + setShowSharedLinksManager(false); } else { navigateToLibrary(router); } @@ -230,6 +234,10 @@ const ProfilePage = () => { setShowStorageManager(true); }; + const handleManageSharedLinks = () => { + setShowSharedLinksManager(true); + }; + if (!mounted) { return null; } @@ -292,13 +300,17 @@ const ProfilePage = () => { planDetails={userPlanDetails} /> - {!showStorageManager && } + {!showStorageManager && !showSharedLinksManager && } {showStorageManager ? (
+ ) : showSharedLinksManager ? ( +
+ +
) : ( <>
@@ -323,6 +335,7 @@ const ProfilePage = () => { onRestorePurchase={handleIAPRestorePurchase} onManageSubscription={handleManageSubscription} onManageStorage={handleManageStorage} + onManageSharedLinks={handleManageSharedLinks} />
diff --git a/apps/readest-app/src/components/SegmentedControl.tsx b/apps/readest-app/src/components/SegmentedControl.tsx new file mode 100644 index 00000000..edc43f21 --- /dev/null +++ b/apps/readest-app/src/components/SegmentedControl.tsx @@ -0,0 +1,93 @@ +import clsx from 'clsx'; +import React from 'react'; + +export interface SegmentedControlOption { + value: T; + label: React.ReactNode; + // Optional accessible label when `label` is a non-text node (icon, badge…). + ariaLabel?: string; + // Per-option disable, in addition to the group-level `disabled` prop. + disabled?: boolean; +} + +interface SegmentedControlProps { + options: ReadonlyArray>; + value: T; + onChange: (value: T) => void; + // Group-level accessible name (rendered as `aria-label` on the wrapper). + ariaLabel?: string; + // Group-level disable. Per-option `disabled` is OR'd with this. + disabled?: boolean; + size?: 'sm' | 'md'; + // Stretch segments to fill the container; otherwise they hug their content. + fullWidth?: boolean; + className?: string; +} + +// iOS-style segmented control: a subtle track holds N equally-weighted +// segments. The active one rises on top as a filled pill with a slight +// shadow; inactive ones are flat, transparent, and slightly muted so the +// group reads as a single control rather than a row of separate buttons. +// +// Generic over the value type so callers preserve number / string / enum +// semantics: +// +// +// options={[{ value: 1, label: '1 day' }, ...]} +// value={days} +// onChange={setDays} +// /> +const SegmentedControl = ({ + options, + value, + onChange, + ariaLabel, + disabled, + size = 'sm', + fullWidth = false, + className, +}: SegmentedControlProps) => { + const sizeClasses = size === 'md' ? 'px-4 py-1.5 text-sm' : 'px-3 py-1 text-sm'; + + return ( +
+ {options.map((option) => { + const selected = option.value === value; + const optionDisabled = !!disabled || !!option.disabled; + return ( + + ); + })} +
+ ); +}; + +export default SegmentedControl; diff --git a/apps/readest-app/src/components/landing/BrandHeader.tsx b/apps/readest-app/src/components/landing/BrandHeader.tsx new file mode 100644 index 00000000..6299cd7c --- /dev/null +++ b/apps/readest-app/src/components/landing/BrandHeader.tsx @@ -0,0 +1,18 @@ +'use client'; + +import React from 'react'; +import Image from 'next/image'; + +interface BrandHeaderProps { + title: string; + subtitle?: string; + alt: string; +} + +export const BrandHeader: React.FC = ({ title, subtitle, alt }) => ( +
+ {alt} +

{title}

+ {subtitle &&

{subtitle}

} +
+); diff --git a/apps/readest-app/src/components/landing/Card.tsx b/apps/readest-app/src/components/landing/Card.tsx new file mode 100644 index 00000000..7bc51f3e --- /dev/null +++ b/apps/readest-app/src/components/landing/Card.tsx @@ -0,0 +1,12 @@ +'use client'; + +import React from 'react'; + +// Card primitive shared by /o (annotation deeplink) and /s (share link) +// landing pages. Mirrors the visual reference in src/app/o/page.tsx so the +// two surfaces feel like the same Readest product. +export const Card: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +
+ {children} +
+); diff --git a/apps/readest-app/src/components/landing/PageFooter.tsx b/apps/readest-app/src/components/landing/PageFooter.tsx new file mode 100644 index 00000000..c0886a86 --- /dev/null +++ b/apps/readest-app/src/components/landing/PageFooter.tsx @@ -0,0 +1,22 @@ +'use client'; + +import React from 'react'; + +interface PageFooterProps { + tagline: string; +} + +export const PageFooter: React.FC = ({ tagline }) => ( +

+ + Readest + + · + {tagline} +

+); diff --git a/apps/readest-app/src/hooks/useOpenShareLink.ts b/apps/readest-app/src/hooks/useOpenShareLink.ts new file mode 100644 index 00000000..909924e7 --- /dev/null +++ b/apps/readest-app/src/hooks/useOpenShareLink.ts @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { getCurrent } from '@tauri-apps/plugin-deep-link'; +import { useEnv } from '@/context/EnvContext'; +import { useLibraryStore } from '@/store/libraryStore'; +import { isTauriAppPlatform } from '@/services/environment'; +import { eventDispatcher } from '@/utils/event'; +import { useAuth } from '@/context/AuthContext'; +import { navigateToReader } from '@/utils/nav'; +import { ShareApiError, confirmDownload, importShare } from '@/libs/share'; +import { parseShareDeepLink, type ShareDeepLink } from '@/utils/share'; +import { useTranslation } from './useTranslation'; + +// Module-scoped flag matches the useOpenAnnotationLink pattern. Tauri's +// getCurrent() keeps returning the launch URL for the entire app session, so +// without this every remount would re-process the cold-start URL. +let coldStartConsumed = false; + +/** + * Receive book share deep links and import the book into the user's library. + * + * Architecture: + * - useOpenWithBooks owns the Tauri URL channels and re-broadcasts every + * URL as the 'app-incoming-url' event. This hook subscribes for warm / + * live deliveries. + * - For cold-start, getCurrent() is read once at module scope. + * - Library-load deferral: on cold-start the URL may arrive before the + * library store hydrates. Stash and replay once libraryLoaded. + * + * Supported URL shapes (see src/utils/share.ts): + * readest://share/{token} + * https://web.readest.com/s/{token} + * + * Auth-gated paths: + * - Logged-in: POST /api/share/[token]/import (server-side R2 byte-copy), + * navigate to the new fileId in the reader at the sharer's cfi. + * - Logged-out: surface a toast directing the user to the web landing + * page where they can download anonymously. We don't try to do an + * anonymous download here — the in-app library is the user's space and + * a logged-out import has nowhere to land. + */ +export function useOpenShareLink() { + const _ = useTranslation(); + const router = useRouter(); + const { appService } = useEnv(); + const { user } = useAuth(); + const libraryLoaded = useLibraryStore((s) => s.libraryLoaded); + const pending = useRef(null); + + const handleShareLink = useCallback( + async ({ token }: ShareDeepLink) => { + if (!user) { + eventDispatcher.dispatch('toast', { + type: 'info', + message: _('Sign in to import shared books'), + timeout: 2500, + }); + return; + } + try { + const result = await importShare(token); + // Best-effort analytics ping; doesn't affect UX. + confirmDownload(token); + + // The reader resolves IDs via getBookByHash, so navigate with the + // book hash (not the `files.id` UUID). + const queryParams = result.cfi ? `cfi=${encodeURIComponent(result.cfi)}` : undefined; + navigateToReader(router, [result.bookHash], queryParams); + + eventDispatcher.dispatch('toast', { + type: 'success', + message: result.alreadyOwned ? _('Already in your library') : _('Added to your library'), + timeout: 2500, + }); + } catch (err) { + const message = + err instanceof ShareApiError + ? err.message + : err instanceof Error + ? err.message + : _('Could not import shared book'); + eventDispatcher.dispatch('toast', { + type: 'error', + message, + timeout: 3000, + }); + } + }, + [_, router, user], + ); + + useEffect(() => { + if (!isTauriAppPlatform() || !appService) return; + + const handle = (url: string) => { + const parsed = parseShareDeepLink(url); + if (!parsed) return; + if (!useLibraryStore.getState().libraryLoaded) { + pending.current = parsed; + return; + } + void handleShareLink(parsed); + }; + + if (!coldStartConsumed) { + coldStartConsumed = true; + getCurrent() + .then((urls) => urls?.forEach(handle)) + .catch(() => { + // Plugin not available on this platform — live channel still works. + }); + } + + const onIncoming = (event: CustomEvent) => { + const { urls } = event.detail as { urls: string[] }; + urls.forEach(handle); + }; + eventDispatcher.on('app-incoming-url', onIncoming); + + return () => { + eventDispatcher.off('app-incoming-url', onIncoming); + }; + }, [appService, handleShareLink]); + + // Replay any deferred deep link once the library hydrates. + useEffect(() => { + if (!libraryLoaded || !pending.current) return; + const parsed = pending.current; + pending.current = null; + void handleShareLink(parsed); + }, [libraryLoaded, handleShareLink]); +} diff --git a/apps/readest-app/src/libs/share-server.ts b/apps/readest-app/src/libs/share-server.ts new file mode 100644 index 00000000..7c919613 --- /dev/null +++ b/apps/readest-app/src/libs/share-server.ts @@ -0,0 +1,156 @@ +import { customAlphabet } from 'nanoid'; +import { createSupabaseAdminClient } from '@/utils/supabase'; + +// 22-char URL-safe alphabet (alphanumeric only — no `-` or `_`). Avoids +// punctuation that some chat clients linkify oddly. +const SHARE_TOKEN_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; +const SHARE_TOKEN_LENGTH = 22; +const generator = customAlphabet(SHARE_TOKEN_ALPHABET, SHARE_TOKEN_LENGTH); + +const SHARE_TOKEN_REGEX = new RegExp(`^[${SHARE_TOKEN_ALPHABET}]{${SHARE_TOKEN_LENGTH}}$`); + +export const isValidShareToken = (token: unknown): token is string => + typeof token === 'string' && SHARE_TOKEN_REGEX.test(token); + +// Generate a fresh share token. The raw value is shown to the user once at +// create-time; only the hash is persisted to the database. A leaked DB read +// therefore cannot recover live bearer credentials. +export const generateShareToken = async (): Promise<{ raw: string; hash: string }> => { + const raw = generator(); + const hash = await hashShareToken(raw); + return { raw, hash }; +}; + +// SHA-256 of the raw token. Used at create (insert) and lookup (constant-time +// comparison via the unique index). Implemented with WebCrypto so it runs in +// both Node and edge runtimes. +export const hashShareToken = async (raw: string): Promise => { + const data = new TextEncoder().encode(raw); + const buffer = await crypto.subtle.digest('SHA-256', data); + return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, '0')).join(''); +}; + +// Reasons a share lookup may reject. +export type ShareLookupRejection = + | { kind: 'invalid_token' } + | { kind: 'not_found' } + | { kind: 'revoked' } + | { kind: 'expired' } + | { kind: 'source_deleted' } + | { kind: 'lookup_failed'; detail?: string }; + +export interface ResolvedShare { + id: string; + userId: string; + bookHash: string; + bookTitle: string; + bookAuthor: string | null; + bookFormat: string; + bookSize: number; + cfi: string | null; + expiresAt: string; + revokedAt: string | null; + downloadCount: number; + createdAt: string; + bookFileKey: string; + coverFileKey: string | null; +} + +const isCoverKey = (fileKey: string): boolean => /\.(png|jpe?g|webp|gif)$/i.test(fileKey); + +// Single source of truth for the "is this share alive and usable?" check. +// Used by the public metadata, download, cover, og.png, and import routes +// so the validation logic stays in one place. +export const resolveActiveShare = async ( + rawToken: string, +): Promise<{ ok: true; share: ResolvedShare } | { ok: false; reason: ShareLookupRejection }> => { + if (!isValidShareToken(rawToken)) { + return { ok: false, reason: { kind: 'invalid_token' } }; + } + + const supabase = createSupabaseAdminClient(); + const tokenHash = await hashShareToken(rawToken); + + const { data: row, error } = await supabase + .from('book_shares') + .select( + 'id, user_id, book_hash, book_title, book_author, book_format, book_size, cfi, expires_at, revoked_at, download_count, created_at', + ) + .eq('token_hash', tokenHash) + .maybeSingle(); + + if (error) { + return { ok: false, reason: { kind: 'lookup_failed', detail: error.message } }; + } + if (!row) { + return { ok: false, reason: { kind: 'not_found' } }; + } + if (row.revoked_at) { + return { ok: false, reason: { kind: 'revoked' } }; + } + if (new Date(row.expires_at).getTime() < Date.now()) { + return { ok: false, reason: { kind: 'expired' } }; + } + + const { data: files, error: filesError } = await supabase + .from('files') + .select('file_key') + .eq('user_id', row.user_id) + .eq('book_hash', row.book_hash) + .is('deleted_at', null); + if (filesError) { + return { ok: false, reason: { kind: 'lookup_failed', detail: filesError.message } }; + } + + const bookFile = files?.find((f) => !isCoverKey(f.file_key)); + if (!bookFile) { + return { ok: false, reason: { kind: 'source_deleted' } }; + } + const coverFile = files?.find((f) => isCoverKey(f.file_key)); + + return { + ok: true, + share: { + id: row.id, + userId: row.user_id, + bookHash: row.book_hash, + bookTitle: row.book_title, + bookAuthor: row.book_author, + bookFormat: row.book_format, + bookSize: row.book_size, + cfi: row.cfi, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + downloadCount: row.download_count, + createdAt: row.created_at, + bookFileKey: bookFile.file_key, + coverFileKey: coverFile?.file_key ?? null, + }, + }; +}; + +// Maps the rejection kinds to the standard HTTP status + code combinations +// used by every share endpoint. Centralized so the JSON error shape is +// consistent across routes. +export const rejectionToHttp = ( + reason: ShareLookupRejection, +): { status: number; body: { error: string; code?: string } } => { + switch (reason.kind) { + case 'invalid_token': + return { status: 400, body: { error: 'Invalid share token', code: 'invalid_token' } }; + case 'not_found': + return { status: 404, body: { error: 'Share not found', code: 'not_found' } }; + case 'revoked': + return { status: 410, body: { error: 'Share has been revoked', code: 'revoked' } }; + case 'expired': + return { status: 410, body: { error: 'Share has expired', code: 'expired' } }; + case 'source_deleted': + return { + status: 410, + body: { error: 'Shared book is no longer available', code: 'source_deleted' }, + }; + case 'lookup_failed': + console.error('Share lookup failed:', reason.detail); + return { status: 500, body: { error: 'Could not look up share' } }; + } +}; diff --git a/apps/readest-app/src/libs/share.ts b/apps/readest-app/src/libs/share.ts new file mode 100644 index 00000000..1d313d14 --- /dev/null +++ b/apps/readest-app/src/libs/share.ts @@ -0,0 +1,150 @@ +import { getAPIBaseUrl } from '@/services/environment'; +import { fetchWithAuth } from '@/utils/fetch'; + +const SHARE_API = getAPIBaseUrl() + '/share'; + +export interface CreateShareInput { + bookHash: string; + expirationDays: number; // must be one of [1, 3, 7] + title: string; + author?: string | null; + format: string; + // Note: `size` is intentionally not part of the input. The server reads the + // canonical size from the user's `files` row to avoid client/server drift. + cfi?: string | null; +} + +export interface CreateShareResponse { + token: string; + url: string; + expiresAt: string; +} + +export interface ShareMetadata { + title: string; + author: string | null; + format: string; + size: number; + expiresAt: string; + hasCover: boolean; + hasCfi: boolean; + downloadCount: number; + // Owner-only fields (returned only when the caller is the sharer). + token?: string; + bookHash?: string; + createdAt?: string; + revokedAt?: string | null; +} + +export interface ShareListResponse { + shares: Array< + ShareMetadata & { + token: string; + bookHash: string; + createdAt: string; + revokedAt: string | null; + } + >; + nextCursor: string | null; +} + +export interface ImportShareResponse { + fileId: string; + alreadyOwned: boolean; + bookHash: string; + cfi: string | null; +} + +export class ShareApiError extends Error { + constructor( + public readonly status: number, + public readonly code: string | undefined, + message: string, + ) { + super(message); + this.name = 'ShareApiError'; + } +} + +const parseError = async (response: Response): Promise => { + let code: string | undefined; + let message = response.statusText || 'Request failed'; + try { + const body = (await response.json()) as { error?: string; code?: string }; + if (body?.error) message = body.error; + if (body?.code) code = body.code; + } catch { + // Body wasn't JSON; keep the default message. + } + return new ShareApiError(response.status, code, message); +}; + +const jsonHeaders = { 'Content-Type': 'application/json' }; + +// Owner-only. Creates a share row for an already-uploaded book. +export const createShare = async (input: CreateShareInput): Promise => { + const response = await fetchWithAuth(`${SHARE_API}/create`, { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify(input), + }); + if (!response.ok) throw await parseError(response); + return (await response.json()) as CreateShareResponse; +}; + +// Public. Used by the landing page to render metadata. +export const getShare = async (token: string): Promise => { + const response = await fetch(`${SHARE_API}/${encodeURIComponent(token)}`, { + method: 'GET', + cache: 'no-store', + }); + if (!response.ok) throw await parseError(response); + return (await response.json()) as ShareMetadata; +}; + +// Owner-only. Revokes a share immediately. Note that already-minted presigned +// download URLs remain valid until their TTL expires (max ~5 min). +export const revokeShare = async (token: string): Promise => { + const response = await fetchWithAuth(`${SHARE_API}/${encodeURIComponent(token)}/revoke`, { + method: 'POST', + }); + if (!response.ok) throw await parseError(response); +}; + +// Owner-only. Paginated list of the caller's shares (active + expired). +export const listShares = async (cursor?: string | null): Promise => { + // SHARE_API is relative in dev (`/api/share`) and absolute in prod, so we + // can't use `new URL()` here unconditionally — relative paths throw + // "Invalid URL" without a base. Build the query string manually. + const qs = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''; + const response = await fetchWithAuth(`${SHARE_API}/list${qs}`, { method: 'GET' }); + if (!response.ok) throw await parseError(response); + return (await response.json()) as ShareListResponse; +}; + +// Recipient-side, requires auth. Adds the shared book to the caller's library +// by R2 server-side byte-copy. Idempotent: if the recipient already owns a +// non-deleted file with the same book_hash, returns alreadyOwned: true and +// the existing fileId. +export const importShare = async (token: string): Promise => { + const response = await fetchWithAuth(`${SHARE_API}/${encodeURIComponent(token)}/import`, { + method: 'POST', + }); + if (!response.ok) throw await parseError(response); + return (await response.json()) as ImportShareResponse; +}; + +// Public. Best-effort analytics ping fired by the landing page Download button +// and the in-app deeplink hook on a successful import. Failures are silent — +// the user-visible action does NOT depend on this succeeding. +export const confirmDownload = async (token: string): Promise => { + try { + await fetch(`${SHARE_API}/${encodeURIComponent(token)}/download/confirm`, { + method: 'POST', + cache: 'no-store', + keepalive: true, + }); + } catch { + // Intentionally swallowed; this is analytics, not a gate. + } +}; diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 4c80ba2e..ccf50676 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -728,6 +728,14 @@ export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web' export const READEST_WEB_BASE_URL = 'https://web.readest.com'; 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; +export const SHARE_DEFAULT_EXPIRATION_DAYS = 3; +export const SHARE_MAX_PER_USER = 50; +export const SHARE_TOKEN_LENGTH = 22; +export const SHARE_PRESIGN_TTL_SECONDS = 300; +export const SHARE_CFI_MAX_LENGTH = 512; + const LATEST_DOWNLOAD_BASE_URL = 'https://download.readest.com/releases'; export const READEST_UPDATER_FILE = `${LATEST_DOWNLOAD_BASE_URL}/latest.json`; diff --git a/apps/readest-app/src/utils/object.ts b/apps/readest-app/src/utils/object.ts index 8911b95a..f4d801e0 100644 --- a/apps/readest-app/src/utils/object.ts +++ b/apps/readest-app/src/utils/object.ts @@ -43,3 +43,39 @@ export const deleteObject = async (fileKey: string, bucketName?: string) => { return await s3Storage.deleteObject(bucketName, fileKey); } }; + +// Returns true if the object exists in storage. Used to verify uploads completed +// before treating a `files` row as shareable. +export const objectExists = async (fileKey: string, bucketName?: string): Promise => { + const storageType = getStorageType(); + try { + if (storageType === 'r2') { + bucketName = bucketName || process.env['R2_BUCKET_NAME'] || ''; + const response = await r2Storage.headObject(bucketName, fileKey); + return response.ok; + } else { + bucketName = bucketName || process.env['S3_BUCKET_NAME'] || ''; + await s3Storage.headObject(bucketName, fileKey); + return true; + } + } catch { + return false; + } +}; + +// Server-side byte copy used by /api/share/[token]/import to clone a shared +// book into the recipient's namespace without egress. +export const copyObject = async ( + sourceFileKey: string, + destFileKey: string, + bucketName?: string, +) => { + const storageType = getStorageType(); + if (storageType === 'r2') { + bucketName = bucketName || process.env['R2_BUCKET_NAME'] || ''; + return await r2Storage.copyObject(bucketName, sourceFileKey, destFileKey); + } else { + bucketName = bucketName || process.env['S3_BUCKET_NAME'] || ''; + return await s3Storage.copyObject(bucketName, sourceFileKey, destFileKey); + } +}; diff --git a/apps/readest-app/src/utils/r2.ts b/apps/readest-app/src/utils/r2.ts index 84fdb682..91249e1f 100644 --- a/apps/readest-app/src/utils/r2.ts +++ b/apps/readest-app/src/utils/r2.ts @@ -59,4 +59,38 @@ export const r2Storage = { method: 'DELETE', }); }, + + headObject: async (bucketName: string, fileKey: string) => { + const response = await r2Storage + .getR2Client() + .fetch(`${r2Storage.getR2Url()}/${bucketName}/${fileKey}`, { + method: 'HEAD', + }); + return response; + }, + + copyObject: async ( + bucketName: string, + sourceFileKey: string, + destFileKey: string, + sourceBucketName?: string, + ) => { + const srcBucket = sourceBucketName || bucketName; + // S3 / R2 require the copy-source header to be URL-encoded segment-by- + // segment. file_key is built from the original filename, so spaces and + // reserved chars (e.g. `My Book.epub`, `A&B.epub`) are common and would + // otherwise break the copy. We encode each path segment but keep the + // separating slashes literal. + const encodeKey = (key: string): string => key.split('/').map(encodeURIComponent).join('/'); + const copySource = `/${srcBucket}/${encodeKey(sourceFileKey)}`; + const response = await r2Storage + .getR2Client() + .fetch(`${r2Storage.getR2Url()}/${bucketName}/${destFileKey}`, { + method: 'PUT', + headers: { + 'x-amz-copy-source': copySource, + }, + }); + return response; + }, }; diff --git a/apps/readest-app/src/utils/s3.ts b/apps/readest-app/src/utils/s3.ts index da9f31fa..da5672ac 100644 --- a/apps/readest-app/src/utils/s3.ts +++ b/apps/readest-app/src/utils/s3.ts @@ -1,5 +1,11 @@ import { S3Client } from '@aws-sdk/client-s3'; -import { GetObjectCommand, DeleteObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { + GetObjectCommand, + DeleteObjectCommand, + PutObjectCommand, + HeadObjectCommand, + CopyObjectCommand, +} from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; const S3_ENDPOINT = process.env['S3_ENDPOINT'] || ''; @@ -71,4 +77,34 @@ export const s3Storage = { return await s3Storage.getClient().send(deleteCommand); }, + + headObject: async (bucketName: string, fileKey: string) => { + const headCommand = new HeadObjectCommand({ + Bucket: bucketName, + Key: fileKey, + }); + + return await s3Storage.getClient().send(headCommand); + }, + + copyObject: async ( + bucketName: string, + sourceFileKey: string, + destFileKey: string, + sourceBucketName?: string, + ) => { + const srcBucket = sourceBucketName || bucketName; + // S3 requires CopySource to be URL-encoded segment-by-segment. file_key + // is built from the original filename, so spaces and reserved chars + // (e.g. `My Book.epub`, `A&B.epub`) are common and would otherwise + // break the copy. + const encodeKey = (key: string): string => key.split('/').map(encodeURIComponent).join('/'); + const copyCommand = new CopyObjectCommand({ + Bucket: bucketName, + Key: destFileKey, + CopySource: `${srcBucket}/${encodeKey(sourceFileKey)}`, + }); + + return await s3Storage.getClient().send(copyCommand); + }, }; diff --git a/apps/readest-app/src/utils/share.ts b/apps/readest-app/src/utils/share.ts new file mode 100644 index 00000000..b75e9206 --- /dev/null +++ b/apps/readest-app/src/utils/share.ts @@ -0,0 +1,53 @@ +import { READEST_WEB_BASE_URL, SHARE_BASE_URL, SHARE_TOKEN_LENGTH } from '@/services/constants'; + +export interface ShareDeepLink { + token: string; + // Reserved for future query params (e.g., recipient locale, share variant). + // Currently no params are emitted, but parseShareDeepLink preserves the + // shape so callers don't need to be updated when more arrive. +} + +const TOKEN_RE = new RegExp(`^[A-Za-z0-9]{${SHARE_TOKEN_LENGTH}}$`); + +const isValidToken = (raw: unknown): raw is string => typeof raw === 'string' && TOKEN_RE.test(raw); + +// Canonical share URL embedded in the dialog, share sheet, and any "copy link" +// affordance. Always points at the public web target. +export const buildShareUrl = (token: string): string => `${SHARE_BASE_URL}/${token}`; + +// Parses both the custom-scheme and HTTPS forms used by the deeplink ingress. +// readest://share/{token} +// https://web.readest.com/s/{token} +// Returns null on invalid input so callers can fall through to other parsers. +export const parseShareDeepLink = (url: string): ShareDeepLink | null => { + if (!url) return null; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.protocol === 'readest:') { + // For readest://share/{token} the host portion holds the path segment + // before the slash. Use pathname for the token; url.host == 'share'. + if (parsed.host !== 'share') return null; + const token = parsed.pathname.replace(/^\/+/, '').replace(/\/+$/, ''); + return isValidToken(token) ? { token } : null; + } + if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { + if (!isWebReadestHost(parsed.host)) return null; + const segments = parsed.pathname.split('/').filter(Boolean); + if (segments.length !== 2 || segments[0] !== 's') return null; + const token = segments[1]!; + return isValidToken(token) ? { token } : null; + } + return null; +}; + +const isWebReadestHost = (host: string): boolean => { + // Matches the production host and any preview domain Readest may serve from. + // Conservative: accepts only the exact production host or a *.readest.com + // subdomain so a third-party site cannot impersonate a share URL. + if (host === new URL(READEST_WEB_BASE_URL).host) return true; + return host.endsWith('.readest.com'); +}; diff --git a/docker/volumes/db/migrations/002_add_book_shares.sql b/docker/volumes/db/migrations/002_add_book_shares.sql new file mode 100644 index 00000000..87f54274 --- /dev/null +++ b/docker/volumes/db/migrations/002_add_book_shares.sql @@ -0,0 +1,63 @@ +-- Migration 002: Add book_shares table for time-limited share links + +CREATE TABLE IF NOT EXISTS public.book_shares ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + -- token_hash is the lookup key (sha256(token)). It is unique-indexed so + -- public landing-page reads and downloads are O(1). The plaintext token + -- is also stored so the owner can copy links after the create dialog + -- closes. RLS prevents anyone but the owner from reading the plaintext. + -- Public endpoints look up by hash only and never select the token column. + token_hash text NOT NULL, + token text NOT NULL, + user_id uuid NOT NULL, + book_hash text NOT NULL, + book_title text NOT NULL, + book_author text NULL, + book_format text NOT NULL, + book_size bigint NOT NULL, + cfi text NULL, + expires_at timestamp with time zone NOT NULL, + revoked_at timestamp with time zone NULL, + download_count integer NOT NULL DEFAULT 0, + created_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT book_shares_pkey PRIMARY KEY (id), + CONSTRAINT book_shares_token_hash_key UNIQUE (token_hash), + CONSTRAINT book_shares_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_book_shares_user_id ON public.book_shares (user_id); +CREATE INDEX IF NOT EXISTS idx_book_shares_user_id_book_hash ON public.book_shares (user_id, book_hash); + +ALTER TABLE public.book_shares ENABLE ROW LEVEL SECURITY; + +CREATE POLICY book_shares_select ON public.book_shares + FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id); +CREATE POLICY book_shares_insert ON public.book_shares + FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id); +CREATE POLICY book_shares_update ON public.book_shares + FOR UPDATE TO authenticated USING ((SELECT auth.uid()) = user_id); +CREATE POLICY book_shares_delete ON public.book_shares + FOR DELETE TO authenticated USING ((SELECT auth.uid()) = user_id); + +-- Atomic download_count increment used by the public /download/confirm beacon. +-- Runs as SECURITY DEFINER so unauthenticated callers can bump the counter +-- (the route gate is "the share is active"; the function enforces that). +-- Only increments rows that are active right now — bypasses revoked/expired +-- so late-firing analytics pings don't pollute the count. +CREATE OR REPLACE FUNCTION public.increment_book_share_download( + p_token_hash text, + p_now timestamp with time zone +) RETURNS void +LANGUAGE sql +SECURITY DEFINER +SET search_path = public +AS $$ + UPDATE public.book_shares + SET download_count = download_count + 1 + WHERE token_hash = p_token_hash + AND revoked_at IS NULL + AND expires_at > p_now; +$$; + +GRANT EXECUTE ON FUNCTION public.increment_book_share_download(text, timestamp with time zone) + TO anon, authenticated, service_role;