diff --git a/.gitignore b/.gitignore index fd7cf4db..2ce6029b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ # testing /coverage +.playwright-mcp # next.js /.next/ diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 62cf0c72..79db3854 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1020,5 +1020,39 @@ "{{count}} book(s) synced_two": "تمت مزامنة كتابين", "{{count}} book(s) synced_few": "تمت مزامنة {{count}} كتب", "{{count}} book(s) synced_many": "تمت مزامنة {{count}} كتاباً", - "{{count}} book(s) synced_other": "تمت مزامنة {{count}} كتاب" + "{{count}} book(s) synced_other": "تمت مزامنة {{count}} كتاب", + "Unable to start RSVP": "تعذر بدء RSVP", + "RSVP not supported for PDF": "RSVP غير مدعوم لملفات PDF", + "Close RSVP": "إغلاق RSVP", + "Select Chapter": "اختر الفصل", + "{{number}} WPM": "{{number}} كلمة في الدقيقة", + "Context": "السياق", + "Ready": "جاهز", + "Chapter Progress": "تقدم الفصل", + "words": "كلمات", + "{{time}} left": "متبقي {{time}}", + "Reading progress": "تقدم القراءة", + "Click to seek": "انقر للتمرير", + "Skip back 15 words": "تخطي للخلف 15 كلمة", + "Back 15 words (Shift+Left)": "للخلف 15 كلمة (Shift+اليسار)", + "Pause (Space)": "إيقاف مؤقت (المسافة)", + "Play (Space)": "تشغيل (المسافة)", + "Skip forward 15 words": "تخطي للأمام 15 كلمة", + "Forward 15 words (Shift+Right)": "للأمام 15 كلمة (Shift+اليمين)", + "Pause:": "إيقاف مؤقت:", + "Decrease speed": "تقليل السرعة", + "Slower (Left/Down)": "أبطأ (اليسار/الأسفل)", + "Current speed": "السرعة الحالية", + "Increase speed": "زيادة السرعة", + "Faster (Right/Up)": "أسرع (اليمين/الأعلى)", + "Start RSVP Reading": "بدء قراءة RSVP", + "Choose where to start reading": "اختر من أين تبدأ القراءة", + "From Chapter Start": "من بداية الفصل", + "Start reading from the beginning of the chapter": "بدء القراءة من بداية الفصل", + "Resume": "استئناف", + "Continue from where you left off": "المتابعة من حيث توقف", + "From Current Page": "من الصفحة الحالية", + "Start from where you are currently reading": "البدء من حيث تقرأ حالياً", + "From Selection": "من التحديد", + "Speed Reading Mode": "وضع القراءة السريعة" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 32fab338..5a90dfa3 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -972,5 +972,39 @@ "Paragraph Mode": "অনুচ্ছেদ মোড", "Embedding Model": "এম্বেডিং মডেল", "{{count}} book(s) synced_one": "{{count}}টি বই সিঙ্ক করা হয়েছে", - "{{count}} book(s) synced_other": "{{count}}টি বই সিঙ্ক করা হয়েছে" + "{{count}} book(s) synced_other": "{{count}}টি বই সিঙ্ক করা হয়েছে", + "Unable to start RSVP": "RSVP शुरू করতে অসমর্থ", + "RSVP not supported for PDF": "PDF-এর জন্য RSVP সমর্থিত নয়", + "Close RSVP": "RSVP বন্ধ করুন", + "Select Chapter": "অধ্যায় নির্বাচন করুন", + "{{number}} WPM": "{{number}} WPM", + "Context": "প্রসঙ্গ", + "Ready": "প্রস্তুত", + "Chapter Progress": "অধ্যায় অগ্রগতি", + "words": "শব্দ", + "{{time}} left": "{{time}} বাকি", + "Reading progress": "পড়ার অগ্রগতি", + "Click to seek": "খুঁজতে ক্লিক করুন", + "Skip back 15 words": "১৫ শব্দ পিছিয়ে যান", + "Back 15 words (Shift+Left)": "১৫ শব্দ পিছিয়ে যান (Shift+Left)", + "Pause (Space)": "বিরতি (Space)", + "Play (Space)": "চালান (Space)", + "Skip forward 15 words": "১৫ শব্দ এগিয়ে যান", + "Forward 15 words (Shift+Right)": "১৫ শব্দ এগিয়ে যান (Shift+Right)", + "Pause:": "বিরতি:", + "Decrease speed": "গতি কমান", + "Slower (Left/Down)": "ধীর (Left/Down)", + "Current speed": "বর্তমান গতি", + "Increase speed": "গতি বাড়ান", + "Faster (Right/Up)": "দ্রুত (Right/Up)", + "Start RSVP Reading": "RSVP পড়া শুরু করুন", + "Choose where to start reading": "কোথা থেকে পড়া শুরু করবেন তা চয়ন করুন", + "From Chapter Start": "অধ্যায় শুরু থেকে", + "Start reading from the beginning of the chapter": "অধ্যায়ের শুরু থেকে পড়া শুরু করুন", + "Resume": "পুনরায় শুরু করুন", + "Continue from where you left off": "যেখানে আপনি ছেড়েছিলেন সেখান থেকে চালিয়ে যান", + "From Current Page": "বর্তমান পৃষ্ঠা থেকে", + "Start from where you are currently reading": "আপনি বর্তমানে যেখানে পড়ছেন সেখান থেকে শুরু করুন", + "From Selection": "নির্বাচন থেকে", + "Speed Reading Mode": "দ্রুত পাঠ্য মোড" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index d319a2f5..74c143ce 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -960,5 +960,39 @@ "Exit Paragraph Mode": "དུམ་མཚམས་རྣམ་པ་ནས་ཕྱིར་ཐོན།", "Paragraph Mode": "དུམ་མཚམས་རྣམ་པ།", "Embedding Model": "གནས་སྒྲིག་དཔེ་གཞི།", - "{{count}} book(s) synced_other": "དེབ་ {{count}} མཉམ་འགྲིག་བྱས་ཟིན།" + "{{count}} book(s) synced_other": "དེབ་ {{count}} མཉམ་འགྲིག་བྱས་ཟིན།", + "Unable to start RSVP": "RSVP འགོ་འཛུགས་མ་ཐུབ།", + "RSVP not supported for PDF": "PDF ལ་ RSVP རྒྱབ་སྐྱོར་མེད།", + "Close RSVP": "RSVP ཁ་རྒྱག་པ།", + "Select Chapter": "ལེའུ་འདེམས་པ།", + "{{number}} WPM": "{{number}} WPM", + "Context": "བརྗོད་དོན།", + "Ready": "གྲ་སྒྲིག་ཡོད།", + "Chapter Progress": "ལེའུའི་འཕེལ་རིམ།", + "words": "ཚིག", + "{{time}} left": "དུས་ཚོད་ {{time}} ལྷག་ཡོད།", + "Reading progress": "ཀློག་པའི་འཕེལ་རིམ།", + "Click to seek": "གནས་ས་འཚོལ་བར་གནོན་པ།", + "Skip back 15 words": "ཚིག་ ༡༥ རྒྱབ་ལ་བཤུད་པ།", + "Back 15 words (Shift+Left)": "ཚིག་ ༡༥ རྒྱབ་ལ་བཤུད་པ། (Shift+Left)", + "Pause (Space)": "མཚམས་འཇོག་པ། (Space)", + "Play (Space)": "གཏོང་བ། (Space)", + "Skip forward 15 words": "ཚིག་ ༡༥ མདུན་ལ་བཤུད་པ།", + "Forward 15 words (Shift+Right)": "ཚིག་ ༡༥ མདུན་ལ་བཤུད་པ། (Shift+Right)", + "Pause:": "མཚམས་འཇོག་པ།", + "Decrease speed": "མགྱོགས་ཚད་འགོར་དུ་གཏོང་བ།", + "Slower (Left/Down)": "འགོར་བ། (Left/Down)", + "Current speed": "ད་ལྟའི་མགྱོགས་ཚད།", + "Increase speed": "མགྱོགས་ཚད་མྱུར་དུ་གཏོང་བ།", + "Faster (Right/Up)": "མྱུར་བ། (Right/Up)", + "Start RSVP Reading": "RSVP ཀློག་འགོ་འཛུགས་པ།", + "Choose where to start reading": "གང་ནས་ཀློག་འགོ་འཛུགས་མིན་འདེམས་པ།", + "From Chapter Start": "ལེའུའི་འགོ་ནས།", + "Start reading from the beginning of the chapter": "ལེའུ་འདིའི་འགོ་ནས་ཀློག་པ།", + "Resume": "མུ་མཐུད་པ།", + "Continue from where you left off": "མཚམས་བཞག་སའི་གནས་ནས་མུ་མཐུད་པ།", + "From Current Page": "ད་ལྟའི་ཤོག་ལྷེ་ནས།", + "Start from where you are currently reading": "ད་ལྟ་ཀློག་བཞིན་པའི་གནས་ནས་འགོ་འཛུགས་པ།", + "From Selection": "བདམས་པའི་ནང་དོན་ནས།", + "Speed Reading Mode": "མྱུར་ཀློག་རྣམ་པ།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 30dc4812..c920f8ab 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -972,5 +972,39 @@ "Paragraph Mode": "Absatzmodus", "Embedding Model": "Einbettungsmodell", "{{count}} book(s) synced_one": "{{count}} Buch synchronisiert", - "{{count}} book(s) synced_other": "{{count}} Bücher synchronisiert" + "{{count}} book(s) synced_other": "{{count}} Bücher synchronisiert", + "Unable to start RSVP": "RSVP kann nicht gestartet werden", + "RSVP not supported for PDF": "RSVP wird für PDF nicht unterstützt", + "Close RSVP": "RSVP schließen", + "Select Chapter": "Kapitel auswählen", + "{{number}} WPM": "{{number}} WPM", + "Context": "Kontext", + "Ready": "Bereit", + "Chapter Progress": "Kapitelfortschritt", + "words": "Wörter", + "{{time}} left": "{{time}} übrig", + "Reading progress": "Lesefortschritt", + "Click to seek": "Klicken zum Suchen", + "Skip back 15 words": "15 Wörter zurück", + "Back 15 words (Shift+Left)": "15 Wörter zurück (Shift+Links)", + "Pause (Space)": "Pause (Leertaste)", + "Play (Space)": "Abspielen (Leertaste)", + "Skip forward 15 words": "15 Wörter vor", + "Forward 15 words (Shift+Right)": "15 Wörter vor (Shift+Rechts)", + "Pause:": "Pause:", + "Decrease speed": "Geschwindigkeit verringern", + "Slower (Left/Down)": "Langsamer (Links/Unten)", + "Current speed": "Aktuelle Geschwindigkeit", + "Increase speed": "Geschwindigkeit erhöhen", + "Faster (Right/Up)": "Schneller (Rechts/Oben)", + "Start RSVP Reading": "RSVP-Lesen starten", + "Choose where to start reading": "Wählen Sie, wo das Lesen beginnen soll", + "From Chapter Start": "Vom Kapitelanfang", + "Start reading from the beginning of the chapter": "Ab dem Anfang des Kapitels lesen", + "Resume": "Fortsetzen", + "Continue from where you left off": "Dort fortfahren, wo Sie aufgehört haben", + "From Current Page": "Von der aktuellen Seite", + "Start from where you are currently reading": "Dort beginnen, wo Sie gerade lesen", + "From Selection": "Von der Auswahl", + "Speed Reading Mode": "Schnelllesemodus" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index e4576273..66605c53 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -972,5 +972,39 @@ "Paragraph Mode": "Λειτουργία παραγράφου", "Embedding Model": "Μοντέλο ενσωμάτωσης", "{{count}} book(s) synced_one": "{{count}} βιβλίο συγχρονίστηκε", - "{{count}} book(s) synced_other": "{{count}} βιβλία συγχρονίστηκαν" + "{{count}} book(s) synced_other": "{{count}} βιβλία συγχρονίστηκαν", + "Unable to start RSVP": "Αδυναμία έναρξης RSVP", + "RSVP not supported for PDF": "Το RSVP δεν υποστηρίζεται για PDF", + "Close RSVP": "Κλείσιμο RSVP", + "Select Chapter": "Επιλογή Κεφαλαίου", + "{{number}} WPM": "{{number}} WPM", + "Context": "Πλαίσιο", + "Ready": "Έτοιμο", + "Chapter Progress": "Πρόοδος Κεφαλαίου", + "words": "λέξεις", + "{{time}} left": "{{time}} απομένουν", + "Reading progress": "Πρόοδος ανάγνωσης", + "Click to seek": "Κάντε κλικ για αναζήτηση", + "Skip back 15 words": "Επιστροφή 15 λέξεων", + "Back 15 words (Shift+Left)": "Πίσω 15 λέξεις (Shift+Αριστερά)", + "Pause (Space)": "Παύση (Διάστημα)", + "Play (Space)": "Αναπαραγωγή (Διάστημα)", + "Skip forward 15 words": "Προώθηση 15 λέξεων", + "Forward 15 words (Shift+Right)": "Εμπρός 15 λέξεις (Shift+Δεξιά)", + "Pause:": "Παύση:", + "Decrease speed": "Μείωση ταχύτητας", + "Slower (Left/Down)": "Πιο αργά (Αριστερά/Κάτω)", + "Current speed": "Τρέχουσα ταχύτητα", + "Increase speed": "Αύξηση ταχύτητας", + "Faster (Right/Up)": "Πιο γρήγορα (Δεξιά/Πάνω)", + "Start RSVP Reading": "Έναρξη ανάγνωσης RSVP", + "Choose where to start reading": "Επιλέξτε από πού θα ξεκινήσετε την ανάγνωση", + "From Chapter Start": "Από την αρχή του κεφαλαίου", + "Start reading from the beginning of the chapter": "Ξεκινήστε την ανάγνωση από την αρχή του κεφαλαίου", + "Resume": "Συνέχεια", + "Continue from where you left off": "Συνεχίστε από εκεί που σταματήσατε", + "From Current Page": "Από την τρέχουσα σελίδα", + "Start from where you are currently reading": "Ξεκινήστε από εκεί που διαβάζετε αυτή τη στιγμή", + "From Selection": "Από την επιλογή", + "Speed Reading Mode": "Λειτουργία ταχείας ανάγνωσης" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 61748b64..48f31be0 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -984,5 +984,39 @@ "Embedding Model": "Modelo de incrustación", "{{count}} book(s) synced_one": "{{count}} libro sincronizado", "{{count}} book(s) synced_many": "{{count}} libros sincronizados", - "{{count}} book(s) synced_other": "{{count}} libros sincronizados" + "{{count}} book(s) synced_other": "{{count}} libros sincronizados", + "Unable to start RSVP": "No se puede iniciar RSVP", + "RSVP not supported for PDF": "RSVP no es compatible con PDF", + "Close RSVP": "Cerrar RSVP", + "Select Chapter": "Seleccionar capítulo", + "{{number}} WPM": "{{number}} PPM", + "Context": "Contexto", + "Ready": "Listo", + "Chapter Progress": "Progreso del capítulo", + "words": "palabras", + "{{time}} left": "faltan {{time}}", + "Reading progress": "Progreso de lectura", + "Click to seek": "Hacer clic para buscar", + "Skip back 15 words": "Retroceder 15 palabras", + "Back 15 words (Shift+Left)": "Retroceder 15 palabras (Shift+Izquierda)", + "Pause (Space)": "Pausa (Espacio)", + "Play (Space)": "Reproducir (Espacio)", + "Skip forward 15 words": "Adelantar 15 palabras", + "Forward 15 words (Shift+Right)": "Adelantar 15 palabras (Shift+Derecha)", + "Pause:": "Pausa:", + "Decrease speed": "Disminuir velocidad", + "Slower (Left/Down)": "Más lento (Izquierda/Abajo)", + "Current speed": "Velocidad actual", + "Increase speed": "Aumentar velocidad", + "Faster (Right/Up)": "Más rápido (Derecha/Arriba)", + "Start RSVP Reading": "Iniciar lectura RSVP", + "Choose where to start reading": "Elegir dónde empezar a leer", + "From Chapter Start": "Desde el inicio del capítulo", + "Start reading from the beginning of the chapter": "Empezar a leer desde el principio del capítulo", + "Resume": "Reanudar", + "Continue from where you left off": "Continuar desde donde lo dejaste", + "From Current Page": "Desde la página actual", + "Start from where you are currently reading": "Empezar desde donde estás leyendo actualmente", + "From Selection": "Desde la selección", + "Speed Reading Mode": "Modo de lectura rápida" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 53a9b8b1..5b9b8bba 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -972,5 +972,39 @@ "Paragraph Mode": "حالت بند", "Embedding Model": "مدل جاسازی", "{{count}} book(s) synced_one": "{{count}} کتاب همگام شد", - "{{count}} book(s) synced_other": "{{count}} کتاب همگام شدند" + "{{count}} book(s) synced_other": "{{count}} کتاب همگام شدند", + "Unable to start RSVP": "قادر به شروع RSVP نیست", + "RSVP not supported for PDF": "RSVP برای PDF پشتیبانی نمی‌شود", + "Close RSVP": "بستن RSVP", + "Select Chapter": "انتخاب فصل", + "{{number}} WPM": "{{number}} کلمه در دقیقه", + "Context": "زمینه", + "Ready": "آماده", + "Chapter Progress": "پیشرفت فصل", + "words": "کلمات", + "{{time}} left": "{{time}} باقی‌مانده", + "Reading progress": "پیشرفت مطالعه", + "Click to seek": "برای جستجو کلیک کنید", + "Skip back 15 words": "۱۵ کلمه به عقب", + "Back 15 words (Shift+Left)": "۱۵ کلمه به عقب (Shift+Left)", + "Pause (Space)": "توقف (Space)", + "Play (Space)": "پخش (Space)", + "Skip forward 15 words": "۱۵ کلمه به جلو", + "Forward 15 words (Shift+Right)": "۱۵ کلمه به جلو (Shift+Right)", + "Pause:": "توقف:", + "Decrease speed": "کاهش سرعت", + "Slower (Left/Down)": "کندتر (Left/Down)", + "Current speed": "سرعت فعلی", + "Increase speed": "افزایش سرعت", + "Faster (Right/Up)": "سریع‌تر (Right/Up)", + "Start RSVP Reading": "شروع مطالعه RSVP", + "Choose where to start reading": "انتخاب کنید از کجا مطالعه شروع شود", + "From Chapter Start": "از ابتدای فصل", + "Start reading from the beginning of the chapter": "شروع مطالعه از ابتدای فصل", + "Resume": "ادامه", + "Continue from where you left off": "ادامه از جایی که متوقف شدید", + "From Current Page": "از صفحه فعلی", + "Start from where you are currently reading": "شروع از جایی که در حال مطالعه هستید", + "From Selection": "از انتخاب", + "Speed Reading Mode": "حالت مطالعه سریع" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 64c8bffa..61095ccb 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -984,5 +984,39 @@ "Embedding Model": "Modèle d'incorporation", "{{count}} book(s) synced_one": "{{count}} livre synchronisé", "{{count}} book(s) synced_many": "{{count}} livres synchronisés", - "{{count}} book(s) synced_other": "{{count}} livres synchronisés" + "{{count}} book(s) synced_other": "{{count}} livres synchronisés", + "Unable to start RSVP": "Impossible de démarrer RSVP", + "RSVP not supported for PDF": "RSVP non pris en charge pour PDF", + "Close RSVP": "Fermer RSVP", + "Select Chapter": "Sélectionner le chapitre", + "{{number}} WPM": "{{number}} MPM", + "Context": "Contexte", + "Ready": "Prêt", + "Chapter Progress": "Progression du chapitre", + "words": "mots", + "{{time}} left": "{{time}} restant", + "Reading progress": "Progression de la lecture", + "Click to seek": "Cliquer pour chercher", + "Skip back 15 words": "Reculer de 15 mots", + "Back 15 words (Shift+Left)": "Reculer de 15 mots (Shift+Left)", + "Pause (Space)": "Pause (Espace)", + "Play (Space)": "Lecture (Espace)", + "Skip forward 15 words": "Avancer de 15 mots", + "Forward 15 words (Shift+Right)": "Avancer de 15 mots (Shift+Right)", + "Pause:": "Pause :", + "Decrease speed": "Diminuer la vitesse", + "Slower (Left/Down)": "Plus lent (Gauche/Bas)", + "Current speed": "Vitesse actuelle", + "Increase speed": "Augmenter la vitesse", + "Faster (Right/Up)": "Plus rapide (Droite/Haut)", + "Start RSVP Reading": "Démarrer la lecture RSVP", + "Choose where to start reading": "Choisir où commencer la lecture", + "From Chapter Start": "Depuis le début du chapitre", + "Start reading from the beginning of the chapter": "Commencer la lecture au début du chapitre", + "Resume": "Reprendre", + "Continue from where you left off": "Continuer là où vous vous êtes arrêté", + "From Current Page": "Depuis la page actuelle", + "Start from where you are currently reading": "Commencer là où vous lisez actuellement", + "From Selection": "Depuis la sélection", + "Speed Reading Mode": "Mode lecture rapide" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 43aab8e8..9c419692 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -972,5 +972,39 @@ "Paragraph Mode": "पैराग्राफ मोड", "Embedding Model": "एंबेडिंग मॉडल", "{{count}} book(s) synced_one": "{{count}} पुस्तक सिंक की गई", - "{{count}} book(s) synced_other": "{{count}} पुस्तकें सिंक की गईं" + "{{count}} book(s) synced_other": "{{count}} पुस्तकें सिंक की गईं", + "Unable to start RSVP": "RSVP शुरू करने में असमर्थ", + "RSVP not supported for PDF": "PDF के लिए RSVP समर्थित नहीं है", + "Close RSVP": "RSVP बंद करें", + "Select Chapter": "अध्याय चुनें", + "{{number}} WPM": "{{number}} WPM", + "Context": "संदर्भ", + "Ready": "तैयार", + "Chapter Progress": "अध्याय प्रगति", + "words": "शब्द", + "{{time}} left": "{{time}} शेष", + "Reading progress": "पठन प्रगति", + "Click to seek": "सीक करने के लिए क्लिक करें", + "Skip back 15 words": "15 शब्द पीछे", + "Back 15 words (Shift+Left)": "15 शब्द पीछे (Shift+Left)", + "Pause (Space)": "विराम (Space)", + "Play (Space)": "चलाएं (Space)", + "Skip forward 15 words": "15 शब्द आगे", + "Forward 15 words (Shift+Right)": "15 शब्द आगे (Shift+Right)", + "Pause:": "विराम:", + "Decrease speed": "गति कम करें", + "Slower (Left/Down)": "धीमी (Left/Down)", + "Current speed": "वर्तमान गति", + "Increase speed": "गति बढ़ाएं", + "Faster (Right/Up)": "तेज़ (Right/Up)", + "Start RSVP Reading": "RSVP पठन शुरू करें", + "Choose where to start reading": "चुनें कि कहाँ से पढ़ना शुरू करना है", + "From Chapter Start": "अध्याय के आरंभ से", + "Start reading from the beginning of the chapter": "अध्याय की शुरुआत से पढ़ना शुरू करें", + "Resume": "फिर से शुरू करें", + "Continue from where you left off": "वहीं से जारी रखें जहाँ आपने छोड़ा था", + "From Current Page": "वर्तमान पृष्ठ से", + "Start from where you are currently reading": "जहाँ आप अभी पढ़ रहे हैं वहीं से शुरू करें", + "From Selection": "चयन से", + "Speed Reading Mode": "त्वरित पठन मोड" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 9942291d..36cdfe3f 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -960,5 +960,39 @@ "Exit Paragraph Mode": "Keluar dari Mode Paragraf", "Paragraph Mode": "Mode Paragraf", "Embedding Model": "Model Penyematan", - "{{count}} book(s) synced_other": "{{count}} buku telah disinkronkan" + "{{count}} book(s) synced_other": "{{count}} buku telah disinkronkan", + "Unable to start RSVP": "Tidak dapat memulai RSVP", + "RSVP not supported for PDF": "RSVP tidak didukung untuk PDF", + "Close RSVP": "Tutup RSVP", + "Select Chapter": "Pilih Bab", + "{{number}} WPM": "{{number}} WPM", + "Context": "Konteks", + "Ready": "Siap", + "Chapter Progress": "Kemajuan Bab", + "words": "kata", + "{{time}} left": "{{time}} tersisa", + "Reading progress": "Kemajuan membaca", + "Click to seek": "Klik untuk mencari", + "Skip back 15 words": "Lompat mundur 15 kata", + "Back 15 words (Shift+Left)": "Mundur 15 kata (Shift+Kiri)", + "Pause (Space)": "Jeda (Spasi)", + "Play (Space)": "Putar (Spasi)", + "Skip forward 15 words": "Lompat maju 15 kata", + "Forward 15 words (Shift+Right)": "Maju 15 kata (Shift+Kanan)", + "Pause:": "Jeda:", + "Decrease speed": "Kurangi kecepatan", + "Slower (Left/Down)": "Lebih lambat (Kiri/Bawah)", + "Current speed": "Kecepatan saat ini", + "Increase speed": "Tambah kecepatan", + "Faster (Right/Up)": "Lebih cepat (Kanan/Atas)", + "Start RSVP Reading": "Mulai Membaca RSVP", + "Choose where to start reading": "Pilih tempat untuk mulai membaca", + "From Chapter Start": "Dari Awal Bab", + "Start reading from the beginning of the chapter": "Mulai membaca dari awal bab", + "Resume": "Lanjutkan", + "Continue from where you left off": "Lanjutkan dari tempat Anda berhenti", + "From Current Page": "Dari Halaman Saat Ini", + "Start from where you are currently reading": "Mulai dari tempat Anda sedang membaca", + "From Selection": "Dari Pilihan", + "Speed Reading Mode": "Mode Baca Cepat" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index b2386f79..ad6ae4ea 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -984,5 +984,39 @@ "Embedding Model": "Modello di incorporazione", "{{count}} book(s) synced_one": "{{count}} libro sincronizzato", "{{count}} book(s) synced_many": "{{count}} libri sincronizzati", - "{{count}} book(s) synced_other": "{{count}} libri sincronizzati" + "{{count}} book(s) synced_other": "{{count}} libri sincronizzati", + "Unable to start RSVP": "Impossibile avviare RSVP", + "RSVP not supported for PDF": "RSVP non supportato per PDF", + "Close RSVP": "Chiudi RSVP", + "Select Chapter": "Seleziona capitolo", + "{{number}} WPM": "{{number}} PPM", + "Context": "Contesto", + "Ready": "Pronto", + "Chapter Progress": "Progresso capitolo", + "words": "parole", + "{{time}} left": "{{time}} rimanenti", + "Reading progress": "Progresso lettura", + "Click to seek": "Clicca per cercare", + "Skip back 15 words": "Indietro di 15 parole", + "Back 15 words (Shift+Left)": "Indietro di 15 parole (Shift+Sinistra)", + "Pause (Space)": "Pausa (Spazio)", + "Play (Space)": "Riproduci (Spazio)", + "Skip forward 15 words": "Avanti di 15 parole", + "Forward 15 words (Shift+Right)": "Avanti di 15 parole (Shift+Destra)", + "Pause:": "Pausa:", + "Decrease speed": "Diminuisci velocità", + "Slower (Left/Down)": "Più lento (Sinistra/Giù)", + "Current speed": "Velocità attuale", + "Increase speed": "Aumenta velocità", + "Faster (Right/Up)": "Più veloce (Destra/Su)", + "Start RSVP Reading": "Avvia lettura RSVP", + "Choose where to start reading": "Scegli dove iniziare a leggere", + "From Chapter Start": "Dall'inizio del capitolo", + "Start reading from the beginning of the chapter": "Inizia a leggere dall'inizio del capitolo", + "Resume": "Riprendi", + "Continue from where you left off": "Continua da dove avevi interrotto", + "From Current Page": "Dalla pagina corrente", + "Start from where you are currently reading": "Inizia da dove stai leggendo", + "From Selection": "Dalla selezione", + "Speed Reading Mode": "Modalità lettura veloce" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 1f555768..0f51fb2f 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -960,5 +960,39 @@ "Exit Paragraph Mode": "段落モードを終了", "Paragraph Mode": "段落モード", "Embedding Model": "埋め込みモデル", - "{{count}} book(s) synced_other": "{{count}} 冊の本が同期されました" + "{{count}} book(s) synced_other": "{{count}} 冊の本が同期されました", + "Unable to start RSVP": "RSVPを起動できません", + "RSVP not supported for PDF": "PDFはRSVPに対応していません", + "Close RSVP": "RSVPを閉じる", + "Select Chapter": "章を選択", + "{{number}} WPM": "{{number}} WPM", + "Context": "コンテキスト", + "Ready": "準備完了", + "Chapter Progress": "章の進捗", + "words": "単語", + "{{time}} left": "残り {{time}}", + "Reading progress": "読書の進捗", + "Click to seek": "クリックしてシーク", + "Skip back 15 words": "15単語戻る", + "Back 15 words (Shift+Left)": "15単語戻る (Shift+左)", + "Pause (Space)": "一時停止 (Space)", + "Play (Space)": "再生 (Space)", + "Skip forward 15 words": "15単語進む", + "Forward 15 words (Shift+Right)": "15単語進む (Shift+右)", + "Pause:": "一時停止:", + "Decrease speed": "速度を下げる", + "Slower (Left/Down)": "低速 (左/下)", + "Current speed": "現在の速度", + "Increase speed": "速度を上げる", + "Faster (Right/Up)": "高速 (右/上)", + "Start RSVP Reading": "RSVP読書を開始", + "Choose where to start reading": "読書を開始する場所を選択", + "From Chapter Start": "章の最初から", + "Start reading from the beginning of the chapter": "この章の最初から読み直す", + "Resume": "再開", + "Continue from where you left off": "中断した場所から再開する", + "From Current Page": "現在のページから", + "Start from where you are currently reading": "現在読んでいる場所から開始する", + "From Selection": "選択範囲から", + "Speed Reading Mode": "速読モード" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 53ed6def..db032a1d 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -960,5 +960,39 @@ "Exit Paragraph Mode": "단락 모드 종료", "Paragraph Mode": "단락 모드", "Embedding Model": "임베딩 모델", - "{{count}} book(s) synced_other": "{{count}}권의 책이 동기화되었습니다" + "{{count}} book(s) synced_other": "{{count}}권의 책이 동기화되었습니다", + "Unable to start RSVP": "RSVP를 시작할 수 없습니다", + "RSVP not supported for PDF": "PDF는 RSVP를 지원하지 않습니다", + "Close RSVP": "RSVP 닫기", + "Select Chapter": "챕터 선택", + "{{number}} WPM": "{{number}} WPM", + "Context": "문맥", + "Ready": "준비됨", + "Chapter Progress": "챕터 진행 상황", + "words": "단어", + "{{time}} left": "{{time}} 남음", + "Reading progress": "독서 진행 상황", + "Click to seek": "클릭하여 탐색", + "Skip back 15 words": "15단어 뒤로", + "Back 15 words (Shift+Left)": "15단어 뒤로 (Shift+왼쪽 화살표)", + "Pause (Space)": "일시정지 (스페이스바)", + "Play (Space)": "재생 (스페이스바)", + "Skip forward 15 words": "15단어 앞으로", + "Forward 15 words (Shift+Right)": "15단어 앞으로 (Shift+오른쪽 화살표)", + "Pause:": "일시정지:", + "Decrease speed": "속도 줄이기", + "Slower (Left/Down)": "느리게 (왼쪽/아래 화살표)", + "Current speed": "현재 속도", + "Increase speed": "속도 높이기", + "Faster (Right/Up)": "빠르게 (오른쪽/위 화살표)", + "Start RSVP Reading": "RSVP 독서 시작", + "Choose where to start reading": "독서를 시작할 위치 선택", + "From Chapter Start": "챕터 시작부터", + "Start reading from the beginning of the chapter": "챕터 처음부터 다시 시작", + "Resume": "재개", + "Continue from where you left off": "중단한 지점부터 계속 읽기", + "From Current Page": "현재 페이지부터", + "Start from where you are currently reading": "현재 읽고 있는 위치부터 시작", + "From Selection": "선택 범위부터", + "Speed Reading Mode": "속독 모드" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index bcc9544e..d7b08ae6 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -960,5 +960,39 @@ "Exit Paragraph Mode": "Keluar dari Mod Perenggan", "Paragraph Mode": "Mod Perenggan", "Embedding Model": "Model Benaman", - "{{count}} book(s) synced_other": "{{count}} buku telah disegerakkan" + "{{count}} book(s) synced_other": "{{count}} buku telah disegerakkan", + "Unable to start RSVP": "Tidak dapat memulakan RSVP", + "RSVP not supported for PDF": "RSVP tidak disokong untuk PDF", + "Close RSVP": "Tutup RSVP", + "Select Chapter": "Pilih Bab", + "{{number}} WPM": "{{number}} WPM", + "Context": "Konteks", + "Ready": "Sedia", + "Chapter Progress": "Kemajuan Bab", + "words": "perkataan", + "{{time}} left": "{{time}} tinggal", + "Reading progress": "Kemajuan membaca", + "Click to seek": "Klik untuk cari", + "Skip back 15 words": "Langkau belakang 15 perkataan", + "Back 15 words (Shift+Left)": "Belakang 15 perkataan (Shift+Kiri)", + "Pause (Space)": "Jeda (Ruang)", + "Play (Space)": "Main (Ruang)", + "Skip forward 15 words": "Langkau depan 15 perkataan", + "Forward 15 words (Shift+Right)": "Depan 15 perkataan (Shift+Kanan)", + "Pause:": "Jeda:", + "Decrease speed": "Kurangkan kelajuan", + "Slower (Left/Down)": "Lebih perlahan (Kiri/Bawah)", + "Current speed": "Kelajuan semasa", + "Increase speed": "Tingkatkan kelajuan", + "Faster (Right/Up)": "Lebih cepat (Kanan/Atas)", + "Start RSVP Reading": "Mulakan Pembacaan RSVP", + "Choose where to start reading": "Pilih tempat untuk mula membaca", + "From Chapter Start": "Dari Permulaan Bab", + "Start reading from the beginning of the chapter": "Mula membaca dari awal bab", + "Resume": "Sambung", + "Continue from where you left off": "Sambung dari tempat anda berhenti", + "From Current Page": "Dari Halaman Semasa", + "Start from where you are currently reading": "Mula dari tempat anda sedang membaca", + "From Selection": "Dari Pilihan", + "Speed Reading Mode": "Mod Bacaan Pantas" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 697d2e6e..68bd443d 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -972,5 +972,39 @@ "Paragraph Mode": "Alineamodus", "Embedding Model": "Insluitmodel", "{{count}} book(s) synced_one": "{{count}} boek gesynchroniseerd", - "{{count}} book(s) synced_other": "{{count}} boeken gesynchroniseerd" + "{{count}} book(s) synced_other": "{{count}} boeken gesynchroniseerd", + "Unable to start RSVP": "Kan RSVP niet starten", + "RSVP not supported for PDF": "RSVP niet ondersteund voor PDF", + "Close RSVP": "RSVP sluiten", + "Select Chapter": "Hoofdstuk selecteren", + "{{number}} WPM": "{{number}} WPM", + "Context": "Context", + "Ready": "Klaar", + "Chapter Progress": "Hoofdstukvoortgang", + "words": "woorden", + "{{time}} left": "{{time}} over", + "Reading progress": "Leesvoortgang", + "Click to seek": "Klik om te zoeken", + "Skip back 15 words": "15 woorden terug", + "Back 15 words (Shift+Left)": "15 woorden terug (Shift+Links)", + "Pause (Space)": "Pauze (Spatie)", + "Play (Space)": "Afspelen (Spatie)", + "Skip forward 15 words": "15 woorden vooruit", + "Forward 15 words (Shift+Right)": "15 woorden vooruit (Shift+Rechts)", + "Pause:": "Pauze:", + "Decrease speed": "Snelheid verlagen", + "Slower (Left/Down)": "Langzamer (Links/Omlaag)", + "Current speed": "Huidige snelheid", + "Increase speed": "Snelheid verhogen", + "Faster (Right/Up)": "Sneller (Rechts/Omhoog)", + "Start RSVP Reading": "Start RSVP-lezen", + "Choose where to start reading": "Kies waar u wilt beginnen met lezen", + "From Chapter Start": "Vanaf begin hoofdstuk", + "Start reading from the beginning of the chapter": "Begin met lezen aan het begin van het hoofdstuk", + "Resume": "Hervatten", + "Continue from where you left off": "Ga verder waar u was gebleven", + "From Current Page": "Vanaf huidige pagina", + "Start from where you are currently reading": "Begin waar u momenteel leest", + "From Selection": "Vanaf selectie", + "Speed Reading Mode": "Snelle leesmodus" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 0df0bb8a..82720d6f 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -996,5 +996,39 @@ "{{count}} book(s) synced_one": "{{count}} książka zsynchronizowana", "{{count}} book(s) synced_few": "{{count}} książki zsynchronizowane", "{{count}} book(s) synced_many": "{{count}} książek zsynchronizowanych", - "{{count}} book(s) synced_other": "{{count}} książek zsynchronizowanych" + "{{count}} book(s) synced_other": "{{count}} książek zsynchronizowanych", + "Unable to start RSVP": "Nie można uruchomić RSVP", + "RSVP not supported for PDF": "RSVP nie jest obsługiwane dla PDF", + "Close RSVP": "Zamknij RSVP", + "Select Chapter": "Wybierz rozdział", + "{{number}} WPM": "{{number}} WPM", + "Context": "Kontekst", + "Ready": "Gotowy", + "Chapter Progress": "Postęp rozdziału", + "words": "słowa", + "{{time}} left": "pozostało {{time}}", + "Reading progress": "Postęp czytania", + "Click to seek": "Kliknij, aby wyszukać", + "Skip back 15 words": "Cofnij o 15 słów", + "Back 15 words (Shift+Left)": "Cofnij o 15 słów (Shift+Lewo)", + "Pause (Space)": "Pauze (Spacja)", + "Play (Space)": "Odtwarzaj (Spacja)", + "Skip forward 15 words": "Naprzód o 15 słów", + "Forward 15 words (Shift+Right)": "Naprzód o 15 słów (Shift+Prawo)", + "Pause:": "Pauza:", + "Decrease speed": "Zmniejsz prędkość", + "Slower (Left/Down)": "Wolniej (Lewo/Dół)", + "Current speed": "Aktualna prędkość", + "Increase speed": "Zwiększ prędkość", + "Faster (Right/Up)": "Szybciej (Prawo/Góra)", + "Start RSVP Reading": "Uruchom czytanie RSVP", + "Choose where to start reading": "Wybierz, gdzie zacząć czytać", + "From Chapter Start": "Od początku rozdziału", + "Start reading from the beginning of the chapter": "Zacznij czytać od początku rozdziału", + "Resume": "Wznów", + "Continue from where you left off": "Kontynuuj od miejsca, w którym przerwałeś", + "From Current Page": "Od bieżącej strony", + "Start from where you are currently reading": "Zacznij od miejsca, w którym aktualnie czytasz", + "From Selection": "Z zaznaczenia", + "Speed Reading Mode": "Tryb szybkiego czytania" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 0c9979b2..63853b0f 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -984,5 +984,39 @@ "Embedding Model": "Modelo de incorporação", "{{count}} book(s) synced_one": "{{count}} livro sincronizado", "{{count}} book(s) synced_many": "{{count}} livros sincronizados", - "{{count}} book(s) synced_other": "{{count}} livros sincronizados" + "{{count}} book(s) synced_other": "{{count}} livros sincronizados", + "Unable to start RSVP": "Não foi possível iniciar o RSVP", + "RSVP not supported for PDF": "RSVP não suportado para PDF", + "Close RSVP": "Fechar RSVP", + "Select Chapter": "Selecionar capítulo", + "{{number}} WPM": "{{number}} PPM", + "Context": "Contexto", + "Ready": "Pronto", + "Chapter Progress": "Progresso do capítulo", + "words": "palavras", + "{{time}} left": "{{time}} restante", + "Reading progress": "Progresso de leitura", + "Click to seek": "Clique para navegar", + "Skip back 15 words": "Voltar 15 palavras", + "Back 15 words (Shift+Left)": "Voltar 15 palavras (Shift+Esquerda)", + "Pause (Space)": "Pausar (Espaço)", + "Play (Space)": "Reproduzir (Espaço)", + "Skip forward 15 words": "Avançar 15 palavras", + "Forward 15 words (Shift+Right)": "Avançar 15 palavras (Shift+Direita)", + "Pause:": "Pausa:", + "Decrease speed": "Diminuir velocidade", + "Slower (Left/Down)": "Mais lento (Esquerda/Baixo)", + "Current speed": "Velocidade atual", + "Increase speed": "Aumentar velocidade", + "Faster (Right/Up)": "Mais rápido (Direita/Cima)", + "Start RSVP Reading": "Iniciar leitura RSVP", + "Choose where to start reading": "Escolha onde começar a ler", + "From Chapter Start": "Do início do capítulo", + "Start reading from the beginning of the chapter": "Começar a ler do início do capítulo", + "Resume": "Retomar", + "Continue from where you left off": "Continuar de onde parou", + "From Current Page": "Da página atual", + "Start from where you are currently reading": "Começar de onde está lendo no momento", + "From Selection": "Da seleção", + "Speed Reading Mode": "Modo de leitura rápida" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 5ad5e9cb..693107d2 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -996,5 +996,39 @@ "{{count}} book(s) synced_one": "{{count}} книга синхронизирована", "{{count}} book(s) synced_few": "{{count}} книги синхронизированы", "{{count}} book(s) synced_many": "{{count}} книг синхронизировано", - "{{count}} book(s) synced_other": "{{count}} книг синхронизировано" + "{{count}} book(s) synced_other": "{{count}} книг синхронизировано", + "Unable to start RSVP": "Не удалось запустить RSVP", + "RSVP not supported for PDF": "RSVP не поддерживается для PDF", + "Close RSVP": "Закрыть RSVP", + "Select Chapter": "Выбрать главу", + "{{number}} WPM": "{{number}} WPM", + "Context": "Контекст", + "Ready": "Готово", + "Chapter Progress": "Прогресс главы", + "words": "слов", + "{{time}} left": "осталось {{time}}", + "Reading progress": "Прогресс чтения", + "Click to seek": "Нажмите для поиска", + "Skip back 15 words": "Назад на 15 слов", + "Back 15 words (Shift+Left)": "Назад на 15 слов (Shift+Влево)", + "Pause (Space)": "Пауза (Пробел)", + "Play (Space)": "Воспроизведение (Пробел)", + "Skip forward 15 words": "Вперед на 15 слов", + "Forward 15 words (Shift+Right)": "Вперед на 15 слов (Shift+Вправо)", + "Pause:": "Пауза:", + "Decrease speed": "Уменьшить скорость", + "Slower (Left/Down)": "Медленнее (Влево/Вниз)", + "Current speed": "Текущая скорость", + "Increase speed": "Увеличить скорость", + "Faster (Right/Up)": "Быстрее (Вправо/Вверх)", + "Start RSVP Reading": "Начать чтение RSVP", + "Choose where to start reading": "Выберите, где начать чтение", + "From Chapter Start": "С начала главы", + "Start reading from the beginning of the chapter": "Начать чтение с начала главы", + "Resume": "Продолжить", + "Continue from where you left off": "Продолжить с того места, где вы остановились", + "From Current Page": "С текущей страницы", + "Start from where you are currently reading": "Начать с того места, где вы сейчас читаете", + "From Selection": "Из выбранного", + "Speed Reading Mode": "Режим быстрого чтения" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 6131857c..81db68e1 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -972,5 +972,39 @@ "Paragraph Mode": "ඡේද ප්‍රකාරය", "Embedding Model": "එබ්බවීමේ ආකෘතිය", "{{count}} book(s) synced_one": "{{count}} පොතක් සමමුහුර්ත කරන ලදී", - "{{count}} book(s) synced_other": "{{count}} පොත් සමමුහුර්ත කරන ලදී" + "{{count}} book(s) synced_other": "{{count}} පොත් සමමුහුර්ත කරන ලදී", + "Unable to start RSVP": "RSVP ආරම්භ කිරීමට නොහැක", + "RSVP not supported for PDF": "PDF සඳහා RSVP සහාය නොදක්වයි", + "Close RSVP": "RSVP වසන්න", + "Select Chapter": "පරිච්ඡේදය තෝරන්න", + "{{number}} WPM": "{{number}} WPM", + "Context": "සන්දර්භය", + "Ready": "සූදානම්", + "Chapter Progress": "පරිච්ඡේදයේ ප්‍රගතිය", + "words": "වචන", + "{{time}} left": "{{time}} ඉතිරිව ඇත", + "Reading progress": "කියවීමේ ප්‍රගතිය", + "Click to seek": "සොයා බැලීමට ක්ලික් කරන්න", + "Skip back 15 words": "වචන 15ක් පසුපසට", + "Back 15 words (Shift+Left)": "වචන 15ක් පසුපසට (Shift+Left)", + "Pause (Space)": "විරාමය (Space)", + "Play (Space)": "ධාවනය (Space)", + "Skip forward 15 words": "වචන 15ක් ඉදිරියට", + "Forward 15 words (Shift+Right)": "වචන 15ක් ඉදිරියට (Shift+Right)", + "Pause:": "විරාමය:", + "Decrease speed": "වේගය අඩු කරන්න", + "Slower (Left/Down)": "මන්දගාමී (Left/Down)", + "Current speed": "වත්මන් වේගය", + "Increase speed": "වේගය වැඩි කරන්න", + "Faster (Right/Up)": "වේගවත් (Right/Up)", + "Start RSVP Reading": "RSVP කියවීම ආරම්භ කරන්න", + "Choose where to start reading": "කියවීම ආරම්භ කළ යුතු ස්ථානය තෝරන්න", + "From Chapter Start": "පරිච්ඡේදයේ ආරම්භයේ සිට", + "Start reading from the beginning of the chapter": "පරිච්ඡේදයේ ආරම්භයේ සිට කියවීම ආරම්භ කරන්න", + "Resume": "නැවත ආරම්භ කරන්න", + "Continue from where you left off": "ඔබ නතර කළ තැන සිට දිගටම කරගෙන යන්න", + "From Current Page": "වත්මන් පිටුවේ සිට", + "Start from where you are currently reading": "ඔබ දැනට කියවන තැනින් ආරම්භ කරන්න", + "From Selection": "තේරීමෙන්", + "Speed Reading Mode": "වේගයෙන් කියවීමේ ක්‍රමය" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index d9933d80..66badc25 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -972,5 +972,39 @@ "Paragraph Mode": "Styckeläge", "Embedding Model": "Inbäddningsmodell", "{{count}} book(s) synced_one": "{{count}} bok synkroniserad", - "{{count}} book(s) synced_other": "{{count}} böcker synkroniserade" + "{{count}} book(s) synced_other": "{{count}} böcker synkroniserade", + "Unable to start RSVP": "Kunde inte starta RSVP", + "RSVP not supported for PDF": "RSVP stöds inte för PDF", + "Close RSVP": "Stäng RSVP", + "Select Chapter": "Välj kapitel", + "{{number}} WPM": "{{number}} ord/min", + "Context": "Sammanhang", + "Ready": "Klar", + "Chapter Progress": "Kapitelframsteg", + "words": "ord", + "{{time}} left": "{{time}} kvar", + "Reading progress": "Läsningens framsteg", + "Click to seek": "Klicka för att söka", + "Skip back 15 words": "Hoppa bakåt 15 ord", + "Back 15 words (Shift+Left)": "Bakåt 15 ord (Shift+Vänster)", + "Pause (Space)": "Pausa (Mellanslag)", + "Play (Space)": "Spela (Mellanslag)", + "Skip forward 15 words": "Hoppa framåt 15 ord", + "Forward 15 words (Shift+Right)": "Framåt 15 ord (Shift+Höger)", + "Pause:": "Paus:", + "Decrease speed": "Sänk hastigheten", + "Slower (Left/Down)": "Långsammare (Vänster/Nedåt)", + "Current speed": "Nuvarande hastighet", + "Increase speed": "Höj hastigheten", + "Faster (Right/Up)": "Snabbare (Höger/Uppåt)", + "Start RSVP Reading": "Starta RSVP-läsning", + "Choose where to start reading": "Välj var du vill börja läsa", + "From Chapter Start": "Från början av kapitlet", + "Start reading from the beginning of the chapter": "Börja läsa från början av kapitlet", + "Resume": "Återuppta", + "Continue from where you left off": "Fortsätt där du slutade", + "From Current Page": "Från den aktuella sidan", + "Start from where you are currently reading": "Börja där du läser just nu", + "From Selection": "Från markeringen", + "Speed Reading Mode": "Snabbläsningsläge" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 6a48246e..83c264ff 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -972,5 +972,39 @@ "Paragraph Mode": "பத்தி பயன்முறை", "Embedding Model": "உட்பொதி மாதிரி", "{{count}} book(s) synced_one": "{{count}} புத்தகம் ஒத்திசைக்கப்பட்டது", - "{{count}} book(s) synced_other": "{{count}} புத்தகங்கள் ஒத்திசைக்கப்பட்டன" + "{{count}} book(s) synced_other": "{{count}} புத்தகங்கள் ஒத்திசைக்கப்பட்டன", + "Unable to start RSVP": "RSVP-ஐத் தொடங்க முடியவில்லை", + "RSVP not supported for PDF": "PDF-க்கு RSVP ஆதரவு இல்லை", + "Close RSVP": "RSVP-ஐ மூடு", + "Select Chapter": "அத்தியாயத்தைத் தேர்ந்தெடு", + "{{number}} WPM": "{{number}} WPM", + "Context": "சூழல்", + "Ready": "தயார்", + "Chapter Progress": "அத்தியாய முன்னேற்றம்", + "words": "வார்த்தைகள்", + "{{time}} left": "{{time}} மீதமுள்ளது", + "Reading progress": "வாசிப்பு முன்னேற்றம்", + "Click to seek": "தேட கிளிக் செய்க", + "Skip back 15 words": "15 வார்த்தைகள் பின்னால் செல்", + "Back 15 words (Shift+Left)": "15 வார்த்தைகள் பின்னால் செல் (Shift+Left)", + "Pause (Space)": "நிறுத்து (Space)", + "Play (Space)": "இயக்கு (Space)", + "Skip forward 15 words": "15 வார்த்தைகள் முன்னால் செல்", + "Forward 15 words (Shift+Right)": "15 வார்த்தைகள் முன்னால் செல் (Shift+Right)", + "Pause:": "நிறுத்து:", + "Decrease speed": "வேகத்தைக் குறை", + "Slower (Left/Down)": "மெதுவாக (Left/Down)", + "Current speed": "தற்போதைய வேகம்", + "Increase speed": "வேகத்தை அதிகரி", + "Faster (Right/Up)": "வேகமாக (Right/Up)", + "Start RSVP Reading": "RSVP வாசிப்பைத் தொடங்கு", + "Choose where to start reading": "எங்கிருந்து வாசிக்கத் தொடங்க வேண்டும் என்பதைத் தேர்வு செய்க", + "From Chapter Start": "அத்தியாயத்தின் தொடக்கத்திலிருந்து", + "Start reading from the beginning of the chapter": "அத்தியாயத்தின் தொடக்கத்திலிருந்து வாசிக்கத் தொடங்கு", + "Resume": "தொடரவும்", + "Continue from where you left off": "நீங்கள் விட்ட இடத்திலிருந்து தொடரவும்", + "From Current Page": "தற்போதைய பக்கத்திலிருந்து", + "Start from where you are currently reading": "நீங்கள் தற்போது வாசிக்கும் இடத்திலிருந்து தொடங்கு", + "From Selection": "தேர்விலிருந்து", + "Speed Reading Mode": "வேக வாசிப்பு முறை" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index fb9312a3..57ccf9f9 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -960,5 +960,39 @@ "Exit Paragraph Mode": "ออกจากโหมดพารากราฟ", "Paragraph Mode": "โหมดพารากราฟ", "Embedding Model": "โมเดลการฝังตัว", - "{{count}} book(s) synced_other": "ซิงค์หนังสือ {{count}} เล่มแล้ว" + "{{count}} book(s) synced_other": "ซิงค์หนังสือ {{count}} เล่มแล้ว", + "Unable to start RSVP": "ไม่สามารถเริ่ม RSVP ได้", + "RSVP not supported for PDF": "ไม่รองรับ RSVP สำหรับ PDF", + "Close RSVP": "ปิด RSVP", + "Select Chapter": "เลือกบท", + "{{number}} WPM": "{{number}} WPM", + "Context": "บริบท", + "Ready": "พร้อม", + "Chapter Progress": "ความคืบหน้าของบท", + "words": "คำ", + "{{time}} left": "เหลือ {{time}}", + "Reading progress": "ความคืบหน้าการอ่าน", + "Click to seek": "คลิกเพื่อค้นหาตำแหน่ง", + "Skip back 15 words": "ย้อนกลับ 15 คำ", + "Back 15 words (Shift+Left)": "ย้อนกลับ 15 คำ (Shift+ซ้าย)", + "Pause (Space)": "หยุดชั่วคราว (Space)", + "Play (Space)": "เล่น (Space)", + "Skip forward 15 words": "ไปข้างหน้า 15 คำ", + "Forward 15 words (Shift+Right)": "ไปข้างหน้า 15 คำ (Shift+ขวา)", + "Pause:": "หยุดชั่วคราว:", + "Decrease speed": "ลดความเร็ว", + "Slower (Left/Down)": "ช้าลง (ซ้าย/ลง)", + "Current speed": "ความเร็วปัจจุบัน", + "Increase speed": "เพิ่มความเร็ว", + "Faster (Right/Up)": "เร็วขึ้น (ขวา/ขึ้น)", + "Start RSVP Reading": "เริ่มการอ่านแบบ RSVP", + "Choose where to start reading": "เลือกจุดที่ต้องการเริ่มอ่าน", + "From Chapter Start": "จากจุดเริ่มต้นของบท", + "Start reading from the beginning of the chapter": "เริ่มอ่านใหม่ตั้งแต่ต้นบทนี้", + "Resume": "อ่านต่อ", + "Continue from where you left off": "อ่านต่อจากจุดที่ค้างไว้", + "From Current Page": "จากหน้าปัจจุบัน", + "Start from where you are currently reading": "เริ่มจากจุดที่คุณกำลังอ่านอยู่ตอนนี้", + "From Selection": "จากส่วนที่เลือก", + "Speed Reading Mode": "โหมดการอ่านเร็ว" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index b722a36e..d62d0074 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -972,5 +972,39 @@ "Paragraph Mode": "Paragraf modu", "Embedding Model": "Gömme Modeli", "{{count}} book(s) synced_one": "{{count}} kitap senkronize edildi", - "{{count}} book(s) synced_other": "{{count}} kitap senkronize edildi" + "{{count}} book(s) synced_other": "{{count}} kitap senkronize edildi", + "Unable to start RSVP": "RSVP başlatılamadı", + "RSVP not supported for PDF": "PDF için RSVP desteklenmiyor", + "Close RSVP": "RSVP'yi Kapat", + "Select Chapter": "Bölüm Seç", + "{{number}} WPM": "{{number}} Kelime/Dakika", + "Context": "Bağlam", + "Ready": "Hazır", + "Chapter Progress": "Bölüm İlerlemesi", + "words": "kelime", + "{{time}} left": "{{time}} kaldı", + "Reading progress": "Okuma ilerlemesi", + "Click to seek": "Konum aramak için tıkla", + "Skip back 15 words": "15 kelime geri atla", + "Back 15 words (Shift+Left)": "15 kelime geri (Shift+Sol)", + "Pause (Space)": "Duraklat (Boşluk)", + "Play (Space)": "Oynat (Boşluk)", + "Skip forward 15 words": "15 kelime ileri atla", + "Forward 15 words (Shift+Right)": "15 kelime ileri (Shift+Sağ)", + "Pause:": "Duraklat:", + "Decrease speed": "Hızı azalt", + "Slower (Left/Down)": "Daha yavaş (Sol/Aşağı)", + "Current speed": "Mevcut hız", + "Increase speed": "Hızı artır", + "Faster (Right/Up)": "Daha hızlı (Sağ/Yukarı)", + "Start RSVP Reading": "RSVP Okumasını Başlat", + "Choose where to start reading": "Nereden okumaya başlayacağınızı seçin", + "From Chapter Start": "Bölüm Başından", + "Start reading from the beginning of the chapter": "Bölümün başından itibaren okumaya başla", + "Resume": "Devam Et", + "Continue from where you left off": "Kaldığınız yerden devam edin", + "From Current Page": "Mevcut Sayfadan", + "Start from where you are currently reading": "Şu anda okuduğunuz yerden başlayın", + "From Selection": "Seçimden", + "Speed Reading Mode": "Hızlı Okuma Modu" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 57b5e07d..361570ed 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -996,5 +996,39 @@ "{{count}} book(s) synced_one": "{{count}} книга синхронізована", "{{count}} book(s) synced_few": "{{count}} книги синхронізовані", "{{count}} book(s) synced_many": "{{count}} книг синхронізовано", - "{{count}} book(s) synced_other": "{{count}} книг синхронізовано" + "{{count}} book(s) synced_other": "{{count}} книг синхронізовано", + "Unable to start RSVP": "Не вдалося запустити RSVP", + "RSVP not supported for PDF": "RSVP не підтримується для PDF", + "Close RSVP": "Закрити RSVP", + "Select Chapter": "Вибрати розділ", + "{{number}} WPM": "{{number}} WPM", + "Context": "Контекст", + "Ready": "Готово", + "Chapter Progress": "Прогрес розділу", + "words": "слів", + "{{time}} left": "залишилося {{time}}", + "Reading progress": "Прогрес читання", + "Click to seek": "Натисніть для пошуку", + "Skip back 15 words": "Назад на 15 слів", + "Back 15 words (Shift+Left)": "Назад на 15 слів (Shift+Вліво)", + "Pause (Space)": "Пауза (Пробіл)", + "Play (Space)": "Відтворити (Пробіл)", + "Skip forward 15 words": "Вперед на 15 слів", + "Forward 15 words (Shift+Right)": "Вперед на 15 слів (Shift+Вправо)", + "Pause:": "Пауза:", + "Decrease speed": "Зменшити швидкість", + "Slower (Left/Down)": "Повільніше (Вліво/Вниз)", + "Current speed": "Поточна швидкість", + "Increase speed": "Збільшити швидкість", + "Faster (Right/Up)": "Швидше (Вправо/Вгору)", + "Start RSVP Reading": "Почати читання RSVP", + "Choose where to start reading": "Виберіть, де почати читання", + "From Chapter Start": "З початку розділу", + "Start reading from the beginning of the chapter": "Почати читання з початку розділу", + "Resume": "Продовжити", + "Continue from where you left off": "Продовжити з того місця, де ви зупинилися", + "From Current Page": "З поточної сторінки", + "Start from where you are currently reading": "Почати з того місця, де ви зараз читаєте", + "From Selection": "З виділеного", + "Speed Reading Mode": "Режим швидкого читання" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 0b22420d..e453deb9 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -960,5 +960,39 @@ "Exit Paragraph Mode": "Thoát chế độ đoạn văn", "Paragraph Mode": "Chế độ đoạn văn", "Embedding Model": "Mô hình nhúng", - "{{count}} book(s) synced_other": "Đã đồng bộ {{count}} cuốn sách" + "{{count}} book(s) synced_other": "Đã đồng bộ {{count}} cuốn sách", + "Unable to start RSVP": "Không thể bắt đầu RSVP", + "RSVP not supported for PDF": "RSVP không được hỗ trợ cho PDF", + "Close RSVP": "Đóng RSVP", + "Select Chapter": "Chọn chương", + "{{number}} WPM": "{{number}} WPM", + "Context": "Ngữ cảnh", + "Ready": "Sẵn sàng", + "Chapter Progress": "Tiến độ chương", + "words": "từ", + "{{time}} left": "còn lại {{time}}", + "Reading progress": "Tiến độ đọc", + "Click to seek": "Nhấp để tua", + "Skip back 15 words": "Lùi lại 15 từ", + "Back 15 words (Shift+Left)": "Lùi lại 15 từ (Shift+Trái)", + "Pause (Space)": "Tạm dừng (Khoảng trắng)", + "Play (Space)": "Phát (Khoảng trắng)", + "Skip forward 15 words": "Tiến tới 15 từ", + "Forward 15 words (Shift+Right)": "Tiến tới 15 từ (Shift+Phải)", + "Pause:": "Tạm dừng:", + "Decrease speed": "Giảm tốc độ", + "Slower (Left/Down)": "Chậm hơn (Trái/Xuống)", + "Current speed": "Tốc độ hiện tại", + "Increase speed": "Tăng tốc độ", + "Faster (Right/Up)": "Nhanh hơn (Phải/Lên)", + "Start RSVP Reading": "Bắt đầu đọc RSVP", + "Choose where to start reading": "Chọn nơi để bắt đầu đọc", + "From Chapter Start": "Từ đầu chương", + "Start reading from the beginning of the chapter": "Bắt đầu đọc lại từ đầu chương này", + "Resume": "Tiếp tục", + "Continue from where you left off": "Tiếp tục từ nơi bạn đã dừng lại", + "From Current Page": "Từ trang hiện tại", + "Start from where you are currently reading": "Bắt đầu từ nơi bạn hiện đang đọc", + "From Selection": "Từ phần đã chọn", + "Speed Reading Mode": "Chế độ đọc nhanh" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index a3545f69..2712429c 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -960,5 +960,39 @@ "Exit Paragraph Mode": "退出段落模式", "Paragraph Mode": "段落模式", "Embedding Model": "嵌入模型", - "{{count}} book(s) synced_other": "{{count}} 本书已同步" + "{{count}} book(s) synced_other": "{{count}} 本书已同步", + "Unable to start RSVP": "无法启动 RSVP", + "RSVP not supported for PDF": "PDF 不支持 RSVP", + "Close RSVP": "关闭 RSVP", + "Select Chapter": "选择章节", + "{{number}} WPM": "{{number}} WPM", + "Context": "上下文", + "Ready": "准备就绪", + "Chapter Progress": "章节进度", + "words": "词", + "{{time}} left": "剩余 {{time}}", + "Reading progress": "阅读进度", + "Click to seek": "点击跳转", + "Skip back 15 words": "回退 15 词", + "Back 15 words (Shift+Left)": "回退 15 词 (Shift+Left)", + "Pause (Space)": "暂停 (空格)", + "Play (Space)": "播放 (空格)", + "Skip forward 15 words": "前进 15 词", + "Forward 15 words (Shift+Right)": "前进 15 词 (Shift+Right)", + "Pause:": "暂停:", + "Decrease speed": "减速", + "Slower (Left/Down)": "较慢 (左/下)", + "Current speed": "当前速度", + "Increase speed": "加速", + "Faster (Right/Up)": "较快 (右/上)", + "Start RSVP Reading": "开始 RSVP 阅读", + "Choose where to start reading": "选择阅读起始位置", + "From Chapter Start": "从章节开始", + "Start reading from the beginning of the chapter": "重新从本章节开始阅读", + "Resume": "继续", + "Continue from where you left off": "从上次离开的地方继续", + "From Current Page": "从当前页", + "Start from where you are currently reading": "从当前正在阅读的位置开始", + "From Selection": "从选中内容", + "Speed Reading Mode": "快读模式" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 390e5e3d..7a7b8601 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -960,5 +960,39 @@ "Exit Paragraph Mode": "退出段落模式", "Paragraph Mode": "段落模式", "Embedding Model": "嵌入模型", - "{{count}} book(s) synced_other": "{{count}} 本書已同步" + "{{count}} book(s) synced_other": "{{count}} 本書已同步", + "Unable to start RSVP": "無法啟動 RSVP", + "RSVP not supported for PDF": "PDF 不支援 RSVP", + "Close RSVP": "關閉 RSVP", + "Select Chapter": "選擇章節", + "{{number}} WPM": "{{number}} WPM", + "Context": "上下文", + "Ready": "準備就緒", + "Chapter Progress": "章節進度", + "words": "詞", + "{{time}} left": "剩餘 {{time}}", + "Reading progress": "閱讀進度", + "Click to seek": "點擊跳轉", + "Skip back 15 words": "回退 15 詞", + "Back 15 words (Shift+Left)": "回退 15 詞 (Shift+Left)", + "Pause (Space)": "暫停 (空格)", + "Play (Space)": "播放 (空格)", + "Skip forward 15 words": "前進 15 詞", + "Forward 15 words (Shift+Right)": "前進 15 詞 (Shift+Right)", + "Pause:": "暫停:", + "Decrease speed": "減速", + "Slower (Left/Down)": "較慢 (左/下)", + "Current speed": "當前速度", + "Increase speed": "加速", + "Faster (Right/Up)": "較快 (右/上)", + "Start RSVP Reading": "開始 RSVP 閱讀", + "Choose where to start reading": "選擇閱讀起始位置", + "From Chapter Start": "從章節開始", + "Start reading from the beginning of the chapter": "重新從本章節開始閱讀", + "Resume": "繼續", + "Continue from where you left off": "從上次離開的地方繼續", + "From Current Page": "從當前頁", + "Start from where you are currently reading": "從當前正在閱讀的位置開始", + "From Selection": "從選取內容", + "Speed Reading Mode": "快讀模式" } diff --git a/apps/readest-app/src/app/reader/components/ViewMenu.tsx b/apps/readest-app/src/app/reader/components/ViewMenu.tsx index 5116a374..cb0edf39 100644 --- a/apps/readest-app/src/app/reader/components/ViewMenu.tsx +++ b/apps/readest-app/src/app/reader/components/ViewMenu.tsx @@ -93,6 +93,11 @@ const ViewMenu: React.FC = ({ bookKey, setIsDropdownOpen }) => { } }; + const handleStartRSVP = () => { + setIsDropdownOpen?.(false); + eventDispatcher.dispatch('rsvp-start', { bookKey }); + }; + useEffect(() => { if (isScrolledMode === viewSettings!.scrolled) return; viewSettings!.scrolled = isScrolledMode; @@ -270,6 +275,8 @@ const ViewMenu: React.FC = ({ bookKey, setIsDropdownOpen }) => { disabled={bookData.isFixedLayout} /> + + = ({ bookKey, setIsDropdownOpen }) => { disabled={bookData.isFixedLayout} /> + + = ({ bookKey, @@ -249,6 +250,7 @@ const FooterBar: React.FC = ({ )} + ); }; diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx new file mode 100644 index 00000000..58c8e92b --- /dev/null +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx @@ -0,0 +1,423 @@ +'use client'; + +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { useReaderStore } from '@/store/readerStore'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { useThemeStore } from '@/store/themeStore'; +import { RSVPController, RsvpStartChoice, RsvpStopPosition } from '@/services/rsvp'; +import { eventDispatcher } from '@/utils/event'; +import { useTranslation } from '@/hooks/useTranslation'; +import { BookNote } from '@/types/book'; +import RSVPOverlay from './RSVPOverlay'; +import RSVPStartDialog from './RSVPStartDialog'; + +interface RSVPControlProps { + bookKey: string; +} + +// Helper to expand a range to include the full sentence +const expandRangeToSentence = (range: Range, doc: Document): Range => { + const sentenceRange = doc.createRange(); + + // Get the text content around the range + const container = range.commonAncestorContainer; + const parentElement = + container.nodeType === Node.TEXT_NODE ? container.parentElement : (container as Element); + + if (!parentElement) return range; + + // Get the full text of the parent paragraph/element + const fullText = parentElement.textContent || ''; + const rangeText = range.toString(); + + // Find the position of our word in the parent text + const wordStart = fullText.indexOf(rangeText); + if (wordStart === -1) return range; + + // Find sentence boundaries (. ! ? or start/end of text) + const sentenceEnders = /[.!?]/g; + let sentenceStart = 0; + let sentenceEnd = fullText.length; + + // Find the sentence start (look backwards for sentence ender) + for (let i = wordStart - 1; i >= 0; i--) { + if (sentenceEnders.test(fullText[i]!)) { + sentenceStart = i + 1; + // Skip any whitespace after the sentence ender + while (sentenceStart < fullText.length && /\s/.test(fullText[sentenceStart]!)) { + sentenceStart++; + } + break; + } + } + + // Find the sentence end (look forward for sentence ender) + for (let i = wordStart; i < fullText.length; i++) { + if (sentenceEnders.test(fullText[i]!)) { + sentenceEnd = i + 1; + break; + } + } + + // Create a tree walker to find the text nodes + const walker = doc.createTreeWalker(parentElement, NodeFilter.SHOW_TEXT, null); + let currentOffset = 0; + let startNode: Text | null = null; + let startOffset = 0; + let endNode: Text | null = null; + let endOffset = 0; + + let node: Text | null; + while ((node = walker.nextNode() as Text | null)) { + const nodeLength = node.textContent?.length || 0; + + if (!startNode && currentOffset + nodeLength > sentenceStart) { + startNode = node; + startOffset = sentenceStart - currentOffset; + } + + if (currentOffset + nodeLength >= sentenceEnd) { + endNode = node; + endOffset = sentenceEnd - currentOffset; + break; + } + + currentOffset += nodeLength; + } + + if (startNode && endNode) { + try { + sentenceRange.setStart(startNode, Math.max(0, startOffset)); + sentenceRange.setEnd(endNode, Math.min(endOffset, endNode.textContent?.length || 0)); + return sentenceRange; + } catch { + return range; + } + } + + return range; +}; + +const RSVPControl: React.FC = ({ bookKey }) => { + const _ = useTranslation(); + const { + getView, + getProgress, + getViewSettings: _getViewSettings, + setProgress: _setProgress, + } = useReaderStore(); + const { getBookData } = useBookDataStore(); + const { themeCode } = useThemeStore(); + + const [isActive, setIsActive] = useState(false); + const [showStartDialog, setShowStartDialog] = useState(false); + const [startChoice, setStartChoice] = useState(null); + const controllerRef = useRef(null); + const tempHighlightRef = useRef(null); + + // Helper to remove any existing RSVP highlight + const removeRsvpHighlight = useCallback(() => { + const view = getView(bookKey); + if (tempHighlightRef.current && view) { + try { + view.addAnnotation(tempHighlightRef.current, true); + } catch { + // Ignore errors when removing + } + } + tempHighlightRef.current = null; + }, [bookKey, getView]); + + // Clean up controller and highlight on unmount + useEffect(() => { + return () => { + if (controllerRef.current) { + controllerRef.current.shutdown(); + controllerRef.current = null; + } + // Remove any existing RSVP highlight when component unmounts + removeRsvpHighlight(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Listen for RSVP start events + useEffect(() => { + const handleRSVPStart = (event: CustomEvent) => { + const { bookKey: rsvpBookKey, selectionText } = event.detail; + if (bookKey !== rsvpBookKey) return; + handleStart(selectionText); + }; + + const handleRSVPStop = (event: CustomEvent) => { + const { bookKey: rsvpBookKey } = event.detail; + if (bookKey !== rsvpBookKey) return; + handleClose(); + }; + + eventDispatcher.on('rsvp-start', handleRSVPStart); + eventDispatcher.on('rsvp-stop', handleRSVPStop); + + return () => { + eventDispatcher.off('rsvp-start', handleRSVPStart); + eventDispatcher.off('rsvp-stop', handleRSVPStop); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bookKey]); + + const handleStart = useCallback( + (selectionText?: string) => { + const view = getView(bookKey); + const bookData = getBookData(bookKey); + const progress = getProgress(bookKey); + + if (!view || !bookData || !bookData.book) { + eventDispatcher.dispatch('toast', { + message: _('Unable to start RSVP'), + type: 'error', + }); + return; + } + + // Remove any existing RSVP highlight when starting new session + removeRsvpHighlight(); + + // Check if format is supported (not PDF) + if (bookData.book.format === 'PDF') { + eventDispatcher.dispatch('toast', { + message: _('RSVP not supported for PDF'), + type: 'warning', + }); + return; + } + + // Create controller if not exists + if (!controllerRef.current) { + controllerRef.current = new RSVPController(view, bookKey); + } + + const controller = controllerRef.current; + + // Set current CFI for position tracking + if (progress?.location) { + controller.setCurrentCfi(progress.location); + } + + // Handle start choice event + const handleStartChoice = (e: Event) => { + const choice = (e as CustomEvent).detail; + setStartChoice(choice); + + // If there's only one option (beginning), start directly + if (!choice.hasSavedPosition && !choice.hasSelection) { + controller.startFromBeginning(); + setIsActive(true); + } else { + // Show dialog for user to choose + setShowStartDialog(true); + } + }; + + controller.addEventListener('rsvp-start-choice', handleStartChoice); + controller.requestStart(selectionText); + + // Clean up listener after handling + setTimeout(() => { + controller.removeEventListener('rsvp-start-choice', handleStartChoice); + }, 100); + }, + [_, bookKey, getBookData, getProgress, getView, removeRsvpHighlight], + ); + + const handleStartDialogSelect = useCallback( + (option: 'beginning' | 'saved' | 'current' | 'selection') => { + setShowStartDialog(false); + const controller = controllerRef.current; + if (!controller) return; + + switch (option) { + case 'beginning': + controller.startFromBeginning(); + break; + case 'saved': + controller.startFromSavedPosition(); + break; + case 'current': + controller.startFromCurrentPosition(); + break; + case 'selection': + if (startChoice?.selectionText) { + controller.startFromSelection(startChoice.selectionText); + } + break; + } + setIsActive(true); + }, + [startChoice], + ); + + const handleClose = useCallback(() => { + const controller = controllerRef.current; + const view = getView(bookKey); + + if (controller && view) { + // Listen for the stop event to get the position + const handleRsvpStop = (e: Event) => { + const stopPosition = (e as CustomEvent).detail; + + if (stopPosition?.range && typeof stopPosition.docIndex === 'number') { + try { + // Get the document from the renderer + const contents = view.renderer.getContents?.(); + const content = contents?.find((c) => c.index === stopPosition.docIndex); + const doc = content?.doc; + + if (doc && stopPosition.range) { + // Expand the range to include the full sentence + const sentenceRange = expandRangeToSentence(stopPosition.range, doc); + + // Get CFI for navigation - MUST get this BEFORE navigating + const cfi = view.getCFI(stopPosition.docIndex, stopPosition.range); + + // Get CFI for the sentence highlight - MUST get this BEFORE navigating + // because goTo() may re-render the document, invalidating the Range objects + const sentenceCfi = cfi ? view.getCFI(stopPosition.docIndex, sentenceRange) : null; + const sentenceText = sentenceRange.toString(); + + if (cfi) { + // Navigate to the position + view.goTo(cfi); + + if (sentenceCfi) { + // Remove any previous RSVP highlight + removeRsvpHighlight(); + + // Create a persistent highlight for the sentence using theme accent color + const highlight: BookNote = { + id: `rsvp-temp-${Date.now()}`, + type: 'annotation', + cfi: sentenceCfi, + text: sentenceText, + style: 'underline', + color: themeCode.primary, // Use theme accent color (same as ORP focal point) + note: '', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + tempHighlightRef.current = highlight; + view.addAnnotation(highlight); + // Note: highlight persists until next page, reader close, or new RSVP session + } + } + } + } catch (err) { + console.warn('Failed to sync RSVP position:', err); + } + } + }; + + controller.addEventListener('rsvp-stop', handleRsvpStop); + controller.stop(); + controller.removeEventListener('rsvp-stop', handleRsvpStop); + } else if (controller) { + controller.stop(); + } + + setIsActive(false); + setShowStartDialog(false); + }, [bookKey, getView, removeRsvpHighlight, themeCode.primary]); + + const handleChapterSelect = useCallback( + (href: string) => { + const view = getView(bookKey); + if (!view) return; + + // Navigate to chapter + view.goTo(href); + + // Wait for navigation, then reload RSVP content + setTimeout(() => { + const controller = controllerRef.current; + if (controller) { + const progress = getProgress(bookKey); + if (progress?.location) { + controller.setCurrentCfi(progress.location); + } + controller.loadNextPageContent(); + } + }, 500); + }, + [bookKey, getProgress, getView], + ); + + const handleRequestNextPage = useCallback(async () => { + const view = getView(bookKey); + if (!view) return; + + // Remove RSVP highlight when moving to next page + removeRsvpHighlight(); + + // RSVP extracts ALL words from the current section via renderer.getContents(). + // When RSVP runs out of words and calls this function, it means the entire + // chapter/section has been read, so we need to go to the next section. + await view.renderer.nextSection?.(); + + // Wait for section change, then load new content + setTimeout(() => { + const controller = controllerRef.current; + if (controller) { + const progress = getProgress(bookKey); + if (progress?.location) { + controller.setCurrentCfi(progress.location); + } + controller.loadNextPageContent(); + } + }, 500); + }, [bookKey, getProgress, getView, removeRsvpHighlight]); + + // Get current chapter info + const progress = getProgress(bookKey); + const bookData = getBookData(bookKey); + const chapters = bookData?.bookDoc?.toc || []; + const currentChapterHref = progress?.sectionHref || null; + + // Use portal to render overlay at body level to avoid stacking context issues + const portalContainer = typeof document !== 'undefined' ? document.body : null; + + return ( + <> + {/* Start dialog - render via portal */} + {showStartDialog && + startChoice && + portalContainer && + createPortal( + setShowStartDialog(false)} + />, + portalContainer, + )} + + {/* RSVP Overlay - render via portal */} + {isActive && + controllerRef.current && + portalContainer && + createPortal( + , + portalContainer, + )} + + ); +}; + +export default RSVPControl; diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx new file mode 100644 index 00000000..e625a7bc --- /dev/null +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx @@ -0,0 +1,576 @@ +'use client'; + +import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import clsx from 'clsx'; +import { RsvpState, RsvpWord, RSVPController } from '@/services/rsvp'; +import { useThemeStore } from '@/store/themeStore'; +import { TOCItem } from '@/libs/document'; +import { + IoClose, + IoPlay, + IoPause, + IoPlaySkipBack, + IoPlaySkipForward, + IoRemove, + IoAdd, +} from 'react-icons/io5'; +import { useTranslation } from '@/hooks/useTranslation'; + +interface FlatChapter { + label: string; + href: string; + level: number; +} + +interface RSVPOverlayProps { + controller: RSVPController; + chapters: TOCItem[]; + currentChapterHref: string | null; + onClose: () => void; + onChapterSelect: (href: string) => void; + onRequestNextPage: () => void; +} + +const RSVPOverlay: React.FC = ({ + controller, + chapters, + currentChapterHref, + onClose, + onChapterSelect, + onRequestNextPage, +}) => { + const _ = useTranslation(); + const { themeCode, isDarkMode: _isDarkMode } = useThemeStore(); + const [state, setState] = useState(controller.currentState); + const [currentWord, setCurrentWord] = useState(controller.currentWord); + const [countdown, setCountdown] = useState(controller.currentCountdown); + const [showChapterDropdown, setShowChapterDropdown] = useState(false); + const touchStartX = useRef(0); + const touchStartY = useRef(0); + const touchStartTime = useRef(0); + const SWIPE_THRESHOLD = 50; + const TAP_THRESHOLD = 10; + + // Flatten chapters for dropdown + const flatChapters = useMemo(() => { + const flatten = (items: TOCItem[], level = 0): FlatChapter[] => { + const result: FlatChapter[] = []; + for (const item of items) { + result.push({ label: item.label || '', href: item.href || '', level }); + if (item.subitems?.length) { + result.push(...flatten(item.subitems, level + 1)); + } + } + return result; + }; + return flatten(chapters); + }, [chapters]); + + // Subscribe to controller events + useEffect(() => { + const handleStateChange = (e: Event) => { + const newState = (e as CustomEvent).detail; + setState(newState); + setCurrentWord(controller.currentWord); + }; + + const handleCountdownChange = (e: Event) => { + setCountdown((e as CustomEvent).detail); + }; + + const handleRequestNextPage = () => { + onRequestNextPage(); + }; + + controller.addEventListener('rsvp-state-change', handleStateChange); + controller.addEventListener('rsvp-countdown-change', handleCountdownChange); + controller.addEventListener('rsvp-request-next-page', handleRequestNextPage); + + return () => { + controller.removeEventListener('rsvp-state-change', handleStateChange); + controller.removeEventListener('rsvp-countdown-change', handleCountdownChange); + controller.removeEventListener('rsvp-request-next-page', handleRequestNextPage); + }; + }, [controller, onRequestNextPage]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyboard = (event: KeyboardEvent) => { + if (!state.active) return; + + switch (event.key) { + case ' ': + event.preventDefault(); + controller.togglePlayPause(); + break; + case 'Escape': + event.preventDefault(); + onClose(); + break; + case 'ArrowLeft': + event.preventDefault(); + if (event.shiftKey) { + controller.skipBackward(15); + } else { + controller.decreaseSpeed(); + } + break; + case 'ArrowRight': + event.preventDefault(); + if (event.shiftKey) { + controller.skipForward(15); + } else { + controller.increaseSpeed(); + } + break; + case 'ArrowUp': + event.preventDefault(); + controller.increaseSpeed(); + break; + case 'ArrowDown': + event.preventDefault(); + controller.decreaseSpeed(); + break; + } + }; + + document.addEventListener('keydown', handleKeyboard); + return () => document.removeEventListener('keydown', handleKeyboard); + }, [state.active, controller, onClose]); + + // Word display helpers + const wordBefore = currentWord ? currentWord.text.substring(0, currentWord.orpIndex) : ''; + const orpChar = currentWord ? currentWord.text.charAt(currentWord.orpIndex) : ''; + const wordAfter = currentWord ? currentWord.text.substring(currentWord.orpIndex + 1) : ''; + + // Time remaining calculation + const getTimeRemaining = useCallback((): string | null => { + if (!state || state.words.length === 0) return null; + const wordsLeft = state.words.length - state.currentIndex; + const minutesLeft = wordsLeft / state.wpm; + + if (minutesLeft < 1) { + const seconds = Math.ceil(minutesLeft * 60); + return `${seconds}s`; + } else if (minutesLeft < 60) { + const mins = Math.floor(minutesLeft); + const secs = Math.round((minutesLeft - mins) * 60); + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; + } else { + const hours = Math.floor(minutesLeft / 60); + const mins = Math.round(minutesLeft % 60); + return `${hours}h ${mins}m`; + } + }, [state]); + + // Context text helpers - show 100 words before and after + const getContextBefore = useCallback((): string => { + if (!state || state.words.length === 0) return ''; + const startIndex = Math.max(0, state.currentIndex - 100); + return state.words + .slice(startIndex, state.currentIndex) + .map((w) => w.text) + .join(' '); + }, [state]); + + const getContextAfter = useCallback((): string => { + if (!state || state.words.length === 0) return ''; + const endIndex = Math.min(state.words.length, state.currentIndex + 101); + return state.words + .slice(state.currentIndex + 1, endIndex) + .map((w) => w.text) + .join(' '); + }, [state]); + + // Chapter helpers + const getCurrentChapterLabel = useCallback((): string => { + if (!currentChapterHref) return 'Select Chapter'; + const normalizedCurrent = currentChapterHref.split('#')[0]?.replace(/^\//, '') || ''; + const chapter = flatChapters.find((c) => { + const normalizedHref = c.href.split('#')[0]?.replace(/^\//, '') || ''; + return normalizedHref === normalizedCurrent; + }); + return chapter?.label || 'Select Chapter'; + }, [currentChapterHref, flatChapters]); + + const isChapterActive = useCallback( + (href: string): boolean => { + if (!currentChapterHref) return false; + const normalizedCurrent = currentChapterHref.split('#')[0]?.replace(/^\//, '') || ''; + const normalizedHref = href.split('#')[0]?.replace(/^\//, '') || ''; + return normalizedHref === normalizedCurrent; + }, + [currentChapterHref], + ); + + // Touch handlers + const handleTouchStart = (event: React.TouchEvent) => { + if (event.touches.length !== 1) return; + const touch = event.touches[0]!; + touchStartX.current = touch.clientX; + touchStartY.current = touch.clientY; + touchStartTime.current = Date.now(); + }; + + const handleTouchEnd = (event: React.TouchEvent) => { + if (event.changedTouches.length !== 1) return; + + const touch = event.changedTouches[0]!; + const deltaX = touch.clientX - touchStartX.current; + const deltaY = touch.clientY - touchStartY.current; + const duration = Date.now() - touchStartTime.current; + + if (Math.abs(deltaX) > SWIPE_THRESHOLD && Math.abs(deltaX) > Math.abs(deltaY)) { + if (deltaX > 0) { + controller.decreaseSpeed(); + } else { + controller.increaseSpeed(); + } + return; + } + + if (Math.abs(deltaX) < TAP_THRESHOLD && Math.abs(deltaY) < TAP_THRESHOLD && duration < 300) { + const target = event.target as HTMLElement; + if (target.closest('.rsvp-controls') || target.closest('.rsvp-header')) { + return; + } + + const screenWidth = window.innerWidth; + const tapX = touch.clientX; + + if (tapX < screenWidth * 0.25) { + controller.skipBackward(15); + } else if (tapX > screenWidth * 0.75) { + controller.skipForward(15); + } else { + controller.togglePlayPause(); + } + } + }; + + // Progress bar click handler + const handleProgressBarClick = (event: React.MouseEvent) => { + const target = event.currentTarget as HTMLElement; + const rect = target.getBoundingClientRect(); + const clickX = event.clientX - rect.left; + const percentage = (clickX / rect.width) * 100; + + const wasPlaying = state.playing; + if (wasPlaying) { + controller.pause(); + } + + controller.seekToPosition(percentage); + + if (wasPlaying) { + setTimeout(() => controller.resume(), 50); + } + }; + + const handleChapterSelect = (href: string) => { + setShowChapterDropdown(false); + controller.pause(); + onChapterSelect(href); + }; + + if (!state.active) return null; + + // Use theme colors directly from themeCode (bg, fg, primary are already resolved from palette) + const bgColor = themeCode.bg; + const fgColor = themeCode.fg; + const accentColor = themeCode.primary; + + return ( +
+ {/* Header */} +
+ + + {/* Chapter selector */} +
+ + {showChapterDropdown && ( +
+ {flatChapters.map((chapter, idx) => ( + + ))} +
+ )} +
+ +
+ {_('{{number}} WPM', { number: state.wpm })} +
+
+ + {/* Context panel (shown when paused) */} + {!state.playing && countdown === null && ( +
+
+ + + + {_('Context')} +
+
+ {getContextBefore()} + + {currentWord?.text || ''} + + {getContextAfter()} +
+
+ )} + + {/* Main content area */} +
+
+
+ {/* Top guide line */} +
+ + {/* Word section */} +
+ {/* Countdown */} + {countdown !== null && ( +
+ + {countdown} + +
+ )} + + {/* Word display */} +
+ {currentWord ? ( + <> + + {wordBefore} + + + {orpChar} + + + {wordAfter} + + + ) : ( + {_('Ready')} + )} +
+
+ + {/* Bottom guide line */} +
+
+
+
+ + {/* Footer */} +
+ {/* Progress section */} +
+
+ + {_('Chapter Progress')} + + + {(state.currentIndex + 1).toLocaleString()} / {state.words.length.toLocaleString()}{' '} + {_('words')} + {getTimeRemaining() && ( + + {' '} + · {_('{{time}} left', { time: getTimeRemaining() })} + + )} + +
+
{ + if (e.key === 'ArrowLeft') controller.skipBackward(); + else if (e.key === 'ArrowRight') controller.skipForward(); + }} + title={_('Click to seek')} + > +
+
+
+
+ + {/* Controls */} +
+ {/* Playback controls - centered on mobile, middle on desktop */} +
+ + + + + +
+ + {/* Secondary controls row on mobile, split on desktop */} +
+ {/* Punctuation pause - left on desktop */} +
+ +
+ + {/* Speed controls - right on desktop */} +
+ + + {state.wpm} + + +
+
+
+
+
+ ); +}; + +export default RSVPOverlay; diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPStartDialog.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPStartDialog.tsx new file mode 100644 index 00000000..6f11a23e --- /dev/null +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPStartDialog.tsx @@ -0,0 +1,146 @@ +'use client'; + +import React from 'react'; +import { RsvpStartChoice } from '@/services/rsvp'; +import { useThemeStore } from '@/store/themeStore'; +import { useTranslation } from '@/hooks/useTranslation'; +import { IoBookmark, IoPlayCircle, IoLocation, IoText } from 'react-icons/io5'; + +interface RSVPStartDialogProps { + startChoice: RsvpStartChoice; + onSelect: (option: 'beginning' | 'saved' | 'current' | 'selection') => void; + onClose: () => void; +} + +const RSVPStartDialog: React.FC = ({ startChoice, onSelect, onClose }) => { + const _ = useTranslation(); + const { themeCode, isDarkMode } = useThemeStore(); + + // Use theme colors directly from themeCode (bg, fg, primary are already resolved from palette) + // For dialog, use a slightly different background using palette['base-200'] or darken/lighten the bg + const bgColor = themeCode.palette['base-200'] || themeCode.bg; + const fgColor = themeCode.fg; + const accentColor = themeCode.primary; + const backdropColor = isDarkMode ? 'rgba(0, 0, 0, 0.75)' : 'rgba(0, 0, 0, 0.6)'; + + return ( +
e.key === 'Escape' && onClose()} + > + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */} +
e.stopPropagation()} + role='dialog' + aria-modal='true' + aria-labelledby='rsvp-dialog-title' + > +

