Compare commits

...

12 Commits

52 changed files with 604 additions and 182 deletions
+5
View File
@@ -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
+3 -1
View File
@@ -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": "禁用雙擊"
}
+13
View File
@@ -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 doubleclick 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();
+1 -1
View File
@@ -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>
+35 -30
View File
@@ -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'>
+1 -1
View File
@@ -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;
+14 -15
View File
@@ -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,
+12 -18
View File
@@ -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');
}
+1
View File
@@ -92,6 +92,7 @@ export interface BookLayout {
scrolled: boolean;
disableClick: boolean;
swapClickArea: boolean;
disableDoubleClick: boolean;
volumeKeysToFlip: boolean;
continuousScroll: boolean;
maxColumnCount: number;
+1 -1
View File
@@ -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 {
+16 -6
View File
@@ -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', {
+30
View File
@@ -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';
}
};
+1 -1
View File
@@ -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;` : ''}
}
+11 -25
View File
@@ -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]!;
+36
View File
@@ -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