forked from akai/readest
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ef126f820 | |||
| 1788b86ced | |||
| 3111e93054 | |||
| 288abc678c | |||
| 7e559c5b90 | |||
| eaaeb844f3 | |||
| 097fde203d | |||
| efd85e67d9 | |||
| bd4bf459e3 | |||
| 8fb1745dbb | |||
| 47c6d6d058 | |||
| 564693d456 |
@@ -58,6 +58,11 @@ jobs:
|
||||
run: |
|
||||
pnpm install && pnpm setup-pdfjs
|
||||
|
||||
- name: run tests
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm test -- --watchAll=false
|
||||
|
||||
- name: build the web App
|
||||
if: matrix.config.platform == 'web'
|
||||
working-directory: apps/readest-app
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.68",
|
||||
"version": "0.9.69",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -73,11 +73,13 @@
|
||||
"cssbeautify": "^0.3.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"foliate-js": "workspace:*",
|
||||
"franc-min": "^6.2.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"iso-639-2": "^3.0.2",
|
||||
"iso-639-3": "^3.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"marked": "^15.0.12",
|
||||
|
||||
@@ -476,5 +476,6 @@
|
||||
"Failed to create checkout session": "فشل في إنشاء جلسة الدفع",
|
||||
"Storage": "التخزين",
|
||||
"Terms of Service": "شروط الخدمة",
|
||||
"Privacy Policy": "سياسة الخصوصية"
|
||||
"Privacy Policy": "سياسة الخصوصية",
|
||||
"Disable Double Click": "تعطيل النقر المزدوج"
|
||||
}
|
||||
|
||||
@@ -460,5 +460,6 @@
|
||||
"Failed to create checkout session": "Fehler beim Erstellen der Checkout-Sitzung",
|
||||
"Storage": "Speicher",
|
||||
"Terms of Service": "Allgemeine Geschäftsbedingungen",
|
||||
"Privacy Policy": "Datenschutzbestimmungen"
|
||||
"Privacy Policy": "Datenschutzbestimmungen",
|
||||
"Disable Double Click": "Doppelklick deaktivieren"
|
||||
}
|
||||
|
||||
@@ -460,5 +460,6 @@
|
||||
"Failed to create checkout session": "Αποτυχία δημιουργίας συνεδρίας πληρωμής",
|
||||
"Storage": "Αποθήκευση",
|
||||
"Terms of Service": "Όροι χρήσης",
|
||||
"Privacy Policy": "Πολιτική απορρήτου"
|
||||
"Privacy Policy": "Πολιτική απορρήτου",
|
||||
"Disable Double Click": "Απενεργοποίηση διπλού κλικ"
|
||||
}
|
||||
|
||||
@@ -464,5 +464,6 @@
|
||||
"Failed to create checkout session": "Error al crear la sesión de pago",
|
||||
"Storage": "Almacenamiento",
|
||||
"Terms of Service": "Condiciones del servicio",
|
||||
"Privacy Policy": "Política de privacidad"
|
||||
"Privacy Policy": "Política de privacidad",
|
||||
"Disable Double Click": "Deshabilitar Doble Clic"
|
||||
}
|
||||
|
||||
@@ -464,5 +464,6 @@
|
||||
"Failed to create checkout session": "Échec de la création de la session de paiement",
|
||||
"Storage": "Stockage",
|
||||
"Terms of Service": "Conditions d'utilisation",
|
||||
"Privacy Policy": "Politique de confidentialité"
|
||||
"Privacy Policy": "Politique de confidentialité",
|
||||
"Disable Double Click": "Désactiver le double-clic"
|
||||
}
|
||||
|
||||
@@ -460,5 +460,6 @@
|
||||
"Failed to create checkout session": "चेकआउट सत्र बनाने में विफल",
|
||||
"Storage": "भंडारण",
|
||||
"Terms of Service": "सेवा की शर्तें",
|
||||
"Privacy Policy": "गोपनीयता नीति"
|
||||
"Privacy Policy": "गोपनीयता नीति",
|
||||
"Disable Double Click": "डबल क्लिक अक्षम करें"
|
||||
}
|
||||
|
||||
@@ -456,5 +456,6 @@
|
||||
"Failed to create checkout session": "Gagal membuat sesi checkout",
|
||||
"Storage": "Penyimpanan",
|
||||
"Terms of Service": "Ketentuan Layanan",
|
||||
"Privacy Policy": "Kebijakan Privasi"
|
||||
"Privacy Policy": "Kebijakan Privasi",
|
||||
"Disable Double Click": "Nonaktifkan Klik Ganda"
|
||||
}
|
||||
|
||||
@@ -464,5 +464,6 @@
|
||||
"Failed to create checkout session": "Impossibile creare la sessione di checkout",
|
||||
"Storage": "Archiviazione",
|
||||
"Terms of Service": "Termini di servizio",
|
||||
"Privacy Policy": "Informativa sulla privacy"
|
||||
"Privacy Policy": "Informativa sulla privacy",
|
||||
"Disable Double Click": "Disabilita Doppio Clic"
|
||||
}
|
||||
|
||||
@@ -456,5 +456,6 @@
|
||||
"Failed to create checkout session": "チェックアウトセッションの作成に失敗しました",
|
||||
"Storage": "ストレージ",
|
||||
"Terms of Service": "利用規約",
|
||||
"Privacy Policy": "プライバシーポリシー"
|
||||
"Privacy Policy": "プライバシーポリシー",
|
||||
"Disable Double Click": "ダブルクリック無効"
|
||||
}
|
||||
|
||||
@@ -456,5 +456,6 @@
|
||||
"Failed to create checkout session": "체크아웃 세션 생성 실패",
|
||||
"Storage": "저장소",
|
||||
"Terms of Service": "서비스 약관",
|
||||
"Privacy Policy": "개인정보 보호정책"
|
||||
"Privacy Policy": "개인정보 보호정책",
|
||||
"Disable Double Click": "더블 클릭 비활성화"
|
||||
}
|
||||
|
||||
@@ -460,5 +460,6 @@
|
||||
"Failed to create checkout session": "Kon de afreken sessie niet aanmaken",
|
||||
"Storage": "Opslag",
|
||||
"Terms of Service": "Gebruiksvoorwaarden",
|
||||
"Privacy Policy": "Privacybeleid"
|
||||
"Privacy Policy": "Privacybeleid",
|
||||
"Disable Double Click": "Dubbelklikken uitschakelen"
|
||||
}
|
||||
|
||||
@@ -468,5 +468,6 @@
|
||||
"Failed to create checkout session": "Nie udało się utworzyć sesji zakupu",
|
||||
"Storage": "Przechowywanie",
|
||||
"Terms of Service": "Warunki korzystania z usługi",
|
||||
"Privacy Policy": "Polityka prywatności"
|
||||
"Privacy Policy": "Polityka prywatności",
|
||||
"Disable Double Click": "Wyłącz podwójne kliknięcie"
|
||||
}
|
||||
|
||||
@@ -464,5 +464,6 @@
|
||||
"Failed to create checkout session": "Falha ao criar sessão de checkout",
|
||||
"Storage": "Armazenamento",
|
||||
"Terms of Service": "Termos de Serviço",
|
||||
"Privacy Policy": "Política de Privacidade"
|
||||
"Privacy Policy": "Política de Privacidade",
|
||||
"Disable Double Click": "Desativar Clique Duplo"
|
||||
}
|
||||
|
||||
@@ -468,5 +468,6 @@
|
||||
"Failed to create checkout session": "Не удалось создать сессию оформления заказа",
|
||||
"Storage": "Хранилище",
|
||||
"Terms of Service": "Условия использования",
|
||||
"Privacy Policy": "Политика конфиденциальности"
|
||||
"Privacy Policy": "Политика конфиденциальности",
|
||||
"Disable Double Click": "Отключить двойной клик"
|
||||
}
|
||||
|
||||
@@ -456,5 +456,6 @@
|
||||
"Failed to create checkout session": "ไม่สามารถสร้างเซสชันการชำระเงินได้",
|
||||
"Storage": "พื้นที่เก็บข้อมูล",
|
||||
"Terms of Service": "ข้อกำหนดในการให้บริการ",
|
||||
"Privacy Policy": "นโยบายความเป็นส่วนตัว"
|
||||
"Privacy Policy": "นโยบายความเป็นส่วนตัว",
|
||||
"Disable Double Click": "ปิดการคลิกสองครั้ง"
|
||||
}
|
||||
|
||||
@@ -460,5 +460,6 @@
|
||||
"Failed to create checkout session": "Ödeme oturumu oluşturma başarısız oldu",
|
||||
"Storage": "Depolama",
|
||||
"Terms of Service": "Hizmet Şartları",
|
||||
"Privacy Policy": "Gizlilik Politikası"
|
||||
"Privacy Policy": "Gizlilik Politikası",
|
||||
"Disable Double Click": "Çift Tıklamayı Devre Dışı Bırak"
|
||||
}
|
||||
|
||||
@@ -468,5 +468,6 @@
|
||||
"Failed to create checkout session": "Не вдалося створити сесію оформлення замовлення",
|
||||
"Storage": "Сховище",
|
||||
"Terms of Service": "Умови обслуговування",
|
||||
"Privacy Policy": "Політика конфіденційності"
|
||||
"Privacy Policy": "Політика конфіденційності",
|
||||
"Disable Double Click": "Вимкнути подвійне клацання"
|
||||
}
|
||||
|
||||
@@ -456,5 +456,6 @@
|
||||
"Failed to create checkout session": "Không thể tạo phiên làm việc thanh toán",
|
||||
"Storage": "Lưu trữ",
|
||||
"Terms of Service": "Điều khoản dịch vụ",
|
||||
"Privacy Policy": "Chính sách bảo mật"
|
||||
"Privacy Policy": "Chính sách bảo mật",
|
||||
"Disable Double Click": "Tắt Nhấp Đôi"
|
||||
}
|
||||
|
||||
@@ -456,5 +456,6 @@
|
||||
"Failed to create checkout session": "创建结账会话失败",
|
||||
"Storage": "云同步空间",
|
||||
"Terms of Service": "服务条款",
|
||||
"Privacy Policy": "隐私政策"
|
||||
"Privacy Policy": "隐私政策",
|
||||
"Disable Double Click": "禁用双击"
|
||||
}
|
||||
|
||||
@@ -456,5 +456,6 @@
|
||||
"Failed to create checkout session": "創建結帳會話失敗",
|
||||
"Storage": "雲同步空間",
|
||||
"Terms of Service": "服務條款",
|
||||
"Privacy Policy": "隱私政策"
|
||||
"Privacy Policy": "隱私政策",
|
||||
"Disable Double Click": "禁用雙擊"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.69": {
|
||||
"date": "2025-08-02",
|
||||
"notes": [
|
||||
"Updater: Fixed permission issue when updating in standalone reader window",
|
||||
"Config: Added option to disable double‑click actions",
|
||||
"EPUB: Fixed missing images in some EPUB files",
|
||||
"EPUB: Corrected formatting for book subjects",
|
||||
"TXT: Improved language detection when parsing TXT files",
|
||||
"CSS: Resolved issue where custom font colors could not be applied",
|
||||
"Layout: Corrected cover image width display in 'Fit' mode for list view",
|
||||
"Layout: Fixed unintended scrolling in reader view when highlighting text"
|
||||
]
|
||||
},
|
||||
"0.9.68": {
|
||||
"date": "2025-07-30",
|
||||
"notes": [
|
||||
|
||||
@@ -43,7 +43,18 @@
|
||||
"identifier": "fs:allow-cache-read",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$CACHEDIR/**/*"
|
||||
"path": "$APPCACHE/**/*"
|
||||
},
|
||||
{
|
||||
"path": "/private/var/mobile/Containers/Data/Application/**/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-cache-write",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$APPCACHE/**/*"
|
||||
},
|
||||
{
|
||||
"path": "/private/var/mobile/Containers/Data/Application/**/*"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "desktop-capability",
|
||||
"windows": ["main", "updater"],
|
||||
"windows": ["main", "updater", "reader-*"],
|
||||
"platforms": ["linux", "macOS", "windows"],
|
||||
"permissions": ["updater:default", "cli:default"]
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"allow": [
|
||||
"$RESOURCE/**",
|
||||
"$APPDATA/**/*",
|
||||
"$APPCACHE/**/*",
|
||||
"$TEMP/**/*",
|
||||
"/private/var/mobile/Containers/Data/Application/**/*"
|
||||
],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { POST } from '@/app/api/apple/iap-verify/route';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { setupSupabaseMocks } from '../helpers/supabase-mock';
|
||||
|
||||
const SKIP_IAP_API_TESTS = !process.env['ENABLE_IAP_API_TESTS'];
|
||||
vi.mock('@/utils/supabase', () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
@@ -14,7 +15,7 @@ vi.mock('@/utils/supabase', () => ({
|
||||
createSupabaseAdminClient: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('/api/apple/iap-verify', () => {
|
||||
describe.skipIf(SKIP_IAP_API_TESTS)('/api/apple/iap-verify', () => {
|
||||
it('should verify a valid transaction', async () => {
|
||||
setupSupabaseMocks();
|
||||
const request = new NextRequest('http://localhost:3000/api/apple/iap-verify', {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { detectLanguage } from '@/utils/lang';
|
||||
|
||||
describe('detectLanguage - Result Tests', () => {
|
||||
describe('English text detection', () => {
|
||||
it('should detect English text', () => {
|
||||
const englishTexts = [
|
||||
'This is a sample English text for language detection testing.',
|
||||
'The quick brown fox jumps over the lazy dog.',
|
||||
'Hello world! This is an English sentence with punctuation.',
|
||||
'Chapter 1: Introduction to Language Detection',
|
||||
'In the beginning was the Word, and the Word was with God.',
|
||||
'To be or not to be, that is the question.',
|
||||
];
|
||||
|
||||
englishTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('en');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chinese text detection', () => {
|
||||
it('should detect Chinese text', () => {
|
||||
const chineseTexts = [
|
||||
'这是一个中文文本的示例,用于语言检测测试。',
|
||||
'天下大势,分久必合,合久必分。',
|
||||
'学而时习之,不亦说乎?有朋自远方来,不亦乐乎?',
|
||||
'春眠不觉晓,处处闻啼鸟。夜来风雨声,花落知多少。',
|
||||
'那时候,天空很蓝,云彩很白,我们都还很年轻。',
|
||||
'人工智能技术的发展正在改变我们的生活方式和工作方式。',
|
||||
];
|
||||
|
||||
chineseTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('zh');
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect Traditional Chinese text', () => {
|
||||
const traditionalChineseTexts = [
|
||||
'這是一個正體中文文本的示例,用於語言檢測測試。',
|
||||
'天下大勢,分久必合,合久必分。',
|
||||
'學而時習之,不亦說乎?有朋自遠方來,不亦樂乎?',
|
||||
'春眠不覺曉,處處聞啼鳥。夜來風雨聲,花落知多少。',
|
||||
'那時候,天空很藍,雲彩很白,我們都還很年輕。',
|
||||
'人工智慧技術的發展正在改變我們的生活方式和工作方式。',
|
||||
];
|
||||
|
||||
traditionalChineseTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('zh');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Japanese text detection', () => {
|
||||
it('should detect Japanese text', () => {
|
||||
const japaneseTexts = [
|
||||
'これは日本語のテキストサンプルです。',
|
||||
'第一章:言語検出について',
|
||||
'こんにちは、世界!これは日本語の文章です。',
|
||||
'桜の花が咲く季節になりました。とても美しいです。',
|
||||
'ひらがな、カタカナ、漢字を使って日本語を書きます。',
|
||||
'東京は日本の首都であり、多くの人々が住んでいます。',
|
||||
];
|
||||
|
||||
japaneseTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('ja');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('French text detection', () => {
|
||||
it('should detect French text', () => {
|
||||
const frenchTexts = [
|
||||
'Ceci est un exemple de texte français pour tester la détection de langue.',
|
||||
'Chapitre 1: Introduction à la détection de langue.',
|
||||
'Bonjour le monde! Ceci est une phrase en français.',
|
||||
'Les Champs-Élysées sont une avenue célèbre à Paris, souvent appelée la plus belle avenue du monde.',
|
||||
'Victor Hugo était un écrivain français très célèbre du XIXe siècle,',
|
||||
"La littérature française possède une richesse extraordinaire qui s'étend sur plusieurs siècles.",
|
||||
];
|
||||
|
||||
frenchTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('fr');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spanish text detection', () => {
|
||||
it('should detect Spanish text', () => {
|
||||
const spanishTexts = [
|
||||
'Este es un ejemplo de texto en español para probar la detección de idioma.',
|
||||
'Capítulo 1: Introducción a la detección de idiomas',
|
||||
'¡Hola mundo! Esta es una oración en español.',
|
||||
'España es un país ubicado en la península ibérica.',
|
||||
'El flamenco es una forma de arte español muy famosa.',
|
||||
'Don Quijote de La Mancha es una obra clásica de la literatura española.',
|
||||
];
|
||||
|
||||
spanishTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('es');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('German text detection', () => {
|
||||
it('should detect German text', () => {
|
||||
const germanTexts = [
|
||||
'Dies ist ein Beispieltext auf Deutsch für die Spracherkennung.',
|
||||
'Kapitel 1: Einführung in die Spracherkennung',
|
||||
'Hallo Welt! Dies ist ein deutscher Satz.',
|
||||
'Deutschland ist ein Land in Mitteleuropa.',
|
||||
'Das Oktoberfest ist ein berühmtes deutsches Festival.',
|
||||
'Die deutsche Sprache hat viele zusammengesetzte Wörter.',
|
||||
];
|
||||
|
||||
germanTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('de');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Arabic text detection', () => {
|
||||
it('should detect Arabic text', () => {
|
||||
const arabicTexts = [
|
||||
'هذا نص عربي للاختبار. يجب أن يتم اكتشاف هذا النص كعربي.',
|
||||
'الفصل الأول: مقدمة في اكتشاف اللغة',
|
||||
'مرحبا بالعالم! هذه جملة باللغة العربية.',
|
||||
'العربية لغة جميلة يتحدث بها ملايين الناس.',
|
||||
'القرآن الكريم نزل باللغة العربية.',
|
||||
'دمشق هي عاصمة سوريا وواحدة من أقدم المدن في العالم.',
|
||||
];
|
||||
|
||||
arabicTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('ar');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Korean text detection', () => {
|
||||
it('should detect Korean text', () => {
|
||||
const koreanTexts = [
|
||||
'이것은 언어 감지 테스트를 위한 한국어 텍스트 샘플입니다.',
|
||||
'제1장: 언어 감지 소개',
|
||||
'안녕하세요 세계! 이것은 한국어 문장입니다.',
|
||||
'서울은 대한민국의 수도이며 많은 사람들이 살고 있습니다.',
|
||||
'한글은 세종대왕이 만든 우리나라의 고유한 문자입니다.',
|
||||
'김치는 한국의 전통 음식 중 하나입니다.',
|
||||
];
|
||||
|
||||
koreanTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('ko');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback behavior', () => {
|
||||
it('should return "en" for very short text', () => {
|
||||
const shortTexts = ['', ' ', 'Hi', 'OK', '123', '!@#'];
|
||||
|
||||
shortTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('en');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return "en" for unrecognizable text', () => {
|
||||
const unrecognizableTexts = [
|
||||
'123456789012345678901234567890',
|
||||
'!@#$%^&*()_+{}|:"<>?[]\\;\',./',
|
||||
'1111111111222222222233333333334444444444',
|
||||
];
|
||||
|
||||
unrecognizableTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('en');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed content', () => {
|
||||
it('should detect predominant language in mixed content', () => {
|
||||
const testCases = [
|
||||
{
|
||||
text: 'This is mostly English text with some 中文 words mixed in but English dominates.',
|
||||
expected: 'en',
|
||||
},
|
||||
{
|
||||
text: '这主要是中文文本,虽然有一些 English words 混合在其中,但中文占主导地位。',
|
||||
expected: 'zh',
|
||||
},
|
||||
{
|
||||
text: 'Ceci est principalement du français avec quelques English words mélangés.',
|
||||
expected: 'fr',
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ text, expected }) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('long text handling', () => {
|
||||
it('should handle long text (over 1000 characters)', () => {
|
||||
const longEnglishText = 'This is a very long English text. '.repeat(100); // ~3400 chars
|
||||
const longChineseText = '这是一个很长的中文文本。'.repeat(100); // ~1200 chars
|
||||
const longFrenchText = 'Ceci est un très long texte français. '.repeat(100); // ~3800 chars
|
||||
|
||||
expect(detectLanguage(longEnglishText)).toBe('en');
|
||||
expect(detectLanguage(longChineseText)).toBe('zh');
|
||||
expect(detectLanguage(longFrenchText)).toBe('fr');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases with whitespace and formatting', () => {
|
||||
it('should handle text with lots of whitespace', () => {
|
||||
const textWithWhitespace = ' This is English text with lots of spaces ';
|
||||
const result = detectLanguage(textWithWhitespace);
|
||||
expect(result).toBe('en');
|
||||
});
|
||||
|
||||
it('should handle text with newlines and tabs', () => {
|
||||
const textWithFormatting = 'This is English text\nwith newlines\tand tabs\nfor formatting.';
|
||||
const result = detectLanguage(textWithFormatting);
|
||||
expect(result).toBe('en');
|
||||
});
|
||||
|
||||
it('should handle text with punctuation', () => {
|
||||
const textWithPunctuation = 'Hello, world! How are you? I am fine. Thank you very much!!!';
|
||||
const result = detectLanguage(textWithPunctuation);
|
||||
expect(result).toBe('en');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectLanguage - Real World Samples', () => {
|
||||
it('should detect language in book excerpts', () => {
|
||||
const bookExcerpts = [
|
||||
{
|
||||
text: 'It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness.',
|
||||
expected: 'en',
|
||||
},
|
||||
{
|
||||
text: 'Dans une trou dans la terre vivait un hobbit. Pas un trou déplaisant, sale et humide.',
|
||||
expected: 'fr',
|
||||
},
|
||||
{
|
||||
text: '昔々、あるところにおじいさんとおばあさんが住んでいました。',
|
||||
expected: 'ja',
|
||||
},
|
||||
{
|
||||
text: '很久很久以前,有一个美丽的公主住在一座城堡里。',
|
||||
expected: 'zh',
|
||||
},
|
||||
];
|
||||
|
||||
bookExcerpts.forEach(({ text, expected }) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -61,9 +61,10 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'relative flex items-end justify-center',
|
||||
coverFit === 'crop' && 'aspect-[28/41] overflow-hidden shadow-md',
|
||||
mode === 'list' && 'min-w-20',
|
||||
'relative flex aspect-[28/41] justify-center',
|
||||
coverFit === 'crop' && 'overflow-hidden shadow-md',
|
||||
mode === 'grid' && 'items-end',
|
||||
mode === 'list' && 'min-w-20 items-center',
|
||||
)}
|
||||
>
|
||||
<BookCover mode={mode} book={book} coverFit={coverFit} showSpine={false} />
|
||||
|
||||
@@ -66,6 +66,7 @@ const FoliateViewer: React.FC<{
|
||||
const viewRef = useRef<FoliateView | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isViewCreated = useRef(false);
|
||||
const doubleClickDisabled = useRef(!!viewSettings?.disableDoubleClick);
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -159,7 +160,7 @@ const FoliateViewer: React.FC<{
|
||||
detail.doc.addEventListener('keydown', handleKeydown.bind(null, bookKey));
|
||||
detail.doc.addEventListener('mousedown', handleMousedown.bind(null, bookKey));
|
||||
detail.doc.addEventListener('mouseup', handleMouseup.bind(null, bookKey));
|
||||
detail.doc.addEventListener('click', handleClick.bind(null, bookKey));
|
||||
detail.doc.addEventListener('click', handleClick.bind(null, bookKey, doubleClickDisabled));
|
||||
detail.doc.addEventListener('wheel', handleWheel.bind(null, bookKey));
|
||||
detail.doc.addEventListener('touchstart', handleTouchStart.bind(null, bookKey));
|
||||
detail.doc.addEventListener('touchmove', handleTouchMove.bind(null, bookKey));
|
||||
@@ -256,6 +257,7 @@ const FoliateViewer: React.FC<{
|
||||
view.renderer.setStyles?.(getStyles(viewSettings));
|
||||
applyTranslationStyle(viewSettings);
|
||||
|
||||
doubleClickDisabled.current = viewSettings.disableDoubleClick!;
|
||||
const animated = viewSettings.animated!;
|
||||
const maxColumnCount = viewSettings.maxColumnCount!;
|
||||
const maxInlineSize = getMaxInlineSize(viewSettings);
|
||||
@@ -325,6 +327,12 @@ const FoliateViewer: React.FC<{
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [themeCode, isDarkMode, viewSettings?.overrideColor, viewSettings?.invertImgColorInDark]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewRef.current && viewRef.current.renderer) {
|
||||
doubleClickDisabled.current = !!viewSettings?.disableDoubleClick;
|
||||
}
|
||||
}, [viewSettings?.disableDoubleClick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewRef.current && viewRef.current.renderer && viewSettings) {
|
||||
applyMarginAndGap();
|
||||
|
||||
@@ -183,10 +183,11 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'footer-bar shadow-xs absolute bottom-0 z-50 flex w-full flex-col',
|
||||
'footer-bar shadow-xs bottom-0 z-50 flex w-full flex-col',
|
||||
'sm:h-[52px] sm:justify-center',
|
||||
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
|
||||
'transition-[opacity,transform] duration-300',
|
||||
appService?.isMobile ? 'fixed' : 'absolute',
|
||||
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
@@ -203,7 +204,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
{/* Mobile footer bar */}
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-200 absolute bottom-16 flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
|
||||
'footerbar-progress-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
|
||||
actionTab === 'progress'
|
||||
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
|
||||
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
|
||||
@@ -257,7 +258,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
|
||||
'footerbar-font-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
|
||||
actionTab === 'font'
|
||||
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
|
||||
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
|
||||
|
||||
@@ -27,6 +27,9 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
|
||||
const [volumeKeysToFlip, setVolumeKeysToFlip] = useState(viewSettings.volumeKeysToFlip!);
|
||||
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
|
||||
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea!);
|
||||
const [isDisableDoubleClick, setIsDisableDoubleClick] = useState(
|
||||
viewSettings.disableDoubleClick!,
|
||||
);
|
||||
const [animated, setAnimated] = useState(viewSettings.animated!);
|
||||
const [allowScript, setAllowScript] = useState(viewSettings.allowScript!);
|
||||
|
||||
@@ -90,6 +93,11 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDisableClick]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'disableDoubleClick', isDisableDoubleClick, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDisableDoubleClick]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'swapClickArea', swapClickArea, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -172,6 +180,15 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
|
||||
onChange={() => setSwapClickArea(!swapClickArea)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Disable Double Click')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={isDisableDoubleClick}
|
||||
onChange={() => setIsDisableDoubleClick(!isDisableDoubleClick)}
|
||||
/>
|
||||
</div>
|
||||
{appService?.isMobileApp && (
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Volume Keys for Page Flip')}</span>
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import {
|
||||
DISABLE_DOUBLE_CLICK_ON_MOBILE,
|
||||
DOUBLE_CLICK_INTERVAL_THRESHOLD_MS,
|
||||
LONG_HOLD_THRESHOLD,
|
||||
} from '@/services/constants';
|
||||
import { DOUBLE_CLICK_INTERVAL_THRESHOLD_MS, LONG_HOLD_THRESHOLD } from '@/services/constants';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
|
||||
const doubleClickEnabled =
|
||||
!DISABLE_DOUBLE_CLICK_ON_MOBILE || !['android', 'ios'].includes(getOSPlatform());
|
||||
|
||||
let lastClickTime = 0;
|
||||
let longHoldTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -93,10 +85,14 @@ export const handleWheel = (bookKey: string, event: WheelEvent) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const handleClick = (bookKey: string, event: MouseEvent) => {
|
||||
export const handleClick = (
|
||||
bookKey: string,
|
||||
doubleClickDisabled: React.MutableRefObject<boolean>,
|
||||
event: MouseEvent,
|
||||
) => {
|
||||
const now = Date.now();
|
||||
|
||||
if (doubleClickEnabled && now - lastClickTime < DOUBLE_CLICK_INTERVAL_THRESHOLD_MS) {
|
||||
if (!doubleClickDisabled.current && now - lastClickTime < DOUBLE_CLICK_INTERVAL_THRESHOLD_MS) {
|
||||
lastClickTime = now;
|
||||
window.postMessage(
|
||||
{
|
||||
@@ -156,7 +152,7 @@ export const handleClick = (bookKey: string, event: MouseEvent) => {
|
||||
'*',
|
||||
);
|
||||
};
|
||||
if (doubleClickEnabled) {
|
||||
if (!doubleClickDisabled.current) {
|
||||
setTimeout(() => {
|
||||
if (Date.now() - lastClickTime >= DOUBLE_CLICK_INTERVAL_THRESHOLD_MS) {
|
||||
postSingleClick();
|
||||
|
||||
@@ -16,7 +16,7 @@ const UpdaterPage = () => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='px-12 py-4'>
|
||||
<div className='bg-base-100 px-12 py-4'>
|
||||
<UpdaterContent />
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
@@ -63,46 +63,50 @@ const BookCover: React.FC<BookCoverProps> = memo<BookCoverProps>(
|
||||
return (
|
||||
<div
|
||||
ref={coverRef}
|
||||
className={clsx(
|
||||
'book-cover-container relative flex w-full',
|
||||
coverFit === 'crop' && 'h-full',
|
||||
className,
|
||||
)}
|
||||
className={clsx('book-cover-container relative flex h-full w-full', className)}
|
||||
>
|
||||
{coverFit === 'crop' ? (
|
||||
<Image
|
||||
src={book.metadata?.coverImageUrl || book.coverImageUrl!}
|
||||
alt={book.title}
|
||||
fill={true}
|
||||
className={clsx('cover-image crop-cover-img object-cover', imageClassName)}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex w-full justify-center',
|
||||
mode === 'grid' ? 'h-full items-end' : 'items-center',
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<Image
|
||||
src={book.metadata?.coverImageUrl || book.coverImageUrl!}
|
||||
alt={book.title}
|
||||
width={0}
|
||||
height={0}
|
||||
sizes='100vw'
|
||||
className={clsx(
|
||||
'cover-image fit-cover-img h-auto max-h-full w-auto max-w-full shadow-md',
|
||||
imageClassName,
|
||||
)}
|
||||
fill={true}
|
||||
className={clsx('cover-image crop-cover-img object-cover', imageClassName)}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
<div
|
||||
className={`book-spine absolute inset-0 ${shouldShowSpine ? 'visible' : 'invisible'}`}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-full w-full justify-center',
|
||||
mode === 'grid' ? 'items-end' : 'items-center',
|
||||
)}
|
||||
>
|
||||
<div className='relative inline-block'>
|
||||
<Image
|
||||
src={book.metadata?.coverImageUrl || book.coverImageUrl!}
|
||||
alt={book.title}
|
||||
width={0}
|
||||
height={0}
|
||||
sizes='100vw'
|
||||
className={clsx(
|
||||
'cover-image fit-cover-img h-auto max-h-full w-auto max-w-full shadow-md',
|
||||
imageClassName,
|
||||
)}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
<div
|
||||
className={`book-spine absolute inset-0 ${shouldShowSpine ? 'visible' : 'invisible'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`book-spine absolute inset-0 ${shouldShowSpine ? 'visible' : 'invisible'}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'fallback-cover invisible absolute inset-0 rounded-none p-2',
|
||||
@@ -139,6 +143,7 @@ const BookCover: React.FC<BookCoverProps> = memo<BookCoverProps>(
|
||||
return (
|
||||
prevProps.book.coverImageUrl === nextProps.book.coverImageUrl &&
|
||||
prevProps.book.metadata?.coverImageUrl === nextProps.book.metadata?.coverImageUrl &&
|
||||
prevProps.book.updatedAt === nextProps.book.updatedAt &&
|
||||
prevProps.mode === nextProps.mode &&
|
||||
prevProps.coverFit === nextProps.coverFit &&
|
||||
prevProps.isPreview === nextProps.isPreview &&
|
||||
|
||||
@@ -106,6 +106,7 @@ export const UpdaterContent = ({
|
||||
}
|
||||
};
|
||||
const checkAndroidUpdate = async () => {
|
||||
if (!appService) return;
|
||||
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
|
||||
const response = await fetch(READEST_UPDATER_FILE);
|
||||
const data = await response.json();
|
||||
@@ -114,7 +115,7 @@ export const UpdaterContent = ({
|
||||
const platformKey = OS_ARCH === 'aarch64' ? 'android-arm64' : 'android-universal';
|
||||
const arch = OS_ARCH === 'aarch64' ? 'arm64' : 'universal';
|
||||
const downloadUrl = data.platforms[platformKey]?.url as string;
|
||||
const cachePrefix = appService?.fs.getPrefix('Cache');
|
||||
const cachePrefix = await appService.fs.getPrefix('Cache');
|
||||
const apkFilePath = `${cachePrefix}/Readest_${data.version}_${arch}.apk`;
|
||||
setUpdate({
|
||||
currentVersion,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Book } from '@/types/book';
|
||||
import { BookMetadata } from '@/libs/document';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { formatAuthors, formatTitle } from '@/utils/book';
|
||||
import { flattenContributors, formatAuthors, formatPublisher, formatTitle } from '@/utils/book';
|
||||
import { FormField } from './FormField';
|
||||
import { IMAGE_ACCEPT_FORMATS, SUPPORTED_IMAGE_EXTS } from '@/services/constants';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
@@ -107,7 +107,7 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
|
||||
{
|
||||
field: 'publisher',
|
||||
label: _('Publisher'),
|
||||
value: metadata.publisher || '',
|
||||
value: formatPublisher(metadata.publisher || ''),
|
||||
placeholder: _('Enter publisher'),
|
||||
},
|
||||
{
|
||||
@@ -135,7 +135,7 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
|
||||
{
|
||||
field: 'subject',
|
||||
label: _('Subjects'),
|
||||
value: Array.isArray(metadata.subject) ? metadata.subject.join(',') : metadata.subject || '',
|
||||
value: flattenContributors(metadata.subject || []),
|
||||
placeholder: _('Fiction, Science, History'),
|
||||
},
|
||||
{
|
||||
@@ -180,8 +180,8 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
|
||||
files = (await selectImageFileTauri()) as string[];
|
||||
if (appService && files.length > 0) {
|
||||
metadata.coverImageFile = files[0]!;
|
||||
const tempName = 'cover-temp.png';
|
||||
const cachePrefix = appService.fs.getPrefix('Cache');
|
||||
const tempName = `cover-${Date.now()}.png`;
|
||||
const cachePrefix = await appService.fs.getPrefix('Cache');
|
||||
await appService.fs.copyFile(files[0]!, tempName, 'Cache');
|
||||
metadata.coverImageUrl = await appService.fs.getURL(`${cachePrefix}/${tempName}`);
|
||||
setNewCoverImageUrl(metadata.coverImageUrl!);
|
||||
@@ -224,7 +224,7 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
|
||||
'border border-gray-300 bg-white hover:bg-gray-50',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'text-sm sm:text-xs',
|
||||
isCoverLocked && '!text-base-content !bg-base-200',
|
||||
isCoverLocked ? '!text-base-content !bg-base-200' : '!text-gray-500',
|
||||
)}
|
||||
title={_('Change cover image')}
|
||||
>
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
formatFileSize,
|
||||
formatLanguage,
|
||||
formatPublisher,
|
||||
formatSubject,
|
||||
formatTitle,
|
||||
} from '@/utils/book';
|
||||
import BookCover from '@/components/BookCover';
|
||||
@@ -120,7 +119,7 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Subjects')}</span>
|
||||
<p className='text-neutral-content line-clamp-3 text-sm'>
|
||||
{formatSubject(metadata.subject) || _('Unknown')}
|
||||
{formatAuthors(metadata.subject || '') || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
|
||||
@@ -68,7 +68,7 @@ export type BookMetadata = {
|
||||
publisher?: string;
|
||||
published?: string;
|
||||
description?: string;
|
||||
subject?: string[];
|
||||
subject?: string | string[] | Contributor;
|
||||
identifier?: string;
|
||||
|
||||
subtitle?: string;
|
||||
|
||||
@@ -129,8 +129,7 @@ export const getAppleIAPVerifier = () => {
|
||||
process.env['APPLE_IAP_PRIVATE_KEY_BASE64']! || '',
|
||||
'base64',
|
||||
).toString('utf-8'),
|
||||
environment: 'sandbox',
|
||||
// environment: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
|
||||
environment: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
|
||||
});
|
||||
}
|
||||
return defaultIAPVerifier;
|
||||
|
||||
@@ -46,6 +46,13 @@ import { ProgressHandler } from '@/utils/transfer';
|
||||
import { TxtToEpubConverter } from '@/utils/txt';
|
||||
import { BOOK_FILE_NOT_FOUND_ERROR } from './errors';
|
||||
|
||||
export type ResolvedPath = {
|
||||
baseDir: number;
|
||||
basePrefix: () => Promise<string>;
|
||||
fp: string;
|
||||
base: BaseDir;
|
||||
};
|
||||
|
||||
export abstract class BaseAppService implements AppService {
|
||||
osPlatform: OsPlatform = getOSPlatform();
|
||||
appPlatform: AppPlatform = 'tauri';
|
||||
@@ -70,11 +77,9 @@ export abstract class BaseAppService implements AppService {
|
||||
|
||||
abstract fs: FileSystem;
|
||||
|
||||
abstract resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string };
|
||||
abstract resolvePath(fp: string, base: BaseDir): ResolvedPath;
|
||||
abstract getCoverImageUrl(book: Book): string;
|
||||
abstract getCoverImageBlobUrl(book: Book): Promise<string>;
|
||||
abstract getInitBooksDir(): Promise<string>;
|
||||
abstract getCacheDir(): Promise<string>;
|
||||
abstract selectDirectory(): Promise<string>;
|
||||
abstract selectFiles(name: string, extensions: string[]): Promise<string[]>;
|
||||
|
||||
@@ -102,7 +107,7 @@ export abstract class BaseAppService implements AppService {
|
||||
settings = JSON.parse(txt as string);
|
||||
const version = settings.version ?? 0;
|
||||
if (this.isAppDataSandbox || version < SYSTEM_SETTINGS_VERSION) {
|
||||
settings.localBooksDir = await this.getInitBooksDir();
|
||||
settings.localBooksDir = await this.fs.getPrefix('Books');
|
||||
settings.version = SYSTEM_SETTINGS_VERSION;
|
||||
}
|
||||
settings = { ...DEFAULT_SYSTEM_SETTINGS, ...settings };
|
||||
@@ -115,7 +120,7 @@ export abstract class BaseAppService implements AppService {
|
||||
settings = {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
version: SYSTEM_SETTINGS_VERSION,
|
||||
localBooksDir: await this.getInitBooksDir(),
|
||||
localBooksDir: await this.fs.getPrefix('Books'),
|
||||
globalReadSettings: {
|
||||
...DEFAULT_READSETTINGS,
|
||||
...(this.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}),
|
||||
@@ -129,15 +134,6 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
|
||||
this.localBooksDir = settings.localBooksDir;
|
||||
const cacheDir = await this.getCacheDir();
|
||||
this.fs.getPrefix = (baseDir: BaseDir) => {
|
||||
if (baseDir === 'Books') {
|
||||
return this.localBooksDir;
|
||||
} else if (baseDir === 'Cache') {
|
||||
return cacheDir;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return settings;
|
||||
}
|
||||
|
||||
@@ -414,12 +410,15 @@ export abstract class BaseAppService implements AppService {
|
||||
const lfp = getCoverFilename(book);
|
||||
const cfp = `${CLOUD_BOOKS_SUBDIR}/${lfp}`;
|
||||
await this.downloadCloudFile(lfp, cfp, handleProgress);
|
||||
completedFiles.count++;
|
||||
bookCoverDownloaded = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// don't throw error here since some books may not have cover images at all
|
||||
console.log(`Failed to download cover file for book: '${book.title}'`, error);
|
||||
} finally {
|
||||
if (needDownCover) {
|
||||
completedFiles.count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (needDownBook) {
|
||||
|
||||
@@ -105,6 +105,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
|
||||
scrolled: false,
|
||||
disableClick: false,
|
||||
swapClickArea: false,
|
||||
disableDoubleClick: false,
|
||||
volumeKeysToFlip: false,
|
||||
continuousScroll: false,
|
||||
maxColumnCount: 2,
|
||||
@@ -143,6 +144,7 @@ export const DEFAULT_MOBILE_VIEW_SETTINGS: Partial<ViewSettings> = {
|
||||
animated: true,
|
||||
defaultFont: 'Sans-serif',
|
||||
marginBottomPx: 16,
|
||||
disableDoubleClick: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_CJK_VIEW_SETTINGS: Partial<ViewSettings> = {
|
||||
|
||||
@@ -14,17 +14,25 @@ import {
|
||||
} from '@tauri-apps/plugin-fs';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { join, basename, appDataDir, appCacheDir } from '@tauri-apps/api/path';
|
||||
import {
|
||||
join,
|
||||
basename,
|
||||
appDataDir,
|
||||
appConfigDir,
|
||||
appCacheDir,
|
||||
appLogDir,
|
||||
tempDir,
|
||||
} from '@tauri-apps/api/path';
|
||||
import { type as osType } from '@tauri-apps/plugin-os';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { FileSystem, BaseDir, AppPlatform } from '@/types/system';
|
||||
import { getOSPlatform, isContentURI, isValidURL } from '@/utils/misc';
|
||||
import { getOSPlatform, isContentURI, isFileURI, isValidURL } from '@/utils/misc';
|
||||
import { getCoverFilename, getFilename } from '@/utils/book';
|
||||
import { copyURIToPath } from '@/utils/bridge';
|
||||
import { NativeFile, RemoteFile } from '@/utils/file';
|
||||
|
||||
import { BaseAppService } from './appService';
|
||||
import { BaseAppService, ResolvedPath } from './appService';
|
||||
import { LOCAL_BOOKS_SUBDIR } from './constants';
|
||||
|
||||
declare global {
|
||||
@@ -36,38 +44,49 @@ declare global {
|
||||
|
||||
const OS_TYPE = osType();
|
||||
|
||||
const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } => {
|
||||
// Usage:
|
||||
// 1. baseDir + fp
|
||||
// 2. basePrefix + fp or the getPrefix util in FileSystem
|
||||
const resolvePath = (path: string, base: BaseDir): ResolvedPath => {
|
||||
switch (base) {
|
||||
case 'Settings':
|
||||
return { baseDir: BaseDirectory.AppConfig, fp, base };
|
||||
return { baseDir: BaseDirectory.AppConfig, basePrefix: appConfigDir, fp: path, base };
|
||||
case 'Data':
|
||||
return { baseDir: BaseDirectory.AppData, fp, base };
|
||||
return { baseDir: BaseDirectory.AppData, basePrefix: appDataDir, fp: path, base };
|
||||
case 'Cache':
|
||||
return { baseDir: BaseDirectory.AppCache, fp, base };
|
||||
return { baseDir: BaseDirectory.AppCache, basePrefix: appCacheDir, fp: path, base };
|
||||
case 'Log':
|
||||
return { baseDir: BaseDirectory.AppLog, fp, base };
|
||||
return { baseDir: BaseDirectory.AppLog, basePrefix: appLogDir, fp: path, base };
|
||||
case 'Books':
|
||||
return {
|
||||
baseDir: BaseDirectory.AppData,
|
||||
fp: `${LOCAL_BOOKS_SUBDIR}/${fp}`,
|
||||
basePrefix: appDataDir,
|
||||
fp: `${LOCAL_BOOKS_SUBDIR}/${path}`,
|
||||
base,
|
||||
};
|
||||
case 'None':
|
||||
return {
|
||||
baseDir: 0,
|
||||
fp,
|
||||
basePrefix: async () => '',
|
||||
fp: path,
|
||||
base,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
baseDir: BaseDirectory.Temp,
|
||||
fp,
|
||||
basePrefix: tempDir,
|
||||
fp: path,
|
||||
base,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const nativeFileSystem: FileSystem = {
|
||||
async getPrefix(base: BaseDir) {
|
||||
const { basePrefix, fp } = resolvePath('', base);
|
||||
const basePath = await basePrefix();
|
||||
return fp ? await join(basePath, fp) : basePath;
|
||||
},
|
||||
getURL(path: string) {
|
||||
return isValidURL(path) ? path : convertFileSrc(path);
|
||||
},
|
||||
@@ -89,8 +108,8 @@ export const nativeFileSystem: FileSystem = {
|
||||
} else {
|
||||
// Otherwise, for content:// URIs (e.g. from MediaStore, Drive, or third-party apps),
|
||||
// we cannot access the file directly — so we copy it to a temporary cache location.
|
||||
const prefix = this.getPrefix('Cache');
|
||||
const dst = `${prefix}/${fname}`;
|
||||
const prefix = await this.getPrefix('Cache');
|
||||
const dst = await join(prefix, fname);
|
||||
const res = await copyURIToPath({ uri: path, dst });
|
||||
if (!res.success) {
|
||||
console.error('Failed to open file:', res);
|
||||
@@ -99,8 +118,11 @@ export const nativeFileSystem: FileSystem = {
|
||||
return await new NativeFile(dst, fname, base ? baseDir : null).open();
|
||||
}
|
||||
} else {
|
||||
const prefix = this.getPrefix(base);
|
||||
const absolutePath = path.startsWith('/') ? path : prefix ? await join(prefix, path) : null;
|
||||
const prefix = await this.getPrefix(base);
|
||||
if (isFileURI(path)) {
|
||||
path = path.replace(/^file:\/\//, '');
|
||||
}
|
||||
const absolutePath = path.startsWith('/') ? path : await join(prefix, path);
|
||||
if (absolutePath && OS_TYPE !== 'android') {
|
||||
// NOTE: RemoteFile currently performs about 2× faster than NativeFile
|
||||
// due to an unresolved performance issue in Tauri (see tauri-apps/tauri#9190).
|
||||
@@ -114,13 +136,13 @@ export const nativeFileSystem: FileSystem = {
|
||||
},
|
||||
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
|
||||
if (isContentURI(srcPath)) {
|
||||
const prefix = this.getPrefix(base);
|
||||
const prefix = await this.getPrefix(base);
|
||||
if (!prefix) {
|
||||
throw new Error('Invalid base directory');
|
||||
}
|
||||
const res = await copyURIToPath({
|
||||
uri: srcPath,
|
||||
dst: `${prefix}/${dstPath}`,
|
||||
dst: await join(prefix, dstPath),
|
||||
});
|
||||
if (!res.success) {
|
||||
console.error('Failed to copy file:', res);
|
||||
@@ -201,9 +223,6 @@ export const nativeFileSystem: FileSystem = {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
getPrefix() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
export class NativeAppService extends BaseAppService {
|
||||
@@ -232,18 +251,10 @@ export class NativeAppService extends BaseAppService {
|
||||
(OS_TYPE === 'ios' && getOSPlatform() === 'ios') || OS_TYPE === 'android';
|
||||
override distChannel = process.env['NEXT_PUBLIC_DIST_CHANNEL'] || 'readest';
|
||||
|
||||
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
|
||||
override resolvePath(fp: string, base: BaseDir): ResolvedPath {
|
||||
return resolvePath(fp, base);
|
||||
}
|
||||
|
||||
async getInitBooksDir(): Promise<string> {
|
||||
return join(await appDataDir(), LOCAL_BOOKS_SUBDIR);
|
||||
}
|
||||
|
||||
async getCacheDir(): Promise<string> {
|
||||
return await appCacheDir();
|
||||
}
|
||||
|
||||
async selectDirectory(): Promise<string> {
|
||||
const selected = await openDialog({
|
||||
directory: true,
|
||||
|
||||
@@ -3,19 +3,20 @@ import { FileSystem, BaseDir, AppPlatform } from '@/types/system';
|
||||
import { getCoverFilename } from '@/utils/book';
|
||||
import { getOSPlatform, isValidURL } from '@/utils/misc';
|
||||
import { RemoteFile } from '@/utils/file';
|
||||
|
||||
import { isPWA } from './environment';
|
||||
import { BaseAppService } from './appService';
|
||||
import { BaseAppService, ResolvedPath } from './appService';
|
||||
import { LOCAL_BOOKS_SUBDIR } from './constants';
|
||||
|
||||
const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } => {
|
||||
const basePrefix = async () => '';
|
||||
|
||||
const resolvePath = (path: string, base: BaseDir): ResolvedPath => {
|
||||
switch (base) {
|
||||
case 'Books':
|
||||
return { baseDir: 0, fp: `${LOCAL_BOOKS_SUBDIR}/${fp}`, base };
|
||||
return { baseDir: 0, basePrefix, fp: `${LOCAL_BOOKS_SUBDIR}/${path}`, base };
|
||||
case 'None':
|
||||
return { baseDir: 0, fp, base };
|
||||
return { baseDir: 0, basePrefix, fp: path, base };
|
||||
default:
|
||||
return { baseDir: 0, fp: `${base}/${fp}`, base };
|
||||
return { baseDir: 0, basePrefix, fp: `${base}/${path}`, base };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -51,7 +52,6 @@ const indexedDBFileSystem: FileSystem = {
|
||||
const content = await this.readFile(path, base, 'binary');
|
||||
return URL.createObjectURL(new Blob([content]));
|
||||
} catch {
|
||||
console.warn(`Failed to get Blob URL for path: ${path} in base: ${base}`);
|
||||
return path;
|
||||
}
|
||||
},
|
||||
@@ -189,8 +189,10 @@ const indexedDBFileSystem: FileSystem = {
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
},
|
||||
getPrefix() {
|
||||
return null;
|
||||
async getPrefix(base: BaseDir) {
|
||||
const { basePrefix, fp } = resolvePath('', base);
|
||||
const basePath = await basePrefix();
|
||||
return fp ? `${basePath}/${fp}` : basePath;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -200,18 +202,10 @@ export class WebAppService extends BaseAppService {
|
||||
override appPlatform = 'web' as AppPlatform;
|
||||
override hasSafeAreaInset = isPWA();
|
||||
|
||||
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
|
||||
override resolvePath(fp: string, base: BaseDir): ResolvedPath {
|
||||
return resolvePath(fp, base);
|
||||
}
|
||||
|
||||
async getInitBooksDir(): Promise<string> {
|
||||
return LOCAL_BOOKS_SUBDIR;
|
||||
}
|
||||
|
||||
async getCacheDir(): Promise<string> {
|
||||
return 'Cache';
|
||||
}
|
||||
|
||||
async selectDirectory(): Promise<string> {
|
||||
throw new Error('selectDirectory is not supported in browser');
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface BookLayout {
|
||||
scrolled: boolean;
|
||||
disableClick: boolean;
|
||||
swapClickArea: boolean;
|
||||
disableDoubleClick: boolean;
|
||||
volumeKeysToFlip: boolean;
|
||||
continuousScroll: boolean;
|
||||
maxColumnCount: number;
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface FileSystem {
|
||||
createDir(path: string, base: BaseDir, recursive?: boolean): Promise<void>;
|
||||
removeDir(path: string, base: BaseDir, recursive?: boolean): Promise<void>;
|
||||
exists(path: string, base: BaseDir): Promise<boolean>;
|
||||
getPrefix(base: BaseDir): string | null;
|
||||
getPrefix(base: BaseDir): Promise<string>;
|
||||
}
|
||||
|
||||
export interface AppService {
|
||||
|
||||
@@ -89,8 +89,23 @@ export const getBookLangCode = (lang: string | string[] | undefined) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const flattenContributors = (
|
||||
contributors: string | string[] | Contributor | Contributor[],
|
||||
) => {
|
||||
if (!contributors) return '';
|
||||
return Array.isArray(contributors)
|
||||
? contributors
|
||||
.map((contributor) =>
|
||||
typeof contributor === 'string' ? contributor : formatLanguageMap(contributor?.name),
|
||||
)
|
||||
.join(', ')
|
||||
: typeof contributors === 'string'
|
||||
? contributors
|
||||
: formatLanguageMap(contributors?.name);
|
||||
};
|
||||
|
||||
export const formatAuthors = (
|
||||
contributors: string | Contributor | [string | Contributor],
|
||||
contributors: string | string[] | Contributor | Contributor[],
|
||||
bookLang?: string | string[],
|
||||
) => {
|
||||
const langCode = getBookLangCode(bookLang) || 'en';
|
||||
@@ -141,11 +156,6 @@ export const formatDate = (date: string | number | Date | null | undefined, isUT
|
||||
}
|
||||
};
|
||||
|
||||
export const formatSubject = (subject: string | string[] | undefined) => {
|
||||
if (!subject) return '';
|
||||
return Array.isArray(subject) ? subject.join(', ') : subject;
|
||||
};
|
||||
|
||||
export const formatFileSize = (size: number | null) => {
|
||||
if (size === null) return '';
|
||||
const formatter = new Intl.NumberFormat('en', {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { franc } from 'franc-min';
|
||||
import { iso6392 } from 'iso-639-2';
|
||||
import { iso6393To1 } from 'iso-639-3';
|
||||
|
||||
export const isCJKStr = (str: string) => {
|
||||
return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(str ?? '');
|
||||
@@ -79,6 +81,23 @@ export const code6392to6391 = (code: string): string => {
|
||||
return lang?.iso6391 || '';
|
||||
};
|
||||
|
||||
const commonIndivToMacro: Record<string, string> = {
|
||||
cmn: 'zho',
|
||||
arb: 'ara',
|
||||
arz: 'ara',
|
||||
ind: 'msa',
|
||||
zsm: 'msa',
|
||||
nob: 'nor',
|
||||
nno: 'nor',
|
||||
pes: 'fas',
|
||||
quy: 'que',
|
||||
};
|
||||
|
||||
export const code6393to6391 = (code: string): string => {
|
||||
const macro = commonIndivToMacro[code] || code;
|
||||
return iso6393To1[macro] || '';
|
||||
};
|
||||
|
||||
export const getLanguageName = (code: string): string => {
|
||||
const lang = normalizedLangCode(code);
|
||||
const language = iso6392.find((l) => l.iso6391 === lang || l.iso6392B === lang);
|
||||
@@ -97,3 +116,14 @@ export const inferLangFromScript = (text: string, lang: string): string => {
|
||||
}
|
||||
return lang;
|
||||
};
|
||||
|
||||
export const detectLanguage = (content: string): string => {
|
||||
try {
|
||||
const iso6393Lang = franc(content.substring(0, 1000));
|
||||
const iso6391Lang = code6393to6391(iso6393Lang) || 'en';
|
||||
return iso6391Lang;
|
||||
} catch {
|
||||
console.warn('Language detection failed, defaulting to en.');
|
||||
return 'en';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,7 +117,7 @@ const getColorStyles = (
|
||||
background-color: var(--theme-bg-color, transparent);
|
||||
background: var(--background-set, none);
|
||||
}
|
||||
div, p, h1, h2, h3, h4, h5, h6 {
|
||||
div, p, font, h1, h2, h3, h4, h5, h6 {
|
||||
${overrideColor ? `background-color: ${bg} !important;` : ''}
|
||||
${overrideColor ? `color: ${fg} !important;` : ''}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getBaseFilename } from './book';
|
||||
import { partialMD5 } from './md5';
|
||||
import { getBaseFilename } from './book';
|
||||
import { detectLanguage } from './lang';
|
||||
|
||||
interface Metadata {
|
||||
bookTitle: string;
|
||||
@@ -11,6 +12,7 @@ interface Metadata {
|
||||
interface Chapter {
|
||||
title: string;
|
||||
content: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface Txt2EpubOptions {
|
||||
@@ -67,7 +69,8 @@ export class TxtToEpubConverter {
|
||||
matchedAuthor = matchedAuthor.replace(/^[\p{P}\p{S}]+|[\p{P}\p{S}]+$/gu, '');
|
||||
} catch {}
|
||||
const author = matchedAuthor || providedAuthor || '';
|
||||
const language = providedLanguage || this.detectLanguage(fileHeader);
|
||||
const language = providedLanguage || detectLanguage(fileHeader);
|
||||
console.log(`Detected language: ${language}`);
|
||||
const identifier = await partialMD5(txtFile);
|
||||
const metadata = { bookTitle, author, language, identifier };
|
||||
|
||||
@@ -176,7 +179,7 @@ export class TxtToEpubConverter {
|
||||
const trimmedSegment = segment.replace(/<!--.*?-->/g, '').trim();
|
||||
if (!trimmedSegment) continue;
|
||||
|
||||
const segmentChapters = [];
|
||||
const segmentChapters: Chapter[] = [];
|
||||
let matches: string[] = [];
|
||||
for (const chapterRegex of chapterRegexps) {
|
||||
const tryMatches = trimmedSegment.split(chapterRegex);
|
||||
@@ -194,7 +197,7 @@ export class TxtToEpubConverter {
|
||||
const formattedSegment = formatSegment(chunks.join('\n'));
|
||||
const title = `${chapters.length + 1}`;
|
||||
const content = `<h2>${title}</h2><p>${formattedSegment}</p>`;
|
||||
chapters.push({ title, content });
|
||||
chapters.push({ title, content, text: chunks.join('\n') });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -215,6 +218,7 @@ export class TxtToEpubConverter {
|
||||
segmentChapters.push({
|
||||
title: escapeXml(title),
|
||||
content: `${headTitle}<p>${formattedSegment}</p>`,
|
||||
text: content,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -228,6 +232,7 @@ export class TxtToEpubConverter {
|
||||
segmentChapters.unshift({
|
||||
title: escapeXml(segmentTitle),
|
||||
content: `<h3></h3><p>${formattedSegment}</p>`,
|
||||
text: initialContent,
|
||||
});
|
||||
}
|
||||
chapters.push(...segmentChapters);
|
||||
@@ -322,9 +327,10 @@ export class TxtToEpubConverter {
|
||||
// Add chapter files
|
||||
for (let i = 0; i < chapters.length; i++) {
|
||||
const chapter = chapters[i]!;
|
||||
const lang = detectLanguage(chapter.text);
|
||||
const chapterContent = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="zh">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="${lang}" xml:lang="${lang}">
|
||||
<head>
|
||||
<title>${chapter.title}</title>
|
||||
<link rel="stylesheet" type="text/css" href="../style.css"/>
|
||||
@@ -426,26 +432,6 @@ export class TxtToEpubConverter {
|
||||
return 'utf-8';
|
||||
}
|
||||
|
||||
private detectLanguage(fileHeader: string): string {
|
||||
const sample = fileHeader;
|
||||
let chineseCount = 0;
|
||||
for (let i = 0; i < sample.length; i++) {
|
||||
const code = sample.charCodeAt(i);
|
||||
if (
|
||||
(code >= 0x4e00 && code <= 0x9fff) ||
|
||||
(code >= 0x3400 && code <= 0x4dbf) ||
|
||||
(code >= 0x20000 && code <= 0x2a6df)
|
||||
) {
|
||||
chineseCount++;
|
||||
}
|
||||
}
|
||||
if (chineseCount / sample.length > 0.05) {
|
||||
return 'zh';
|
||||
}
|
||||
|
||||
return 'en';
|
||||
}
|
||||
|
||||
private extractBookTitle(filename: string): string {
|
||||
const match = filename.match(/《([^》]+)》/);
|
||||
return match ? match[1]! : filename.split('.')[0]!;
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 97b906075f...655100371f
Generated
+36
@@ -131,6 +131,9 @@ importers:
|
||||
foliate-js:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/foliate-js
|
||||
franc-min:
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.0
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
@@ -146,6 +149,9 @@ importers:
|
||||
iso-639-2:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
iso-639-3:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
js-md5:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
@@ -3697,6 +3703,9 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
collapse-white-space@2.1.0:
|
||||
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -4404,6 +4413,9 @@ packages:
|
||||
fraction.js@4.3.7:
|
||||
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
||||
|
||||
franc-min@6.2.0:
|
||||
resolution: {integrity: sha512-1uDIEUSlUZgvJa2AKYR/dmJC66v/PvGQ9mWfI9nOr/kPpMFyvswK0gPXOwpYJYiYD008PpHLkGfG58SPjQJFxw==}
|
||||
|
||||
fresh@0.5.2:
|
||||
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -4864,6 +4876,9 @@ packages:
|
||||
iso-639-2@3.0.2:
|
||||
resolution: {integrity: sha512-tna50aWwcGTIn81S9MzD1NSovHYTpFgmPVszHiLF5Vg/xmXAJ9XAkMOB9a8TH9Vi7qwf/x/8NJy2F+lM5OEwAw==}
|
||||
|
||||
iso-639-3@3.0.1:
|
||||
resolution: {integrity: sha512-SdljCYXOexv/JmbQ0tvigHN43yECoscVpe2y2hlEqy/CStXQlroPhZLj7zKLRiGqLJfw8k7B973UAMDoQczVgQ==}
|
||||
|
||||
isobject@3.0.1:
|
||||
resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5209,6 +5224,9 @@ packages:
|
||||
mz@2.7.0:
|
||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||
|
||||
n-gram@2.0.2:
|
||||
resolution: {integrity: sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ==}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -6340,6 +6358,9 @@ packages:
|
||||
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
trigram-utils@2.0.1:
|
||||
resolution: {integrity: sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ==}
|
||||
|
||||
ts-api-utils@1.4.3:
|
||||
resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -11468,6 +11489,8 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
collapse-white-space@2.1.0: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -12315,6 +12338,10 @@ snapshots:
|
||||
|
||||
fraction.js@4.3.7: {}
|
||||
|
||||
franc-min@6.2.0:
|
||||
dependencies:
|
||||
trigram-utils: 2.0.1
|
||||
|
||||
fresh@0.5.2: {}
|
||||
|
||||
fresh@2.0.0: {}
|
||||
@@ -12780,6 +12807,8 @@ snapshots:
|
||||
|
||||
iso-639-2@3.0.2: {}
|
||||
|
||||
iso-639-3@3.0.1: {}
|
||||
|
||||
isobject@3.0.1: {}
|
||||
|
||||
isows@1.0.7(ws@8.18.2):
|
||||
@@ -13090,6 +13119,8 @@ snapshots:
|
||||
object-assign: 4.1.1
|
||||
thenify-all: 1.6.0
|
||||
|
||||
n-gram@2.0.2: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@3.3.8: {}
|
||||
@@ -14270,6 +14301,11 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
trigram-utils@2.0.1:
|
||||
dependencies:
|
||||
collapse-white-space: 2.1.0
|
||||
n-gram: 2.0.2
|
||||
|
||||
ts-api-utils@1.4.3(typescript@5.7.2):
|
||||
dependencies:
|
||||
typescript: 5.7.2
|
||||
|
||||
Reference in New Issue
Block a user