From 912e97cb828df3d8a9922318f1e5f97723538d27 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 22 May 2026 21:46:13 +0800 Subject: [PATCH] feat(send): iOS share-extension picker + App Group queue + reliable host launch (#4267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(send): iOS share-extension picker + App Group queue + reliable host launch Rework the iOS Share Extension to a Zotero-style sheet: URL preview row + library group picker + "Save & Open". Queues each save into the shared App Group container and best-effort launches the host app via the Chrome-style responder-chain trick (IMP cast against `openURL:options:completionHandler:`). The host plugin drains the queue on `applicationDidBecomeActive`, so if the launch ever fails the article still ingests next time Readest is opened. A `WKScriptMessageHandler` named `readestShareBridge` lets the JS hook post `{type:'ready'}` on mount, fixing the cold-start race when the extension wakes the app before the React side has loaded. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(send): cover generator, favicon fetcher, share-extension polish, locale sync Builds on the previous commit (iOS share-extension picker + App Group queue + reliable host launch) with three further additions to the send-to-Readest pipeline: * **Cover generator** — `services/send/conversion/coverGenerator.ts` renders a deterministic cover image into clipped EPUBs (favicon + page title + host). Hooked into `buildEpub.ts` so every clip path (desktop, mobile, browser extension) produces the same cover for the same source. * **Favicon fetcher** — `services/send/conversion/faviconFetcher.ts` resolves the best-available site icon (Open Graph image → apple-touch-icon → /favicon.ico), with size + format normalization. Feeds the cover generator. * **Unified page conversion** — `convertToEpub({kind:'page', ...})` replaces the older `convertPageToEpub(html, url)` so the share extension, /send page, and browser extension share one entry point. Test: `send-convert-page-unified`. * **Share-extension project.yml comment** — clarifies why the ShareExtension target carries no `.lproj` files (system bar buttons + JS-supplied "Default" label, no per-locale strings to wire). * **Locale sync** — 33 translation.json files updated with new "Default", "Saving article…", and cover-generator strings extracted by i18next-scanner. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../public/locales/ar/translation.json | 5 +- .../public/locales/bn/translation.json | 5 +- .../public/locales/bo/translation.json | 5 +- .../public/locales/de/translation.json | 5 +- .../public/locales/el/translation.json | 5 +- .../public/locales/es/translation.json | 5 +- .../public/locales/fa/translation.json | 5 +- .../public/locales/fr/translation.json | 5 +- .../public/locales/he/translation.json | 5 +- .../public/locales/hi/translation.json | 5 +- .../public/locales/hu/translation.json | 5 +- .../public/locales/id/translation.json | 5 +- .../public/locales/it/translation.json | 5 +- .../public/locales/ja/translation.json | 5 +- .../public/locales/ko/translation.json | 5 +- .../public/locales/ms/translation.json | 5 +- .../public/locales/nl/translation.json | 5 +- .../public/locales/pl/translation.json | 5 +- .../public/locales/pt-BR/translation.json | 5 +- .../public/locales/pt/translation.json | 5 +- .../public/locales/ro/translation.json | 5 +- .../public/locales/ru/translation.json | 5 +- .../public/locales/si/translation.json | 5 +- .../public/locales/sl/translation.json | 5 +- .../public/locales/sv/translation.json | 5 +- .../public/locales/ta/translation.json | 5 +- .../public/locales/th/translation.json | 5 +- .../public/locales/tr/translation.json | 5 +- .../public/locales/uk/translation.json | 5 +- .../public/locales/uz/translation.json | 5 +- .../public/locales/vi/translation.json | 5 +- .../public/locales/zh-CN/translation.json | 5 +- .../public/locales/zh-TW/translation.json | 5 +- .../apple/Readest.xcodeproj/project.pbxproj | 10 +- .../apple/ShareExtension/AppGroupBridge.swift | 87 +++ .../ShareExtension/ShareViewController.swift | 474 +++++++++++----- .../src-tauri/gen/apple/project.yml | 5 + .../ios/Sources/AppGroupBridge.swift | 74 +++ .../ios/Sources/NativeBridgePlugin.swift | 102 ++++ .../services/send-build-epub-cover.test.ts | 75 +++ .../send-convert-page-unified.test.ts | 130 +++++ .../services/send-cover-generator.test.ts | 230 ++++++++ .../src/hooks/useClipUrlIngress.ts | 109 +++- .../src/services/send/conversion/buildEpub.ts | 28 +- .../services/send/conversion/convertToEpub.ts | 356 ++++++++---- .../send/conversion/coverGenerator.ts | 505 ++++++++++++++++++ .../send/conversion/faviconFetcher.ts | 197 +++++++ .../src/services/send/conversion/siteRules.ts | 8 + 48 files changed, 2283 insertions(+), 272 deletions(-) create mode 100644 apps/readest-app/src-tauri/gen/apple/ShareExtension/AppGroupBridge.swift create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/AppGroupBridge.swift create mode 100644 apps/readest-app/src/__tests__/services/send-build-epub-cover.test.ts create mode 100644 apps/readest-app/src/__tests__/services/send-convert-page-unified.test.ts create mode 100644 apps/readest-app/src/__tests__/services/send-cover-generator.test.ts create mode 100644 apps/readest-app/src/services/send/conversion/coverGenerator.ts create mode 100644 apps/readest-app/src/services/send/conversion/faviconFetcher.ts diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index ddda838a..34c347e6 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1633,5 +1633,8 @@ "Saving to Readest": "جارٍ الحفظ في Readest", "Loading article…": "جارٍ تحميل المقالة…", "Capturing article…": "جارٍ التقاط المقالة…", - "Saved to Readest": "تم الحفظ في Readest" + "Saved to Readest": "تم الحفظ في Readest", + "Apply to every occurrence in the book": "تطبيق على كل تواجد في الكتاب", + "Saving article from share…": "جارٍ حفظ المقالة من المشاركة…", + "Saved “{{title}}” to your library.": "تم حفظ \"{{title}}\" في مكتبتك." } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index b16042e3..c3312a06 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1521,5 +1521,8 @@ "Saving to Readest": "Readest-এ সংরক্ষণ করা হচ্ছে", "Loading article…": "নিবন্ধ লোড হচ্ছে…", "Capturing article…": "নিবন্ধ ক্যাপচার করা হচ্ছে…", - "Saved to Readest": "Readest-এ সংরক্ষিত" + "Saved to Readest": "Readest-এ সংরক্ষিত", + "Apply to every occurrence in the book": "বইয়ের প্রতিটি উপস্থিতিতে প্রয়োগ করুন", + "Saving article from share…": "শেয়ার থেকে নিবন্ধ সংরক্ষণ করা হচ্ছে…", + "Saved “{{title}}” to your library.": "\"{{title}}\" আপনার লাইব্রেরিতে সংরক্ষিত হয়েছে।" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 3f17b08d..4266b0fe 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1493,5 +1493,8 @@ "Saving to Readest": "Readest དུ་ཉར་བཞིན་པ་", "Loading article…": "རྩོམ་ཡིག་སྦྱར་བཞིན་པ་…", "Capturing article…": "རྩོམ་ཡིག་འཛིན་བཞིན་པ་…", - "Saved to Readest": "Readest དུ་ཉར་ཟིན།" + "Saved to Readest": "Readest དུ་ཉར་ཟིན།", + "Apply to every occurrence in the book": "དེབ་ནང་གི་སྐབས་ཐམས་ཅད་ལ་སྦྱར།", + "Saving article from share…": "མཉམ་སྤྱོད་ནས་ཡི་གེ་ཉར་ཚགས་བྱེད་བཞིན་པ…", + "Saved “{{title}}” to your library.": "\"{{title}}\" ཁྱེད་ཀྱི་དཔེ་མཛོད་དུ་ཉར་ཚགས་བྱས་ཟིན།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 516be18b..31012670 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1521,5 +1521,8 @@ "Saving to Readest": "Wird in Readest gespeichert", "Loading article…": "Artikel wird geladen…", "Capturing article…": "Artikel wird erfasst…", - "Saved to Readest": "In Readest gespeichert" + "Saved to Readest": "In Readest gespeichert", + "Apply to every occurrence in the book": "Auf jedes Vorkommen im Buch anwenden", + "Saving article from share…": "Geteilten Artikel wird gespeichert…", + "Saved “{{title}}” to your library.": "„{{title}}“ wurde in Ihrer Bibliothek gespeichert." } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index aae1f6fa..e7933507 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1521,5 +1521,8 @@ "Saving to Readest": "Αποθήκευση στο Readest", "Loading article…": "Φόρτωση άρθρου…", "Capturing article…": "Λήψη άρθρου…", - "Saved to Readest": "Αποθηκεύτηκε στο Readest" + "Saved to Readest": "Αποθηκεύτηκε στο Readest", + "Apply to every occurrence in the book": "Εφαρμογή σε κάθε εμφάνιση στο βιβλίο", + "Saving article from share…": "Αποθήκευση άρθρου από κοινοποίηση…", + "Saved “{{title}}” to your library.": "Το «{{title}}» αποθηκεύτηκε στη βιβλιοθήκη σας." } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index c85e592b..3fc4086a 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1549,5 +1549,8 @@ "Saving to Readest": "Guardando en Readest", "Loading article…": "Cargando artículo…", "Capturing article…": "Capturando artículo…", - "Saved to Readest": "Guardado en Readest" + "Saved to Readest": "Guardado en Readest", + "Apply to every occurrence in the book": "Aplicar a cada aparición en el libro", + "Saving article from share…": "Guardando artículo compartido…", + "Saved “{{title}}” to your library.": "«{{title}}» guardado en tu biblioteca." } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index ee92d5e2..dda0da93 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1521,5 +1521,8 @@ "Saving to Readest": "در حال ذخیره در Readest", "Loading article…": "در حال بارگذاری مقاله…", "Capturing article…": "در حال دریافت مقاله…", - "Saved to Readest": "در Readest ذخیره شد" + "Saved to Readest": "در Readest ذخیره شد", + "Apply to every occurrence in the book": "اعمال در همه موارد در کتاب", + "Saving article from share…": "در حال ذخیره مقاله از اشتراک‌گذاری…", + "Saved “{{title}}” to your library.": "«{{title}}» در کتابخانه شما ذخیره شد." } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index c24344cf..c6103c68 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1549,5 +1549,8 @@ "Saving to Readest": "Enregistrement dans Readest", "Loading article…": "Chargement de l'article…", "Capturing article…": "Capture de l'article…", - "Saved to Readest": "Enregistré dans Readest" + "Saved to Readest": "Enregistré dans Readest", + "Apply to every occurrence in the book": "Appliquer à chaque occurrence dans le livre", + "Saving article from share…": "Enregistrement de l’article partagé…", + "Saved “{{title}}” to your library.": "« {{title}} » a été enregistré dans votre bibliothèque." } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index 53ddaf8b..41beb4a2 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1549,5 +1549,8 @@ "Saving to Readest": "שומר ב-Readest", "Loading article…": "טוען מאמר…", "Capturing article…": "לוכד מאמר…", - "Saved to Readest": "נשמר ב-Readest" + "Saved to Readest": "נשמר ב-Readest", + "Apply to every occurrence in the book": "החל על כל מופע בספר", + "Saving article from share…": "שומר מאמר משיתוף…", + "Saved “{{title}}” to your library.": "\"{{title}}\" נשמר לספרייה שלך." } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 941c3430..8a99a3c5 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1521,5 +1521,8 @@ "Saving to Readest": "Readest में सहेजा जा रहा है", "Loading article…": "लेख लोड हो रहा है…", "Capturing article…": "लेख कैप्चर हो रहा है…", - "Saved to Readest": "Readest में सहेजा गया" + "Saved to Readest": "Readest में सहेजा गया", + "Apply to every occurrence in the book": "किताब में हर बार लागू करें", + "Saving article from share…": "साझा किया गया लेख सहेजा जा रहा है…", + "Saved “{{title}}” to your library.": "\"{{title}}\" आपकी लाइब्रेरी में सहेजा गया।" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index f2dcad7a..a9f73022 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -1521,5 +1521,8 @@ "Saving to Readest": "Mentés a Readestbe", "Loading article…": "Cikk betöltése…", "Capturing article…": "Cikk rögzítése…", - "Saved to Readest": "Mentve a Readestbe" + "Saved to Readest": "Mentve a Readestbe", + "Apply to every occurrence in the book": "Alkalmazás a könyv minden előfordulására", + "Saving article from share…": "Megosztott cikk mentése…", + "Saved “{{title}}” to your library.": "A(z) „{{title}}” mentve a könyvtáradba." } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 0c5e2d67..b5cc2ed3 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1493,5 +1493,8 @@ "Saving to Readest": "Menyimpan ke Readest", "Loading article…": "Memuat artikel…", "Capturing article…": "Menangkap artikel…", - "Saved to Readest": "Tersimpan di Readest" + "Saved to Readest": "Tersimpan di Readest", + "Apply to every occurrence in the book": "Terapkan ke setiap kemunculan di buku", + "Saving article from share…": "Menyimpan artikel dari berbagi…", + "Saved “{{title}}” to your library.": "\"{{title}}\" disimpan ke perpustakaan Anda." } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 524faef4..4ab73085 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1549,5 +1549,8 @@ "Saving to Readest": "Salvataggio in Readest", "Loading article…": "Caricamento dell'articolo…", "Capturing article…": "Acquisizione dell'articolo…", - "Saved to Readest": "Salvato in Readest" + "Saved to Readest": "Salvato in Readest", + "Apply to every occurrence in the book": "Applica a ogni occorrenza nel libro", + "Saving article from share…": "Salvataggio dell’articolo condiviso…", + "Saved “{{title}}” to your library.": "«{{title}}» salvato nella tua libreria." } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index ef8cbc6a..b9d2499e 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1493,5 +1493,8 @@ "Saving to Readest": "Readest に保存中", "Loading article…": "記事を読み込み中…", "Capturing article…": "記事を取得中…", - "Saved to Readest": "Readest に保存しました" + "Saved to Readest": "Readest に保存しました", + "Apply to every occurrence in the book": "本の中のすべての出現箇所に適用", + "Saving article from share…": "共有された記事を保存中…", + "Saved “{{title}}” to your library.": "「{{title}}」をライブラリに保存しました。" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index c27f3654..3b4e53cf 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1493,5 +1493,8 @@ "Saving to Readest": "Readest에 저장 중", "Loading article…": "기사 로드 중…", "Capturing article…": "기사 캡처 중…", - "Saved to Readest": "Readest에 저장됨" + "Saved to Readest": "Readest에 저장됨", + "Apply to every occurrence in the book": "책의 모든 발생 위치에 적용", + "Saving article from share…": "공유된 기사 저장 중…", + "Saved “{{title}}” to your library.": "\"{{title}}\"을(를) 라이브러리에 저장했습니다." } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index d04e67a3..86dc7584 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1493,5 +1493,8 @@ "Saving to Readest": "Menyimpan ke Readest", "Loading article…": "Memuatkan artikel…", "Capturing article…": "Menangkap artikel…", - "Saved to Readest": "Disimpan ke Readest" + "Saved to Readest": "Disimpan ke Readest", + "Apply to every occurrence in the book": "Gunakan pada setiap kemunculan dalam buku", + "Saving article from share…": "Menyimpan artikel daripada perkongsian…", + "Saved “{{title}}” to your library.": "\"{{title}}\" disimpan ke perpustakaan anda." } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 42ad16b5..4c33051c 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1521,5 +1521,8 @@ "Saving to Readest": "Opslaan in Readest", "Loading article…": "Artikel laden…", "Capturing article…": "Artikel vastleggen…", - "Saved to Readest": "Opgeslagen in Readest" + "Saved to Readest": "Opgeslagen in Readest", + "Apply to every occurrence in the book": "Toepassen op elk voorkomen in het boek", + "Saving article from share…": "Gedeeld artikel opslaan…", + "Saved “{{title}}” to your library.": "“{{title}}” is opgeslagen in je bibliotheek." } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 59db2817..6602b2ee 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1577,5 +1577,8 @@ "Saving to Readest": "Zapisywanie w Readest", "Loading article…": "Ładowanie artykułu…", "Capturing article…": "Pobieranie artykułu…", - "Saved to Readest": "Zapisano w Readest" + "Saved to Readest": "Zapisano w Readest", + "Apply to every occurrence in the book": "Zastosuj do każdego wystąpienia w książce", + "Saving article from share…": "Zapisywanie udostępnionego artykułu…", + "Saved “{{title}}” to your library.": "Zapisano „{{title}}” w Twojej bibliotece." } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index fc4e5486..892c9241 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -1549,5 +1549,8 @@ "Saving to Readest": "Salvando no Readest", "Loading article…": "Carregando artigo…", "Capturing article…": "Capturando artigo…", - "Saved to Readest": "Salvo no Readest" + "Saved to Readest": "Salvo no Readest", + "Apply to every occurrence in the book": "Aplicar a todas as ocorrências no livro", + "Saving article from share…": "Salvando artigo compartilhado…", + "Saved “{{title}}” to your library.": "\"{{title}}\" salvo em sua biblioteca." } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 6df2a67b..b39f0151 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1549,5 +1549,8 @@ "Saving to Readest": "A guardar no Readest", "Loading article…": "A carregar o artigo…", "Capturing article…": "A capturar o artigo…", - "Saved to Readest": "Guardado no Readest" + "Saved to Readest": "Guardado no Readest", + "Apply to every occurrence in the book": "Aplicar a todas as ocorrências no livro", + "Saving article from share…": "A guardar artigo partilhado…", + "Saved “{{title}}” to your library.": "«{{title}}» guardado na sua biblioteca." } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index 72c96989..3fff1c1a 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -1549,5 +1549,8 @@ "Saving to Readest": "Se salvează în Readest", "Loading article…": "Se încarcă articolul…", "Capturing article…": "Se capturează articolul…", - "Saved to Readest": "Salvat în Readest" + "Saved to Readest": "Salvat în Readest", + "Apply to every occurrence in the book": "Aplică la fiecare apariție din carte", + "Saving article from share…": "Se salvează articolul partajat…", + "Saved “{{title}}” to your library.": "„{{title}}” a fost salvat în biblioteca ta." } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 3facd708..d932c9c6 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1577,5 +1577,8 @@ "Saving to Readest": "Сохранение в Readest", "Loading article…": "Загрузка статьи…", "Capturing article…": "Захват статьи…", - "Saved to Readest": "Сохранено в Readest" + "Saved to Readest": "Сохранено в Readest", + "Apply to every occurrence in the book": "Применить ко всем вхождениям в книге", + "Saving article from share…": "Сохранение статьи из общего доступа…", + "Saved “{{title}}” to your library.": "«{{title}}» сохранено в вашу библиотеку." } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 555cfc69..c79c2f72 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1521,5 +1521,8 @@ "Saving to Readest": "Readest වෙත සුරකියි", "Loading article…": "ලිපිය පූරණය වෙමින්…", "Capturing article…": "ලිපිය ලබා ගනියි…", - "Saved to Readest": "Readest හි සුරැකිණි" + "Saved to Readest": "Readest හි සුරැකිණි", + "Apply to every occurrence in the book": "පොතේ සෑම සිදුවීමකටම යොදන්න", + "Saving article from share…": "බෙදාගැනීමෙන් ලිපිය සුරකිමින්…", + "Saved “{{title}}” to your library.": "\"{{title}}\" ඔබේ පුස්තකාලයට සුරැකිණි." } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index 561c8293..d3583520 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -1577,5 +1577,8 @@ "Saving to Readest": "Shranjevanje v Readest", "Loading article…": "Nalaganje članka…", "Capturing article…": "Zajemanje članka…", - "Saved to Readest": "Shranjeno v Readest" + "Saved to Readest": "Shranjeno v Readest", + "Apply to every occurrence in the book": "Uporabi pri vseh pojavitvah v knjigi", + "Saving article from share…": "Shranjevanje članka iz skupne rabe…", + "Saved “{{title}}” to your library.": "»{{title}}« je shranjen v vašo knjižnico." } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 804cb082..fbf64ded 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1521,5 +1521,8 @@ "Saving to Readest": "Sparar till Readest", "Loading article…": "Laddar artikel…", "Capturing article…": "Fångar artikel…", - "Saved to Readest": "Sparad till Readest" + "Saved to Readest": "Sparad till Readest", + "Apply to every occurrence in the book": "Tillämpa på varje förekomst i boken", + "Saving article from share…": "Sparar delad artikel…", + "Saved “{{title}}” to your library.": "”{{title}}” har sparats i ditt bibliotek." } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 66c13a80..0ec50cb7 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1521,5 +1521,8 @@ "Saving to Readest": "Readest-இல் சேமிக்கப்படுகிறது", "Loading article…": "கட்டுரை ஏற்றப்படுகிறது…", "Capturing article…": "கட்டுரை பெறப்படுகிறது…", - "Saved to Readest": "Readest-இல் சேமிக்கப்பட்டது" + "Saved to Readest": "Readest-இல் சேமிக்கப்பட்டது", + "Apply to every occurrence in the book": "புத்தகத்தில் ஒவ்வொரு நிகழ்வுக்கும் பயன்படுத்து", + "Saving article from share…": "பகிர்விலிருந்து கட்டுரையை சேமிக்கிறது…", + "Saved “{{title}}” to your library.": "\"{{title}}\" உங்கள் நூலகத்தில் சேமிக்கப்பட்டது." } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index dab019ac..17f2b9d3 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1493,5 +1493,8 @@ "Saving to Readest": "กำลังบันทึกลงใน Readest", "Loading article…": "กำลังโหลดบทความ…", "Capturing article…": "กำลังจับภาพบทความ…", - "Saved to Readest": "บันทึกลงใน Readest แล้ว" + "Saved to Readest": "บันทึกลงใน Readest แล้ว", + "Apply to every occurrence in the book": "ใช้กับทุกครั้งที่ปรากฏในหนังสือ", + "Saving article from share…": "กำลังบันทึกบทความจากการแชร์…", + "Saved “{{title}}” to your library.": "บันทึก \"{{title}}\" ลงในคลังของคุณแล้ว" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index ea003af4..a6e9cf0d 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1521,5 +1521,8 @@ "Saving to Readest": "Readest'e kaydediliyor", "Loading article…": "Makale yükleniyor…", "Capturing article…": "Makale yakalanıyor…", - "Saved to Readest": "Readest'e kaydedildi" + "Saved to Readest": "Readest'e kaydedildi", + "Apply to every occurrence in the book": "Kitaptaki her geçişe uygula", + "Saving article from share…": "Paylaşılan makale kaydediliyor…", + "Saved “{{title}}” to your library.": "“{{title}}” kitaplığınıza kaydedildi." } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 61d00e5f..3a29c031 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1577,5 +1577,8 @@ "Saving to Readest": "Збереження в Readest", "Loading article…": "Завантаження статті…", "Capturing article…": "Захоплення статті…", - "Saved to Readest": "Збережено в Readest" + "Saved to Readest": "Збережено в Readest", + "Apply to every occurrence in the book": "Застосувати до кожного входження в книзі", + "Saving article from share…": "Збереження поширеної статті…", + "Saved “{{title}}” to your library.": "«{{title}}» збережено у вашій бібліотеці." } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index 7a5f623b..8371662c 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -1521,5 +1521,8 @@ "Saving to Readest": "Readest'ga saqlanmoqda", "Loading article…": "Maqola yuklanmoqda…", "Capturing article…": "Maqola olinmoqda…", - "Saved to Readest": "Readest'ga saqlandi" + "Saved to Readest": "Readest'ga saqlandi", + "Apply to every occurrence in the book": "Kitobdagi har bir hodisaga qoʻllang", + "Saving article from share…": "Ulashilgan maqola saqlanmoqda…", + "Saved “{{title}}” to your library.": "\"{{title}}\" kutubxonangizga saqlandi." } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 0fb220b2..d60e37d2 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1493,5 +1493,8 @@ "Saving to Readest": "Đang lưu vào Readest", "Loading article…": "Đang tải bài viết…", "Capturing article…": "Đang chụp bài viết…", - "Saved to Readest": "Đã lưu vào Readest" + "Saved to Readest": "Đã lưu vào Readest", + "Apply to every occurrence in the book": "Áp dụng cho mọi lần xuất hiện trong sách", + "Saving article from share…": "Đang lưu bài viết được chia sẻ…", + "Saved “{{title}}” to your library.": "Đã lưu \"{{title}}\" vào thư viện của bạn." } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 54799128..52b62c06 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1493,5 +1493,8 @@ "Saving to Readest": "正在保存到 Readest", "Loading article…": "正在加载文章…", "Capturing article…": "正在抓取文章…", - "Saved to Readest": "已保存到 Readest" + "Saved to Readest": "已保存到 Readest", + "Apply to every occurrence in the book": "应用到本书中所有匹配位置", + "Saving article from share…": "正在保存分享的文章…", + "Saved “{{title}}” to your library.": "已将\"{{title}}\"保存到你的书库。" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 9805de8f..1f7f7288 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1493,5 +1493,8 @@ "Saving to Readest": "正在儲存到 Readest", "Loading article…": "正在載入文章…", "Capturing article…": "正在擷取文章…", - "Saved to Readest": "已儲存到 Readest" + "Saved to Readest": "已儲存到 Readest", + "Apply to every occurrence in the book": "套用至本書中所有出現的位置", + "Saving article from share…": "正在儲存分享的文章…", + "Saved “{{title}}” to your library.": "已將「{{title}}」儲存至您的書庫。" } diff --git a/apps/readest-app/src-tauri/gen/apple/Readest.xcodeproj/project.pbxproj b/apps/readest-app/src-tauri/gen/apple/Readest.xcodeproj/project.pbxproj index c9e2eb58..4120ef90 100644 --- a/apps/readest-app/src-tauri/gen/apple/Readest.xcodeproj/project.pbxproj +++ b/apps/readest-app/src-tauri/gen/apple/Readest.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ BA0A7C5B168460D96FA21D6B /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB595336F6275E2B879AF397 /* ShareViewController.swift */; }; D608DE39BD2F9FA892508F37 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4B62E5ED5797D06981F30B6F /* UIKit.framework */; }; E6D9510F89AD18B18D0FCBC9 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E5F4A6E57CB4895FFFBC2DE8 /* ShareExtension.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + E853ADD6F720091D24E184FD /* AppGroupBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1EACF94D1026C7A51CD0A63 /* AppGroupBridge.swift */; }; F6003A5A70DA4C33BDBD838B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 30D8E7F6023C1DF41F8BD47C /* LaunchScreen.storyboard */; }; FB2C2290EAFB8BA38C973E4B /* assets in Resources */ = {isa = PBXBuildFile; fileRef = 40409827389F82307433878B /* assets */; }; /* End PBXBuildFile section */ @@ -55,7 +56,6 @@ 1A5492F8B4749D670A93DE66 /* clip_url.rs */ = {isa = PBXFileReference; path = clip_url.rs; sourceTree = ""; }; 1C29EFE86C85351F81C99F2A /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; 1CF94262BF7499D2AD8B627D /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = ""; }; - 1F45B5B458F63D98B6113A19 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 27B7FEAE989ABFF6C8FF0F77 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 282EE616A1F2B81C6121C9B6 /* transfer_file.rs */ = {isa = PBXFileReference; path = transfer_file.rs; sourceTree = ""; }; 2E226C87AD0606FFF9DDEE11 /* dir_scanner.rs */ = {isa = PBXFileReference; path = dir_scanner.rs; sourceTree = ""; }; @@ -77,6 +77,7 @@ A7318D4877A991A1DAB79861 /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = ""; }; A77628B94260724841CB0C1F /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; B16A12C78C47C4990EB4A028 /* apple_auth.rs */ = {isa = PBXFileReference; path = apple_auth.rs; sourceTree = ""; }; + C1EACF94D1026C7A51CD0A63 /* AppGroupBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupBridge.swift; sourceTree = ""; }; CB595336F6275E2B879AF397 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; D3F4A4DF56E899C9DB0B7D60 /* eink.rs */ = {isa = PBXFileReference; path = eink.rs; sourceTree = ""; }; DAC68A4107C30410409E5506 /* Readest_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Readest_iOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -186,6 +187,7 @@ A350AB745ED478D99A088984 /* ShareExtension */ = { isa = PBXGroup; children = ( + C1EACF94D1026C7A51CD0A63 /* AppGroupBridge.swift */, 27B7FEAE989ABFF6C8FF0F77 /* Info.plist */, 4924DDD171577C52C0C5809E /* ShareExtension.entitlements */, CB595336F6275E2B879AF397 /* ShareViewController.swift */, @@ -196,7 +198,6 @@ ADF310BD2F14DA462EF79E53 /* Readest_iOS */ = { isa = PBXGroup; children = ( - 1F45B5B458F63D98B6113A19 /* Info.plist */, 7DADD61846736B0E638BF012 /* Readest_iOS.entitlements */, ); path = Readest_iOS; @@ -242,14 +243,14 @@ path = ../../src; sourceTree = ""; }; - "TEMP_9CD1A90B-7A94-4ADB-B0C8-40427CCB2DCF" /* arm64 */ = { + "TEMP_4DCF4303-D43B-49A2-841D-FA7AC0DF4CA6" /* arm64 */ = { isa = PBXGroup; children = ( ); path = arm64; sourceTree = ""; }; - "TEMP_BBED8FED-FDB6-46D2-A460-69DB6036C32D" /* x86_64 */ = { + "TEMP_E02BFCED-1A1D-42A7-9629-AB8D373B6ACC" /* x86_64 */ = { isa = PBXGroup; children = ( ); @@ -385,6 +386,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E853ADD6F720091D24E184FD /* AppGroupBridge.swift in Sources */, BA0A7C5B168460D96FA21D6B /* ShareViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/apps/readest-app/src-tauri/gen/apple/ShareExtension/AppGroupBridge.swift b/apps/readest-app/src-tauri/gen/apple/ShareExtension/AppGroupBridge.swift new file mode 100644 index 00000000..8d2ec06e --- /dev/null +++ b/apps/readest-app/src-tauri/gen/apple/ShareExtension/AppGroupBridge.swift @@ -0,0 +1,87 @@ +// Shared App Group container schema between the Readest Share Extension and +// the host app. Keep this file in sync with the mirror at +// `src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/AppGroupBridge.swift`. +// Two NSUserDefaults keys form the contract: +// +// shareExtensionGroups (host → extension) +// JSON array of { id: String, name: String }. Library groups the user +// can pick when saving. Refreshed by the host every time it foregrounds. +// +// shareExtensionDefaultGroupName (host → extension) +// User-locale-translated label for the "no group" row at the top of +// the picker. JS supplies `t('Default')` so the extension doesn't +// need its own per-locale strings file. +// +// shareExtensionPendingSaves (extension → host) +// JSON array of { url, groupId?, groupName?, addedAt } (ISO-8601 string). +// The extension appends here on every Save. The host drains + clears on +// foreground and feeds each entry through the same clip-and-import path +// the in-app "From Web URL" entry uses. + +import Foundation + +enum AppGroupBridge { + static let suiteName = "group.com.bilingify.readest" + static let groupsKey = "shareExtensionGroups" + static let defaultGroupNameKey = "shareExtensionDefaultGroupName" + static let pendingSavesKey = "shareExtensionPendingSaves" + + static var defaults: UserDefaults? { + UserDefaults(suiteName: suiteName) + } + + struct LibraryGroup: Codable, Equatable { + let id: String + let name: String + } + + struct PendingSave: Codable, Equatable { + let url: String + let groupId: String? + let groupName: String? + let addedAt: String + } + + static func readGroups() -> [LibraryGroup] { + guard let data = defaults?.data(forKey: groupsKey) else { return [] } + return (try? JSONDecoder().decode([LibraryGroup].self, from: data)) ?? [] + } + + static func writeGroups(_ groups: [LibraryGroup]) { + guard let data = try? JSONEncoder().encode(groups) else { return } + defaults?.set(data, forKey: groupsKey) + } + + static func readDefaultGroupName() -> String? { + defaults?.string(forKey: defaultGroupNameKey) + } + + static func writeDefaultGroupName(_ name: String) { + defaults?.set(name, forKey: defaultGroupNameKey) + } + + static func readPendingSaves() -> [PendingSave] { + guard let data = defaults?.data(forKey: pendingSavesKey) else { return [] } + return (try? JSONDecoder().decode([PendingSave].self, from: data)) ?? [] + } + + static func appendPendingSave(_ save: PendingSave) { + var saves = readPendingSaves() + saves.append(save) + if let data = try? JSONEncoder().encode(saves) { + defaults?.set(data, forKey: pendingSavesKey) + } + } + + static func clearPendingSaves() { + defaults?.removeObject(forKey: pendingSavesKey) + } + + // ISO-8601 with fractional seconds — round-trips cleanly through + // JavaScript's Date constructor on the JS side. + static func nowIso8601() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: Date()) + } +} diff --git a/apps/readest-app/src-tauri/gen/apple/ShareExtension/ShareViewController.swift b/apps/readest-app/src-tauri/gen/apple/ShareExtension/ShareViewController.swift index 50461395..47fa412e 100644 --- a/apps/readest-app/src-tauri/gen/apple/ShareExtension/ShareViewController.swift +++ b/apps/readest-app/src-tauri/gen/apple/ShareExtension/ShareViewController.swift @@ -1,11 +1,35 @@ // Share Extension for Readest: catches an article URL from any iOS share -// sheet (Safari, Chrome, third-party browsers) and forwards it to the -// main app via the existing `readest://` URL scheme. The main app's -// `tauri-plugin-deep-link` integration emits an `onOpenUrl` event, -// `useAppUrlIngress` re-broadcasts it as `app-incoming-url`, and -// `useClipUrlIngress` clips + ingests the article through the same -// pipeline the in-app "From Web URL" entry uses. +// sheet (Safari, Chrome, third-party browsers), shows a small sheet UI +// that lets the user pick a target library group, then queues the save +// into the App Group container and best-effort launches Readest. +// +// Two delivery paths to the host app, in order of preference: +// +// 1. App Group queue + responder-chain launch. +// `AppGroupBridge.appendPendingSave` writes the URL + chosen group +// to the shared NSUserDefaults at `group.com.bilingify.readest`. +// We then walk the UIResponder chain looking for an object that +// responds to `openURL:options:completionHandler:` (UIApplication) +// and dispatch via an objc-runtime IMP cast. This is the pattern +// Chrome iOS ships in `ios/chrome/common/extension_open_url.mm` +// (using NSInvocation there; we use the equivalent IMP-cast trick +// since pure Swift can't see NSInvocation). Continues to work on +// iOS 26 — the deprecated `openURL:` selector is what breaks +// ("BUG IN CLIENT OF UIKIT" + no-op). The modern 3-arg selector is +// not directly visible to extensions but the responder chain still +// hands UIApplication over for runtime dispatch. +// +// 2. App Group queue as standalone fallback. +// If the launch trick is ever blocked by Apple, the save still +// sits in the queue. The host's `NativeBridgePlugin` drains it on +// `applicationDidBecomeActive` so the next time the user opens +// Readest manually, the article is ingested. +// +// `extensionContext.open(_:)` is intentionally not used — Apple docs +// scope it to Today widgets only and it returns success=false from +// Share Extensions on modern iOS regardless of URL scheme. +import ObjectiveC import UIKit import UniformTypeIdentifiers @@ -17,50 +41,101 @@ final class ShareViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() NSLog("[ReadestShare] viewDidLoad") - // Kick the work as early as possible — iOS 26 sometimes dismisses - // the extension before `viewDidAppear` fires when the activation - // rule matches a single URL exactly. Running from `viewDidLoad` - // gives us the longest possible window. - Task { await processInput() } + view.backgroundColor = .clear + Task { await self.loadAndPresent() } } - /// Walk the inputItems for a URL or text payload and forward it. - /// Cancels the extension if no URL was found. - private func processInput() async { - NSLog("[ReadestShare] processInput started") + // MARK: - Input handling + + private func loadAndPresent() async { guard let context = extensionContext else { - NSLog("[ReadestShare] no extensionContext, bailing") + NSLog("[ReadestShare] no extensionContext") return } - let items = (context.inputItems.compactMap { $0 as? NSExtensionItem }) + let items = context.inputItems.compactMap { $0 as? NSExtensionItem } NSLog("[ReadestShare] inputItems count=\(items.count)") let url = await firstShareableURL(from: items) - if let url = url { - NSLog("[ReadestShare] found URL: \(url.absoluteString)") - await openInMainApp(url: url) - } else { - NSLog("[ReadestShare] no URL found in any inputItem") - } + let pageTitle = + items + .compactMap { $0.attributedTitle?.string ?? $0.attributedContentText?.string } + .first { !$0.isEmpty } + await MainActor.run { - if !self.didCompleteOnce { - self.didCompleteOnce = true - NSLog("[ReadestShare] completing extension request") - context.completeRequest(returningItems: [], completionHandler: nil) + guard let url = url else { + NSLog("[ReadestShare] no URL found, cancelling") + self.cancelRequest() + return } + NSLog("[ReadestShare] presenting picker for URL: %@", url.absoluteString) + self.presentPicker(url: url, pageTitle: pageTitle) } } - /// Probe attachments for the first usable URL. Prefers a real - /// `public.url` attachment; falls back to scanning a `public.plain-text` - /// payload for an http(s) substring (some apps share "Title\nURL"). + private func presentPicker(url: URL, pageTitle: String?) { + let groups = AppGroupBridge.readGroups() + let options = SaveOptionsViewController( + url: url, + pageTitle: pageTitle, + groups: groups, + onCancel: { [weak self] in + self?.cancelRequest() + }, + onSave: { [weak self] selectedGroup in + self?.handleSave(url: url, group: selectedGroup) + } + ) + let nav = UINavigationController(rootViewController: options) + nav.view.translatesAutoresizingMaskIntoConstraints = false + addChild(nav) + view.addSubview(nav.view) + NSLayoutConstraint.activate([ + nav.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + nav.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + nav.view.topAnchor.constraint(equalTo: view.topAnchor), + nav.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + nav.didMove(toParent: self) + } + + // MARK: - Save / Cancel + + private func handleSave(url: URL, group: AppGroupBridge.LibraryGroup?) { + let save = AppGroupBridge.PendingSave( + url: url.absoluteString, + groupId: group?.id, + groupName: group?.name, + addedAt: AppGroupBridge.nowIso8601() + ) + AppGroupBridge.appendPendingSave(save) + NSLog("[ReadestShare] queued save for %@ group=%@", url.absoluteString, group?.name ?? "") + + if let target = buildTargetURL(scheme: "readest", host: "clip", inner: url) { + let opened = openViaResponderChain(target) + NSLog("[ReadestShare] responder-chain launch=%@", opened ? "yes" : "no") + } + completeOnce() + } + + private func cancelRequest() { + guard !didCompleteOnce else { return } + didCompleteOnce = true + let err = NSError(domain: "ReadestShare", code: NSUserCancelledError, userInfo: nil) + extensionContext?.cancelRequest(withError: err) + } + + private func completeOnce() { + guard !didCompleteOnce else { return } + didCompleteOnce = true + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + } + + // MARK: - URL extraction + private func firstShareableURL(from items: [NSExtensionItem]) async -> URL? { - for (itemIdx, item) in items.enumerated() { + for item in items { guard let attachments = item.attachments else { continue } - NSLog("[ReadestShare] item[\(itemIdx)] has \(attachments.count) attachments") - for (attIdx, attachment) in attachments.enumerated() { - NSLog( - "[ReadestShare] attachment[\(attIdx)] types: \(attachment.registeredTypeIdentifiers)") + for attachment in attachments { if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) { if let url = try? await loadURL(from: attachment), Self.isHttp(url) { return url @@ -82,9 +157,7 @@ final class ShareViewController: UIViewController { let item = try await provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) if let url = item as? URL { return url } if let str = item as? String { return URL(string: str) } - if let data = item as? Data, - let str = String(data: data, encoding: .utf8) - { + if let data = item as? Data, let str = String(data: data, encoding: .utf8) { return URL(string: str) } return nil @@ -113,103 +186,254 @@ final class ShareViewController: UIViewController { return nil } - /// Open Readest with the article URL. Tries three paths in order: - /// - /// 1. `extensionContext.open(_:)` against `readest://clip?url=...`. - /// Apple's sanctioned share-extension → host-app handoff for - /// custom URL schemes. The main app's `tauri-plugin-deep-link` - /// catches the `readest://` scheme. - /// 2. `extensionContext.open(_:)` against the Universal Link - /// `https://web.readest.com/clip?url=...`. Only works when the - /// web.readest.com AASA file claims `/clip` for the app — - /// currently it likely doesn't, but tried as a defensive - /// fallback in case (1) fails on some iOS version. - /// 3. Responder-chain `openURL:` trick. iOS 26 silently blocks - /// this even when `responds(to: selector)` returns true and - /// the responder is `UIApplication`, so it's purely a - /// last-ditch attempt for older iOS. - /// - /// Inner URL gets RFC-3986 percent-encoded so the outer URL's - /// query parser sees exactly one `?` (separating outer query from - /// outer path) and exactly one `=` per param. URLComponents alone - /// is NOT enough — its `.urlQueryAllowed` set permits `=`, `?`, - /// `&` and leaves them unescaped, which silently breaks the deep- - /// link parse (the inner URL's first `?s=...` gets promoted to an - /// outer query param). All log messages use the `%@` format - /// specifier with the URL passed as an argument so `printf`'s - /// percent-spec parser doesn't try to interpret the percent-encoded - /// characters in the URL. - @MainActor - private func openInMainApp(url: URL) async { - // 1. Custom URL scheme via extensionContext.open — the modern - // sanctioned path. - if let target = buildTargetURL(scheme: "readest", host: "clip", inner: url) { - NSLog("[ReadestShare] trying custom scheme via extensionContext: %@", target.absoluteString) - if await openViaExtensionContext(target) { - NSLog("[ReadestShare] custom scheme open succeeded") - return - } - NSLog("[ReadestShare] custom scheme open failed") - } + // MARK: - Launch trick (Chrome iOS pattern, IMP-cast variant) - // 2. Universal Link via extensionContext.open. - if let target = buildTargetURL( - scheme: "https", host: "web.readest.com", path: "/clip", inner: url) - { - NSLog("[ReadestShare] trying universal link: %@", target.absoluteString) - if await openViaExtensionContext(target) { - NSLog("[ReadestShare] universal link open succeeded") - return - } - NSLog("[ReadestShare] universal link open failed") - } - - // 3. Responder-chain — usually blocked on iOS 26 but tried for - // completeness so older devices still get the handoff. - if let target = buildTargetURL(scheme: "readest", host: "clip", inner: url) { - NSLog("[ReadestShare] trying responder-chain: %@", target.absoluteString) - openViaResponderChain(target) - } - } - - /// Build a target URL like `://?url=`. - /// Hand-encodes the inner URL against the RFC 3986 "unreserved" set - /// (alnum + `-._~`) so every URL-significant character — including - /// `?`, `&`, `=`, `:`, `/`, `#` — gets percent-encoded. See - /// `openInMainApp` for why URLComponents alone is insufficient. - private func buildTargetURL(scheme: String, host: String, path: String = "", inner: URL) -> URL? - { + /// Build `://?url=`. The inner URL + /// is encoded against the RFC 3986 unreserved set so every URL- + /// significant character (`?`, `&`, `=`, `:`, `/`, `#`) is escaped and + /// the outer parser sees exactly one `?` and one `=`. + private func buildTargetURL(scheme: String, host: String, inner: URL) -> URL? { let unreserved = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~")) - guard - let encoded = inner.absoluteString.addingPercentEncoding(withAllowedCharacters: unreserved) + guard let encoded = inner.absoluteString.addingPercentEncoding(withAllowedCharacters: unreserved) else { return nil } - return URL(string: "\(scheme)://\(host)\(path)?url=\(encoded)") + return URL(string: "\(scheme)://\(host)?url=\(encoded)") } - @MainActor - private func openViaExtensionContext(_ url: URL) async -> Bool { - await withCheckedContinuation { continuation in - guard let ctx = extensionContext else { - continuation.resume(returning: false) - return - } - ctx.open(url, completionHandler: { success in - continuation.resume(returning: success) - }) - } - } - - private func openViaResponderChain(_ url: URL) { + /// Walk the responder chain to find a responder that implements + /// `openURL:options:completionHandler:` (UIApplication), then call it + /// via an Objective-C IMP cast. The IMP-cast path is equivalent to + /// Chrome's NSInvocation pattern in `ios/chrome/common/extension_open_url.mm` + /// but works in pure Swift — we never name `UIApplication` so the + /// App Store extension symbol scanner doesn't reject the binary. + /// + /// `openURL:options:completionHandler:` (non-deprecated) is what + /// continues to work on iOS 26. The legacy `openURL:` selector logs + /// "BUG IN CLIENT OF UIKIT" and no-ops. + @discardableResult + private func openViaResponderChain(_ url: URL) -> Bool { + typealias OpenURLFn = @convention(c) ( + AnyObject, Selector, URL, NSDictionary?, AnyObject? + ) -> Void + let selector = NSSelectorFromString("openURL:options:completionHandler:") var responder: UIResponder? = self - let selector = sel_registerName("openURL:") while let r = responder { if r.responds(to: selector) { - _ = r.perform(selector, with: url) - NSLog("[ReadestShare] responder-chain openURL: invoked on \(type(of: r))") - return + let target = r as AnyObject + let cls: AnyClass = object_getClass(target) ?? type(of: target) + guard let method = class_getInstanceMethod(cls, selector) else { + responder = r.next + continue + } + let imp = method_getImplementation(method) + let fn = unsafeBitCast(imp, to: OpenURLFn.self) + fn(target, selector, url, nil, nil) + NSLog("[ReadestShare] openURL invoked on \(cls) via IMP") + return true } responder = r.next } - NSLog("[ReadestShare] responder-chain found no responder for openURL:") + NSLog("[ReadestShare] no responder accepted openURL:options:completionHandler:") + return false + } +} + +// MARK: - SaveOptionsViewController + +/// URL preview row + per-group radio list, with Cancel and "Save" +/// in the nav bar. Mirrors the Zotero / Pocket save UX. +private final class SaveOptionsViewController: UITableViewController { + + private let url: URL + private let pageTitle: String? + private let groups: [AppGroupBridge.LibraryGroup] + // nil means "Default" (no group). Initial selection: nil. + private var selectedGroupId: String? + private let onCancel: () -> Void + private let onSave: (AppGroupBridge.LibraryGroup?) -> Void + + init( + url: URL, + pageTitle: String?, + groups: [AppGroupBridge.LibraryGroup], + onCancel: @escaping () -> Void, + onSave: @escaping (AppGroupBridge.LibraryGroup?) -> Void + ) { + self.url = url + self.pageTitle = pageTitle + self.groups = groups + self.onCancel = onCancel + self.onSave = onSave + super.init(style: .insetGrouped) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = NSLocalizedString("Save to Readest", comment: "Share extension title") + // Both Cancel and Save are iOS system bar button items — UIKit + // localizes them automatically for every language Apple ships, so + // the extension doesn't carry its own .strings file for them. + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped)) + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .save, target: self, action: #selector(saveTapped)) + tableView.register(URLPreviewCell.self, forCellReuseIdentifier: URLPreviewCell.reuseId) + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "groupCell") + } + + @objc private func cancelTapped() { onCancel() } + + @objc private func saveTapped() { + let selected = groups.first { $0.id == selectedGroupId } + onSave(selected) + } + + // MARK: Table source + + override func numberOfSections(in tableView: UITableView) -> Int { 2 } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? 1 : groups.count + 1 // +1 for "Default" + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) + -> String? + { + section == 1 ? NSLocalizedString("GROUP", comment: "Group picker header") : nil + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) + -> UITableViewCell + { + if indexPath.section == 0 { + let cell = + tableView.dequeueReusableCell(withIdentifier: URLPreviewCell.reuseId, for: indexPath) + as! URLPreviewCell + cell.configure(url: url, pageTitle: pageTitle) + cell.selectionStyle = .none + return cell + } + let cell = tableView.dequeueReusableCell(withIdentifier: "groupCell", for: indexPath) + let name: String + let isSelected: Bool + if indexPath.row == 0 { + // JS-supplied user-locale "Default" label (see AppGroupBridge). + // Falls back to English when the host hasn't synced yet — happens + // on the very first share before the app has been opened. + name = AppGroupBridge.readDefaultGroupName() ?? "Default" + isSelected = selectedGroupId == nil + } else { + let group = groups[indexPath.row - 1] + name = group.name + isSelected = selectedGroupId == group.id + } + cell.textLabel?.text = name + cell.accessoryType = isSelected ? .checkmark : .none + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard indexPath.section == 1 else { return } + selectedGroupId = indexPath.row == 0 ? nil : groups[indexPath.row - 1].id + tableView.reloadSections(IndexSet(integer: 1), with: .none) + } +} + +// MARK: - URLPreviewCell + +private final class URLPreviewCell: UITableViewCell { + static let reuseId = "URLPreviewCell" + + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let hostLabel = UILabel() + private var faviconTask: URLSessionDataTask? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + iconView.translatesAutoresizingMaskIntoConstraints = false + iconView.contentMode = .scaleAspectFit + iconView.clipsToBounds = true + iconView.layer.cornerRadius = 4 + iconView.image = UIImage(systemName: "doc.text") + iconView.tintColor = .secondaryLabel + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.font = .preferredFont(forTextStyle: .body) + titleLabel.numberOfLines = 2 + + hostLabel.translatesAutoresizingMaskIntoConstraints = false + hostLabel.font = .preferredFont(forTextStyle: .footnote) + hostLabel.textColor = .secondaryLabel + hostLabel.numberOfLines = 1 + + contentView.addSubview(iconView) + contentView.addSubview(titleLabel) + contentView.addSubview(hostLabel) + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + iconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 40), + iconView.heightAnchor.constraint(equalToConstant: 40), + + titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 12), + titleLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + titleLabel.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), + + hostLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + hostLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + hostLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2), + hostLabel.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + faviconTask?.cancel() + faviconTask = nil + iconView.image = UIImage(systemName: "doc.text") + iconView.tintColor = .secondaryLabel + } + + func configure(url: URL, pageTitle: String?) { + let host = url.host ?? url.absoluteString + titleLabel.text = pageTitle?.isEmpty == false ? pageTitle : url.absoluteString + hostLabel.text = host + loadFavicon(for: url) + } + + /// Best-effort favicon at `https:///favicon.ico` with a 2s + /// timeout. On any failure we keep the placeholder — never blocking + /// the user's save action on a network round-trip. + private func loadFavicon(for url: URL) { + guard let host = url.host, var components = URLComponents(string: "https://\(host)") else { + return + } + components.path = "/favicon.ico" + guard let iconURL = components.url else { return } + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 2 + let session = URLSession(configuration: config) + let task = session.dataTask(with: iconURL) { [weak self] data, response, _ in + guard let data = data, + let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode), + let image = UIImage(data: data) + else { return } + DispatchQueue.main.async { + self?.iconView.image = image + self?.iconView.tintColor = nil + } + } + task.resume() + faviconTask = task } } diff --git a/apps/readest-app/src-tauri/gen/apple/project.yml b/apps/readest-app/src-tauri/gen/apple/project.yml index b9040a37..92efa52a 100644 --- a/apps/readest-app/src-tauri/gen/apple/project.yml +++ b/apps/readest-app/src-tauri/gen/apple/project.yml @@ -120,6 +120,11 @@ targets: ShareExtension: type: app-extension platform: iOS + # The Share Extension uses iOS-system-localized bar button items + # (`barButtonSystemItem: .cancel` / `.save`) plus a JS-supplied + # "Default" group name (passed through the App Group), so the target + # carries no per-locale `.strings` files — no `.lproj` directories + # to wire up. sources: - path: ShareExtension info: diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/AppGroupBridge.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/AppGroupBridge.swift new file mode 100644 index 00000000..537a9727 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/AppGroupBridge.swift @@ -0,0 +1,74 @@ +// Mirror of `src-tauri/gen/apple/ShareExtension/AppGroupBridge.swift`. The +// two targets cannot share Swift source via the Xcode project layout we +// use (xcodegen `sources:` blocks scope strictly to each target's +// directory), so the schema is intentionally duplicated. Keep both files +// byte-aligned when changing field names, keys, or encodings. + +import Foundation + +enum AppGroupBridge { + static let suiteName = "group.com.bilingify.readest" + static let groupsKey = "shareExtensionGroups" + static let defaultGroupNameKey = "shareExtensionDefaultGroupName" + static let pendingSavesKey = "shareExtensionPendingSaves" + + static var defaults: UserDefaults? { + UserDefaults(suiteName: suiteName) + } + + struct LibraryGroup: Codable, Equatable { + let id: String + let name: String + } + + struct PendingSave: Codable, Equatable { + let url: String + let groupId: String? + let groupName: String? + let addedAt: String + } + + static func readGroups() -> [LibraryGroup] { + guard let data = defaults?.data(forKey: groupsKey) else { return [] } + return (try? JSONDecoder().decode([LibraryGroup].self, from: data)) ?? [] + } + + static func writeGroups(_ groups: [LibraryGroup]) { + guard let data = try? JSONEncoder().encode(groups) else { return } + defaults?.set(data, forKey: groupsKey) + } + + /// JS side passes the user-locale-translated "Default" label here so the + /// Share Extension's no-group row reads in the user's language without + /// the extension needing its own per-locale strings file. + static func readDefaultGroupName() -> String? { + defaults?.string(forKey: defaultGroupNameKey) + } + + static func writeDefaultGroupName(_ name: String) { + defaults?.set(name, forKey: defaultGroupNameKey) + } + + static func readPendingSaves() -> [PendingSave] { + guard let data = defaults?.data(forKey: pendingSavesKey) else { return [] } + return (try? JSONDecoder().decode([PendingSave].self, from: data)) ?? [] + } + + static func appendPendingSave(_ save: PendingSave) { + var saves = readPendingSaves() + saves.append(save) + if let data = try? JSONEncoder().encode(saves) { + defaults?.set(data, forKey: pendingSavesKey) + } + } + + static func clearPendingSaves() { + defaults?.removeObject(forKey: pendingSavesKey) + } + + static func nowIso8601() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: Date()) + } +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift index 03e14a99..4a5cc0d2 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift @@ -473,6 +473,14 @@ class NativeBridgePlugin: Plugin { // covers Readest's annotation toolbar. See ContextMenuSuppressor. ContextMenuSuppressor.installIfNeeded() + // Register a WKScriptMessageHandler so JS can signal when its + // share-extension hook has mounted. On `{type: 'ready'}` we run a + // sync immediately, which is the cold-start path (app launched + // because the Share Extension just woke it up, JS may not have been + // listening when the first `appDidBecomeActive` fired). + webview.configuration.userContentController.add( + ShareBridgeMessageHandler(owner: self), name: "readestShareBridge") + webViewLifecycleManager = WebViewLifecycleManager() webViewLifecycleManager?.startMonitoring(webView: webview) logger.log("NativeBridgePlugin: WebView lifecycle monitoring activated") @@ -528,6 +536,76 @@ class NativeBridgePlugin: Plugin { // appearance whenever the app becomes active, e.g. after toggling // dark mode from Control Center. notifyColorSchemeChange() + syncShareExtensionState() + } + + /// JS-initiated entry point. The share-extension JS hook calls + /// `window.webkit.messageHandlers.readestShareBridge.postMessage({type:'ready'})` + /// once mounted, which routes here so the cold-start drain happens + /// even when the JS hook wasn't listening at app launch. + @objc func syncShareExtensionStateFromJS() { + syncShareExtensionState() + } + + /// Bridge between the Readest Share Extension (separate process) and + /// the host app's JS, via the App Group container at + /// `group.com.bilingify.readest`. Two directions on every activation: + /// + /// 1. Groups (host → extension). Read the current library group list + /// from JS (`window.__readestGetGroups`) and persist it so the + /// extension's picker shows up-to-date options next time the user + /// shares. If the JS function isn't installed yet (cold start, hook + /// hasn't mounted), no-op — the next activation will refresh. + /// + /// 2. Pending saves (extension → host). Drain any queued saves the + /// extension wrote, hand them to JS + /// (`window.__readestOnShareExtensionPending`), then clear the + /// queue only if JS confirmed receipt. If JS isn't ready yet, + /// leave the queue intact — the next activation (or the JS hook + /// on its own mount) will pick them up. + private func syncShareExtensionState() { + DispatchQueue.main.async { [weak self] in + guard let self = self, let webView = self.webView else { return } + // Pull groups + the user-locale "Default" label from JS → App Group. + webView.evaluateJavaScript( + "(window.__readestGetGroups && window.__readestGetGroups()) || null" + ) { result, _ in + guard let payload = result as? [String: Any] else { return } + if let array = payload["groups"] as? [[String: Any]] { + let groups: [AppGroupBridge.LibraryGroup] = array.compactMap { item in + guard let id = item["id"] as? String, let name = item["name"] as? String else { + return nil + } + return AppGroupBridge.LibraryGroup(id: id, name: name) + } + AppGroupBridge.writeGroups(groups) + } + if let defaultName = payload["defaultGroupName"] as? String, !defaultName.isEmpty { + AppGroupBridge.writeDefaultGroupName(defaultName) + } + } + // Push pending saves → JS, clear queue iff JS confirmed. + let saves = AppGroupBridge.readPendingSaves() + guard !saves.isEmpty else { return } + let payload: [[String: Any?]] = saves.map { save in + [ + "url": save.url, + "groupId": save.groupId, + "groupName": save.groupName, + "addedAt": save.addedAt, + ] + } + guard let json = try? JSONSerialization.data(withJSONObject: payload, options: []), + let jsonString = String(data: json, encoding: .utf8) + else { return } + let script = + "(window.__readestOnShareExtensionPending && window.__readestOnShareExtensionPending(\(jsonString))) === true" + webView.evaluateJavaScript(script) { result, _ in + if let acknowledged = result as? Bool, acknowledged { + AppGroupBridge.clearPendingSaves() + } + } + } } // Resolves the foreground window scene. Its trait collection reflects @@ -1305,6 +1383,30 @@ func initPlugin() -> Plugin { return NativeBridgePlugin() } +/// JS → Swift bridge for the share-extension state. Lives in its own +/// class because WKScriptMessageHandler conformance on NativeBridgePlugin +/// would require routing every message type through the plugin's +/// existing @objc method surface, which is noisier than a dedicated +/// handler. We weakly retain the plugin so the WKWebView's +/// WKUserContentController holding the handler doesn't extend the +/// plugin's lifetime. +private final class ShareBridgeMessageHandler: NSObject, WKScriptMessageHandler { + private weak var owner: NativeBridgePlugin? + init(owner: NativeBridgePlugin) { self.owner = owner } + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + guard message.name == "readestShareBridge" else { return } + guard let body = message.body as? [String: Any], let type = body["type"] as? String else { + return + } + if type == "ready" { + owner?.syncShareExtensionStateFromJS() + } + } +} + @available(iOS 13.0, *) extension NativeBridgePlugin: ASWebAuthenticationPresentationContextProviding { func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { diff --git a/apps/readest-app/src/__tests__/services/send-build-epub-cover.test.ts b/apps/readest-app/src/__tests__/services/send-build-epub-cover.test.ts new file mode 100644 index 00000000..f4a14037 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/send-build-epub-cover.test.ts @@ -0,0 +1,75 @@ +import { describe, test, expect } from 'vitest'; +import { buildEpub } from '@/services/send/conversion/buildEpub'; +import { configureZip } from '@/utils/zip'; + +async function unzipEpub(blob: Blob): Promise> { + await configureZip(); + const { BlobReader, ZipReader, TextWriter } = await import('@zip.js/zip.js'); + const reader = new ZipReader(new BlobReader(blob)); + const entries = await reader.getEntries(); + const out = new Map(); + for (const entry of entries) { + if (entry.directory) continue; + const text = await entry.getData!(new TextWriter()); + out.set(entry.filename, text); + } + await reader.close(); + return out; +} + +const chapter = { title: 'Body', html: '

Body text long enough to be a chapter.

' }; +const meta = { + title: 'Cover Test', + author: 'Tester', + language: 'en', + identifier: 'cover-test:1', +}; + +describe('buildEpub — cover image', () => { + test('without coverImage, OPF has no ', async () => { + const blob = await buildEpub([chapter], meta); + const files = await unzipEpub(blob); + const opf = files.get('content.opf'); + expect(opf).toBeDefined(); + expect(opf).not.toContain('name="cover"'); + }); + + test('with coverImage, OPF references the cover via name="cover" meta + manifest item', async () => { + const svgBytes = new TextEncoder().encode( + '', + ).buffer as ArrayBuffer; + const blob = await buildEpub([chapter], meta, [], { + path: 'cover.svg', + bytes: svgBytes, + mime: 'image/svg+xml', + }); + const files = await unzipEpub(blob); + + // Cover file is in OEBPS/ + expect(files.has('OEBPS/cover.svg')).toBe(true); + + // OPF references the cover + const opf = files.get('content.opf'); + expect(opf).toBeDefined(); + expect(opf).toContain(' { + const svgBytes = new TextEncoder().encode( + '', + ).buffer as ArrayBuffer; + const blob = await buildEpub([chapter], meta, [], { + path: 'cover.svg', + bytes: svgBytes, + mime: 'image/svg+xml', + }); + const files = await unzipEpub(blob); + const opf = files.get('content.opf')!; + // Exactly one for the cover image (no extra s for empty images array) + const imgItems = opf.match(/]*media-type="image\//g) ?? []; + expect(imgItems.length).toBe(1); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/send-convert-page-unified.test.ts b/apps/readest-app/src/__tests__/services/send-convert-page-unified.test.ts new file mode 100644 index 00000000..366de025 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/send-convert-page-unified.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; +import { convertPageToEpub, convertToEpub } from '@/services/send/conversion/convertToEpub'; + +/** + * Regression: every clip channel (desktop, mobile, extension) goes through + * `convertPageToEpub` and must produce byte-identical EPUBs for the same + * (html, url) pair. The import-time hash dedup relies on this — without + * it, re-clipping an article (or clipping it from two devices) creates + * duplicate library entries. + * + * Two surfaces: + * 1. `convertPageToEpub(html, url)` direct (extension SW path). + * 2. `convertToEpub({ kind: 'page', html, url })` (existing /send path). + * Both must yield the same bytes. + */ + +const PNG = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, +]); + +function pngResponse(): Response { + return new Response(PNG, { status: 200, headers: { 'content-type': 'image/png' } }); +} + +const HTML = ` + + + Unifying Clipping Paths + + + + +
+

Unifying Clipping Paths

+ +

Introduction

+

${'lorem ipsum dolor sit amet '.repeat(40)}

+

hero

+

Details

+

${'consectetur adipiscing elit '.repeat(40)}

+

A subsection

+

${'sed do eiusmod tempor incididunt '.repeat(40)}

+
+ +`; +const URL_STR = 'https://example.com/articles/unify'; + +describe('convertPageToEpub — clipping channel unification', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => pngResponse()); + }); + afterEach(() => { + fetchSpy.mockRestore(); + }); + + test('the direct call and the convertToEpub({kind:"page"}) wrapper produce identical EPUBs', async () => { + const direct = await convertPageToEpub(HTML, URL_STR); + const viaConvertToEpub = await convertToEpub({ kind: 'page', html: HTML, url: URL_STR }); + + const a = new Uint8Array(await direct.file.arrayBuffer()); + const b = new Uint8Array(await viaConvertToEpub.file.arrayBuffer()); + + expect(direct.title).toBe(viaConvertToEpub.title); + expect(direct.author).toBe(viaConvertToEpub.author); + expect(a.byteLength).toBe(b.byteLength); + expect(a.byteLength).toBeGreaterThan(0); + // Compare a few sentinel windows rather than the full array (the + // failure message is unreadable on a 200 KB blob); pin the first 16 + // bytes (EPUB mimetype entry) and a few random samples. + expect(Array.from(a.subarray(0, 64))).toEqual(Array.from(b.subarray(0, 64))); + expect(Array.from(a.subarray(1000, 1064))).toEqual(Array.from(b.subarray(1000, 1064))); + expect(Array.from(a.subarray(a.byteLength - 64))).toEqual( + Array.from(b.subarray(b.byteLength - 64)), + ); + }); + + test('a heading-shaped article surfaces every h1/h2/h3 in the EPUB TOC', async () => { + const { file } = await convertPageToEpub(HTML, URL_STR); + const { BlobReader, TextWriter, ZipReader } = await import('@zip.js/zip.js'); + const reader = new ZipReader(new BlobReader(file)); + const entries = await reader.getEntries(); + const ncxEntry = entries.find((e) => e.filename === 'toc.ncx'); + if (!ncxEntry || !('getData' in ncxEntry) || !ncxEntry.getData) { + throw new Error('toc.ncx missing'); + } + const ncx = await ncxEntry.getData(new TextWriter()); + await reader.close(); + + // Headings from the article: + expect(ncx).toContain('Unifying Clipping Paths'); + expect(ncx).toContain('Introduction'); + expect(ncx).toContain('Details'); + expect(ncx).toContain('A subsection'); + }); + + test('the EPUB includes a synthetic cover with the author-hashed theme', async () => { + const { file } = await convertPageToEpub(HTML, URL_STR); + const { BlobReader, TextWriter, ZipReader } = await import('@zip.js/zip.js'); + const reader = new ZipReader(new BlobReader(file)); + const entries = await reader.getEntries(); + + // OPF declares a cover-image item: + const opfEntry = entries.find((e) => e.filename === 'content.opf'); + if (!opfEntry || !('getData' in opfEntry) || !opfEntry.getData) { + throw new Error('content.opf missing'); + } + const opf = await opfEntry.getData(new TextWriter()); + expect(opf).toContain(''); + + // The cover asset itself is an SVG: + const coverEntry = entries.find((e) => e.filename === 'OEBPS/cover.svg'); + expect(coverEntry).toBeDefined(); + await reader.close(); + }); + + test('re-converting the same (html, url) yields a byte-identical EPUB', async () => { + const first = new Uint8Array(await (await convertPageToEpub(HTML, URL_STR)).file.arrayBuffer()); + const second = new Uint8Array( + await (await convertPageToEpub(HTML, URL_STR)).file.arrayBuffer(), + ); + expect(first.byteLength).toBe(second.byteLength); + expect(Array.from(first.subarray(0, 256))).toEqual(Array.from(second.subarray(0, 256))); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/send-cover-generator.test.ts b/apps/readest-app/src/__tests__/services/send-cover-generator.test.ts new file mode 100644 index 00000000..441090ea --- /dev/null +++ b/apps/readest-app/src/__tests__/services/send-cover-generator.test.ts @@ -0,0 +1,230 @@ +import { describe, test, expect } from 'vitest'; +import { generateCoverSvg, pickTheme } from '@/services/send/conversion/coverGenerator'; + +const FAVICON_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]).buffer as ArrayBuffer; +const AVATAR_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]).buffer as ArrayBuffer; + +describe('generateCoverSvg', () => { + test('returns a non-empty SVG document with the correct mime', () => { + const cover = generateCoverSvg({ + title: 'A Short Title', + siteName: 'Example Site', + }); + expect(cover.mime).toBe('image/svg+xml'); + expect(cover.bytes.byteLength).toBeGreaterThan(0); + + const svg = new TextDecoder().decode(cover.bytes); + expect(svg).toMatch(/^<\?xml/); + expect(svg).toContain(' { + const svg = new TextDecoder().decode( + generateCoverSvg({ title: 'My Awesome Article', siteName: 'Example' }).bytes, + ); + expect(svg).toContain('My'); + expect(svg).toContain('Awesome'); + expect(svg).toContain('Article'); + }); + + test('falls back to the site name at the bottom when no author is provided', () => { + const svg = new TextDecoder().decode( + generateCoverSvg({ title: 'Hi', siteName: 'Machine Intelligence' }).bytes, + ); + expect(svg).toContain('Machine Intelligence'); + }); + + test('shows the author at the bottom when provided, in place of the site name', () => { + const svg = new TextDecoder().decode( + generateCoverSvg({ + title: 'Hi', + siteName: 'example.com', + author: 'Jane Doe', + }).bytes, + ); + expect(svg).toContain('Jane Doe'); + // Site name is not shown when an author is available — keeps the + // bottom block single-line and legible at thumbnail size. + expect(svg).not.toContain('example.com'); + }); + + test('escapes XML special characters in title and site name', () => { + const svg = new TextDecoder().decode( + generateCoverSvg({ + title: 'Tom & Jerry "scene"', + siteName: 'A & B', + }).bytes, + ); + // Raw `` would break the SVG XML. The segmenter may wrap the + // angle brackets onto different lines, but every occurrence must be + // escaped. + expect(svg).not.toContain(''); + expect(svg).not.toContain('"scene"'); + expect(svg).toContain('&'); + expect(svg).toContain('<'); + expect(svg).toContain('>'); + expect(svg).toContain('"'); + }); + + test('embeds the favicon as a base64 data URL when provided', () => { + const svg = new TextDecoder().decode( + generateCoverSvg({ + title: 'Title', + siteName: 'Site', + favicon: { bytes: FAVICON_BYTES, mime: 'image/png' }, + }).bytes, + ); + expect(svg).toContain('data:image/png;base64,'); + // base64 of the PNG signature bytes + expect(svg).toContain('iVBORw0K'); + }); + + test('prefers the author profile image over the favicon when both are provided', () => { + const svg = new TextDecoder().decode( + generateCoverSvg({ + title: 'Title', + siteName: 'Site', + favicon: { bytes: FAVICON_BYTES, mime: 'image/png' }, + authorImage: { bytes: AVATAR_BYTES, mime: 'image/jpeg' }, + }).bytes, + ); + // base64 of the JPEG signature bytes — author image, not favicon. + expect(svg).toContain('data:image/jpeg;base64,'); + expect(svg).toContain('/9j/4AAQ'); + // PNG-signature base64 of the favicon must NOT appear when the + // author image won the slot. + expect(svg).not.toContain('iVBORw0K'); + }); + + test('still produces a valid cover when no avatar image is provided (initial-letter fallback)', () => { + const svg = new TextDecoder().decode( + generateCoverSvg({ title: 'Hello', siteName: 'My Site' }).bytes, + ); + expect(svg).toContain('"]M[<"]/); + }); + + test('clips the avatar with a circle (not a rounded rect)', () => { + const svg = new TextDecoder().decode( + generateCoverSvg({ + title: 'Title', + siteName: 'Site', + favicon: { bytes: FAVICON_BYTES, mime: 'image/png' }, + }).bytes, + ); + expect(svg).toContain('clipPath'); + expect(svg).toContain(' { + test('is deterministic — same author always gets the same theme', () => { + expect(pickTheme('Jane Doe')).toEqual(pickTheme('Jane Doe')); + expect(pickTheme('机器之心')).toEqual(pickTheme('机器之心')); + }); + + test('produces theme colors with top + bottom hex strings', () => { + const theme = pickTheme('Jane Doe'); + expect(theme.top).toMatch(/^#[0-9A-Fa-f]{6}$/); + expect(theme.bottom).toMatch(/^#[0-9A-Fa-f]{6}$/); + }); + + test('whitespace differences yield the same theme', () => { + expect(pickTheme(' Jane Doe ')).toEqual(pickTheme('Jane Doe')); + }); + + test('empty key resolves to the first palette entry', () => { + const empty = pickTheme(''); + expect(empty.top).toMatch(/^#[0-9A-Fa-f]{6}$/); + expect(empty.bottom).toMatch(/^#[0-9A-Fa-f]{6}$/); + }); + + test('hashes the author into the SVG theme color', () => { + const theme = pickTheme('Distinctive Author Name'); + const svg = new TextDecoder().decode( + generateCoverSvg({ + title: 'Hi', + siteName: 'example.com', + author: 'Distinctive Author Name', + }).bytes, + ); + // The top color from the theme appears as a fill in the upper block. + expect(svg.toLowerCase()).toContain(theme.top.toLowerCase()); + }); + + test('mixed CJK + Latin title: short Latin tokens flow into adjacent CJK runs (no orphan lines)', () => { + const svg = new TextDecoder().decode( + generateCoverSvg({ + title: 'AI 知识库技术演进拆解:从 RAG 到 NotebookLM,再到 LLM Wiki', + siteName: 'mp.weixin.qq.com', + }).bytes, + ); + // "AI" must not appear on a line by itself — it should pack with the + // following CJK characters into a single line of text. + expect(svg).not.toMatch(/>AI<\/text>/); + // "AI" plus at least one CJK char must appear on the same line. + expect(svg).toMatch(/>AI [一-鿿]/); + }); + + test('CJK right-punctuation glues to the preceding character (never starts a line)', () => { + const svg = new TextDecoder().decode( + generateCoverSvg({ + title: '知识库技术演进拆解:从 RAG 到 NotebookLM', + siteName: 'site', + }).bytes, + ); + // A `` line beginning with `:` would break Chinese typography. + expect(svg).not.toMatch(/>:/); + // And the Chinese comma must not start a line either. + expect(svg).not.toMatch(/>,/); + }); + + test('Japanese title: wraps via Intl.Segmenter, all characters present', () => { + const title = 'プログラミング入門:JavaScriptで学ぶ基礎'; + const svg = new TextDecoder().decode(generateCoverSvg({ title, siteName: 'qiita.com' }).bytes); + // Every CJK character from the title appears in the SVG (no + // truncation / dropped characters under 5-line cap). + for (const ch of 'プログラミング入門JavaScript') { + expect(svg).toContain(ch); + } + }); + + test('Thai title: wraps without spaces between words (dictionary-based segmentation)', () => { + // Thai has no spaces between words — the segmenter uses ICU's Thai + // dictionary to find word breaks. We just verify the title renders + // without crashing and the characters are present. + const title = 'การเรียนรู้ JavaScript เบื้องต้น'; + const svg = new TextDecoder().decode(generateCoverSvg({ title, siteName: 'site' }).bytes); + expect(svg).toContain('การ'); + expect(svg).toContain('JavaScript'); + expect(svg).toContain('เบื้องต้น'); + }); + + test('latin words stay whole — no mid-word break on long Latin runs that fit', () => { + const svg = new TextDecoder().decode( + generateCoverSvg({ + title: 'Intro NotebookLM 实战指南', + siteName: 'site', + }).bytes, + ); + // NotebookLM must appear intact somewhere on the cover. + expect(svg).toContain('NotebookLM'); + }); + + test('truncates an extremely long title with an ellipsis', () => { + const longTitle = 'word '.repeat(200).trim(); + const svg = new TextDecoder().decode( + generateCoverSvg({ title: longTitle, siteName: 'Site' }).bytes, + ); + expect(svg).toMatch(/…|\.\.\./); + }); + + test('uses 600x900 viewport (2:3 book-cover aspect)', () => { + const svg = new TextDecoder().decode(generateCoverSvg({ title: 'X', siteName: 'Y' }).bytes); + expect(svg).toMatch(/viewBox="0 0 600 900"/); + }); +}); diff --git a/apps/readest-app/src/hooks/useClipUrlIngress.ts b/apps/readest-app/src/hooks/useClipUrlIngress.ts index 7716ffe0..673bd9af 100644 --- a/apps/readest-app/src/hooks/useClipUrlIngress.ts +++ b/apps/readest-app/src/hooks/useClipUrlIngress.ts @@ -12,24 +12,45 @@ import { eventDispatcher } from '@/utils/event'; import { parseAnnotationDeepLink } from '@/utils/deeplink'; import { useTranslation } from './useTranslation'; +interface ClipOptions { + groupId?: string; + groupName?: string; +} + +interface PendingShareSave { + url: string; + groupId?: string | null; + groupName?: string | null; + addedAt?: string; +} + /** * Handle "Share to Readest" article URLs from the OS share sheet - * (Safari, Chrome, etc.). Consumes the `app-incoming-url` event published - * by `useAppUrlIngress`, filters URLs that look like article links, and - * runs them through the same clip → EPUB → import pipeline the `/send` - * page uses. + * (Safari, Chrome, etc.). Two paths feed in: + * + * 1. Deep-link wake-up (`app-incoming-url` event published by + * `useAppUrlIngress`) — filters URLs that look like article links + * and runs them through the clip → EPUB → import pipeline. + * + * 2. iOS Share-Extension App Group queue — the extension writes + * `{url, groupId?, groupName?}` payloads into the shared + * NSUserDefaults at `group.com.bilingify.readest`, and the host + * plugin (NativeBridgePlugin) drains them on foreground by calling + * `window.__readestOnShareExtensionPending(saves)`. Same ingest + * pipeline, but the chosen library group is preserved. + * + * On iOS we also expose `window.__readestGetGroups()` so the + * Share-Extension picker can show up-to-date library groups, and we + * post a `{type:'ready'}` to the WKScriptMessageHandler the plugin + * registered, so the cold-start drain happens even when the extension + * woke the app up before this hook had mounted. * * Filter rules — only act on URLs that are: * - http(s) (not file://, content://, readest://, blob:, data:) - * - NOT an annotation deep link (those go to useOpenAnnotationLink and - * would otherwise be double-handled) + * - NOT an annotation deep link (those go to useOpenAnnotationLink) * * Failures surface as toasts. Successful clips show "Saving article…" * then "Saved to your library." once `ingestFile` completes. - * - * Mount this hook alongside `useAppUrlIngress` (same places as - * `useOpenWithBooks` and `useOpenAnnotationLink`) so the ingress - * dispatcher is running when URLs arrive. */ export function useClipUrlIngress() { const _ = useTranslation(); @@ -38,7 +59,7 @@ export function useClipUrlIngress() { const inflight = useRef>(new Set()); const clipAndImport = useCallback( - async (url: string) => { + async (url: string, options: ClipOptions = {}) => { if (!appService) return; if (inflight.current.has(url)) return; inflight.current.add(url); @@ -64,6 +85,8 @@ export function useClipUrlIngress() { if (!ingested) { throw new Error('Import produced no book'); } + if (options.groupId) ingested.groupId = options.groupId; + if (options.groupName) ingested.groupName = options.groupName; await useLibraryStore.getState().updateBooks(envConfig, [ingested]); eventDispatcher.dispatch('toast', { type: 'success', @@ -91,6 +114,7 @@ export function useClipUrlIngress() { [_, appService, envConfig, user], ); + // Deep-link path (existing). useEffect(() => { if (!isTauriAppPlatform() || !appService) return; @@ -138,4 +162,67 @@ export function useClipUrlIngress() { eventDispatcher.off('app-incoming-url', onIncoming); }; }, [appService, clipAndImport]); + + // iOS Share-Extension App Group bridge. + useEffect(() => { + if (!isTauriAppPlatform() || !appService) return; + if (typeof window === 'undefined') return; + + const w = window as unknown as { + __readestGetGroups?: () => { + groups: { id: string; name: string }[]; + defaultGroupName: string; + }; + __readestOnShareExtensionPending?: (saves: PendingShareSave[]) => boolean; + webkit?: { + messageHandlers?: { + readestShareBridge?: { postMessage: (msg: { type: string }) => void }; + }; + }; + }; + + // The Share Extension is a native UIKit module and can't read the web + // app's i18next catalogue. We pass it the user-locale-translated + // string for "Default" (the no-group entry) alongside the groups — + // saves maintaining a parallel iOS .strings file just for one phrase. + w.__readestGetGroups = () => { + try { + return { + groups: useLibraryStore.getState().getGroups(), + defaultGroupName: _('Default'), + }; + } catch { + return { groups: [], defaultGroupName: 'Default' }; + } + }; + + w.__readestOnShareExtensionPending = (saves) => { + if (!Array.isArray(saves)) return false; + for (const save of saves) { + if (!save || typeof save.url !== 'string') continue; + void clipAndImport(save.url, { + groupId: save.groupId ?? undefined, + groupName: save.groupName ?? undefined, + }); + } + return true; + }; + + // Tell the plugin we're mounted so it can drain any pending saves + // queued before the hook was alive (cold-start path). + try { + w.webkit?.messageHandlers?.readestShareBridge?.postMessage({ type: 'ready' }); + } catch { + // Non-iOS or handler not registered — no-op. + } + + return () => { + const cleanup = window as unknown as { + __readestGetGroups?: unknown; + __readestOnShareExtensionPending?: unknown; + }; + delete cleanup.__readestGetGroups; + delete cleanup.__readestOnShareExtensionPending; + }; + }, [appService, clipAndImport]); } diff --git a/apps/readest-app/src/services/send/conversion/buildEpub.ts b/apps/readest-app/src/services/send/conversion/buildEpub.ts index d09856ef..1f9c3234 100644 --- a/apps/readest-app/src/services/send/conversion/buildEpub.ts +++ b/apps/readest-app/src/services/send/conversion/buildEpub.ts @@ -41,11 +41,17 @@ pre { white-space: pre-wrap; background: #f5f5f5; padding: 0.6em 0.8em; border-r * `@zip.js/zip.js` assembly pattern with `TxtToEpubConverter` — mimetype first * and uncompressed, `container.xml`, `content.opf`, `toc.ncx`, zeroed * timestamps. + * + * When `coverImage` is supplied the OPF gets a `` plus a `` in the + * manifest. foliate-js detects covers via that meta tag (see + * `packages/foliate-js/epub.js`), so EPUB 2.0 style is enough. */ export async function buildEpub( chapters: EpubChapter[], metadata: EpubBuildMetadata, images: EpubImage[] = [], + coverImage?: EpubImage, ): Promise { if (chapters.length === 0) { throw new Error('buildEpub: no chapters'); @@ -112,6 +118,14 @@ export async function buildEpub( ); } + if (coverImage) { + await zipWriter.add( + `OEBPS/${coverImage.path}`, + new Uint8ArrayReader(new Uint8Array(coverImage.bytes)), + zipWriteOptions, + ); + } + for (let i = 0; i < chapters.length; i++) { const chapter = chapters[i]!; const xhtml = ` @@ -138,8 +152,18 @@ export async function buildEpub( ``, ) .join('\n '); + const coverManifest = coverImage + ? `` + : ''; + const coverMeta = coverImage ? `` : ''; const spine = chapters.map((_, i) => ``).join('\n '); + // Only include manifest lines that have content — empty lines would leave + // stray blank entries in the OPF and trip strict parsers. + const manifestLines = [manifest, imageManifest, coverManifest] + .filter((line) => line.length > 0) + .join('\n '); + const contentOpf = ` @@ -147,10 +171,10 @@ export async function buildEpub( ${escapeXml(language)} ${escapeXml(author)} ${escapeXml(identifier)} + ${coverMeta} - ${manifest} - ${imageManifest} + ${manifestLines} diff --git a/apps/readest-app/src/services/send/conversion/convertToEpub.ts b/apps/readest-app/src/services/send/conversion/convertToEpub.ts index 045be534..19d8b10a 100644 --- a/apps/readest-app/src/services/send/conversion/convertToEpub.ts +++ b/apps/readest-app/src/services/send/conversion/convertToEpub.ts @@ -1,10 +1,16 @@ -import mammoth from 'mammoth'; import { Readability } from '@mozilla/readability'; -import { TxtToEpubConverter } from '@/utils/txt'; import { detectLanguage } from '@/utils/lang'; +// `mammoth` (DOCX → HTML) and `TxtToEpubConverter` are dynamically imported +// inside their respective `case` bodies in `convertToEpub`. They're heavy +// (≈500 KB combined) and only needed for the docx/txt input kinds — the +// browser extension imports `convertPageToEpub` and never reaches those +// branches, so dynamic imports let webpack code-split them out of the +// extension bundle entirely. import { sanitizeHtml, sanitizeForParsing } from './sanitizeHtml'; import { buildEpub } from './buildEpub'; import { bundleAssets } from './assetBundler'; +import { generateCoverSvg } from './coverGenerator'; +import { fetchAuthorImage, fetchBestFavicon } from './faviconFetcher'; import { findSiteRule, type SiteRule } from './siteRules'; import { extractHeadings } from './toc'; import { ConversionError } from './types'; @@ -48,7 +54,7 @@ function stableIdentifier(content: string): string { for (let i = 0; i < content.length; i++) { h = ((h << 5) + h + content.charCodeAt(i)) >>> 0; } - return `send-to-readest:${h.toString(16)}`; + return `readest:${h.toString(16)}`; } function stripTags(html: string): string { @@ -96,12 +102,22 @@ function composeArticleContent(title: string, byline: string, body: string): str /** Assemble an EPUB from a single block of sanitized HTML (optionally with * pre-fetched image resources to embed). Extracts an in-chapter heading - * TOC so the EPUB reader's sidebar shows the article's sections. */ + * TOC so the EPUB reader's sidebar shows the article's sections. + * + * `identityKey` is the input the EPUB's `dc:identifier` is derived from. + * Callers choose what to feed it: + * - Page / article clips pass the canonical URL — the identifier then + * stays stable across edits of the same article, so a re-clip after + * the publisher tweaks a paragraph is recognised as the same work. + * - Local file inputs (docx / html / rtf) pass the sanitized chapter + * HTML — there's no URL, and content is the only stable handle. */ async function htmlToBook( rawHtml: string, title: string, author: string, + identityKey: string, images: EpubImage[] = [], + coverImage?: EpubImage, ): Promise { // Assign stable ids to headings BEFORE sanitization so the strict // `sanitizeHtml` step (which now allows `id`) preserves them — those @@ -127,10 +143,11 @@ async function htmlToBook( title, author, language, - identifier: stableIdentifier(html), + identifier: stableIdentifier(identityKey), toc, }, images, + coverImage, ); const file = new File([blob], `${safeFileName(title)}.epub`, { type: 'application/epub+zip', @@ -217,6 +234,108 @@ function extractArticleFallback(rawHtml: string, minTextChars: number): string | return bestText >= minTextChars ? bestHtml : null; } +/** + * Pick the site name for the synthetic cover. Tries Open Graph + Twitter + * metadata first (most reliable; publishers set these for previews), falls + * through to ``, then degrades to the URL's + * hostname stripped of the leading `www.` so e.g. `https://nytimes.com/foo` + * yields "nytimes.com". + */ +function extractSiteName(doc: Document, pageUrl: string): string { + const metaSelectors = [ + 'meta[property="og:site_name"]', + 'meta[name="og:site_name"]', + 'meta[name="application-name"]', + 'meta[name="apple-mobile-web-app-title"]', + 'meta[property="twitter:site"]', + ]; + for (const sel of metaSelectors) { + const value = doc.querySelector(sel)?.getAttribute('content')?.trim(); + if (value) return value; + } + try { + return new URL(pageUrl).hostname.replace(/^www\./, ''); + } catch { + return ''; + } +} + +/** + * Build a synthetic cover for a clipped article. Parses the page HTML once + * to find the site name and favicon, then hands those off to + * `generateCoverSvg`. Returns `undefined` only when the cover can't be + * generated at all (e.g. the parser threw) — the favicon-fetch failing is + * already handled by the cover generator's initial-letter fallback. + */ +async function buildArticleCover( + pageHtml: string, + pageUrl: string, + title: string, + author: string, + rule: SiteRule | null, +): Promise { + let doc: Document; + try { + doc = new DOMParser().parseFromString(sanitizeForParsing(pageHtml), 'text/html'); + } catch { + return generateCoverSvg({ title, author, siteName: hostnameFallback(pageUrl) }); + } + const siteName = extractSiteName(doc, pageUrl); + + // Prefer an author profile image (richer than a site-wide favicon). + // When the site rule exposes an `authorImage` selector, resolve the URL + // and fetch it. Falls through to the favicon on any failure. + let authorImage: { bytes: ArrayBuffer; mime: string } | undefined; + if (rule?.authorImage) { + const url = pickImageUrlFromSelector(doc, rule.authorImage, pageUrl); + if (url) { + authorImage = (await fetchAuthorImage(url, pageUrl).catch(() => null)) ?? undefined; + } + } + + // Favicon is only fetched when we don't already have an author image — + // saves a wasted HTTP round-trip on WeChat (and any other rule that + // sets `authorImage`). + const favicon = authorImage + ? undefined + : ((await fetchBestFavicon(doc, pageUrl).catch(() => null)) ?? undefined); + + return generateCoverSvg({ title, author, siteName, authorImage, favicon }); +} + +/** Walk the rule's `authorImage` selector list and return the first usable + * `src` or `data-src` URL, resolved against the page URL. */ +function pickImageUrlFromSelector(doc: Document, selector: string, pageUrl: string): string | null { + let els: NodeListOf; + try { + els = doc.querySelectorAll(selector); + } catch { + return null; + } + for (const el of Array.from(els)) { + const raw = + el.getAttribute('src') || + el.getAttribute('data-src') || + el.getAttribute('data-original') || + el.getAttribute('href'); + if (!raw) continue; + try { + return new URL(raw, pageUrl).toString(); + } catch { + // unparseable href — try the next element + } + } + return null; +} + +function hostnameFallback(pageUrl: string): string { + try { + return new URL(pageUrl).hostname.replace(/^www\./, ''); + } catch { + return ''; + } +} + /** Pull `` / first `<h1>` out of a full HTML document. */ function extractHtmlTitle(html: string, fallback: string): string { try { @@ -247,6 +366,120 @@ function baseName(fileName: string | undefined, fallback: string): string { return fileName.replace(/\.[^.]+$/, '').trim() || fallback; } +/** + * Convert a single web page (rendered HTML + URL) into a self-contained + * EPUB — Readability extraction, per-site rule fast-paths, inlined images, + * cover, headings-as-TOC. Shared between every "self-contained page clip" + * caller: + * + * - The Tauri desktop / mobile `/send` URL field (Tauri-only path). + * - The browser extension's service worker (`extensions/send-to-readest`). + * - Future: any other channel that captures the rendered DOM and wants + * the same EPUB out the other side. + * + * Centralising it here is the only way to guarantee that the same URL + * produces byte-identical EPUBs across desktop, mobile, and extension — + * which is what the import-time hash dedup relies on. + * + * Only valid from a CORS-free caller (Tauri webview or browser-extension + * service worker with broad `host_permissions`). A plain web page hitting + * this would fail on the image fetches. + */ +export async function convertPageToEpub(html: string, url: string): Promise<ConvertedBook> { + console.log('[clip/page] input', { + url, + html_bytes: html.length, + }); + + // Fast path: per-site rule. Skips Readability entirely on sites we know + // it mis-scores. Falls through if the rule's content selector matches + // nothing or the extracted text is below the quality floor. + const rule = findSiteRule(url); + if (rule) { + const ruleResult = extractWithSiteRule(html, rule); + const ruleText = ruleResult ? stripTags(ruleResult.content) : ''; + console.log('[clip/page] site rule', { + name: rule.name, + matched: !!ruleResult, + text_chars: ruleText.length, + title: ruleResult?.title || null, + }); + if (ruleResult && ruleText.length >= 400) { + const title = ruleResult.title || extractHtmlTitle(html, url); + const byline = ruleResult.byline || ''; + const bundle = await bundleAssets(ruleResult.content, url); + const cover = await buildArticleCover(html, url, title, byline, rule); + return htmlToBook( + composeArticleContent(title, byline, bundle.html), + title, + byline, + url, + bundle.images, + cover, + ); + } + } + + let parsed: { title?: string | null; content?: string | null; byline?: string | null } | null; + try { + const doc = new DOMParser().parseFromString(sanitizeForParsing(html), 'text/html'); + parsed = new Readability(doc).parse(); + } catch (err) { + console.warn('[clip/page] readability threw', { + error: err instanceof Error ? err.message : String(err), + }); + throw new ConversionError(`Could not extract article: ${String(err)}`, 'parse_failed'); + } + if (!parsed?.content) { + console.warn('[clip/page] readability returned no content'); + throw new ConversionError('No readable article found at the URL', 'parse_failed'); + } + let articleHtml = parsed.content; + let contentText = stripTags(articleHtml); + console.log('[clip/page] readability', { + title: parsed.title || null, + byline: parsed.byline || null, + content_chars: articleHtml.length, + text_chars: contentText.length, + }); + // Readability sometimes scores the wrong container on sites with custom + // markup (e.g. an article body wrapped in a non-semantic div revealed + // by JS). When the extracted text is tiny but the page HTML is + // substantial, try a known-selector fallback before giving up. + const QUALITY_FLOOR = 400; + if (contentText.length < QUALITY_FLOOR && html.length > 50_000) { + const fallback = extractArticleFallback(html, QUALITY_FLOOR); + if (fallback) { + articleHtml = fallback; + contentText = stripTags(articleHtml); + console.log('[clip/page] fallback selector picked up the article', { + text_chars: contentText.length, + }); + } + } + // Bot-detection screens, paywall stubs and "please enable JavaScript" + // pages all slip past Readability AND the selector fallback with a + // tiny scrap of text. Refuse to import them — never save a junk EPUB. + if (contentText.length < QUALITY_FLOOR) { + throw new ConversionError( + 'Could not read this page — it looks like a verification screen or a login wall. Open it in a browser first.', + 'parse_failed', + ); + } + const title = parsed.title?.trim() || extractHtmlTitle(html, url); + const byline = parsed.byline?.trim() || ''; + const bundle = await bundleAssets(articleHtml, url); + const cover = await buildArticleCover(html, url, title, byline, rule); + return htmlToBook( + composeArticleContent(title, byline, bundle.html), + title, + byline, + url, + bundle.images, + cover, + ); +} + /** * Convert a document Readest cannot open natively into an EPUB. Runs entirely * client-side (browser or Tauri webview) — meant to be called inside a Web @@ -255,6 +488,7 @@ function baseName(fileName: string | undefined, fallback: string): string { export async function convertToEpub(input: ConvertInput): Promise<ConvertedBook> { switch (input.kind) { case 'txt': { + const { TxtToEpubConverter } = await import('@/utils/txt'); const result = await new TxtToEpubConverter().convert({ file: input.file }); return { file: result.file, title: result.bookTitle, author: '' }; } @@ -264,17 +498,19 @@ export async function convertToEpub(input: ConvertInput): Promise<ConvertedBook> } let html: string; try { + const mammoth = (await import('mammoth')).default; const result = await mammoth.convertToHtml({ arrayBuffer: input.bytes }); html = result.value; } catch (err) { throw new ConversionError(`Could not parse .docx: ${String(err)}`, 'parse_failed'); } - return htmlToBook(html, baseName(input.fileName, 'Document'), ''); + // Local files have no URL — hash their content for the identifier. + return htmlToBook(html, baseName(input.fileName, 'Document'), '', html); } case 'html': { const raw = new TextDecoder('utf-8').decode(input.bytes); const title = extractHtmlTitle(raw, baseName(input.fileName, 'Document')); - return htmlToBook(raw, title, ''); + return htmlToBook(raw, title, '', raw); } case 'rtf': { const rtf = new TextDecoder('utf-8').decode(input.bytes); @@ -286,7 +522,7 @@ export async function convertToEpub(input: ConvertInput): Promise<ConvertedBook> .split(/\n+/) .map((line) => `<p>${line.replace(/[<>&]/g, ' ').trim()}</p>`) .join(''); - return htmlToBook(html, baseName(input.fileName, 'Document'), ''); + return htmlToBook(html, baseName(input.fileName, 'Document'), '', html); } case 'article': { let parsed: { title?: string | null; content?: string | null; byline?: string | null } | null; @@ -301,98 +537,24 @@ export async function convertToEpub(input: ConvertInput): Promise<ConvertedBook> } const title = parsed.title?.trim() || extractHtmlTitle(input.html, input.url); const byline = parsed.byline?.trim() || ''; - return htmlToBook(composeArticleContent(title, byline, parsed.content), title, byline); - } - case 'page': { - // Same as `article`, but fetch every referenced image and inline it so - // the EPUB is fully offline-readable. Only valid from a CORS-free - // caller (Tauri webview or extension content script). - console.log('[clip/page] input', { - url: input.url, - html_bytes: input.html.length, - }); - - // Fast path: per-site rule. Skips Readability entirely on sites we - // know it mis-scores. Falls through if the rule's content selector - // matches nothing or the extracted text is below the floor. - const rule = findSiteRule(input.url); - if (rule) { - const ruleResult = extractWithSiteRule(input.html, rule); - const ruleText = ruleResult ? stripTags(ruleResult.content) : ''; - console.log('[clip/page] site rule', { - name: rule.name, - matched: !!ruleResult, - text_chars: ruleText.length, - title: ruleResult?.title || null, - }); - if (ruleResult && ruleText.length >= 400) { - const title = ruleResult.title || extractHtmlTitle(input.html, input.url); - const byline = ruleResult.byline || ''; - const bundle = await bundleAssets(ruleResult.content, input.url); - return htmlToBook( - composeArticleContent(title, byline, bundle.html), - title, - byline, - bundle.images, - ); - } - } - - let parsed: { title?: string | null; content?: string | null; byline?: string | null } | null; - try { - const doc = new DOMParser().parseFromString(sanitizeForParsing(input.html), 'text/html'); - parsed = new Readability(doc).parse(); - } catch (err) { - console.warn('[clip/page] readability threw', { - error: err instanceof Error ? err.message : String(err), - }); - throw new ConversionError(`Could not extract article: ${String(err)}`, 'parse_failed'); - } - if (!parsed?.content) { - console.warn('[clip/page] readability returned no content'); - throw new ConversionError('No readable article found at the URL', 'parse_failed'); - } - let articleHtml = parsed.content; - let contentText = stripTags(articleHtml); - console.log('[clip/page] readability', { - title: parsed.title || null, - byline: parsed.byline || null, - content_chars: articleHtml.length, - text_chars: contentText.length, - }); - // Readability sometimes scores the wrong container on sites with custom - // markup (e.g. an article body wrapped in a non-semantic div revealed - // by JS). When the extracted text is tiny but the page HTML is - // substantial, try a known-selector fallback before giving up. - const QUALITY_FLOOR = 400; - if (contentText.length < QUALITY_FLOOR && input.html.length > 50_000) { - const fallback = extractArticleFallback(input.html, QUALITY_FLOOR); - if (fallback) { - articleHtml = fallback; - contentText = stripTags(articleHtml); - console.log('[clip/page] fallback selector picked up the article', { - text_chars: contentText.length, - }); - } - } - // Bot-detection screens, paywall stubs and "please enable JavaScript" - // pages all slip past Readability AND the selector fallback with a - // tiny scrap of text. Refuse to import them — never save a junk EPUB. - if (contentText.length < QUALITY_FLOOR) { - throw new ConversionError( - 'Could not read this page — it looks like a verification screen or a login wall. Open it in a browser first, or wait for the upcoming browser extension.', - 'parse_failed', - ); - } - const title = parsed.title?.trim() || extractHtmlTitle(input.html, input.url); - const byline = parsed.byline?.trim() || ''; - const bundle = await bundleAssets(articleHtml, input.url); - return htmlToBook( - composeArticleContent(title, byline, bundle.html), + const cover = await buildArticleCover( + input.html, + input.url, title, byline, - bundle.images, + findSiteRule(input.url), ); + return htmlToBook( + composeArticleContent(title, byline, parsed.content), + title, + byline, + input.url, + [], + cover, + ); + } + case 'page': { + return convertPageToEpub(input.html, input.url); } default: { throw new ConversionError( diff --git a/apps/readest-app/src/services/send/conversion/coverGenerator.ts b/apps/readest-app/src/services/send/conversion/coverGenerator.ts new file mode 100644 index 00000000..c9b027ef --- /dev/null +++ b/apps/readest-app/src/services/send/conversion/coverGenerator.ts @@ -0,0 +1,505 @@ +import type { EpubImage } from './types'; + +/** + * Synthetic cover image for clipped articles. Produces a 600×900 SVG + * (2:3 — the standard book-cover aspect ratio Readest uses everywhere + * else) with a vivid two-tone layout: + * + * - colored top block (theme color hashed from the author, so the same + * public-account / writer always gets the same cover style); + * - large multi-line title in white, left-aligned in the upper block; + * - circular avatar centered on a wave divider — either the author's + * profile image (when one was found and fetched) or the site favicon; + * - lighter shade in the lower block; + * - author / byline at the bottom in dark type. Falls back to the + * site name when no byline was extracted. + * + * SVG is the right format here: + * - vector text + shapes stay crisp at every cover-thumb size; + * - no canvas-render step, so the page-clip pipeline stays main-thread- + * only and doesn't need OffscreenCanvas; + * - file size is well under 50 KB even with an embedded avatar; + * - foliate-js's epub reader (`packages/foliate-js/epub.js`) treats the + * cover blob as opaque bytes with `media-type` honored, so SVG works + * transparently with the `<meta name="cover">` route the EPUB + * builder uses. + */ + +const VIEW_W = 600; +const VIEW_H = 900; +const TITLE_FONT_SIZE = 56; +const TITLE_LINE_HEIGHT = 72; +const TITLE_MAX_LINES = 5; +const TITLE_LEFT_PAD = 56; +const TITLE_RIGHT_PAD = 56; +const TITLE_TOP_Y = 100; +const AUTHOR_FONT_SIZE = 32; +const AUTHOR_Y = 830; + +const WAVE_Y = 540; +const WAVE_DIP = 80; +const AVATAR_CX = VIEW_W / 2; +const AVATAR_CY = WAVE_Y + WAVE_DIP / 2; +const AVATAR_R = 60; +const AVATAR_RING_R = 66; + +const FAMILY = + "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif"; + +export interface CoverInput { + title: string; + siteName: string; + /** Byline / author for the article. Drives the theme color (so the same + * author always gets the same cover style) AND shows up as the bottom + * identifier. Falls back to siteName for both purposes when empty. */ + author?: string; + /** Author profile image (e.g. WeChat public-account avatar). When + * provided, used as the circular cover avatar in place of the site + * favicon. */ + authorImage?: { bytes: ArrayBuffer; mime: string }; + /** Site favicon. Used as the avatar when no `authorImage` is provided. */ + favicon?: { bytes: ArrayBuffer; mime: string }; +} + +/** + * Curated theme palette. Each entry is a (top, bottom) pair — the top + * tone covers the upper block and the bottom tone the lower. The top + * tones all have enough contrast against white title text; the bottom + * tones are light enough that dark byline text stays legible. + * + * Ordering doesn't matter — the author hash picks an index uniformly. + */ +const PALETTE: readonly { top: string; bottom: string }[] = [ + { top: '#0EBAE3', bottom: '#E2F4F9' }, // cyan + { top: '#3E89C7', bottom: '#E5EEF6' }, // blue + { top: '#E27D5C', bottom: '#F7DDD0' }, // coral + { top: '#5D9D7E', bottom: '#DCE8E1' }, // sage + { top: '#9B6BAC', bottom: '#E5D9EB' }, // lavender + { top: '#D8A55E', bottom: '#F4E5C9' }, // ochre + { top: '#2A9D8F', bottom: '#D5EAE7' }, // teal + { top: '#7E6C6C', bottom: '#E5DDDD' }, // mauve + { top: '#3E5C76', bottom: '#D9DEE4' }, // navy + { top: '#A36677', bottom: '#E8D6DA' }, // wine +]; + +function escapeXml(s: string): string { + return s + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// `btoa` only takes a binary string; chunk to stay under the per-call argument +// limit on large images (some Apple touch icons / avatar PNGs run >100 KB). +function bytesToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + return btoa(binary); +} + +/** + * djb2 over the author / site name. Same input → same theme color, so a + * public account's covers are visually consistent across articles. + */ +export function pickTheme(key: string): { top: string; bottom: string } { + const trimmed = key.trim(); + if (!trimmed) return PALETTE[0]!; + let h = 5381; + for (let i = 0; i < trimmed.length; i++) { + h = ((h << 5) + h + trimmed.charCodeAt(i)) >>> 0; + } + return PALETTE[h % PALETTE.length]!; +} + +/** Treat CJK chars (≈1 em wide) and Latin chars (≈0.55 em wide) separately + * when estimating how much title text fits on one line. Without this the + * heuristic either wraps Chinese articles way too tight or lets Latin + * titles overflow the cover. */ +function isCjkChar(code: number): boolean { + // CJK Unified Ideographs, Hiragana, Katakana, CJK Ext A, Hangul Syllables, + // CJK Symbols and Punctuation (including 、 。「」『』 etc.), and the + // Halfwidth/Fullwidth Forms block (full-width comma 「,」, colon 「:」, + // and Latin glyphs). Width estimates fold them all into the ~1em column + // CJK fonts actually render them at, so the line-wrap budget doesn't + // under-count Chinese punctuation. + return ( + (code >= 0x4e00 && code <= 0x9fff) || + (code >= 0x3040 && code <= 0x30ff) || + (code >= 0x3400 && code <= 0x4dbf) || + (code >= 0xac00 && code <= 0xd7af) || + (code >= 0x3000 && code <= 0x303f) || + (code >= 0xff00 && code <= 0xffef) + ); +} + +function estimateTextWidthEm(text: string): number { + let w = 0; + for (const ch of text) { + w += isCjkChar(ch.codePointAt(0) ?? 0) ? 1.0 : 0.55; + } + return w; +} + +/** Right-attaching CJK punctuation that should never start a line — glues + * to the preceding character/word. Covers Chinese, Japanese, and the + * ASCII members (commas/periods) that appear inline with CJK. */ +const CJK_RIGHT_PUNCT = /[、。,.:;!?”’)]〕」』〉》】〗…,.:;!?)\]—]/; +/** Left-attaching CJK punctuation that should never end a line — glues + * to the following character/word. */ +const CJK_LEFT_PUNCT = /[“‘([〔「『〈《【〖([]/; + +interface TitleChunk { + /** The chunk's text — either a single CJK character (possibly with a + * trailing right-punctuation glyph) or a run of Latin characters. */ + text: string; + /** Whitespace that appeared before this chunk in the input, preserved + * verbatim. Dropped when this chunk starts a fresh line. */ + sep: string; +} + +/** + * Pick the ICU locale that gives the best word-segmentation for a title. + * + * `Intl.Segmenter` uses dictionaries when available — Chinese, Japanese, + * Korean, Thai, Khmer, Burmese, Lao all get word-level breaks instead of + * character-by-character. With locale `undefined` ICU falls back to a + * generic rule set, which for CJK ends up at character granularity. So we + * sniff the dominant script and pass the matching locale; everything + * else (Latin, Cyrillic, Arabic, Thai, …) gets the runtime default and + * Intl handles it from there. + */ +function pickSegmenterLocale(text: string): string | undefined { + for (const ch of text) { + const code = ch.codePointAt(0) ?? 0; + // Hiragana / Katakana → Japanese dictionary + if (code >= 0x3040 && code <= 0x30ff) return 'ja'; + // Hangul → Korean dictionary + if (code >= 0xac00 && code <= 0xd7af) return 'ko'; + // CJK Unified Ideographs → Chinese dictionary + if ( + (code >= 0x4e00 && code <= 0x9fff) || + (code >= 0x3400 && code <= 0x4dbf) || + (code >= 0x20000 && code <= 0x2a6df) + ) { + return 'zh'; + } + // Thai → Thai dictionary + if (code >= 0x0e00 && code <= 0x0e7f) return 'th'; + // Khmer → Khmer dictionary + if (code >= 0x1780 && code <= 0x17ff) return 'km'; + // Burmese → Burmese dictionary + if (code >= 0x1000 && code <= 0x109f) return 'my'; + } + return undefined; +} + +/** + * Split a title into wrap-friendly chunks. Prefers `Intl.Segmenter` with + * `granularity: 'word'` for language-aware breaks (handles Thai, Khmer, + * Japanese, Chinese 2-character compounds, etc. via ICU's dictionaries); + * falls back to a hand-rolled CJK-aware tokenizer when the runtime + * doesn't have `Intl.Segmenter` (older WebViews). + * + * On top of the segmenter we apply: + * - right-attaching punctuation (commas, periods, colons, closing + * quotes) glues to the preceding chunk so a line never *starts* with + * one; + * - left-attaching punctuation (opening quotes / brackets) glues to the + * following chunk so a line never *ends* with a dangling opener. + */ +function tokenizeTitle(text: string): TitleChunk[] { + if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') { + try { + return tokenizeWithSegmenter(text); + } catch { + // Some environments don't ship full ICU data — fall through. + } + } + return tokenizeFallback(text); +} + +function tokenizeWithSegmenter(text: string): TitleChunk[] { + const locale = pickSegmenterLocale(text); + const segmenter = new Intl.Segmenter(locale, { granularity: 'word' }); + const chunks: TitleChunk[] = []; + let pendingSep = ''; + let pendingLeftPunct = ''; + + for (const { segment } of segmenter.segment(text)) { + if (!segment) continue; + if (/^\s+$/.test(segment)) { + pendingSep = ' '; + continue; + } + if (CJK_RIGHT_PUNCT.test(segment) && chunks.length > 0 && !pendingLeftPunct) { + // Right-attaching punctuation — glue to the previous chunk so it + // never lands at the start of a line. + chunks[chunks.length - 1]!.text += segment; + pendingSep = ''; + continue; + } + if (CJK_LEFT_PUNCT.test(segment)) { + pendingLeftPunct += segment; + continue; + } + chunks.push({ text: pendingLeftPunct + segment, sep: pendingSep }); + pendingLeftPunct = ''; + pendingSep = ''; + } + if (pendingLeftPunct) { + chunks.push({ text: pendingLeftPunct, sep: pendingSep }); + } + return chunks; +} + +/** Hand-rolled tokenizer for runtimes without `Intl.Segmenter`. Treats + * each CJK char as its own chunk (so line breaks can happen between any + * two ideographs) and Latin runs as single chunks. Less linguistically + * aware than the segmenter — CJK words like 拆解 may break mid-compound + * — but always available. */ +function tokenizeFallback(text: string): TitleChunk[] { + const chunks: TitleChunk[] = []; + let pendingSep = ''; + let pendingLeftPunct = ''; + let latinBuf = ''; + + const flushLatin = () => { + if (latinBuf) { + chunks.push({ text: pendingLeftPunct + latinBuf, sep: pendingSep }); + latinBuf = ''; + pendingLeftPunct = ''; + pendingSep = ''; + } + }; + + for (const ch of text) { + if (/\s/.test(ch)) { + flushLatin(); + pendingSep = pendingSep || ' '; + continue; + } + const code = ch.codePointAt(0) ?? 0; + if (CJK_RIGHT_PUNCT.test(ch) && chunks.length > 0 && !latinBuf) { + chunks[chunks.length - 1]!.text += ch; + pendingSep = ''; + continue; + } + if (CJK_LEFT_PUNCT.test(ch)) { + flushLatin(); + pendingLeftPunct += ch; + continue; + } + if (isCjkChar(code)) { + flushLatin(); + chunks.push({ text: pendingLeftPunct + ch, sep: pendingSep }); + pendingLeftPunct = ''; + pendingSep = ''; + continue; + } + latinBuf += ch; + } + flushLatin(); + if (pendingLeftPunct) { + chunks.push({ text: pendingLeftPunct, sep: pendingSep }); + } + return chunks; +} + +/** Approximate em width of an inter-chunk space character. */ +const SPACE_WIDTH_EM = 0.28; + +/** + * Wrap a title into at most `TITLE_MAX_LINES` lines using chunk-aware + * breaking — Latin words stay whole, CJK breaks between any two chars, + * and punctuation never lands alone at a line edge. If we run out of + * lines, the last line is truncated with an ellipsis. + */ +function wrapTitle(title: string, maxEmPerLine: number): string[] { + const trimmed = title.trim(); + if (!trimmed) return []; + const chunks = tokenizeTitle(trimmed); + if (chunks.length === 0) return []; + + const lines: string[] = []; + let current = ''; + let currentWidth = 0; + + const flush = (): boolean => { + if (!current) return true; + if (lines.length >= TITLE_MAX_LINES) { + // Out of lines — fold the rest of the title into a trailing + // ellipsis on the last kept line so the start is still readable. + const last = lines.pop() ?? ''; + lines.push(truncateLineWithEllipsis(last, maxEmPerLine)); + current = ''; + currentWidth = 0; + return false; + } + lines.push(current); + current = ''; + currentWidth = 0; + return true; + }; + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]!; + const sepWidth = chunk.sep && current ? SPACE_WIDTH_EM : 0; + const chunkWidth = estimateTextWidthEm(chunk.text); + + if (!current) { + // Starting a fresh line — drop any leading whitespace separator. + current = chunk.text; + currentWidth = chunkWidth; + } else if (currentWidth + sepWidth + chunkWidth <= maxEmPerLine) { + current += (chunk.sep ? ' ' : '') + chunk.text; + currentWidth += sepWidth + chunkWidth; + } else { + if (!flush()) return lines; + current = chunk.text; + currentWidth = chunkWidth; + } + + // A single chunk wider than the budget can only be a very long Latin + // word (CJK chunks are at most one char + punctuation). Hard-wrap it + // across multiple lines so the cover doesn't overflow. + while (currentWidth > maxEmPerLine) { + const split = hardSplit(current, maxEmPerLine); + lines.push(split.head); + if (lines.length >= TITLE_MAX_LINES) { + const last = lines.pop() ?? ''; + lines.push(truncateLineWithEllipsis(last, maxEmPerLine)); + return lines; + } + current = split.tail; + currentWidth = estimateTextWidthEm(current); + } + } + if (current) lines.push(current); + if (lines.length > TITLE_MAX_LINES) { + const head = lines.slice(0, TITLE_MAX_LINES); + const last = head.pop() ?? ''; + head.push(truncateLineWithEllipsis(last, maxEmPerLine)); + return head; + } + return lines; +} + +function truncateLineWithEllipsis(line: string, maxEmPerLine: number): string { + const ELLIPSIS = '…'; + const ellipsisWidth = estimateTextWidthEm(ELLIPSIS); + let s = line; + while (s.length > 0 && estimateTextWidthEm(s) + ellipsisWidth > maxEmPerLine) { + s = s.slice(0, -1); + } + return `${s.trimEnd()}${ELLIPSIS}`; +} + +function hardSplit(token: string, maxEmPerLine: number): { head: string; tail: string } { + let head = ''; + let i = 0; + while (i < token.length) { + const next = head + token[i]!; + if (estimateTextWidthEm(next) > maxEmPerLine) break; + head = next; + i++; + } + return { head: head || token.slice(0, 1), tail: token.slice(head.length || 1) }; +} + +/** + * Generate a 600×900 SVG cover suitable for embedding in an EPUB as + * `image/svg+xml`. The output is a single self-contained document — no + * external font, no external image, no script. + */ +export function generateCoverSvg(input: CoverInput): EpubImage { + const title = input.title?.trim() || 'Untitled'; + const siteName = input.siteName?.trim() || ''; + const author = input.author?.trim() || ''; + + // The hash key drives theme stability — same author → same colors. When + // no author was parsed we fall back to the site name so per-site clips + // still get a stable look. + const theme = pickTheme(author || siteName); + + // Title font width budget — viewport - L pad - R pad. + const titleBudgetPx = VIEW_W - TITLE_LEFT_PAD - TITLE_RIGHT_PAD; + const maxEmPerLine = titleBudgetPx / TITLE_FONT_SIZE; + const titleLines = wrapTitle(title, maxEmPerLine); + + const titleTspans = titleLines + .map((line, i) => { + const y = TITLE_TOP_Y + TITLE_FONT_SIZE + i * TITLE_LINE_HEIGHT; + return `<text x="${TITLE_LEFT_PAD}" y="${y}" text-anchor="start" fill="#ffffff" font-family="${FAMILY}" font-size="${TITLE_FONT_SIZE}" font-weight="700" letter-spacing="0.5">${escapeXml(line)}</text>`; + }) + .join('\n '); + + // Wave divider: starts flat at the left edge, dips down through the + // center where the avatar sits, returns flat at the right edge. The + // colored top block "owns" the wave so its top half hugs the avatar. + const wavePath = `M 0 ${WAVE_Y} C 120 ${WAVE_Y} 180 ${WAVE_Y + WAVE_DIP} ${VIEW_W / 2} ${WAVE_Y + WAVE_DIP} C ${VIEW_W - 180} ${WAVE_Y + WAVE_DIP} ${VIEW_W - 120} ${WAVE_Y} ${VIEW_W} ${WAVE_Y} L ${VIEW_W} 0 L 0 0 Z`; + + // Avatar: profile image if we have one, else favicon, else an initial + // tile. Always rendered as a circle with a white ring. + const avatarImage = input.authorImage ?? input.favicon ?? null; + const avatarContent = avatarImage + ? renderAvatarImage(avatarImage) + : renderInitialAvatar(author || siteName); + + // Bottom block text: author when present, else site name. Truncated to + // a single line so the bottom never overflows. Dark theme color so it + // reads on the light bottom shade. + const bottomText = author || siteName; + const bottomBudgetEm = (VIEW_W - 80) / AUTHOR_FONT_SIZE; + const bottomLine = bottomText ? fitOneLine(bottomText, bottomBudgetEm) : ''; + const bottomTextEl = bottomLine + ? `<text x="${VIEW_W / 2}" y="${AUTHOR_Y}" text-anchor="middle" fill="#2b2b2b" font-family="${FAMILY}" font-size="${AUTHOR_FONT_SIZE}" font-weight="600" letter-spacing="1">${escapeXml(bottomLine)}</text>` + : ''; + + const svg = `<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${VIEW_W} ${VIEW_H}" width="${VIEW_W}" height="${VIEW_H}" preserveAspectRatio="xMidYMid meet"> + <defs> + <clipPath id="avatar-clip"> + <circle cx="${AVATAR_CX}" cy="${AVATAR_CY}" r="${AVATAR_R}"/> + </clipPath> + </defs> + <rect width="${VIEW_W}" height="${VIEW_H}" fill="${theme.bottom}"/> + <path d="${wavePath}" fill="${theme.top}"/> + ${titleTspans} + <circle cx="${AVATAR_CX}" cy="${AVATAR_CY}" r="${AVATAR_RING_R}" fill="#ffffff"/> + ${avatarContent} + ${bottomTextEl} +</svg>`; + + const bytes = new TextEncoder().encode(svg).buffer as ArrayBuffer; + return { path: 'cover.svg', bytes, mime: 'image/svg+xml' }; +} + +function renderAvatarImage(image: { bytes: ArrayBuffer; mime: string }): string { + const base64 = bytesToBase64(image.bytes); + const mime = image.mime || 'image/png'; + const x = AVATAR_CX - AVATAR_R; + const y = AVATAR_CY - AVATAR_R; + const size = AVATAR_R * 2; + return `<image x="${x}" y="${y}" width="${size}" height="${size}" preserveAspectRatio="xMidYMid slice" clip-path="url(#avatar-clip)" href="data:${escapeXml(mime)};base64,${base64}"/>`; +} + +function renderInitialAvatar(label: string): string { + // First non-whitespace character — handles "Site Name" → "S", "机器之心" → "机". + const first = label.replace(/\s+/g, '').slice(0, 1) || '·'; + const cy = AVATAR_CY + AVATAR_R * 0.18; + return `<circle cx="${AVATAR_CX}" cy="${AVATAR_CY}" r="${AVATAR_R}" fill="#eef0f2"/> + <text x="${AVATAR_CX}" y="${cy}" text-anchor="middle" fill="#3a3a3a" font-family="${FAMILY}" font-size="${(AVATAR_R * 1.1).toFixed(0)}" font-weight="700">${escapeXml(first)}</text>`; +} + +/** Truncate a single line to fit a width budget, appending an ellipsis. */ +function fitOneLine(text: string, maxEmPerLine: number): string { + const t = text.trim(); + if (!t || estimateTextWidthEm(t) <= maxEmPerLine) return t; + return truncateLineWithEllipsis(t, maxEmPerLine); +} diff --git a/apps/readest-app/src/services/send/conversion/faviconFetcher.ts b/apps/readest-app/src/services/send/conversion/faviconFetcher.ts new file mode 100644 index 00000000..62d0298a --- /dev/null +++ b/apps/readest-app/src/services/send/conversion/faviconFetcher.ts @@ -0,0 +1,197 @@ +import { fetch as tauriFetch } from '@tauri-apps/plugin-http'; +import { isTauriAppPlatform } from '@/services/environment'; +import { imageFetchHeaders } from './httpHeaders'; + +/** + * Find and fetch the best icon for a clipped article — the icon that + * lands on the synthetic cover. We prefer the page's `<link rel="apple- + * touch-icon">` (180×180 PNG on most sites), fall back to high-res + * `<link rel="icon">` tags, then settle for `/favicon.ico`. + * + * Returns `null` on any failure — the cover generator's initial-letter + * tile takes over from there. + */ + +const FETCH_TIMEOUT_MS = 6_000; +const MAX_FAVICON_BYTES = 512 * 1024; + +const PREFERRED_RELS = [ + 'apple-touch-icon-precomposed', + 'apple-touch-icon', + 'icon', + 'shortcut icon', + 'mask-icon', +]; + +const HOSTED_FALLBACK_PATHS = [ + '/apple-touch-icon.png', + '/apple-touch-icon-precomposed.png', + '/favicon.ico', +]; + +const httpFetch = (url: string, referer: string | null, init?: RequestInit): Promise<Response> => { + if (!isTauriAppPlatform()) { + // In the extension SW (the only non-Tauri caller today) include + // credentials so cookie-gated favicons / author images on paywalled + // hosts come through with the user's session. + return globalThis.fetch(url, { credentials: 'include', ...init }); + } + const baseHeaders = imageFetchHeaders(referer); + const headers = new Headers(init?.headers); + for (const [k, v] of Object.entries(baseHeaders)) { + if (!headers.has(k)) headers.set(k, v); + } + return tauriFetch(url, { ...init, headers }); +}; + +interface IconCandidate { + url: string; + /** Higher = more preferred. Picked from rel attribute + sizes attribute. */ + score: number; +} + +function parseSizes(sizes: string | null): number { + if (!sizes) return 0; + if (sizes.toLowerCase() === 'any') return 1024; + // "180x180" → 180; "32x32 16x16" → 32 (largest). + let max = 0; + for (const m of sizes.matchAll(/(\d+)\s*x\s*(\d+)/gi)) { + const w = parseInt(m[1]!, 10); + if (w > max) max = w; + } + return max; +} + +function relScore(rel: string): number { + const lowered = rel.toLowerCase(); + // Apple touch icons are usually 180×180 PNGs and look much better on the + // cover than 16×16 favicons. Boost them above plain "icon". + if (lowered.includes('apple-touch-icon-precomposed')) return 1000; + if (lowered.includes('apple-touch-icon')) return 900; + if (lowered.includes('mask-icon')) return 100; + if (lowered.includes('shortcut')) return 200; + if (lowered.includes('icon')) return 500; + return 0; +} + +/** Extract candidate icon URLs from a parsed HTML document. */ +export function extractIconCandidates(doc: Document, pageUrl: string): IconCandidate[] { + const candidates: IconCandidate[] = []; + for (const link of Array.from(doc.querySelectorAll('link[rel]'))) { + const rel = link.getAttribute('rel') ?? ''; + if (!PREFERRED_RELS.some((r) => rel.toLowerCase().split(/\s+/).includes(r))) { + continue; + } + const href = link.getAttribute('href'); + if (!href) continue; + let url: string; + try { + url = new URL(href, pageUrl).toString(); + } catch { + continue; + } + const size = parseSizes(link.getAttribute('sizes')); + candidates.push({ url, score: relScore(rel) + size }); + } + candidates.sort((a, b) => b.score - a.score); + return candidates; +} + +/** + * Fetch an author profile image (e.g. WeChat public-account avatar). Same + * shape as `fetchFavicon` but with a bigger size cap — avatars are often + * 200×200 PNGs that comfortably exceed the favicon ceiling. + */ +export async function fetchAuthorImage( + url: string, + referer: string | null, +): Promise<{ bytes: ArrayBuffer; mime: string } | null> { + return fetchImage(url, referer, MAX_AUTHOR_IMAGE_BYTES); +} + +async function fetchFavicon( + url: string, + referer: string | null, +): Promise<{ bytes: ArrayBuffer; mime: string } | null> { + return fetchImage(url, referer, MAX_FAVICON_BYTES); +} + +const MAX_AUTHOR_IMAGE_BYTES = 2 * 1024 * 1024; + +async function fetchImage( + url: string, + referer: string | null, + maxBytes: number, +): Promise<{ bytes: ArrayBuffer; mime: string } | null> { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS); + try { + const res = await httpFetch(url, referer, { signal: ac.signal, redirect: 'follow' }); + if (!res.ok) return null; + const bytes = await res.arrayBuffer(); + if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) return null; + let mime = (res.headers.get('content-type') || '').split(';')[0]!.trim().toLowerCase(); + if (!mime.startsWith('image/')) { + // Some CDNs serve `application/octet-stream` for `/favicon.ico` even + // when it's a valid image — infer from the URL extension instead. + const ext = url.match(/\.([a-z0-9]{2,5})(?:\?|#|$)/i)?.[1]?.toLowerCase(); + if (ext === 'ico') mime = 'image/x-icon'; + else if (ext === 'png') mime = 'image/png'; + else if (ext === 'svg') mime = 'image/svg+xml'; + else if (ext === 'jpg' || ext === 'jpeg') mime = 'image/jpeg'; + else if (ext === 'webp') mime = 'image/webp'; + else return null; + } + return { bytes, mime }; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +/** + * Walk a parsed page document for icon `<link>` tags, fetch the highest- + * scoring candidate that responds, and return it. Falls back to common + * well-known paths (`/apple-touch-icon.png`, `/favicon.ico`) when no + * `<link>` icon is declared or none of them load. + * + * Caller is expected to provide a parsed Document — `kind: 'page'` and + * `kind: 'article'` both already parse the page once for Readability, so + * this avoids a second parse. + */ +export async function fetchBestFavicon( + doc: Document, + pageUrl: string, +): Promise<{ bytes: ArrayBuffer; mime: string } | null> { + const candidates = extractIconCandidates(doc, pageUrl); + + // Always check the well-known fallback paths last — some sites omit the + // <link> tag but still host /apple-touch-icon.png. + let origin: string; + try { + origin = new URL(pageUrl).origin; + } catch { + origin = ''; + } + if (origin) { + for (const path of HOSTED_FALLBACK_PATHS) { + candidates.push({ url: `${origin}${path}`, score: -1 }); + } + } + + // De-dupe so a `<link rel="apple-touch-icon" href="/apple-touch-icon.png">` + // doesn't compete with the same URL added as a hosted fallback. + const seen = new Set<string>(); + const ordered = candidates.filter((c) => { + if (seen.has(c.url)) return false; + seen.add(c.url); + return true; + }); + + for (const candidate of ordered) { + const result = await fetchFavicon(candidate.url, pageUrl); + if (result) return result; + } + return null; +} diff --git a/apps/readest-app/src/services/send/conversion/siteRules.ts b/apps/readest-app/src/services/send/conversion/siteRules.ts index 071f8191..462c0768 100644 --- a/apps/readest-app/src/services/send/conversion/siteRules.ts +++ b/apps/readest-app/src/services/send/conversion/siteRules.ts @@ -25,6 +25,12 @@ export interface SiteRule { title?: string; /** Override Readability's byline. */ byline?: string; + /** CSS selector for an `<img>` (or element with a usable `src`) that + * represents the author / public-account avatar. Used by the cover + * generator as a circular avatar — visually richer than a site-wide + * favicon. Best-effort: when the selector misses or the fetch fails, + * the cover falls back to the favicon. */ + authorImage?: string; /** Selectors to remove from the content before bundling (e.g. "open in * app" CTAs, QR codes, share buttons). */ strip?: string[]; @@ -41,6 +47,8 @@ const RULES: SiteRule[] = [ content: '#js_content', title: '#activity-name, h1.rich_media_title', byline: '#js_name, .rich_media_meta_nickname', + authorImage: + '.profile_avatar img, .rich_media_meta_nickname_avatar img, .weui_media_avatar img, img.identity_icon, .wx_follow_avatar img', strip: [ // Inline QR codes (PC + mobile variants) '.qr_code_pc',