diff --git a/.github/memory-bank/active-context.md b/.github/memory-bank/active-context.md index dbe1b454..f5be9caa 100644 --- a/.github/memory-bank/active-context.md +++ b/.github/memory-bank/active-context.md @@ -1,7 +1,7 @@ # DexReader Active Context **Last Updated**: 25 May 2026 -**Version**: v1.6.0 +**Version**: v1.7.0 **Mode**: Active Development > **Purpose**: This is your session dashboard. Read this FIRST when resuming work to understand what's happening NOW, what was decided recently, and what to work on next. Keep all entries as short, concise as possible @@ -10,18 +10,18 @@ ## Current Status -**v1.6.0 Released**: 25 May 2026 ✅ +**v1.7.0 Released**: 25 May 2026 ✅ **Monitoring Period**: Now through ~8 June 2026 -- Monitor for user-reported issues or bugs in sandboxing implementation -- Watch for any regression in functionality +- Monitor for any issues with ESM migration +- Watch for compatibility issues with dependencies - Collect feedback on stability and performance **Next Planned Work:** -- P2-T02: ESM Migration (v1.7.0) - Planned to start after monitoring period - Monitor for `drizzle-kit` updates to resolve transitive esbuild vulnerability +- Plan next feature development cycle --- @@ -48,30 +48,25 @@ ## Recent Changes (Last 1-2 Weeks) -### 25 May 2026 - v1.6.0 Release ✅ +### 25 May 2026 - v1.7.0 Release ✅ -- **Type**: Security Enhancement -- **Summary**: Enabled Electron renderer sandboxing for improved security posture +- **Type**: Technical Enhancement +- **Summary**: Migrated entire codebase to ECMAScript Modules (ESM) - **Key Changes**: - - Enabled sandbox mode in BrowserWindow webPreferences - - Fixed preload bundling: changed `externalizeDeps: false` in electron.vite.config - - Sandboxed preload now bundles dependencies (cannot access node_modules at runtime) - - Localized unsaved changes dialogs (window close & navigation blocking) - - Comprehensive testing: all features verified working -- **Impact**: Improved security against malicious content, better Electron compliance + - Updated package.json to specify `"type": "module"` for native ESM support + - Refactored main process for full ESM compatibility + - Implemented CommonJS compatibility workaround for electron-updater + - Fixed IPC response handling in DownloadsView and dialog components + - Fixed filesystem deleteDir recursive flag handling + - Enhanced translation coverage for Downloads and favorite actions +- **Impact**: Modernized codebase with better Node.js ecosystem alignment, improved module loading - **Status**: ✅ Released -- **CHANGELOG**: All changes documented in CHANGELOG.md v1.6.0 section +- **CHANGELOG**: All changes documented in CHANGELOG.md v1.7.0 section -### 22 May 2026 - v1.5.0 Release +### 25 May 2026 - Previous Releases Summary -- **Type**: Release -- **Summary**: Content Language Settings and enhanced settings management -- **Key Features**: - - Content Language Settings with priority language selection (up to 3 languages) - - Settings infrastructure migrated from JSON to electron-store - - Translation improvements across all locales -- **Impact**: Users can filter manga content by preferred languages -- **Status**: ✅ Released +- **v1.6.0**: Renderer sandboxing for enhanced security +- **v1.5.0**: Content Language Settings with priority language selection diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b39bdedb..fe153c5f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -4,6 +4,9 @@ on: push: branches: [main] +permissions: + contents: write # Required for creating and pushing tags + jobs: # Single job that runs all CI checks sequentially # More efficient than parallel jobs (1x checkout + 1x npm ci vs 3x each) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39d92e81..bc12db24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog], and this project adheres to [Semantic Versioning]. +## [1.7.0] - 2026-05-25 + +### Changed + +- Migrate project to ECMAScript Modules (ESM) + - Update package.json to specify `"type": "module"` for native ESM support + - Refactor main process for full ESM compatibility + - Modernize codebase with native ESM imports/exports throughout application + - Implement compatibility workaround for electron-updater CommonJS dependency + - Improved module loading and better alignment with Node.js ecosystem standards + +### Fixed + +- Fix IPC response handling issues in DownloadsView and dialog components +- Fix filesystem deleteDir method not consistently applying recursive flag when removing directories +- Fix missing translation keys for Downloads view and favorite/unfavorite actions +- Fix Vietnamese locale translation coverage gaps + +--- + ## [1.6.0] - 2026-05-25 ### Added diff --git a/README.md b/README.md index 8f57c74c..5e8d3d83 100644 --- a/README.md +++ b/README.md @@ -92,11 +92,12 @@ npm run build:linux ## Project Status -**Current Version**: 1.6.0 (May 25, 2026) 🔒 +**Current Version**: 1.7.0 (May 25, 2026) 🚀 -DexReader v1.6.0 brings enhanced security with Electron renderer sandboxing: +DexReader v1.7.0 brings modernized codebase with ECMAScript Modules (ESM): - ✅ Complete MangaDex extensive manga library integration (browse, search, read) +- ✅ **ESM Migration** - Modernized module system for better Node.js ecosystem alignment - ✅ **Renderer Sandboxing** - Enhanced security isolation for improved protection against malicious content - ✅ **Content Language Preferences** - Configure up to 3 priority languages for manga content filtering - ✅ **Multi-Language UI** - Three locales supported (British English, American English, Vietnamese) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f63d46cc..7b085e6b 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,7 +1,12 @@ import fs from 'node:fs/promises' import { defineConfig } from 'electron-vite' import react from '@vitejs/plugin-react' -import path from 'node:path' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +// ESM: Get __dirname equivalent +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) export default defineConfig({ main: { @@ -9,8 +14,15 @@ export default defineConfig({ rollupOptions: { external: [ // Exclude scripts directory from production build - /^.*\/scripts\/.*/ - ] + /^.*\/scripts\/.*/, + // CommonJS-only modules (must stay external in ESM) + 'electron-updater', + 'better-sqlite3' + ], + output: { + format: 'es', // ESM format to match project type + entryFileNames: '[name].js' + } }, externalizeDeps: true }, @@ -19,8 +31,8 @@ export default defineConfig({ name: 'copy-migrations', async writeBundle() { // Copy migrations folder to output directory - const src = path.resolve(__dirname, 'src/main/database/migrations') - const dest = path.resolve(__dirname, 'out/main/database/migrations') + const src = resolve(__dirname, 'src/main/database/migrations') + const dest = resolve(__dirname, 'out/main/database/migrations') // Create destination directory await fs.mkdir(dest, { recursive: true }) @@ -28,8 +40,8 @@ export default defineConfig({ // Copy all files const files = await fs.readdir(src) files.forEach(async (file: string) => { - const srcPath = path.join(src, file) - const destPath = path.join(dest, file) + const srcPath = join(src, file) + const destPath = join(dest, file) if ((await fs.stat(srcPath)).isDirectory()) { // Copy directory recursively (for meta folder) @@ -44,15 +56,15 @@ export default defineConfig({ { name: 'copy-protobuf-schema', async writeBundle() { - const src = path.resolve(__dirname, 'src/main/services/protobuf/schemas') - const dest = path.resolve(__dirname, 'out/main/services/protobuf/schemas') + const src = resolve(__dirname, 'src/main/services/protobuf/schemas') + const dest = resolve(__dirname, 'out/main/services/protobuf/schemas') await fs.mkdir(dest, { recursive: true }) const files = await fs.readdir(src, { recursive: true }) files.forEach(async (file: string) => { - const srcPath = path.join(src, file) - const destPath = path.join(dest, file) + const srcPath = join(src, file) + const destPath = join(dest, file) if ((await fs.stat(srcPath)).isDirectory()) { fs.cp(srcPath, destPath, { recursive: true }) @@ -65,15 +77,15 @@ export default defineConfig({ { name: 'copy-locales', async writeBundle() { - const src = path.resolve(__dirname, 'src/locales') - const dest = path.resolve(__dirname, 'out/locales') + const src = resolve(__dirname, 'src/locales') + const dest = resolve(__dirname, 'out/locales') await fs.mkdir(dest, { recursive: true }) const files = await fs.readdir(src) for (const file of files) { - const srcPath = path.join(src, file) - const destPath = path.join(dest, file) + const srcPath = join(src, file) + const destPath = join(dest, file) if ((await fs.stat(srcPath)).isDirectory()) { // Copy language directory, but only JSON files @@ -81,7 +93,7 @@ export default defineConfig({ const langFiles = await fs.readdir(srcPath) for (const langFile of langFiles) { if (langFile.endsWith('.json')) { - await fs.copyFile(path.join(srcPath, langFile), path.join(destPath, langFile)) + await fs.copyFile(join(srcPath, langFile), join(destPath, langFile)) } } } else if (file.endsWith('.json')) { @@ -100,7 +112,8 @@ export default defineConfig({ externalizeDeps: false, rollupOptions: { output: { - format: 'cjs' // Preload must be CommonJS (Electron requirement) + format: 'cjs', // Preload MUST be CommonJS (Electron sandboxed limitation) + entryFileNames: '[name].cjs' // Output as .cjs to avoid ESM confusion } } } @@ -108,7 +121,7 @@ export default defineConfig({ renderer: { resolve: { alias: { - '@renderer': path.resolve('src/renderer/src') + '@renderer': resolve('src/renderer/src') } }, build: { diff --git a/eslint.config.mjs b/eslint.config.mjs index 7e8e01b2..aff5d3f9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,12 +28,5 @@ export default defineConfig( ...eslintPluginReactRefresh.configs.vite.rules } }, - { - // Allow CommonJS in script files - files: ['scripts/**/*.js'], - rules: { - '@typescript-eslint/no-require-imports': 'off' - } - }, eslintConfigPrettier ) diff --git a/package-lock.json b/package-lock.json index 08f63726..d01d1aef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dexreader", - "version": "1.6.0", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dexreader", - "version": "1.6.0", + "version": "1.7.0", "hasInstallScript": true, "dependencies": { "@electron-toolkit/preload": "^3.0.2", diff --git a/package.json b/package.json index a8365171..853f6d41 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "dexreader", "productName": "DexReader", - "version": "1.6.0", - "description": "An Electron application with React and TypeScript", + "version": "1.7.0", + "description": "A personal MangaDex client built with Electron and React.", + "type": "module", "main": "./out/main/index.js", "author": "remichan97", - "homepage": "https://electron-vite.org", + "homepage": "https://github.com/remichan97/dexreader", "scripts": { "format": "prettier --write .", "lint": "eslint --cache .", diff --git a/scripts/run-analyze-plans.js b/scripts/run-analyze-plans.js deleted file mode 100644 index e8351411..00000000 --- a/scripts/run-analyze-plans.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Electron Runtime Wrapper for EXPLAIN QUERY PLAN Analysis - * - * Purpose: Run query plan analysis in Electron context to access better-sqlite3 - * compiled for Electron's Node.js version. - * - * Usage: npm run analyze:plans - */ - -const { spawn } = require('child_process') -const path = require('path') - -const electronPath = require('electron') -const scriptPath = path.join( - __dirname, - '..', - 'src', - 'main', - 'scripts', - 'database-performance', - 'analysis', - 'analyze-query-plans.ts' -) - -console.log('Starting query plan analysis in Electron context...\n') - -const child = spawn( - electronPath, - [ - // Run tsx through Electron's Node.js (ELECTRON_RUN_AS_NODE=1) - require.resolve('tsx/cli'), - scriptPath - ], - { - stdio: 'inherit', - env: { - ...process.env, - ELECTRON_RUN_AS_NODE: '1', // Critical: Run as Node.js instead of opening window - NODE_ENV: 'development' - } - } -) - -child.on('exit', (code) => { - if (code === 0) { - console.log('\n✅ Query plan analysis complete') - process.exit(0) - } else { - console.error(`\n❌ Analysis failed with exit code ${code}`) - process.exit(code) - } -}) - -child.on('error', (err) => { - console.error('Failed to start analysis:', err) - process.exit(1) -}) diff --git a/src/locales/en-GB/dialogs.json b/src/locales/en-GB/dialogs.json index c7c0363e..0de209f0 100644 --- a/src/locales/en-GB/dialogs.json +++ b/src/locales/en-GB/dialogs.json @@ -102,9 +102,39 @@ "deleteChapterDownload": { "title": "Remove Download", "message": "You are about to remove \"{{title}}\", which will delete downloaded chapter files.\nYou can also choose to just hide it from the list if you want to keep the files for offline reading.\n\nHow should we proceed?", + "buttons": { + "cancel": "Cancel", + "hideFromView": "Hide from View (Keep Files for Offline Reading)", + "deleteForever": "Delete Forever (Cannot be Undone)" + }, + "finalConfirmation": { + "title": "Are you absolutely certain?", + "message": "This will be your last chance to back out before the chapter files are permanently deleted. File deletion cannot be undone, but you can always re-download the chapter if you change your mind.\n\nJust a reminder, you are deleting chapter: {{title}}", + "confirmButton": "Yes, Delete Permanently", + "cancelButton": "Nevermind" + } + }, + "removeFromLibrary": { + "withDownloads": { + "title": "Remove from library?", + "message": "{{title}}\n\nThis manga has {{count}} downloaded chapter{{plural}} ({{size}}).\n\nDownloads will still be accessible in the Downloads view unless you choose to delete them.", + "buttons": { + "keepDownloads": "Remove from library (keep downloads)", + "deleteEverything": "Remove everything (both bookmark and downloads will be removed)", + "cancel": "Cancel" + } + }, + "noDownloads": { + "title": "Remove from library?", + "message": "{{title}}\n\nYou can always add it back later.", + "confirmButton": "Remove", + "cancelButton": "Cancel" + }, "finalConfirmation": { "title": "Are you absolutely certain?", - "message": "This will be your last chance to back out before the chapter files are permanently deleted. File deletion cannot be undone, but you can always re-download the chapter if you change your mind.\n\nJust a reminder, you are deleting chapter: {{title}}" + "message": "This will be your last chance to back out before the chapter files are permanently deleted. File deletion cannot be undone, but you can always re-download the manga if you change your mind.\n\nJust a reminder, you are deleting downloads for: {{title}}", + "confirmButton": "Yes, Delete Permanently", + "cancelButton": "Nevermind" } }, "clearCoverCache": { diff --git a/src/locales/en-GB/downloads.json b/src/locales/en-GB/downloads.json index e8612d20..ed9f5e08 100644 --- a/src/locales/en-GB/downloads.json +++ b/src/locales/en-GB/downloads.json @@ -69,6 +69,17 @@ "title": "Deleted", "message": "Download and files permanently deleted" }, + "error": { + "title": "Error", + "notFound": "Download not found", + "cancelFailed": "Failed to cancel download", + "retryFailed": "Failed to retry download", + "removeFailed": "Failed to remove download", + "hideFailed": "Failed to hide download", + "deleteFailed": "Failed to delete download", + "clearCompletedFailed": "Failed to hide completed downloads", + "openFolderFailed": "Failed to open downloads folder" + }, "retryAllQueued": "Queued {{count}} failed download{{s}} for retry", "cancelledAll": "Cancelled {{count}} queued download{{s}}" } diff --git a/src/locales/en-GB/index.ts b/src/locales/en-GB/index.ts index 4568df24..b052766c 100644 --- a/src/locales/en-GB/index.ts +++ b/src/locales/en-GB/index.ts @@ -1,16 +1,16 @@ -import common from './common.json' -import menu from './menu.json' -import errors from './errors.json' -import dialogs from './dialogs.json' -import validation from './validation.json' -import shortcuts from './shortcuts.json' -import browse from './browse.json' -import library from './library.json' -import downloads from './downloads.json' -import reader from './reader.json' -import settings from './settings.json' -import history from './history.json' -import mangaDetail from './mangaDetail.json' +import common from './common.json' with { type: 'json' } +import menu from './menu.json' with { type: 'json' } +import errors from './errors.json' with { type: 'json' } +import dialogs from './dialogs.json' with { type: 'json' } +import validation from './validation.json' with { type: 'json' } +import shortcuts from './shortcuts.json' with { type: 'json' } +import browse from './browse.json' with { type: 'json' } +import library from './library.json' with { type: 'json' } +import downloads from './downloads.json' with { type: 'json' } +import reader from './reader.json' with { type: 'json' } +import settings from './settings.json' with { type: 'json' } +import history from './history.json' with { type: 'json' } +import mangaDetail from './mangaDetail.json' with { type: 'json' } export default { common, diff --git a/src/locales/en-GB/library.json b/src/locales/en-GB/library.json index 205cd4c3..ccfa25a2 100644 --- a/src/locales/en-GB/library.json +++ b/src/locales/en-GB/library.json @@ -38,15 +38,20 @@ "addedToLibrary": "Added to Library", "addedSuccess": "Manga added to your collection", "error": "Error", + "checkDownloadsFailed": "Failed to check downloads", "mangaNotFound": "Manga not found", "failedToAdd": "Failed to add to library", + "failedToRemove": "Failed to remove from library", "removedFromLibrary": "Removed from library", "removedCompletely": "Removed completely", + "removedWithDetails": "{{title}} and {{count}} chapter{{plural}} deleted", + "couldNotRemove": "Could not complete removal. Please try again.", "partiallyRemoved": { "title": "Partially removed", "libraryOnly": "Removed from library, but failed to delete some downloads", "downloadsOnly": "Downloads deleted, but failed to remove from library" - } + }, + "unexpectedError": "Unexpected error" }, "collections": { "toasts": { diff --git a/src/locales/en-US/dialogs.json b/src/locales/en-US/dialogs.json index bd72cdc9..1749fbf7 100644 --- a/src/locales/en-US/dialogs.json +++ b/src/locales/en-US/dialogs.json @@ -102,9 +102,39 @@ "deleteChapterDownload": { "title": "Remove Download", "message": "You are about to remove \"{{title}}\", which will delete downloaded chapter files.\nYou can also choose to just hide it from the list if you want to keep the files for offline reading.\n\nHow should we proceed?", + "buttons": { + "cancel": "Cancel", + "hideFromView": "Hide from View (Keep Files for Offline Reading)", + "deleteForever": "Delete Forever (Cannot be Undone)" + }, + "finalConfirmation": { + "title": "Are you absolutely certain?", + "message": "This will be your last chance to back out before the chapter files are permanently deleted. File deletion cannot be undone, but you can always re-download the chapter if you change your mind.\n\nJust a reminder, you are deleting chapter: {{title}}", + "confirmButton": "Yes, Delete Permanently", + "cancelButton": "Nevermind" + } + }, + "removeFromLibrary": { + "withDownloads": { + "title": "Remove from library?", + "message": "{{title}}\n\nThis manga has {{count}} downloaded chapter{{plural}} ({{size}}).\n\nDownloads will still be accessible in the Downloads view unless you choose to delete them.", + "buttons": { + "keepDownloads": "Remove from library (keep downloads)", + "deleteEverything": "Remove everything (both bookmark and downloads will be removed)", + "cancel": "Cancel" + } + }, + "noDownloads": { + "title": "Remove from library?", + "message": "{{title}}\n\nYou can always add it back later.", + "confirmButton": "Remove", + "cancelButton": "Cancel" + }, "finalConfirmation": { "title": "Are you absolutely certain?", - "message": "This will be your last chance to back out before the chapter files are permanently deleted. File deletion cannot be undone, but you can always re-download the chapter if you change your mind.\n\nJust a reminder, you are deleting chapter: {{title}}" + "message": "This will be your last chance to back out before the chapter files are permanently deleted. File deletion cannot be undone, but you can always re-download the manga if you change your mind.\n\nJust a reminder, you are deleting downloads for: {{title}}", + "confirmButton": "Yes, Delete Permanently", + "cancelButton": "Nevermind" } }, "clearCoverCache": { diff --git a/src/locales/en-US/downloads.json b/src/locales/en-US/downloads.json index d514e266..2ae0964e 100644 --- a/src/locales/en-US/downloads.json +++ b/src/locales/en-US/downloads.json @@ -69,6 +69,17 @@ "title": "Deleted", "message": "Download and files permanently deleted" }, + "error": { + "title": "Error", + "notFound": "Download not found", + "cancelFailed": "Failed to cancel download", + "retryFailed": "Failed to retry download", + "removeFailed": "Failed to remove download", + "hideFailed": "Failed to hide download", + "deleteFailed": "Failed to delete download", + "clearCompletedFailed": "Failed to hide completed downloads", + "openFolderFailed": "Failed to open downloads folder" + }, "retryAllQueued": "Queued {{count}} failed download{{s}} for retry", "cancelledAll": "Canceled {{count}} queued download{{s}}" } diff --git a/src/locales/en-US/index.ts b/src/locales/en-US/index.ts index 4568df24..b052766c 100644 --- a/src/locales/en-US/index.ts +++ b/src/locales/en-US/index.ts @@ -1,16 +1,16 @@ -import common from './common.json' -import menu from './menu.json' -import errors from './errors.json' -import dialogs from './dialogs.json' -import validation from './validation.json' -import shortcuts from './shortcuts.json' -import browse from './browse.json' -import library from './library.json' -import downloads from './downloads.json' -import reader from './reader.json' -import settings from './settings.json' -import history from './history.json' -import mangaDetail from './mangaDetail.json' +import common from './common.json' with { type: 'json' } +import menu from './menu.json' with { type: 'json' } +import errors from './errors.json' with { type: 'json' } +import dialogs from './dialogs.json' with { type: 'json' } +import validation from './validation.json' with { type: 'json' } +import shortcuts from './shortcuts.json' with { type: 'json' } +import browse from './browse.json' with { type: 'json' } +import library from './library.json' with { type: 'json' } +import downloads from './downloads.json' with { type: 'json' } +import reader from './reader.json' with { type: 'json' } +import settings from './settings.json' with { type: 'json' } +import history from './history.json' with { type: 'json' } +import mangaDetail from './mangaDetail.json' with { type: 'json' } export default { common, diff --git a/src/locales/en-US/library.json b/src/locales/en-US/library.json index 415f5f61..c4879375 100644 --- a/src/locales/en-US/library.json +++ b/src/locales/en-US/library.json @@ -38,15 +38,20 @@ "addedToLibrary": "Added to Library", "addedSuccess": "Manga added to your collection", "error": "Error", + "checkDownloadsFailed": "Failed to check downloads", "mangaNotFound": "Manga not found", "failedToAdd": "Failed to add to library", + "failedToRemove": "Failed to remove from library", "removedFromLibrary": "Removed from library", "removedCompletely": "Removed completely", + "removedWithDetails": "{{title}} and {{count}} chapter{{plural}} deleted", + "couldNotRemove": "Could not complete removal. Please try again.", "partiallyRemoved": { "title": "Partially removed", "libraryOnly": "Removed from library, but failed to delete some downloads", "downloadsOnly": "Downloads deleted, but failed to remove from library" - } + }, + "unexpectedError": "Unexpected error" }, "collections": { "toasts": { diff --git a/src/locales/vi-VN/dialogs.json b/src/locales/vi-VN/dialogs.json index 7ee4ff3a..8e34cc68 100644 --- a/src/locales/vi-VN/dialogs.json +++ b/src/locales/vi-VN/dialogs.json @@ -100,16 +100,41 @@ "message": "Thao tác này sẽ xóa vĩnh viễn {{count}} chương từ {{title}}.\n\nThao tác này không thể hoàn tác." }, "deleteChapterDownload": { - "title": "Xóa tải xuống", - "message": "Bạn sắp xóa \"{{title}}\", thao tác này sẽ xóa các tệp chương đã tải xuống.\nBạn cũng có thể chọn chỉ ẩn nó khỏi danh sách nếu bạn muốn giữ các tệp để đọc ngoại tuyến.\n\nBạn muốn làm gì tiếp theo?", - + "title": "Xóa Tải Xuống", + "message": "Bạn sắp xóa \"{{title}}\", điều này sẽ xóa các tệp chương đã tải xuống.\nBạn cũng có thể chọn chỉ ẩn nó khỏi danh sách nếu bạn muốn giữ các tệp để đọc ngoại tuyến.\n\nBạn muốn tiếp tục như thế nào?", + "buttons": { + "cancel": "Hủy", + "hideFromView": "Ẩn Khỏi Danh Sách (Giữ Tệp Để Đọc Ngoại Tuyến)", + "deleteForever": "Xóa Vĩnh Viễn (Không Thể Hoàn Tác)" + }, "finalConfirmation": { - "title": "Bạn có chắc chắn không?", - "message": "Đây sẽ là cơ hội cuối cùng để bạn có thể dừng thao tác này trước khi các tệp chương bị xóa vĩnh viễn. Việc xóa tệp không thể hoàn tác, nhưng bạn vẫn có thể tải lại chương này nếu thay đổi ý định.\n\nNhắc lại, bạn đang xóa chương: {{title}}", + "title": "Bạn có hoàn toàn chắc chắn không?", + "message": "Đây sẽ là cơ hội cuối cùng của bạn để dừng thao tác này trước khi các tệp chương bị xóa vĩnh viễn. Việc xóa tệp không thể hoàn tác, nhưng bạn vẫn có thể tải lại chương nếu bạn thay đổi ý định.\n\nNhắc lại, bạn đang xóa chương: {{title}}", + "confirmButton": "Có, Xóa Vĩnh Viễn", + "cancelButton": "Không" + } + }, + "removeFromLibrary": { + "withDownloads": { + "title": "Xóa khỏi thư viện?", + "message": "{{title}}\n\nManga này có {{count}} chương đã tải xuống ({{size}}).\n\nCác tải xuống sẽ vẫn có thể truy cập trong chế độ xem Tải xuống trừ khi bạn chọn xóa chúng.", "buttons": { - "delete": "Có, Xóa vĩnh viễn", + "keepDownloads": "Xóa khỏi thư viện (giữ lại tải xuống)", + "deleteEverything": "Xóa mọi thứ (cả dấu trang và tải xuống sẽ bị xóa)", "cancel": "Hủy" } + }, + "noDownloads": { + "title": "Xóa khỏi thư viện?", + "message": "{{title}}\n\nBạn luôn có thể thêm lại sau.", + "confirmButton": "Xóa", + "cancelButton": "Hủy" + }, + "finalConfirmation": { + "title": "Bạn có hoàn toàn chắc chắn không?", + "message": "Đây sẽ là cơ hội cuối cùng của bạn để dừng thao tác này trước khi các tệp chương bị xóa vĩnh viễn. Việc xóa tệp không thể hoàn tác, nhưng bạn vẫn có thể tải lại manga nếu bạn thay đổi ý định.\n\nNhắc lại, bạn đang xóa tải xuống cho: {{title}}", + "confirmButton": "Có, Xóa Vĩnh Viễn", + "cancelButton": "Không" } }, "clearCoverCache": { diff --git a/src/locales/vi-VN/downloads.json b/src/locales/vi-VN/downloads.json index eb7a0221..09bc231f 100644 --- a/src/locales/vi-VN/downloads.json +++ b/src/locales/vi-VN/downloads.json @@ -69,6 +69,17 @@ "title": "Đã xóa vĩnh viễn", "message": "Tải xuống và tệp đã được xóa vĩnh viễn" }, + "error": { + "title": "Lỗi", + "notFound": "Không tìm thấy tải xuống", + "cancelFailed": "Không thể hủy tải xuống", + "retryFailed": "Không thể thử lại tải xuống", + "removeFailed": "Không thể xóa tải xuống", + "hideFailed": "Không thể ẩn tải xuống", + "deleteFailed": "Không thể xóa tải xuống", + "clearCompletedFailed": "Không thể ẩn tải xuống đã hoàn thành", + "openFolderFailed": "Không thể mở thư mục tải xuống" + }, "retryAllQueued": "Đã thêm {{count}} tải xuống thất bại vào hàng chờ để thử lại", "cancelledAll": "Đã hủy {{count}} tải xuống đang chờ" } diff --git a/src/locales/vi-VN/index.ts b/src/locales/vi-VN/index.ts index 4568df24..b052766c 100644 --- a/src/locales/vi-VN/index.ts +++ b/src/locales/vi-VN/index.ts @@ -1,16 +1,16 @@ -import common from './common.json' -import menu from './menu.json' -import errors from './errors.json' -import dialogs from './dialogs.json' -import validation from './validation.json' -import shortcuts from './shortcuts.json' -import browse from './browse.json' -import library from './library.json' -import downloads from './downloads.json' -import reader from './reader.json' -import settings from './settings.json' -import history from './history.json' -import mangaDetail from './mangaDetail.json' +import common from './common.json' with { type: 'json' } +import menu from './menu.json' with { type: 'json' } +import errors from './errors.json' with { type: 'json' } +import dialogs from './dialogs.json' with { type: 'json' } +import validation from './validation.json' with { type: 'json' } +import shortcuts from './shortcuts.json' with { type: 'json' } +import browse from './browse.json' with { type: 'json' } +import library from './library.json' with { type: 'json' } +import downloads from './downloads.json' with { type: 'json' } +import reader from './reader.json' with { type: 'json' } +import settings from './settings.json' with { type: 'json' } +import history from './history.json' with { type: 'json' } +import mangaDetail from './mangaDetail.json' with { type: 'json' } export default { common, diff --git a/src/locales/vi-VN/library.json b/src/locales/vi-VN/library.json index 47bc0804..56260d37 100644 --- a/src/locales/vi-VN/library.json +++ b/src/locales/vi-VN/library.json @@ -38,15 +38,20 @@ "addedToLibrary": "Đã thêm vào Thư viện", "addedSuccess": "Manga đã được thêm vào bộ sưu tập của bạn", "error": "Lỗi", + "checkDownloadsFailed": "Không thể kiểm tra tải xuống", "mangaNotFound": "Không tìm thấy Manga", "failedToAdd": "Không thể thêm vào thư viện", + "failedToRemove": "Không thể xóa khỏi thư viện", "removedFromLibrary": "Đã xóa khỏi thư viện", "removedCompletely": "Đã xóa hoàn toàn", + "removedWithDetails": "{{title}} và {{count}} chương đã được xóa", + "couldNotRemove": "Không thể hoàn tất việc xóa. Vui lòng thử lại.", "partiallyRemoved": { "title": "Xóa một phần", "libraryOnly": "Đã xóa khỏi thư viện, nhưng không thể xóa một số tải xuống", "downloadsOnly": "Đã xóa tải xuống, nhưng không thể xóa khỏi thư viện" - } + }, + "unexpectedError": "Lỗi không mong muốn" }, "collections": { "toasts": { diff --git a/src/main/api/utils/disk-cache.util.ts b/src/main/api/utils/disk-cache.util.ts index 75532d68..2e3d6256 100644 --- a/src/main/api/utils/disk-cache.util.ts +++ b/src/main/api/utils/disk-cache.util.ts @@ -103,7 +103,7 @@ export class DiskCacheUtil { // Delete directories recursively, files directly if (stats.isDirectory()) { - await secureFs.deleteDir(filePath, { recursive: true }) + await secureFs.deleteDir(filePath) } else { await secureFs.deleteFile(filePath) } @@ -197,7 +197,7 @@ export class DiskCacheUtil { } private async getMaxCacheSize(): Promise { - const cacheLimit = (await settingsManager.getByPath('downloads', 'maxDiskCacheSize')) as number + const cacheLimit = settingsManager.getByPath('downloads', 'maxDiskCacheSize') as number return cacheLimit ?? 50 * 1024 * 1024 // Default to 50MB if not set, 0 means unlimited } diff --git a/src/main/database/migrations/migrations.ts b/src/main/database/migrations/migrations.ts index 47357bb2..330c884f 100644 --- a/src/main/database/migrations/migrations.ts +++ b/src/main/database/migrations/migrations.ts @@ -1,8 +1,13 @@ -import path from 'node:path' +import path, { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' import { databaseConnection } from '../connection' import { migrate } from 'drizzle-orm/better-sqlite3/migrator' import { mainLog } from '../../services/logging/main-logging.service' +// ESM: Get __dirname equivalent +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + export function runMigrations(): void { try { const db = databaseConnection.getDb() diff --git a/src/main/filesystem/secure-fs.ts b/src/main/filesystem/secure-fs.ts index 8d293470..d4fe881c 100644 --- a/src/main/filesystem/secure-fs.ts +++ b/src/main/filesystem/secure-fs.ts @@ -23,9 +23,9 @@ export const secureFs = { return fs.unlink(validPath) }, - async deleteDir(dirPath: string, options?: { recursive?: boolean }): Promise { + async deleteDir(dirPath: string): Promise { const validPath = validatePath(dirPath) - return fs.rm(validPath, { recursive: options?.recursive ?? false, force: true }) + return fs.rm(validPath, { recursive: true, force: true }) }, async isExists(path: string): Promise { diff --git a/src/main/index.ts b/src/main/index.ts index 5d80b421..e25699b9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -41,7 +41,7 @@ async function initFileSystem(): Promise { await diskCacheUtil.initCachePath() // Load App settings - const settings = await settingsManager.load() + const settings = settingsManager.load() mainLog.info('[Main] Settings loaded:', settings) // Apply user language settings to i18next diff --git a/src/main/ipc/handlers/app-settings.handler.ts b/src/main/ipc/handlers/app-settings.handler.ts index 644ca974..4ff89c2b 100644 --- a/src/main/ipc/handlers/app-settings.handler.ts +++ b/src/main/ipc/handlers/app-settings.handler.ts @@ -139,7 +139,7 @@ export function registerAppSettingsHandlers(imageProxy?: ImageProxy): void { * await window.api.openSettingsFile() */ wrapIpcHandler('settings:open-settings-file', async () => { - return await settingsManager.openSettingsFile() + return settingsManager.openSettingsFile() }) /** diff --git a/src/main/ipc/handlers/file-systems.handler.ts b/src/main/ipc/handlers/file-systems.handler.ts index 3a1e79e2..5d0d937f 100644 --- a/src/main/ipc/handlers/file-systems.handler.ts +++ b/src/main/ipc/handlers/file-systems.handler.ts @@ -199,9 +199,9 @@ export function registerFileSystemHandlers(getWindow: () => BrowserWindow): void * // Delete directory and all contents * await window.api.deleteDir('C:\\...\\Downloads\\manga\\old-series', { recursive: true }) */ - wrapIpcHandler('fs:rm', async (_event, dirPath: unknown, options: unknown) => { + wrapIpcHandler('fs:rmdir', async (_event, dirPath: unknown) => { const validPath = validatePath(dirPath, 'dirPath') - await secureFs.deleteDir(validPath, options as { recursive?: boolean } | undefined) + await secureFs.deleteDir(validPath) return true }) diff --git a/src/main/ipc/handlers/storage.handler.ts b/src/main/ipc/handlers/storage.handler.ts index b4dc849d..9ff050fd 100644 --- a/src/main/ipc/handlers/storage.handler.ts +++ b/src/main/ipc/handlers/storage.handler.ts @@ -100,7 +100,7 @@ export function registerStorageHandlers(): void { } // Use validated approach: load settings, update field, validate section, save - const settings = await settingsManager.load() + const settings = settingsManager.load() settings.downloads.maxDiskCacheSize = byteLimit if (!isDownloadsSettings(settings.downloads)) { diff --git a/src/main/scripts/database-performance/shared/database-helpers.ts b/src/main/scripts/database-performance/shared/database-helpers.ts index 66dfc03f..7fd09981 100644 --- a/src/main/scripts/database-performance/shared/database-helpers.ts +++ b/src/main/scripts/database-performance/shared/database-helpers.ts @@ -8,10 +8,15 @@ import Database from 'better-sqlite3' import { drizzle, BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' import { migrate } from 'drizzle-orm/better-sqlite3/migrator' -import path from 'node:path' +import path, { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' import fs from 'node:fs' import * as schema from '../../../database/schemas' +// ESM: Get __dirname equivalent +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + export interface DatabaseTestOptions { dbPath?: string // Custom database path (defaults to dexreader-benchmark.db) cleanStart?: boolean // Delete existing database before creating diff --git a/src/main/services/app-update.service.ts b/src/main/services/app-update.service.ts index 1bf15421..cc3d53f5 100644 --- a/src/main/services/app-update.service.ts +++ b/src/main/services/app-update.service.ts @@ -1,6 +1,7 @@ import { is } from '@electron-toolkit/utils' import { app, BrowserWindow } from 'electron' -import { autoUpdater } from 'electron-updater' +import pkg from 'electron-updater' +const { autoUpdater } = pkg import { mainLog } from './logging/main-logging.service' import { settingsManager } from '../settings/settings-manager' @@ -38,7 +39,7 @@ class AppUpdateService { } if (!isManual) { - const isAutoUpdate = (await settingsManager.getByPath('update', 'autoCheck')) as boolean + const isAutoUpdate = settingsManager.getByPath('update', 'autoCheck') as boolean if (!isAutoUpdate) { mainLog.info('[AppUpdate] Auto-update is disabled. Skipping update check.') @@ -120,7 +121,7 @@ class AppUpdateService { } private async shouldAutoDownload(): Promise { - const autoDownload = (await settingsManager.getByPath('update', 'autoDownload')) as boolean + const autoDownload = settingsManager.getByPath('update', 'autoDownload') as boolean return autoDownload } diff --git a/src/main/services/dexreader/dexreader-export.service.ts b/src/main/services/dexreader/dexreader-export.service.ts index 03840ade..b7e103ea 100644 --- a/src/main/services/dexreader/dexreader-export.service.ts +++ b/src/main/services/dexreader/dexreader-export.service.ts @@ -1,6 +1,11 @@ -import path from 'node:path' +import path, { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' import fs from 'node:fs/promises' import { DexreaderExportOption } from '../options/dexreader-export.option' + +// ESM: Get __dirname equivalent +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) import { DexReaderExportResult } from '../results/dexreader/export.result' import { LibraryData } from '../types/dexreader/library.type' import { mangaRepo } from '../../database/repositories/manga.repo' diff --git a/src/main/services/dexreader/dexreader-import.service.ts b/src/main/services/dexreader/dexreader-import.service.ts index c9c18228..bae7a3f5 100644 --- a/src/main/services/dexreader/dexreader-import.service.ts +++ b/src/main/services/dexreader/dexreader-import.service.ts @@ -1,6 +1,11 @@ import { UpdateFirstReadCommand } from './../../database/commands/progress/update-firstread.command' -import path from 'node:path' +import path, { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' import fs from 'node:fs/promises' + +// ESM: Get __dirname equivalent +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) import { DexReaderImportResult } from '../results/dexreader/import.result' import Pako from 'pako' import protobuf from 'protobufjs' diff --git a/src/main/services/download-queue.service.ts b/src/main/services/download-queue.service.ts index 70b34c5d..8c499c5d 100644 --- a/src/main/services/download-queue.service.ts +++ b/src/main/services/download-queue.service.ts @@ -482,10 +482,7 @@ class DownloadQueueService { // Always get fresh number of concurrent downloads from settings in case the user changes it while downloading private async getConcurrentDownloadsSize(): Promise { - const maxConcurrent = (await settingsManager.getByPath( - 'downloads', - 'maxConcurrentDownloads' - )) as number + const maxConcurrent = settingsManager.getByPath('downloads', 'maxConcurrentDownloads') as number return maxConcurrent } diff --git a/src/main/services/download.service.ts b/src/main/services/download.service.ts index b4b1e803..5c522eae 100644 --- a/src/main/services/download.service.ts +++ b/src/main/services/download.service.ts @@ -221,7 +221,7 @@ class DownloadService { for (const download of downloadsToBeDeleted) { const fullPath = path.join(download.downloadsBasePath, download.filePath) try { - await secureFs.deleteDir(fullPath, { recursive: true }) + await secureFs.deleteDir(fullPath) successfulDeletions.push({ chapterId: download.chapterId, isDeletePermanent: true diff --git a/src/main/services/mihon/mihon-backup.service.ts b/src/main/services/mihon/mihon-backup.service.ts index f20d0fdc..5096308c 100644 --- a/src/main/services/mihon/mihon-backup.service.ts +++ b/src/main/services/mihon/mihon-backup.service.ts @@ -1,6 +1,12 @@ import { SaveChapterCommand } from '../../database/commands/progress/save-chapter.command' import { mihonBackup } from '../helpers/mihon-backup.helper' import fs from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path, { dirname } from 'node:path' + +// ESM: Get __dirname equivalent +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) import { UpsertMangaCommand } from '../../database/commands/manga/upsert-manga.command' import Pako from 'pako' import { ImportResult } from '../results/mihon/import.result' @@ -11,7 +17,6 @@ import { Backup } from '../types/mihon/backup.type' import { collectionRepo } from '../../database/repositories/collection.repo' import { mangaRepo } from '../../database/repositories/manga.repo' import { AddToCollectionCommand } from '../../database/commands/collections/add-to-collection.command' -import path from 'node:path' import { SaveProgressCommand } from '../../database/commands/progress/save-progress.command' import { progressRepo } from '../../database/repositories/manga-progress.repo' import { chapterRepo } from '../../database/repositories/chapter.repo' diff --git a/src/main/services/mihon/mihon-export.service.ts b/src/main/services/mihon/mihon-export.service.ts index 83a45600..45671b24 100644 --- a/src/main/services/mihon/mihon-export.service.ts +++ b/src/main/services/mihon/mihon-export.service.ts @@ -1,6 +1,11 @@ -import path from 'node:path' +import path, { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' import fs from 'node:fs/promises' import { ChapterWithMetadata } from '../../database/queries/manga/chapter-with-metadata.query' + +// ESM: Get __dirname equivalent +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) import { chapterRepo } from '../../database/repositories/chapter.repo' import { collectionRepo } from '../../database/repositories/collection.repo' import { progressRepo } from '../../database/repositories/manga-progress.repo' diff --git a/src/main/settings/settings-manager.ts b/src/main/settings/settings-manager.ts index 7eee02d8..ea7e0ca4 100644 --- a/src/main/settings/settings-manager.ts +++ b/src/main/settings/settings-manager.ts @@ -9,7 +9,6 @@ import { ImageQuality } from '../api/enums' import { AppTheme } from './enums/theme-mode.enum' import { ReadingMode } from './enums/reading-mode.enum' import { AppSettings } from './entities/app-settings.entity' -import type Store from 'electron-store' import { MangaReadingSettings } from './entities/reading-settings.entity' import { readerSettingsRepo } from '../database/repositories/reader-settings.repo' import { DownloadConfirmation } from './enums/download-confirmation.enum' @@ -19,19 +18,16 @@ import { mainLog } from '../services/logging/main-logging.service' import { StartupPage } from './enums/startup-page.enum' import { CURRENT_SETTINGS_VERSION, migrateSettings } from './utils/settings-migration.util' import { DisplayLanguage } from './enums/display-languages.enum' +import Store from 'electron-store' class SettingsManager { private settingsStore!: Store - private initPromise: Promise constructor() { - this.initPromise = this.initialize() + this.initialize() } - // TODO: Dynamic importing isn't my cup of tea, consider moving the whole project to transpile to ESM and using native imports for better readability and maintainability - private async initialize(): Promise { - // Dynamic import for ES module (electron-store v11+) - const Store = (await import('electron-store')).default + private initialize(): void { this.settingsStore = new Store({ name: 'settings', defaults: SettingsManager.getDefaultSettings(), @@ -39,12 +35,7 @@ class SettingsManager { }) } - private async ensureInitialized(): Promise { - await this.initPromise - } - - async load(): Promise { - await this.ensureInitialized() + load(): AppSettings { const settings = this.settingsStore.store if (settings.version !== CURRENT_SETTINGS_VERSION) { @@ -59,8 +50,7 @@ class SettingsManager { return settings } - async getByPath(key: K, path?: string): Promise { - await this.ensureInitialized() + getByPath(key: K, path?: string): unknown { const section = this.settingsStore.store[key] if (!path) { @@ -85,8 +75,7 @@ class SettingsManager { return current } - async update(section: T, value: AppSettings[T]): Promise { - await this.ensureInitialized() + update(section: T, value: AppSettings[T]): void { mainLog.debug(`[SettingsManager] Updating setting '${section}'`) const currentSettings = this.settingsStore.store this.settingsStore.store = { @@ -96,25 +85,21 @@ class SettingsManager { mainLog.info(`[SettingsManager] Setting '${section}' updated successfully`) } - async save(settings: AppSettings): Promise { - await this.ensureInitialized() + save(settings: AppSettings): void { this.settingsStore.store = settings mainLog.info('[SettingsManager] Settings saved successfully.') } - async reset(): Promise { - await this.ensureInitialized() + reset(): void { this.settingsStore.clear() mainLog.info('[SettingsManager] Settings reset to defaults.') } - async openSettingsFile(): Promise { - await this.ensureInitialized() + openSettingsFile(): Promise { return this.settingsStore.openInEditor() } async setDownloadsPath(newPath: string): Promise { - await this.ensureInitialized() mainLog.info(`[SettingsManager] Attempting to set downloads path: ${newPath}`) // Sanitize the new path (remove control characters including null bytes) // eslint-disable-next-line no-control-regex @@ -149,7 +134,7 @@ class SettingsManager { } async initializeDownloadsPath(): Promise { - const settings = await this.load() + const settings = this.load() if (settings.downloads.downloadPath) { try { @@ -161,19 +146,17 @@ class SettingsManager { `[SettingsManager] Using default downloads path at ${getDownloadsPath()} instead.` ) // Reset to default in settings - await this.update('downloads', { ...settings.downloads, downloadPath: undefined }) + this.update('downloads', { ...settings.downloads, downloadPath: undefined }) } } } - async getMangaReaderSettings(mangaId: string): Promise { + getMangaReaderSettings(mangaId: string): MangaReadingSettings { const override = readerSettingsRepo.getMangaOverride(mangaId) if (override) { return override } - - await this.ensureInitialized() const settings = this.settingsStore.store return settings.reader.global } diff --git a/src/main/window.ts b/src/main/window.ts index 19c09577..ca4582e8 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -1,12 +1,17 @@ import { BrowserWindow, Menu, shell, ipcMain } from 'electron' import icon from '../../resources/icon.png?asset' -import { join } from 'node:path' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' import { createMenu } from './menu/index' import { setupThemeDetection } from './theme' import { is } from '@electron-toolkit/utils' import { mainLog } from './services/logging/main-logging.service' import i18next from './i18n/i18n.config' +// ESM: Get __dirname equivalent +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + let mainWindow: BrowserWindow | undefined = undefined let isQuitting = false let hasUnsavedChanges = false @@ -25,7 +30,7 @@ export function createWindow(): void { autoHideMenuBar: false, ...(process.platform === 'linux' ? { icon } : {}), webPreferences: { - preload: join(__dirname, '../preload/index.js'), + preload: join(__dirname, '../preload/index.cjs'), sandbox: true, contextIsolation: true, // Disable DevTools in production for security diff --git a/src/renderer/src/utils/unfavouriteHandler.ts b/src/renderer/src/utils/unfavouriteHandler.ts index a6e3a371..26aca722 100644 --- a/src/renderer/src/utils/unfavouriteHandler.ts +++ b/src/renderer/src/utils/unfavouriteHandler.ts @@ -1,6 +1,7 @@ import { useToastStore } from '@renderer/stores/toastStore' import { formatBytes } from './formatBytes' import { rendererLog } from '@renderer/services/logging.service' +import i18next from 'i18next' export interface UnfavouriteOptions { mangaId: string @@ -31,10 +32,10 @@ export async function handleUnfavourite(options: UnfavouriteOptions): Promise 1 ? 's' : ''} (${formatBytes(totalBytes)}).\n\nDownloads will still be accessible in the Downloads view unless you choose to delete them.`, + message: i18next.t('dialogs:confirmations.removeFromLibrary.withDownloads.title'), + detail: i18next.t('dialogs:confirmations.removeFromLibrary.withDownloads.message', { + title: mangaTitle, + count: chapterCount, + plural: chapterCount > 1 ? 's' : '', + size: formatBytes(totalBytes) + }), buttons: [ - 'Remove from library (keep downloads)', - 'Remove everything (both bookmark and downloads will be removed)', - 'Cancel' + i18next.t('dialogs:confirmations.removeFromLibrary.withDownloads.buttons.keepDownloads'), + i18next.t( + 'dialogs:confirmations.removeFromLibrary.withDownloads.buttons.deleteEverything' + ), + i18next.t('dialogs:confirmations.removeFromLibrary.withDownloads.buttons.cancel') ], type: 'warning', defaultId: 2, // Cancel is safe default @@ -62,10 +70,23 @@ export async function handleUnfavourite(options: UnfavouriteOptions): Promise 1 ? 's' : ''} deleted` + title: i18next.t('library:toasts.removedCompletely'), + message: i18next.t('library:toasts.removedWithDetails', { + title: mangaTitle, + count: chapterCount, + plural: chapterCount > 1 ? 's' : '' + }) }) onSuccess?.() } else if (librarySuccess && !downloadsSuccess) { useToastStore.getState().show({ variant: 'warning', - title: 'Partially removed', - message: 'Removed from library, but failed to delete some downloads' + title: i18next.t('library:toasts.partiallyRemoved.title'), + message: i18next.t('library:toasts.partiallyRemoved.libraryOnly') }) onSuccess?.() // Still call success because library removal worked } else if (!librarySuccess && downloadsSuccess) { useToastStore.getState().show({ variant: 'warning', - title: 'Partially removed', - message: 'Downloads deleted, but failed to remove from library' + title: i18next.t('library:toasts.partiallyRemoved.title'), + message: i18next.t('library:toasts.partiallyRemoved.downloadsOnly') }) onSuccess?.() // Still call success because downloads removal worked } else { useToastStore.getState().show({ variant: 'error', - title: 'Failed to remove', - message: 'Could not complete removal. Please try again.' + title: i18next.t('library:toasts.error'), + message: i18next.t('library:toasts.couldNotRemove') }) onError?.('Both operations failed') } diff --git a/src/renderer/src/views/DownloadsView/hooks/useDownloadActions.ts b/src/renderer/src/views/DownloadsView/hooks/useDownloadActions.ts index 2fee4aa1..3ed4ff35 100644 --- a/src/renderer/src/views/DownloadsView/hooks/useDownloadActions.ts +++ b/src/renderer/src/views/DownloadsView/hooks/useDownloadActions.ts @@ -1,6 +1,7 @@ import { useToast } from '@renderer/components/Toast' import { rendererLog } from '@renderer/services/logging.service' import type { Download } from '@renderer/types/download.types' +import i18next from 'i18next' export interface UseDownloadActionsParams { downloads: Download[] @@ -30,16 +31,16 @@ export function useDownloadActions({ if (response.success) { showToast({ - title: 'Cancelled', - message: 'Download cancelled', + title: i18next.t('downloads:toasts.cancelled.title'), + message: i18next.t('downloads:toasts.cancelled.message'), variant: 'warning', duration: 2000 }) await onRefresh() } else { showToast({ - title: 'Error', - message: response.error?.message || 'Failed to cancel download', + title: i18next.t('downloads:toasts.error.title'), + message: response.error?.message || i18next.t('downloads:toasts.error.cancelFailed'), variant: 'error', duration: 3000 }) @@ -51,16 +52,16 @@ export function useDownloadActions({ if (response.success) { showToast({ - title: 'Retrying', - message: 'Download queued for retry', + title: i18next.t('downloads:toasts.retrying.title'), + message: i18next.t('downloads:toasts.retrying.message'), variant: 'info', duration: 2000 }) await onRefresh() } else { showToast({ - title: 'Error', - message: response.error?.message || 'Failed to retry download', + title: i18next.t('downloads:toasts.error.title'), + message: response.error?.message || i18next.t('downloads:toasts.error.retryFailed'), variant: 'error', duration: 3000 }) @@ -72,8 +73,8 @@ export function useDownloadActions({ if (!download) { showToast({ - title: 'Error', - message: 'Download not found', + title: i18next.t('downloads:toasts.error.title'), + message: i18next.t('downloads:toasts.error.notFound'), variant: 'error', duration: 3000 }) @@ -88,16 +89,16 @@ export function useDownloadActions({ const response = await globalThis.downloads.removeFromQueue(chapterId) if (response.success) { showToast({ - title: 'Cancelled', - message: 'Download cancelled', + title: i18next.t('downloads:toasts.cancelled.title'), + message: i18next.t('downloads:toasts.cancelled.message'), variant: 'warning', duration: 2000 }) await onRefresh() } else { showToast({ - title: 'Error', - message: response.error?.message || 'Failed to cancel download', + title: i18next.t('downloads:toasts.error.title'), + message: response.error?.message || i18next.t('downloads:toasts.error.cancelFailed'), variant: 'error', duration: 3000 }) @@ -113,16 +114,16 @@ export function useDownloadActions({ }) if (response.success) { showToast({ - title: 'Removed', - message: 'Failed download removed from view', + title: i18next.t('downloads:toasts.removed.title'), + message: i18next.t('downloads:toasts.removed.message'), variant: 'success', duration: 2000 }) await onRefresh() } else { showToast({ - title: 'Error', - message: response.error?.message || 'Failed to remove download', + title: i18next.t('downloads:toasts.error.title'), + message: response.error?.message || i18next.t('downloads:toasts.error.removeFailed'), variant: 'error', duration: 3000 }) @@ -136,18 +137,20 @@ export function useDownloadActions({ download.chapterTitle || `Chapter ${download.chapterNumber || download.id}` const result = await globalThis.api.showDialog({ type: 'warning', - message: 'Remove Download?', - detail: `You are about to remove "${chapterTitle}", which will delete downloaded chapter files.\nYou can also choose to just hide it from the list if you want to keep the files for offline reading.\n\nHow should we proceed?`, + message: i18next.t('dialogs:confirmations.deleteChapterDownload.title'), + detail: i18next.t('dialogs:confirmations.deleteChapterDownload.message', { + title: chapterTitle + }), buttons: [ - 'Cancel', - 'Hide from View (Keep Files for Offline Reading)', - 'Delete Forever (Cannot be Undone)' + i18next.t('dialogs:confirmations.deleteChapterDownload.buttons.cancel'), + i18next.t('dialogs:confirmations.deleteChapterDownload.buttons.hideFromView'), + i18next.t('dialogs:confirmations.deleteChapterDownload.buttons.deleteForever') ], defaultId: 0, // Cancel is default (safest) cancelId: 0 }) - if (result.response === 1) { + if (result.success && result.data.response === 1) { // Hide from view (soft delete) const response = await globalThis.downloads.deleteChapter({ chapterId, @@ -157,23 +160,26 @@ export function useDownloadActions({ await onRefresh() } else { showToast({ - title: 'Error', - message: response.error?.message || 'Failed to hide download', + title: i18next.t('downloads:toasts.error.title'), + message: response.error?.message || i18next.t('downloads:toasts.error.hideFailed'), variant: 'error', duration: 3000 }) } - } else if (result.response === 2) { + } else if (result.success && result.data.response === 2) { // User chose to permanently delete, give them a final chance to back out - const confirmation = await globalThis.api.showConfirmDialog( - 'Are you absolutely certain?', - 'This will be your last chance to back out before the chapter files are permanently deleted. File deletion cannot be undone, but you can always re-download the chapter if you change your mind.\n\nJust a reminder, you are deleting chapter: ' + - chapterTitle, - 'Yes, Delete Permanently', - 'Nevermind' + const confirmed = await globalThis.api.showConfirmDialog( + i18next.t('dialogs:confirmations.deleteChapterDownload.finalConfirmation.title'), + i18next.t('dialogs:confirmations.deleteChapterDownload.finalConfirmation.message', { + title: chapterTitle + }), + i18next.t( + 'dialogs:confirmations.deleteChapterDownload.finalConfirmation.confirmButton' + ), + i18next.t('dialogs:confirmations.deleteChapterDownload.finalConfirmation.cancelButton') ) - if (!confirmation.success || !confirmation.data) return + if (!confirmed.success || !confirmed.data) return // Delete permanently const response = await globalThis.downloads.deleteChapter({ @@ -182,16 +188,16 @@ export function useDownloadActions({ }) if (response.success) { showToast({ - title: 'Deleted', - message: 'Download and files permanently deleted', + title: i18next.t('downloads:toasts.deleted.title'), + message: i18next.t('downloads:toasts.deleted.message'), variant: 'success', duration: 3000 }) await onRefresh() } else { showToast({ - title: 'Error', - message: response.error?.message || 'Failed to delete download', + title: i18next.t('downloads:toasts.error.title'), + message: response.error?.message || i18next.t('downloads:toasts.error.deleteFailed'), variant: 'error', duration: 3000 }) @@ -210,8 +216,9 @@ export function useDownloadActions({ await onRefresh() } else { showToast({ - title: 'Error', - message: response.error?.message || 'Failed to hide completed downloads', + title: i18next.t('downloads:toasts.error.title'), + message: + response.error?.message || i18next.t('downloads:toasts.error.clearCompletedFailed'), variant: 'error', duration: 3000 }) @@ -230,8 +237,11 @@ export function useDownloadActions({ const successCount = results.filter((r) => r.status === 'fulfilled').length showToast({ - title: 'Retrying', - message: `Queued ${successCount} failed download${successCount === 1 ? '' : 's'} for retry`, + title: i18next.t('downloads:toasts.retrying.title'), + message: i18next.t('downloads:toasts.retryAllQueued', { + count: successCount, + s: successCount === 1 ? '' : 's' + }), variant: 'info', duration: 2000 }) @@ -250,8 +260,11 @@ export function useDownloadActions({ const cancelledCount = response.data showToast({ - title: 'Cancelled', - message: `Cancelled ${cancelledCount} queued download${cancelledCount === 1 ? '' : 's'}`, + title: i18next.t('downloads:toasts.cancelled.title'), + message: i18next.t('downloads:toasts.cancelledAll', { + count: cancelledCount, + s: cancelledCount === 1 ? '' : 's' + }), variant: 'warning', duration: 2000 }) @@ -259,8 +272,8 @@ export function useDownloadActions({ await onRefresh() } else { showToast({ - title: 'Error', - message: 'Failed to cancel queued downloads', + title: i18next.t('downloads:toasts.error.title'), + message: i18next.t('downloads:toasts.error.cancelFailed'), variant: 'error', duration: 3000 }) @@ -272,16 +285,16 @@ export function useDownloadActions({ const response = await globalThis.fileSystem.openDownloadsFolder() if (!response.success) { showToast({ - title: 'Error', - message: 'Failed to open downloads folder', + title: i18next.t('downloads:toasts.error.title'), + message: i18next.t('downloads:toasts.error.openFolderFailed'), variant: 'error' }) } } catch (error) { rendererLog.error('[useDownloadActions] Error opening downloads folder:', error) showToast({ - title: 'Error', - message: 'Failed to open downloads folder', + title: i18next.t('downloads:toasts.error.title'), + message: i18next.t('downloads:toasts.error.openFolderFailed'), variant: 'error' }) }