+ {_('Start RSVP Reading')} +

+

{_('Choose where to start reading')}

+ +
+ {/* Start from beginning */} + + + {/* Resume from saved position */} + {startChoice.hasSavedPosition && ( + + )} + + {/* Start from current position */} + + + {/* Start from selection */} + {startChoice.hasSelection && startChoice.selectionText && ( + + )} +
+ + {/* Cancel button */} + +
+
+ ); +}; + +export default RSVPStartDialog; diff --git a/apps/readest-app/src/app/reader/components/rsvp/index.ts b/apps/readest-app/src/app/reader/components/rsvp/index.ts new file mode 100644 index 00000000..b7fcfdbf --- /dev/null +++ b/apps/readest-app/src/app/reader/components/rsvp/index.ts @@ -0,0 +1,3 @@ +export { default as RSVPControl } from './RSVPControl'; +export { default as RSVPOverlay } from './RSVPOverlay'; +export { default as RSVPStartDialog } from './RSVPStartDialog'; diff --git a/apps/readest-app/src/services/rsvp/RSVPController.ts b/apps/readest-app/src/services/rsvp/RSVPController.ts new file mode 100644 index 00000000..1400d4a2 --- /dev/null +++ b/apps/readest-app/src/services/rsvp/RSVPController.ts @@ -0,0 +1,710 @@ +import { FoliateView } from '@/types/view'; +import { RsvpWord, RsvpState, RsvpPosition, RsvpStopPosition, RsvpStartChoice } from './types'; +import { containsCJK, splitTextIntoWords } from './utils'; + +const DEFAULT_WPM = 300; +const MIN_WPM = 100; +const MAX_WPM = 1000; +const WPM_STEP = 50; +const DEFAULT_PUNCTUATION_PAUSE_MS = 100; +const PUNCTUATION_PAUSE_OPTIONS = [25, 50, 75, 100, 125, 150, 175, 200]; +const STORAGE_KEY_PREFIX = 'readest_rsvp_wpm_'; +const PUNCTUATION_PAUSE_KEY_PREFIX = 'readest_rsvp_pause_'; +const POSITION_KEY_PREFIX = 'readest_rsvp_pos_'; + +export class RSVPController extends EventTarget { + private view: FoliateView; + private bookKey: string; + private currentCfi: string | null = null; + + private state: RsvpState = { + active: false, + playing: false, + words: [], + currentIndex: 0, + wpm: DEFAULT_WPM, + punctuationPauseMs: DEFAULT_PUNCTUATION_PAUSE_MS, + progress: 0, + resumedFromIndex: null, + }; + + private playbackTimer: ReturnType | null = null; + private countdownTimer: ReturnType | null = null; + private pendingStartWordIndex: number | null = null; + private countdown: number | null = null; + + constructor(view: FoliateView, bookKey: string) { + super(); + this.view = view; + this.bookKey = bookKey; + this.loadSettings(); + } + + private loadSettings(): void { + const savedWpm = this.loadWpmFromStorage(); + if (savedWpm) { + this.state.wpm = savedWpm; + } + const savedPause = this.loadPunctuationPauseFromStorage(); + if (savedPause) { + this.state.punctuationPauseMs = savedPause; + } + } + + get currentState(): RsvpState { + return { ...this.state }; + } + + get currentWord(): RsvpWord | null { + if (this.state.currentIndex >= 0 && this.state.currentIndex < this.state.words.length) { + return this.state.words[this.state.currentIndex]!; + } + return null; + } + + get currentCountdown(): number | null { + return this.countdown; + } + + getPunctuationPauseOptions(): number[] { + return PUNCTUATION_PAUSE_OPTIONS; + } + + setPunctuationPause(pauseMs: number): void { + if (PUNCTUATION_PAUSE_OPTIONS.includes(pauseMs)) { + this.state.punctuationPauseMs = pauseMs; + this.savePunctuationPauseToStorage(pauseMs); + this.emitStateChange(); + } + } + + private loadPunctuationPauseFromStorage(): number | null { + const stored = localStorage.getItem(`${PUNCTUATION_PAUSE_KEY_PREFIX}${this.bookKey}`); + if (stored) { + const parsed = parseInt(stored, 10); + if (!isNaN(parsed) && PUNCTUATION_PAUSE_OPTIONS.includes(parsed)) { + return parsed; + } + } + return null; + } + + private savePunctuationPauseToStorage(pauseMs: number): void { + localStorage.setItem(`${PUNCTUATION_PAUSE_KEY_PREFIX}${this.bookKey}`, pauseMs.toString()); + } + + setWpm(wpm: number): void { + const clampedWpm = Math.max(MIN_WPM, Math.min(MAX_WPM, wpm)); + this.state.wpm = clampedWpm; + this.saveWpmToStorage(clampedWpm); + this.emitStateChange(); + } + + private loadWpmFromStorage(): number | null { + const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${this.bookKey}`); + if (stored) { + const parsed = parseInt(stored, 10); + if (!isNaN(parsed) && parsed >= MIN_WPM && parsed <= MAX_WPM) { + return parsed; + } + } + return null; + } + + private saveWpmToStorage(wpm: number): void { + localStorage.setItem(`${STORAGE_KEY_PREFIX}${this.bookKey}`, wpm.toString()); + } + + setCurrentCfi(cfi: string | null): void { + this.currentCfi = cfi; + } + + private loadPositionFromStorage(): RsvpPosition | null { + const stored = localStorage.getItem(`${POSITION_KEY_PREFIX}${this.bookKey}`); + if (stored) { + try { + return JSON.parse(stored) as RsvpPosition; + } catch { + return null; + } + } + return null; + } + + private savePositionToStorage(): void { + if (!this.currentCfi) return; + if (this.state.words.length === 0) return; + + const position: RsvpPosition = { + cfi: this.currentCfi, + wordIndex: this.state.currentIndex, + wordText: this.state.words[this.state.currentIndex]?.text || '', + }; + localStorage.setItem(`${POSITION_KEY_PREFIX}${this.bookKey}`, JSON.stringify(position)); + } + + private clearPositionFromStorage(): void { + localStorage.removeItem(`${POSITION_KEY_PREFIX}${this.bookKey}`); + } + + private extractBaseCfi(cfi: string): string { + const inner = cfi.replace(/^epubcfi\(/, '').replace(/\)$/, ''); + const parts = inner.split(','); + let basePath = parts[0]!; + const match = basePath.match(/^(.*\][^\/]*)/); + if (match) { + basePath = match[1]!; + } + return basePath; + } + + private isSameSection(cfi1: string | null, cfi2: string | null): boolean { + if (!cfi1 || !cfi2) return false; + const base1 = this.extractBaseCfi(cfi1); + const base2 = this.extractBaseCfi(cfi2); + return base1 === base2; + } + + start(retryCount = 0): void { + const words = this.extractWordsWithRanges(); + if (words.length === 0) { + if (retryCount < 3) { + setTimeout(() => this.start(retryCount + 1), 150 * (retryCount + 1)); + return; + } + return; + } + + let startIndex = 0; + let resumedFromIndex: number | null = null; + + if (this.pendingStartWordIndex !== null && this.pendingStartWordIndex < words.length) { + startIndex = this.pendingStartWordIndex; + this.pendingStartWordIndex = null; + } else { + const savedPosition = this.loadPositionFromStorage(); + if (savedPosition && this.isSameSection(savedPosition.cfi, this.currentCfi)) { + if (savedPosition.wordIndex < words.length) { + if (words[savedPosition.wordIndex]?.text === savedPosition.wordText) { + startIndex = savedPosition.wordIndex; + resumedFromIndex = savedPosition.wordIndex; + } + } + } + } + + this.state = { + ...this.state, + active: true, + playing: false, + words, + currentIndex: startIndex, + progress: (startIndex / words.length) * 100, + resumedFromIndex, + }; + this.emitStateChange(); + + this.startCountdown(() => { + this.state.playing = true; + this.emitStateChange(); + this.scheduleNextWord(); + }); + } + + pause(): void { + this.clearTimer(); + this.clearCountdown(); + this.state.playing = false; + this.emitStateChange(); + } + + resume(): void { + if (!this.state.active) return; + this.startCountdown(() => { + this.state.playing = true; + this.emitStateChange(); + this.scheduleNextWord(); + }); + } + + private startCountdown(onComplete: () => void): void { + this.clearCountdown(); + let count = 3; + this.countdown = count; + this.emitCountdownChange(); + + this.countdownTimer = setInterval(() => { + count--; + if (count > 0) { + this.countdown = count; + this.emitCountdownChange(); + } else { + this.clearCountdown(); + onComplete(); + } + }, 800); + } + + private clearCountdown(): void { + if (this.countdownTimer) { + clearInterval(this.countdownTimer); + this.countdownTimer = null; + } + this.countdown = null; + this.emitCountdownChange(); + } + + togglePlayPause(): void { + if (this.state.playing) { + this.pause(); + } else { + this.resume(); + } + } + + stop(): void { + this.savePositionToStorage(); + + const stopPosition: RsvpStopPosition | null = + this.state.words.length > 0 + ? { + wordIndex: this.state.currentIndex, + totalWords: this.state.words.length, + text: this.state.words[this.state.currentIndex]?.text || '', + range: this.state.words[this.state.currentIndex]?.range, + docIndex: this.state.words[this.state.currentIndex]?.docIndex, + } + : null; + + this.dispatchEvent(new CustomEvent('rsvp-stop', { detail: stopPosition })); + + this.clearTimer(); + this.clearCountdown(); + this.state = { + ...this.state, + active: false, + playing: false, + words: [], + currentIndex: 0, + progress: 0, + resumedFromIndex: null, + }; + this.emitStateChange(); + } + + requestStart(selectionText?: string): void { + const words = this.extractWordsWithRanges(); + const firstVisibleWordIndex = this.findFirstVisibleWordIndex(words); + + const savedPosition = this.loadPositionFromStorage(); + const hasSavedPosition = !!( + savedPosition && this.isSameSection(savedPosition.cfi, this.currentCfi) + ); + const hasSelection = !!selectionText && selectionText.trim().length > 0; + + const startChoice: RsvpStartChoice = { + hasSavedPosition, + hasSelection, + selectionText: selectionText?.trim(), + firstVisibleWordIndex, + }; + + this.dispatchEvent(new CustomEvent('rsvp-start-choice', { detail: startChoice })); + } + + startFromBeginning(): void { + this.clearPositionFromStorage(); + this.pendingStartWordIndex = null; + this.start(); + } + + startFromSavedPosition(): void { + this.pendingStartWordIndex = null; + this.start(); + } + + startFromCurrentPosition(): void { + this.clearPositionFromStorage(); + const words = this.extractWordsWithRanges(); + const firstVisibleIndex = this.findFirstVisibleWordIndex(words); + this.pendingStartWordIndex = firstVisibleIndex > 0 ? firstVisibleIndex : null; + this.start(); + } + + startFromSelection(selectionText: string): void { + this.clearPositionFromStorage(); + const words = this.extractWordsWithRanges(); + const selectionIndex = this.findWordIndexBySelection(words, selectionText); + this.pendingStartWordIndex = selectionIndex >= 0 ? selectionIndex : null; + this.start(); + } + + private findFirstVisibleWordIndex(words: RsvpWord[]): number { + if (words.length === 0) return 0; + + try { + const renderer = this.view.renderer; + const scrollStart = renderer.start ?? 0; + const pageSize = renderer.size ?? 0; + + if (pageSize > 0) { + const visibleStart = scrollStart - pageSize; + const visibleEnd = scrollStart; + + for (let i = 0; i < words.length; i++) { + const word = words[i]; + if (word?.range) { + try { + const rect = word.range.getBoundingClientRect(); + if (rect.width > 0 && rect.left >= visibleStart && rect.left < visibleEnd) { + return i; + } + } catch { + continue; + } + } + } + } + } catch { + // Fall through to return 0 + } + + return 0; + } + + private findWordIndexBySelection(words: RsvpWord[], selectionText: string): number { + if (!selectionText || words.length === 0) return -1; + + const cleanSelection = selectionText.trim(); + if (!cleanSelection) return -1; + + const hasCJK = containsCJK(cleanSelection); + + if (hasCJK) { + const selectionLower = cleanSelection.toLowerCase(); + + // Build a continuous text from words for matching + for (let i = 0; i < words.length; i++) { + let continuousText = ''; + for (let j = i; j < Math.min(i + 20, words.length); j++) { + continuousText += words[j]!.text; + if (continuousText.toLowerCase().includes(selectionLower)) { + return i; + } + } + } + + // Fallback: try to match first few characters + const firstChars = cleanSelection.slice(0, Math.min(3, cleanSelection.length)).toLowerCase(); + for (let i = 0; i < words.length; i++) { + if (words[i]!.text.toLowerCase().includes(firstChars)) { + return i; + } + } + + return -1; + } + + const cleanSelectionLower = cleanSelection.toLowerCase(); + const selectionWords = cleanSelectionLower.split(/\s+/); + if (selectionWords.length === 0) return -1; + + const firstSelectionWord = selectionWords[0]!; + + for (let i = 0; i < words.length; i++) { + const word = words[i]!; + const cleanWord = word.text.toLowerCase().replace(/[^\w]/g, ''); + const cleanFirstWord = firstSelectionWord.replace(/[^\w]/g, ''); + + if ( + cleanWord === cleanFirstWord || + cleanWord.includes(cleanFirstWord) || + cleanFirstWord.includes(cleanWord) + ) { + if (selectionWords.length === 1) { + return i; + } + + let matchCount = 1; + for (let j = 1; j < selectionWords.length && i + j < words.length; j++) { + const nextWord = words[i + j]!.text.toLowerCase().replace(/[^\w]/g, ''); + const nextSelectionWord = selectionWords[j]!.replace(/[^\w]/g, ''); + if (nextWord === nextSelectionWord || nextWord.includes(nextSelectionWord)) { + matchCount++; + } else { + break; + } + } + + if (matchCount >= Math.ceil(selectionWords.length / 2)) { + return i; + } + } + } + + return -1; + } + + increaseSpeed(): void { + const newWpm = Math.min(MAX_WPM, this.state.wpm + WPM_STEP); + this.state.wpm = newWpm; + this.saveWpmToStorage(newWpm); + this.emitStateChange(); + } + + decreaseSpeed(): void { + const newWpm = Math.max(MIN_WPM, this.state.wpm - WPM_STEP); + this.state.wpm = newWpm; + this.saveWpmToStorage(newWpm); + this.emitStateChange(); + } + + skipForward(count: number = 10): void { + const newIndex = Math.min(this.state.words.length - 1, this.state.currentIndex + count); + this.state.currentIndex = newIndex; + this.state.progress = (newIndex / this.state.words.length) * 100; + this.emitStateChange(); + } + + skipBackward(count: number = 10): void { + const newIndex = Math.max(0, this.state.currentIndex - count); + this.state.currentIndex = newIndex; + this.state.progress = (newIndex / this.state.words.length) * 100; + this.emitStateChange(); + } + + seekToPosition(percentage: number): void { + if (this.state.words.length === 0) return; + const newIndex = Math.floor((percentage / 100) * this.state.words.length); + const clampedIndex = Math.max(0, Math.min(this.state.words.length - 1, newIndex)); + this.state.currentIndex = clampedIndex; + this.state.progress = (clampedIndex / this.state.words.length) * 100; + this.emitStateChange(); + } + + loadNextPageContent(retryCount = 0): void { + this.clearPositionFromStorage(); + + const words = this.extractWordsWithRanges(); + if (words.length === 0) { + if (retryCount < 3) { + setTimeout(() => this.loadNextPageContent(retryCount + 1), 200 * (retryCount + 1)); + return; + } + this.pause(); + return; + } + + const wasPlaying = this.state.playing; + + this.state = { + ...this.state, + words, + currentIndex: 0, + progress: 0, + resumedFromIndex: null, + playing: false, + }; + this.emitStateChange(); + + if (wasPlaying) { + this.startCountdown(() => { + this.state.playing = true; + this.emitStateChange(); + this.scheduleNextWord(); + }); + } + } + + private scheduleNextWord(): void { + this.clearTimer(); + + if (!this.state.playing || !this.state.active) return; + + if (this.state.currentIndex >= this.state.words.length) { + this.dispatchEvent(new CustomEvent('rsvp-request-next-page')); + return; + } + + const word = this.state.words[this.state.currentIndex]!; + const duration = this.getWordDisplayDuration(word, this.state.wpm); + + this.playbackTimer = setTimeout(() => { + this.advanceToNextWord(); + }, duration); + } + + private advanceToNextWord(): void { + const newIndex = this.state.currentIndex + 1; + + if (newIndex >= this.state.words.length) { + this.dispatchEvent(new CustomEvent('rsvp-request-next-page')); + return; + } + + this.state.currentIndex = newIndex; + this.state.progress = (newIndex / this.state.words.length) * 100; + this.emitStateChange(); + + this.scheduleNextWord(); + } + + private clearTimer(): void { + if (this.playbackTimer) { + clearTimeout(this.playbackTimer); + this.playbackTimer = null; + } + } + + private extractWordsWithRanges(): RsvpWord[] { + const renderer = this.view.renderer; + if (!renderer) return []; + + const contents = renderer.getContents?.(); + if (!contents || contents.length === 0) return []; + + const allWords: RsvpWord[] = []; + + for (const content of contents) { + const { doc, index: docIndex } = content as { doc: Document; index: number }; + if (!doc?.body) continue; + + const words = this.extractWordsFromElement(doc.body, doc, docIndex); + allWords.push(...words); + } + + return allWords; + } + + private extractWordsFromElement( + element: HTMLElement, + doc: Document, + docIndex: number, + ): RsvpWord[] { + const excludeTags = new Set(['SCRIPT', 'STYLE', 'NAV', 'HEADER', 'FOOTER', 'ASIDE']); + const words: RsvpWord[] = []; + + const walk = (node: Node): void => { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent || ''; + const nodeWords = splitTextIntoWords(text); + console.log('Extracted words from text node:', nodeWords); + + let offset = 0; + for (const word of nodeWords) { + const wordStart = text.indexOf(word, offset); + if (wordStart === -1) continue; + + try { + const range = doc.createRange(); + range.setStart(node, wordStart); + range.setEnd(node, wordStart + word.length); + + words.push({ + text: word, + orpIndex: this.calculateORP(word), + pauseMultiplier: this.getPauseMultiplier(word), + range, + docIndex, + }); + } catch { + words.push({ + text: word, + orpIndex: this.calculateORP(word), + pauseMultiplier: this.getPauseMultiplier(word), + }); + } + + offset = wordStart + word.length; + } + return; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return; + } + + const el = node as HTMLElement; + const tagName = el.tagName.toUpperCase(); + + if (excludeTags.has(tagName)) { + return; + } + + const style = el.ownerDocument.defaultView?.getComputedStyle(el); + if (style?.display === 'none' || style?.visibility === 'hidden') { + return; + } + + for (const child of Array.from(el.childNodes)) { + walk(child); + } + }; + + walk(element); + return words; + } + + private calculateORP(word: string): number { + const hasCJK = containsCJK(word); + + if (hasCJK) { + // For CJK characters, center the ORP since each character is more balanced + const len = word.length; + return Math.floor(len / 2); + } + + const cleanWord = word.replace(/[^\w]/g, ''); + const len = cleanWord.length; + + if (len <= 1) return 0; + if (len <= 3) return 0; + if (len <= 5) return 1; + if (len <= 8) return 2; + return 3; + } + + private getPauseMultiplier(word: string): number { + const hasCJK = containsCJK(word); + + if (hasCJK) { + // CJK characters are information-dense, adjust pause based on character count + // With semantic segmentation, words can vary in length + const len = word.length; + if (len >= 5) return 1.4; // Longer compound words + if (len >= 4) return 1.3; + if (len >= 3) return 1.2; + if (len >= 2) return 1.0; + return 0.9; // Single characters + } + + if (word.length > 12) return 1.3; + if (word.length > 8) return 1.1; + return 1.0; + } + + private getWordDisplayDuration(word: RsvpWord, wpm: number): number { + const baseMs = 60000 / wpm; + let duration = baseMs * word.pauseMultiplier; + + if (/[.!?,;:]$/.test(word.text)) { + duration += this.state.punctuationPauseMs; + } + + return duration; + } + + private emitStateChange(): void { + this.dispatchEvent(new CustomEvent('rsvp-state-change', { detail: this.currentState })); + } + + private emitCountdownChange(): void { + this.dispatchEvent(new CustomEvent('rsvp-countdown-change', { detail: this.countdown })); + } + + shutdown(): void { + this.stop(); + this.clearPositionFromStorage(); + this.currentCfi = null; + } +} diff --git a/apps/readest-app/src/services/rsvp/index.ts b/apps/readest-app/src/services/rsvp/index.ts new file mode 100644 index 00000000..a7ed1003 --- /dev/null +++ b/apps/readest-app/src/services/rsvp/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './RSVPController'; diff --git a/apps/readest-app/src/services/rsvp/types.ts b/apps/readest-app/src/services/rsvp/types.ts new file mode 100644 index 00000000..7d0a1efd --- /dev/null +++ b/apps/readest-app/src/services/rsvp/types.ts @@ -0,0 +1,39 @@ +export interface RsvpWord { + text: string; + orpIndex: number; + pauseMultiplier: number; + range?: Range; + docIndex?: number; +} + +export interface RsvpState { + active: boolean; + playing: boolean; + words: RsvpWord[]; + currentIndex: number; + wpm: number; + punctuationPauseMs: number; + progress: number; + resumedFromIndex: number | null; +} + +export interface RsvpPosition { + cfi: string; + wordIndex: number; + wordText: string; +} + +export interface RsvpStopPosition { + wordIndex: number; + totalWords: number; + text: string; + range?: Range; + docIndex?: number; +} + +export interface RsvpStartChoice { + hasSavedPosition: boolean; + hasSelection: boolean; + selectionText?: string; + firstVisibleWordIndex: number; +} diff --git a/apps/readest-app/src/services/rsvp/utils.ts b/apps/readest-app/src/services/rsvp/utils.ts new file mode 100644 index 00000000..c4d7c30b --- /dev/null +++ b/apps/readest-app/src/services/rsvp/utils.ts @@ -0,0 +1,243 @@ +/** + * Utility functions for CJK (Chinese, Japanese, Korean) text processing + */ + +/** + * Check if a character is a CJK character + */ +export function isCJK(char: string): boolean { + const code = char.charCodeAt(0); + return ( + (code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs + (code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A + (code >= 0x20000 && code <= 0x2a6df) || // CJK Extension B + (code >= 0x2a700 && code <= 0x2b73f) || // CJK Extension C + (code >= 0x2b740 && code <= 0x2b81f) || // CJK Extension D + (code >= 0x2b820 && code <= 0x2ceaf) || // CJK Extension E + (code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs + (code >= 0x3040 && code <= 0x309f) || // Hiragana + (code >= 0x30a0 && code <= 0x30ff) || // Katakana + (code >= 0xac00 && code <= 0xd7af) // Hangul Syllables + ); +} + +/** + * Check if text contains any CJK characters + */ +export function containsCJK(text: string): boolean { + for (let i = 0; i < text.length; i++) { + if (isCJK(text[i]!)) { + return true; + } + } + return false; +} + +/** + * Check if text is CJK punctuation + */ +export function isCJKPunctuation(text: string): boolean { + // Check if text is CJK punctuation (single character or string) + // Includes: CJK symbols, full-width forms, and halfwidth variants + const cjkPunctuationPattern = + /[。!?,、;:""''()《》【】『』「」〈〉〔〕〖〗〘〙〚〛…—~·․‥⋯﹐﹑﹒﹔﹕﹖﹗﹙﹚﹛﹜﹝﹞!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~⦅⦆。「」、・\u3000-\u303F\uFF00-\uFFEF]/; + return cjkPunctuationPattern.test(text); +} + +/** + * Detect the appropriate locale for text segmentation based on character ranges + */ +export function getSegmenterLocale(text: string): string | null { + // Detect which CJK language based on character ranges + for (const char of text) { + const code = char.charCodeAt(0); + + // Japanese-specific characters + if ((code >= 0x3040 && code <= 0x309f) || (code >= 0x30a0 && code <= 0x30ff)) { + return 'ja'; + } + + // Korean Hangul + if (code >= 0xac00 && code <= 0xd7af) { + return 'ko'; + } + + // Chinese characters (most common CJK range) + if (code >= 0x4e00 && code <= 0x9fff) { + return 'zh'; + } + } + + return null; +} + +/** + * Segment CJK text into words using Intl.Segmenter with punctuation attachment + */ +export function segmentCJKText(text: string): string[] { + // Try to use Intl.Segmenter for semantic word segmentation + if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) { + try { + const locale = getSegmenterLocale(text) || 'zh'; + const segmenter = new Intl.Segmenter(locale, { granularity: 'word' }); + const segments = Array.from(segmenter.segment(text)); + const words: string[] = []; + let i = 0; + + while (i < segments.length) { + const segment = segments[i]!; + const segmentText = segment.segment; + + // Only process actual words (skip pure whitespace) + if ((segment.isWordLike || containsCJK(segmentText)) && segmentText.trim()) { + let wordWithPunct = segmentText; + // Look ahead for trailing punctuation in the next segments + let j = i + 1; + while (j < segments.length) { + const nextSegment = segments[j]!; + const nextText = nextSegment.segment; + + // If next segment is whitespace, skip it but continue looking + if (nextText.trim() === '') { + j++; + continue; + } + + // If next segment is punctuation, attach it + if (isCJKPunctuation(nextText)) { + wordWithPunct += nextText; + j++; + } else { + // Stop at the next word + break; + } + } + + words.push(wordWithPunct); + i = j; // Skip to after the punctuation we just processed + } else { + i++; + } + } + + return words; + } catch (error) { + console.warn('Intl.Segmenter failed, falling back to simple segmentation:', error); + } + } + + // Fallback: Simple character-based segmentation with punctuation + const words: string[] = []; + let currentWord = ''; + + for (let i = 0; i < text.length; i++) { + const char = text[i]!; + + if (char.match(/\s/)) { + if (currentWord) { + words.push(currentWord); + currentWord = ''; + } + } else if (isCJK(char)) { + currentWord += char; + // Group 2 characters for readability + if (currentWord.length >= 2) { + // Look ahead for punctuation + let j = i + 1; + while (j < text.length && isCJKPunctuation(text[j]!)) { + currentWord += text[j]; + i = j; + j++; + } + words.push(currentWord); + currentWord = ''; + } + } else if (isCJKPunctuation(char)) { + // Attach punctuation to current word + currentWord += char; + } else { + currentWord += char; + } + } + + if (currentWord) { + words.push(currentWord); + } + + return words.filter((w) => w.trim().length > 0); +} + +/** + * Split text into words, handling both CJK and non-CJK text + */ +export function splitTextIntoWords(text: string): string[] { + const hasCJK = containsCJK(text); + + if (!hasCJK) { + // Use space-based splitting for non-CJK text + return text.split(/(\s+)/).filter((w) => w.trim().length > 0); + } + + // For CJK text, use semantic segmentation + const words: string[] = []; + let currentSegment = ''; + let inCJKSequence = false; + + for (let i = 0; i < text.length; i++) { + const char = text[i]!; + const charIsCJK = isCJK(char); + const charIsPunct = isCJKPunctuation(char); + + if (charIsCJK) { + if (!inCJKSequence && currentSegment) { + // Push non-CJK segment + words.push(currentSegment); + currentSegment = ''; + } + currentSegment += char; + inCJKSequence = true; + } else if (charIsPunct) { + // CJK punctuation should be kept with CJK segment + if (inCJKSequence) { + currentSegment += char; + // Don't change inCJKSequence, keep collecting + } else if (currentSegment) { + // Non-CJK text followed by punctuation + currentSegment += char; + } else { + // Standalone punctuation at start + currentSegment = char; + } + } else if (char.match(/\s/)) { + if (currentSegment) { + if (inCJKSequence) { + // Segment the CJK text (with any trailing punctuation) + words.push(...segmentCJKText(currentSegment)); + } else { + words.push(currentSegment); + } + currentSegment = ''; + } + inCJKSequence = false; + } else { + // Non-CJK, non-punctuation, non-whitespace character + if (inCJKSequence && currentSegment) { + // Segment the CJK text before continuing with non-CJK + words.push(...segmentCJKText(currentSegment)); + currentSegment = ''; + } + currentSegment += char; + inCJKSequence = false; + } + } + + if (currentSegment) { + if (inCJKSequence) { + words.push(...segmentCJKText(currentSegment)); + } else { + words.push(currentSegment); + } + } + + return words.filter((w) => w.trim().length > 0); +} diff --git a/apps/readest-app/src/utils/note.ts b/apps/readest-app/src/utils/note.ts index c0eaae0d..2c908ed3 100644 --- a/apps/readest-app/src/utils/note.ts +++ b/apps/readest-app/src/utils/note.ts @@ -30,6 +30,10 @@ env.addFilter('date', (value: number | string | undefined, format?: string) => { if (value === undefined || value === null) return ''; let date: Date; + // Check if the input is a date-only string (YYYY-MM-DD) which gets parsed as UTC midnight + // In this case, we should use UTC methods to avoid timezone offset issues + const isDateOnlyString = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value); + if (typeof value === 'number') { date = new Date(value); } else if (typeof value === 'string') { @@ -45,37 +49,74 @@ env.addFilter('date', (value: number | string | undefined, format?: string) => { } // Convert Python strftime format to actual values + // Use UTC methods for date-only strings to maintain consistency + const getYear = () => (isDateOnlyString ? date.getUTCFullYear() : date.getFullYear()); + const getMonth = () => (isDateOnlyString ? date.getUTCMonth() : date.getMonth()); + const getDate = () => (isDateOnlyString ? date.getUTCDate() : date.getDate()); + const getHours = () => (isDateOnlyString ? date.getUTCHours() : date.getHours()); + const getMinutes = () => (isDateOnlyString ? date.getUTCMinutes() : date.getMinutes()); + const getSeconds = () => (isDateOnlyString ? date.getUTCSeconds() : date.getSeconds()); + const getDay = () => (isDateOnlyString ? date.getUTCDay() : date.getDay()); + return format - .replace(/%Y/g, date.getFullYear().toString()) - .replace(/%m/g, String(date.getMonth() + 1).padStart(2, '0')) - .replace(/%d/g, String(date.getDate()).padStart(2, '0')) - .replace(/%H/g, String(date.getHours()).padStart(2, '0')) - .replace(/%M/g, String(date.getMinutes()).padStart(2, '0')) - .replace(/%S/g, String(date.getSeconds()).padStart(2, '0')) - .replace(/%I/g, String(date.getHours() % 12 || 12).padStart(2, '0')) - .replace(/%p/g, date.getHours() >= 12 ? 'PM' : 'AM') - .replace(/%w/g, String(date.getDay())) - .replace(/%j/g, getDayOfYear(date).toString().padStart(3, '0')) - .replace(/%U/g, getWeekNumber(date).toString().padStart(2, '0')) - .replace(/%W/g, getWeekNumber(date, true).toString().padStart(2, '0')) - .replace(/%a/g, date.toLocaleDateString('en-US', { weekday: 'short' })) - .replace(/%A/g, date.toLocaleDateString('en-US', { weekday: 'long' })) - .replace(/%b/g, date.toLocaleDateString('en-US', { month: 'short' })) - .replace(/%B/g, date.toLocaleDateString('en-US', { month: 'long' })) + .replace(/%Y/g, getYear().toString()) + .replace(/%m/g, String(getMonth() + 1).padStart(2, '0')) + .replace(/%d/g, String(getDate()).padStart(2, '0')) + .replace(/%H/g, String(getHours()).padStart(2, '0')) + .replace(/%M/g, String(getMinutes()).padStart(2, '0')) + .replace(/%S/g, String(getSeconds()).padStart(2, '0')) + .replace(/%I/g, String(getHours() % 12 || 12).padStart(2, '0')) + .replace(/%p/g, getHours() >= 12 ? 'PM' : 'AM') + .replace(/%w/g, String(getDay())) + .replace(/%j/g, getDayOfYear(date, isDateOnlyString).toString().padStart(3, '0')) + .replace(/%U/g, getWeekNumber(date, false, isDateOnlyString).toString().padStart(2, '0')) + .replace(/%W/g, getWeekNumber(date, true, isDateOnlyString).toString().padStart(2, '0')) + .replace( + /%a/g, + date.toLocaleDateString('en-US', { + weekday: 'short', + timeZone: isDateOnlyString ? 'UTC' : undefined, + }), + ) + .replace( + /%A/g, + date.toLocaleDateString('en-US', { + weekday: 'long', + timeZone: isDateOnlyString ? 'UTC' : undefined, + }), + ) + .replace( + /%b/g, + date.toLocaleDateString('en-US', { + month: 'short', + timeZone: isDateOnlyString ? 'UTC' : undefined, + }), + ) + .replace( + /%B/g, + date.toLocaleDateString('en-US', { + month: 'long', + timeZone: isDateOnlyString ? 'UTC' : undefined, + }), + ) .replace(/%%/g, '%'); }); // Helper to get day of year -function getDayOfYear(date: Date): number { - const start = new Date(date.getFullYear(), 0, 0); +function getDayOfYear(date: Date, useUTC = false): number { + const year = useUTC ? date.getUTCFullYear() : date.getFullYear(); + const start = useUTC ? new Date(Date.UTC(year, 0, 0)) : new Date(year, 0, 0); const diff = date.getTime() - start.getTime(); const oneDay = 1000 * 60 * 60 * 24; return Math.floor(diff / oneDay); } // Helper to get week number -function getWeekNumber(date: Date, mondayFirst = false): number { - const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); +function getWeekNumber(date: Date, mondayFirst = false, useUTC = false): number { + const year = useUTC ? date.getUTCFullYear() : date.getFullYear(); + const month = useUTC ? date.getUTCMonth() : date.getMonth(); + const day = useUTC ? date.getUTCDate() : date.getDate(); + const d = new Date(Date.UTC(year, month, day)); const dayNum = mondayFirst ? d.getUTCDay() || 7 : d.getUTCDay(); d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 368b24e1..5567c3f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,7 +82,7 @@ importers: version: 2.0.0 '@opennextjs/cloudflare': specifier: ^1.15.1 - version: 1.15.1(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.60.0) + version: 1.15.1(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.60.0) '@radix-ui/react-collapsible': specifier: ^1.1.12 version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -115,7 +115,7 @@ importers: version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@serwist/next': specifier: ^9.4.2 - version: 9.5.0(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3)(webpack@5.104.1) + version: 9.5.0(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3)(webpack@5.104.1) '@stripe/react-stripe-js': specifier: ^3.7.0 version: 3.10.0(@stripe/stripe-js@7.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -268,7 +268,7 @@ importers: version: 5.1.6 next: specifier: 16.1.6 - version: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) nunjucks: specifier: ^3.2.4 version: 3.2.4(chokidar@3.6.0) @@ -625,28 +625,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@ast-grep/napi-linux-arm64-musl@0.40.0': resolution: {integrity: sha512-MS9qalLRjUnF2PCzuTKTvCMVSORYHxxe3Qa0+SSaVULsXRBmuy5C/b1FeWwMFnwNnC0uie3VDet31Zujwi8q6A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@ast-grep/napi-linux-x64-gnu@0.40.0': resolution: {integrity: sha512-BeHZVMNXhM3WV3XE2yghO0fRxhMOt8BTN972p5piYEQUvKeSHmS8oeGcs6Ahgx5znBclqqqq37ZfioYANiTqJA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@ast-grep/napi-linux-x64-musl@0.40.0': resolution: {integrity: sha512-rG1YujF7O+lszX8fd5u6qkFTuv4FwHXjWvt1CCvCxXwQLSY96LaCW88oVKg7WoEYQh54y++Fk57F+Wh9Gv9nVQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@ast-grep/napi-win32-arm64-msvc@0.40.0': resolution: {integrity: sha512-9SqmnQqd4zTEUk6yx0TuW2ycZZs2+e569O/R0QnhSiQNpgwiJCYOe/yPS0BC9HkiaozQm6jjAcasWpFtz/dp+w==} @@ -1782,105 +1778,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1967,35 +1947,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.88': resolution: {integrity: sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.88': resolution: {integrity: sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.88': resolution: {integrity: sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.88': resolution: {integrity: sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/canvas-win32-arm64-msvc@0.1.88': resolution: {integrity: sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==} @@ -2042,28 +2017,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.1.6': resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} @@ -2202,6 +2173,11 @@ packages: resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==} engines: {node: '>=14'} + '@playwright/test@1.58.1': + resolution: {integrity: sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -2753,79 +2729,66 @@ packages: resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.56.0': resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.56.0': resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.56.0': resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.56.0': resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.56.0': resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.56.0': resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.56.0': resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.56.0': resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.56.0': resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.56.0': resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.56.0': resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.56.0': resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.56.0': resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==} @@ -3379,35 +3342,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.9.6': resolution: {integrity: sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.9.6': resolution: {integrity: sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.9.6': resolution: {integrity: sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.9.6': resolution: {integrity: sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.9.6': resolution: {integrity: sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==} @@ -3828,49 +3786,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -5276,6 +5226,11 @@ packages: resolution: {integrity: sha512-UTOY+59K6IA94tec8Wjqm0FSh5OVudGNB0NL/P6fB3HiE3bYOY3VYBGijsnOHNkQSwC1FKkU77pmq7xp9CskLw==} engines: {node: '>=10.13.0'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -6586,6 +6541,16 @@ packages: resolution: {integrity: sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ==} engines: {node: '>= 0.4.0'} + playwright-core@1.58.1: + resolution: {integrity: sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.1: + resolution: {integrity: sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==} + engines: {node: '>=18'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -10221,7 +10186,7 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@opennextjs/aws@3.9.12(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))': + '@opennextjs/aws@3.9.12(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))': dependencies: '@ast-grep/napi': 0.40.0 '@aws-sdk/client-cloudfront': 3.398.0 @@ -10237,7 +10202,7 @@ snapshots: cookie: 1.1.1 esbuild: 0.25.4 express: 5.2.1 - next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) path-to-regexp: 6.3.0 urlpattern-polyfill: 10.1.0 yaml: 2.8.2 @@ -10245,15 +10210,15 @@ snapshots: - aws-crt - supports-color - '@opennextjs/cloudflare@1.15.1(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.60.0)': + '@opennextjs/cloudflare@1.15.1(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.60.0)': dependencies: '@ast-grep/napi': 0.40.0 '@dotenvx/dotenvx': 1.31.0 - '@opennextjs/aws': 3.9.12(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) + '@opennextjs/aws': 3.9.12(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) cloudflare: 4.5.0 enquirer: 2.4.1 glob: 13.0.0 - next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) ts-tqdm: 0.8.6 wrangler: 4.60.0 yargs: 18.0.0 @@ -10338,6 +10303,11 @@ snapshots: '@opentelemetry/semantic-conventions@1.39.0': {} + '@playwright/test@1.58.1': + dependencies: + playwright: 1.58.1 + optional: true + '@polka/url@1.0.0-next.29': {} '@poppinss/colors@4.1.6': @@ -10945,7 +10915,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@serwist/next@9.5.0(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3)(webpack@5.104.1)': + '@serwist/next@9.5.0(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3)(webpack@5.104.1)': dependencies: '@serwist/build': 9.5.0(typescript@5.9.3) '@serwist/utils': 9.5.0 @@ -10954,7 +10924,7 @@ snapshots: browserslist: 4.28.1 glob: 13.0.0 kolorist: 1.8.0 - next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 semver: 7.7.3 serwist: 9.5.0(typescript@5.9.3) @@ -13893,6 +13863,9 @@ snapshots: - bare-abort-controller - react-native-b4a + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -15262,7 +15235,7 @@ snapshots: neo-async@2.6.2: {} - next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 @@ -15282,6 +15255,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.1.6 '@next/swc-win32-x64-msvc': 16.1.6 '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.58.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -15520,6 +15494,16 @@ snapshots: pkginfo@0.4.1: {} + playwright-core@1.58.1: + optional: true + + playwright@1.58.1: + dependencies: + playwright-core: 1.58.1 + optionalDependencies: + fsevents: 2.3.2 + optional: true + points-on-curve@0.2.0: {} points-on-path@0.2.1: