feat(w2): ship v1.3.0 project pack UI, preload, and E2E
Wire .novel/.novel-all export-import through modals, settings backup, and home bulk export with progress IPC. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -58,8 +58,25 @@ export function registerPackHandlers(
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.PACK_PICK_FILE, (_e, { mode }: { mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' }) =>
|
||||
ipcMain.handle(IPC.PACK_PICK_FILE, (_e, { mode }: { mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import' }) =>
|
||||
wrap(async () => {
|
||||
if (process.env.BILIN_E2E === '1') {
|
||||
if (
|
||||
(mode === 'save-novel' || mode === 'save-all') &&
|
||||
process.env.BILIN_E2E_PACK_SAVE
|
||||
) {
|
||||
return process.env.BILIN_E2E_PACK_SAVE
|
||||
}
|
||||
if (
|
||||
(mode === 'import' || mode === 'novel' || mode === 'novel-all') &&
|
||||
process.env.BILIN_E2E_PACK_IMPORT_FILE
|
||||
) {
|
||||
return process.env.BILIN_E2E_PACK_IMPORT_FILE
|
||||
}
|
||||
if (mode === 'import' && process.env.BILIN_E2E_IMPORT_FILE) {
|
||||
return process.env.BILIN_E2E_IMPORT_FILE
|
||||
}
|
||||
}
|
||||
if (mode === 'save-novel' || mode === 'save-all') {
|
||||
const ext = mode === 'save-all' ? 'novel-all' : 'novel'
|
||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||
@@ -69,7 +86,12 @@ export function registerPackHandlers(
|
||||
if (canceled || !filePath) return null
|
||||
return filePath
|
||||
}
|
||||
const extensions = mode === 'novel-all' ? ['novel-all'] : ['novel', 'novel-all']
|
||||
const extensions =
|
||||
mode === 'import'
|
||||
? ['txt', 'md', 'docx', 'novel', 'novel-all']
|
||||
: mode === 'novel-all'
|
||||
? ['novel-all']
|
||||
: ['novel', 'novel-all']
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Bilin Pack', extensions }]
|
||||
|
||||
@@ -60,6 +60,11 @@ import type {
|
||||
ImportExecuteParams,
|
||||
ImportSplitMode
|
||||
} from '../shared/types'
|
||||
import type {
|
||||
PackImportStrategy,
|
||||
PackImportReport,
|
||||
PackProgressEvent
|
||||
} from '../shared/novel-pack'
|
||||
|
||||
const electronAPI = {
|
||||
window: {
|
||||
@@ -599,6 +604,25 @@ const electronAPI = {
|
||||
saveTxt: (text: string, defaultName: string): Promise<IpcResult<string | null>> =>
|
||||
ipcRenderer.invoke(IPC.EXPORT_SAVE_TXT, { text, defaultName })
|
||||
},
|
||||
pack: {
|
||||
pickFile: (
|
||||
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
|
||||
): Promise<IpcResult<string | null>> => ipcRenderer.invoke(IPC.PACK_PICK_FILE, { mode }),
|
||||
exportBook: (bookId: string, destPath: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.PACK_EXPORT_BOOK, { bookId, destPath }),
|
||||
importBook: (filePath: string): Promise<IpcResult<string>> =>
|
||||
ipcRenderer.invoke(IPC.PACK_IMPORT_BOOK, { filePath }),
|
||||
exportAll: (destPath: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.PACK_EXPORT_ALL, { destPath }),
|
||||
importAll: (
|
||||
filePath: string,
|
||||
strategy: PackImportStrategy
|
||||
): Promise<IpcResult<PackImportReport>> =>
|
||||
ipcRenderer.invoke(IPC.PACK_IMPORT_ALL, { filePath, strategy }),
|
||||
estimateExportAll: (): Promise<IpcResult<{ bytes: number; needsConfirm: boolean }>> =>
|
||||
ipcRenderer.invoke(IPC.PACK_ESTIMATE_EXPORT_ALL),
|
||||
cancel: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.PACK_CANCEL)
|
||||
},
|
||||
bridge: {
|
||||
get: (
|
||||
bookId: string,
|
||||
@@ -674,6 +698,13 @@ const electronAPI = {
|
||||
}
|
||||
ipcRenderer.on(IPC.IMPORT_PROGRESS, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.IMPORT_PROGRESS, handler)
|
||||
},
|
||||
onPackProgress: (callback: (payload: PackProgressEvent) => void): (() => void) => {
|
||||
const handler = (_event: IpcRendererEvent, payload: PackProgressEvent) => {
|
||||
callback(payload)
|
||||
}
|
||||
ipcRenderer.on(IPC.PACK_PROGRESS, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.PACK_PROGRESS, handler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { SearchModal } from '@renderer/components/search/SearchModal'
|
||||
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
|
||||
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
|
||||
import { ExportAllModal } from '@renderer/components/export/ExportAllModal'
|
||||
import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
|
||||
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
|
||||
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
||||
@@ -209,6 +210,7 @@ function AppInner(): React.JSX.Element {
|
||||
<SearchModal />
|
||||
<InspirationModal />
|
||||
<ImportBookModal />
|
||||
<ExportAllModal />
|
||||
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { PackProgressEvent } from '@shared/novel-pack'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function ExportAllModal(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useAppStore((s) => s.exportAllModalOpen)
|
||||
const setOpen = useAppStore((s) => s.setExportAllModalOpen)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
|
||||
const [estimating, setEstimating] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [bytes, setBytes] = useState(0)
|
||||
const [needsConfirm, setNeedsConfirm] = useState(false)
|
||||
const [confirmed, setConfirmed] = useState(false)
|
||||
const [progress, setProgress] = useState<PackProgressEvent | null>(null)
|
||||
const [destPath, setDestPath] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setEstimating(true)
|
||||
setExporting(false)
|
||||
setBytes(0)
|
||||
setNeedsConfirm(false)
|
||||
setConfirmed(false)
|
||||
setProgress(null)
|
||||
setDestPath('')
|
||||
void ipcCall(() => window.electronAPI.pack.estimateExportAll())
|
||||
.then((info) => {
|
||||
setBytes(info.bytes)
|
||||
setNeedsConfirm(info.needsConfirm)
|
||||
})
|
||||
.catch(() => showToast(t('pack.exportAllFailed')))
|
||||
.finally(() => setEstimating(false))
|
||||
}, [open, showToast, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const unsub = window.electronAPI.onPackProgress((payload) => {
|
||||
setProgress(payload)
|
||||
})
|
||||
return unsub
|
||||
}, [open])
|
||||
|
||||
const handlePickDest = async (): Promise<void> => {
|
||||
const path = await ipcCall(() => window.electronAPI.pack.pickFile('save-all'))
|
||||
if (path) setDestPath(path)
|
||||
}
|
||||
|
||||
const handleExport = async (): Promise<void> => {
|
||||
if (!destPath) return
|
||||
if (needsConfirm && !confirmed) return
|
||||
setExporting(true)
|
||||
setProgress(null)
|
||||
try {
|
||||
await ipcCall(() => window.electronAPI.pack.exportAll(destPath))
|
||||
showToast(t('pack.exportAllSuccess'))
|
||||
setOpen(false)
|
||||
} catch {
|
||||
showToast(t('pack.exportAllFailed'))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
setProgress(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = (): void => {
|
||||
if (exporting) {
|
||||
void ipcCall(() => window.electronAPI.pack.cancel()).catch(() => undefined)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const progressPct =
|
||||
progress && progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
data-testid="export-all-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('pack.exportAllTitle')}
|
||||
>
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h3>{t('pack.exportAllTitle')}</h3>
|
||||
<button type="button" className="modal-close" onClick={handleCancel}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p className="text-muted">{t('pack.exportAllDesc')}</p>
|
||||
{estimating ? (
|
||||
<p>{t('pack.estimating')}</p>
|
||||
) : (
|
||||
<p data-testid="export-all-size">
|
||||
{t('pack.estimatedSize', { size: formatFileSize(bytes) })}
|
||||
</p>
|
||||
)}
|
||||
<div className="import-file-row">
|
||||
<input
|
||||
className="form-control"
|
||||
readOnly
|
||||
value={destPath || t('pack.noDest')}
|
||||
data-testid="export-all-dest"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="export-all-pick"
|
||||
disabled={exporting}
|
||||
onClick={() => void handlePickDest()}
|
||||
>
|
||||
{t('pack.pickDest')}
|
||||
</button>
|
||||
</div>
|
||||
{needsConfirm && (
|
||||
<label className="form-label" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="export-all-confirm"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
/>
|
||||
{t('pack.largeExportConfirm', { size: formatFileSize(bytes) })}
|
||||
</label>
|
||||
)}
|
||||
{progress && (
|
||||
<div className="import-progress" data-testid="export-all-progress">
|
||||
<div className="import-progress-fill" style={{ width: `${progressPct}%` }} />
|
||||
<span>
|
||||
{progress.message ??
|
||||
t('pack.progress', { current: progress.current, total: progress.total })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={handleCancel}>
|
||||
{exporting ? t('pack.cancel') : t('dialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="export-all-start"
|
||||
disabled={!destPath || exporting || estimating || (needsConfirm && !confirmed)}
|
||||
onClick={() => void handleExport()}
|
||||
>
|
||||
{exporting ? t('pack.exporting') : t('pack.exportAllStart')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,12 +8,15 @@ import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
type ExportKind = 'submission' | 'novel'
|
||||
|
||||
export function ExportModal(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useExportStore((s) => s.open)
|
||||
const close = useExportStore((s) => s.close)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const bookName = useBookStore((s) => s.books.find((b) => b.id === bookId)?.name)
|
||||
const chapters = useBookStore((s) => s.chapters)
|
||||
const target = useEditStore((s) => s.target)
|
||||
const customPresets = useSettingsStore((s) => s.submissionPresets)
|
||||
@@ -23,17 +26,20 @@ export function ExportModal(): React.JSX.Element | null {
|
||||
const chapterId = target?.kind === 'chapter' ? target.id : null
|
||||
const chapter = chapterId ? chapters.find((c) => c.id === chapterId) : null
|
||||
|
||||
const [exportKind, setExportKind] = useState<ExportKind>('submission')
|
||||
const [presetId, setPresetId] = useState(defaultPresetId)
|
||||
const [preview, setPreview] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exportingNovel, setExportingNovel] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setExportKind('submission')
|
||||
setPresetId(defaultPresetId)
|
||||
}, [open, defaultPresetId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !bookId || !chapterId) {
|
||||
if (!open || exportKind !== 'submission' || !bookId || !chapterId) {
|
||||
setPreview('')
|
||||
return
|
||||
}
|
||||
@@ -42,7 +48,7 @@ export function ExportModal(): React.JSX.Element | null {
|
||||
.then((text) => setPreview(text.slice(0, 500)))
|
||||
.catch(() => setPreview(''))
|
||||
.finally(() => setLoading(false))
|
||||
}, [open, bookId, chapterId, presetId])
|
||||
}, [open, exportKind, bookId, chapterId, presetId])
|
||||
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
if (!bookId || !chapterId) return
|
||||
@@ -65,6 +71,22 @@ export function ExportModal(): React.JSX.Element | null {
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportNovel = async (): Promise<void> => {
|
||||
if (!bookId) return
|
||||
const destPath = await ipcCall(() => window.electronAPI.pack.pickFile('save-novel'))
|
||||
if (!destPath) return
|
||||
setExportingNovel(true)
|
||||
try {
|
||||
await ipcCall(() => window.electronAPI.pack.exportBook(bookId, destPath))
|
||||
showToast(t('pack.exportBookSuccess', { name: bookName ?? '' }))
|
||||
close()
|
||||
} catch {
|
||||
showToast(t('pack.exportBookFailed'))
|
||||
} finally {
|
||||
setExportingNovel(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
@@ -80,53 +102,86 @@ export function ExportModal(): React.JSX.Element | null {
|
||||
<div className="modal-header">
|
||||
<h3>
|
||||
{t('export.title')}
|
||||
{chapter ? ` — ${chapter.title}` : ''}
|
||||
{exportKind === 'submission' && chapter ? ` — ${chapter.title}` : ''}
|
||||
{exportKind === 'novel' && bookName ? ` — ${bookName}` : ''}
|
||||
</h3>
|
||||
<button type="button" className="modal-close" onClick={close}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<label className="form-label">{t('export.submissionPresets')}</label>
|
||||
<label className="form-label">{t('export.kind')}</label>
|
||||
<select
|
||||
className="form-control form-control--wide"
|
||||
data-testid="export-preset-select"
|
||||
value={presetId}
|
||||
onChange={(e) => setPresetId(e.target.value)}
|
||||
data-testid="export-kind-select"
|
||||
value={exportKind}
|
||||
onChange={(e) => setExportKind(e.target.value as ExportKind)}
|
||||
>
|
||||
{presets.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="submission">{t('export.kindSubmission')}</option>
|
||||
<option value="novel">{t('export.kindNovel')}</option>
|
||||
</select>
|
||||
<label className="form-label">{t('export.preview')}</label>
|
||||
<pre className="export-preview" data-testid="export-preview">
|
||||
{loading ? t('export.loading') : preview || t('export.noPreview')}
|
||||
</pre>
|
||||
{exportKind === 'submission' ? (
|
||||
<>
|
||||
<label className="form-label">{t('export.submissionPresets')}</label>
|
||||
<select
|
||||
className="form-control form-control--wide"
|
||||
data-testid="export-preset-select"
|
||||
value={presetId}
|
||||
onChange={(e) => setPresetId(e.target.value)}
|
||||
>
|
||||
{presets.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="form-label">{t('export.preview')}</label>
|
||||
<pre className="export-preview" data-testid="export-preview">
|
||||
{loading ? t('export.loading') : preview || t('export.noPreview')}
|
||||
</pre>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted" style={{ lineHeight: 1.6 }}>
|
||||
{t('export.novelDesc')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={close}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="export-save-txt"
|
||||
disabled={!chapterId}
|
||||
onClick={() => void handleSaveTxt()}
|
||||
>
|
||||
{t('export.saveTxt')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="export-copy-clipboard"
|
||||
disabled={!chapterId}
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
{t('export.copyClipboard')}
|
||||
</button>
|
||||
{exportKind === 'submission' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="export-save-txt"
|
||||
disabled={!chapterId}
|
||||
onClick={() => void handleSaveTxt()}
|
||||
>
|
||||
{t('export.saveTxt')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="export-copy-clipboard"
|
||||
disabled={!chapterId}
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
{t('export.copyClipboard')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="export-novel-btn"
|
||||
disabled={!bookId || exportingNovel}
|
||||
onClick={() => void handleExportNovel()}
|
||||
>
|
||||
{exportingNovel ? t('pack.exporting') : t('export.exportNovel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
export function HomePage(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const setExportAllModalOpen = useAppStore((s) => s.setExportAllModalOpen)
|
||||
const setView = useAppStore((s) => s.setView)
|
||||
const { books, loadBooks, openBook } = useBookStore()
|
||||
const { openBookTab, openSettingsTab } = useTabStore()
|
||||
@@ -63,8 +64,7 @@ export function HomePage(): React.JSX.Element {
|
||||
type="button"
|
||||
className="btn-large"
|
||||
data-testid="home-export-all"
|
||||
disabled
|
||||
title={t('home.exportAllWave2')}
|
||||
onClick={() => setExportAllModalOpen(true)}
|
||||
>
|
||||
{t('home.exportAll')}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ImportPreviewResult, ImportSplitMode } from '@shared/types'
|
||||
import type { PackImportStrategy } from '@shared/novel-pack'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useTabStore } from '@renderer/stores/useTabStore'
|
||||
@@ -14,6 +15,13 @@ function formatFileSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function detectFileKind(filePath: string): 'text' | 'novel' | 'novel-all' {
|
||||
const lower = filePath.toLowerCase()
|
||||
if (lower.endsWith('.novel-all')) return 'novel-all'
|
||||
if (lower.endsWith('.novel')) return 'novel'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
export function ImportBookModal(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useAppStore((s) => s.importBookModalOpen)
|
||||
@@ -25,63 +33,115 @@ export function ImportBookModal(): React.JSX.Element | null {
|
||||
|
||||
const [filePath, setFilePath] = useState('')
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [fileKind, setFileKind] = useState<'text' | 'novel' | 'novel-all'>('text')
|
||||
const [bookName, setBookName] = useState('')
|
||||
const [category, setCategory] = useState('玄幻')
|
||||
const [splitMode, setSplitMode] = useState<ImportSplitMode>('auto')
|
||||
const [customRegex, setCustomRegex] = useState('')
|
||||
const [packStrategy, setPackStrategy] = useState<PackImportStrategy>('new')
|
||||
const [preview, setPreview] = useState<ImportPreviewResult | null>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null)
|
||||
const [progress, setProgress] = useState<{ current: number; total: number; message?: string } | null>(
|
||||
null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setFilePath('')
|
||||
setFileName('')
|
||||
setFileKind('text')
|
||||
setBookName('')
|
||||
setCategory('玄幻')
|
||||
setSplitMode('auto')
|
||||
setCustomRegex('')
|
||||
setPackStrategy('new')
|
||||
setPreview(null)
|
||||
setProgress(null)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const unsub = window.electronAPI.onImportProgress((payload) => {
|
||||
const unsubImport = window.electronAPI.onImportProgress((payload) => {
|
||||
setProgress(payload)
|
||||
})
|
||||
return unsub
|
||||
const unsubPack = window.electronAPI.onPackProgress((payload) => {
|
||||
setProgress(payload)
|
||||
})
|
||||
return () => {
|
||||
unsubImport()
|
||||
unsubPack()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handlePickFile = async (): Promise<void> => {
|
||||
const result = await ipcCall(() => window.electronAPI.import.pickFile())
|
||||
if (result.canceled || !result.filePath) return
|
||||
setFilePath(result.filePath)
|
||||
const name = result.filePath.split(/[/\\]/).pop() ?? ''
|
||||
const path = await ipcCall(() => window.electronAPI.pack.pickFile('import'))
|
||||
if (!path) return
|
||||
setFilePath(path)
|
||||
const name = path.split(/[/\\]/).pop() ?? ''
|
||||
setFileName(name)
|
||||
const baseName = name.replace(/\.(txt|md|docx)$/i, '')
|
||||
if (!bookName.trim()) setBookName(baseName)
|
||||
const kind = detectFileKind(path)
|
||||
setFileKind(kind)
|
||||
setPreview(null)
|
||||
if (kind === 'text') {
|
||||
const baseName = name.replace(/\.(txt|md|docx)$/i, '')
|
||||
if (!bookName.trim()) setBookName(baseName)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreview = async (): Promise<void> => {
|
||||
if (!filePath) return
|
||||
if (!filePath || fileKind !== 'text') return
|
||||
const result = await ipcCall(() =>
|
||||
window.electronAPI.import.preview(filePath, splitMode, customRegex || undefined)
|
||||
)
|
||||
setPreview(result)
|
||||
}
|
||||
|
||||
const openImportedBook = async (bookId: string, name: string): Promise<void> => {
|
||||
await loadBooks()
|
||||
const meta = useBookStore.getState().books.find((b) => b.id === bookId)
|
||||
await openBook(bookId)
|
||||
openBookTab(bookId, meta?.name ?? name)
|
||||
setView('editor')
|
||||
showToast(t('import.success'))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleImport = async (): Promise<void> => {
|
||||
if (!filePath || !bookName.trim()) return
|
||||
if (preview && preview.fileSizeBytes > LARGE_FILE_BYTES) {
|
||||
if (!filePath) return
|
||||
if (fileKind === 'text' && !bookName.trim()) return
|
||||
|
||||
if (fileKind === 'text' && preview && preview.fileSizeBytes > LARGE_FILE_BYTES) {
|
||||
const ok = confirm(
|
||||
t('import.largeFileConfirm', { size: formatFileSize(preview.fileSizeBytes) })
|
||||
)
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
setImporting(true)
|
||||
setProgress(null)
|
||||
try {
|
||||
if (fileKind === 'novel') {
|
||||
const bookId = await ipcCall(() => window.electronAPI.pack.importBook(filePath))
|
||||
await openImportedBook(bookId, bookName.trim() || fileName)
|
||||
return
|
||||
}
|
||||
|
||||
if (fileKind === 'novel-all') {
|
||||
const report = await ipcCall(() =>
|
||||
window.electronAPI.pack.importAll(filePath, packStrategy)
|
||||
)
|
||||
await loadBooks()
|
||||
showToast(
|
||||
t('pack.importAllSuccess', {
|
||||
imported: report.imported,
|
||||
skipped: report.skipped,
|
||||
overwritten: report.overwritten
|
||||
})
|
||||
)
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!preview) await handlePreview()
|
||||
const bookId = await ipcCall(() =>
|
||||
window.electronAPI.import.execute({
|
||||
@@ -92,13 +152,7 @@ export function ImportBookModal(): React.JSX.Element | null {
|
||||
customRegex: customRegex || undefined
|
||||
})
|
||||
)
|
||||
await loadBooks()
|
||||
const meta = useBookStore.getState().books.find((b) => b.id === bookId)
|
||||
await openBook(bookId)
|
||||
openBookTab(bookId, meta?.name ?? bookName.trim())
|
||||
setView('editor')
|
||||
showToast(t('import.success'))
|
||||
setOpen(false)
|
||||
await openImportedBook(bookId, bookName.trim())
|
||||
} catch {
|
||||
showToast(t('import.failed'))
|
||||
} finally {
|
||||
@@ -110,6 +164,7 @@ export function ImportBookModal(): React.JSX.Element | null {
|
||||
if (!open) return null
|
||||
|
||||
const previewTitles = preview?.chapters.slice(0, 5).map((ch) => ch.title) ?? []
|
||||
const isPack = fileKind === 'novel' || fileKind === 'novel-all'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -144,64 +199,89 @@ export function ImportBookModal(): React.JSX.Element | null {
|
||||
{t('import.pickFile')}
|
||||
</button>
|
||||
</div>
|
||||
<label className="form-label">{t('dialog.newBook.name')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="import-book-name"
|
||||
value={bookName}
|
||||
onChange={(e) => setBookName(e.target.value)}
|
||||
/>
|
||||
<label className="form-label">{t('dialog.newBook.category')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="import-book-category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
/>
|
||||
<label className="form-label">{t('import.splitMode')}</label>
|
||||
<select
|
||||
className="form-control form-control--wide"
|
||||
data-testid="import-split-mode"
|
||||
value={splitMode}
|
||||
onChange={(e) => setSplitMode(e.target.value as ImportSplitMode)}
|
||||
>
|
||||
<option value="auto">{t('import.splitAuto')}</option>
|
||||
<option value="paragraph">{t('import.splitParagraph')}</option>
|
||||
<option value="regex">{t('import.splitRegex')}</option>
|
||||
</select>
|
||||
{splitMode === 'regex' && (
|
||||
{fileKind === 'novel' && (
|
||||
<p className="text-muted" data-testid="import-pack-hint">
|
||||
{t('import.novelHint')}
|
||||
</p>
|
||||
)}
|
||||
{fileKind === 'novel-all' && (
|
||||
<>
|
||||
<label className="form-label">{t('import.customRegex')}</label>
|
||||
<input
|
||||
<p className="text-muted">{t('import.novelAllHint')}</p>
|
||||
<label className="form-label">{t('pack.importStrategy')}</label>
|
||||
<select
|
||||
className="form-control form-control--wide"
|
||||
data-testid="import-custom-regex"
|
||||
value={customRegex}
|
||||
onChange={(e) => setCustomRegex(e.target.value)}
|
||||
placeholder="^第.+章"
|
||||
/>
|
||||
data-testid="import-pack-strategy"
|
||||
value={packStrategy}
|
||||
onChange={(e) => setPackStrategy(e.target.value as PackImportStrategy)}
|
||||
>
|
||||
<option value="new">{t('pack.strategyNew')}</option>
|
||||
<option value="skip">{t('pack.strategySkip')}</option>
|
||||
<option value="overwrite">{t('pack.strategyOverwrite')}</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="import-preview-btn"
|
||||
disabled={!filePath}
|
||||
onClick={() => void handlePreview()}
|
||||
>
|
||||
{t('import.preview')}
|
||||
</button>
|
||||
{preview && (
|
||||
<div className="import-preview" data-testid="import-preview">
|
||||
<p>
|
||||
{t('import.chapterCount', { count: preview.chapters.length })}
|
||||
{preview.warnings.length > 0 && ` (${preview.warnings.join(', ')})`}
|
||||
</p>
|
||||
<ul className="import-preview-list">
|
||||
{previewTitles.map((title, i) => (
|
||||
<li key={i}>{title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{fileKind === 'text' && (
|
||||
<>
|
||||
<label className="form-label">{t('dialog.newBook.name')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="import-book-name"
|
||||
value={bookName}
|
||||
onChange={(e) => setBookName(e.target.value)}
|
||||
/>
|
||||
<label className="form-label">{t('dialog.newBook.category')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="import-book-category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
/>
|
||||
<label className="form-label">{t('import.splitMode')}</label>
|
||||
<select
|
||||
className="form-control form-control--wide"
|
||||
data-testid="import-split-mode"
|
||||
value={splitMode}
|
||||
onChange={(e) => setSplitMode(e.target.value as ImportSplitMode)}
|
||||
>
|
||||
<option value="auto">{t('import.splitAuto')}</option>
|
||||
<option value="paragraph">{t('import.splitParagraph')}</option>
|
||||
<option value="regex">{t('import.splitRegex')}</option>
|
||||
</select>
|
||||
{splitMode === 'regex' && (
|
||||
<>
|
||||
<label className="form-label">{t('import.customRegex')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="import-custom-regex"
|
||||
value={customRegex}
|
||||
onChange={(e) => setCustomRegex(e.target.value)}
|
||||
placeholder="^第.+章"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="import-preview-btn"
|
||||
disabled={!filePath}
|
||||
onClick={() => void handlePreview()}
|
||||
>
|
||||
{t('import.preview')}
|
||||
</button>
|
||||
{preview && (
|
||||
<div className="import-preview" data-testid="import-preview">
|
||||
<p>
|
||||
{t('import.chapterCount', { count: preview.chapters.length })}
|
||||
{preview.warnings.length > 0 && ` (${preview.warnings.join(', ')})`}
|
||||
</p>
|
||||
<ul className="import-preview-list">
|
||||
{previewTitles.map((title, i) => (
|
||||
<li key={i}>{title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{progress && (
|
||||
<div className="import-progress" data-testid="import-progress-bar">
|
||||
@@ -209,7 +289,10 @@ export function ImportBookModal(): React.JSX.Element | null {
|
||||
className="import-progress-fill"
|
||||
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
||||
/>
|
||||
<span>{t('import.progress', { current: progress.current, total: progress.total })}</span>
|
||||
<span>
|
||||
{progress.message ??
|
||||
t('import.progress', { current: progress.current, total: progress.total })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -221,7 +304,7 @@ export function ImportBookModal(): React.JSX.Element | null {
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="import-execute-btn"
|
||||
disabled={!filePath || !bookName.trim() || importing}
|
||||
disabled={!filePath || (!isPack && !bookName.trim()) || importing}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
{importing ? t('import.importing') : t('import.start')}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { PackProgressEvent } from '@shared/novel-pack'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
export function BackupSettingsTab(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const setExportAllModalOpen = useAppStore((s) => s.setExportAllModalOpen)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const bookName = useBookStore((s) => s.books.find((b) => b.id === bookId)?.name)
|
||||
|
||||
const [exportingBook, setExportingBook] = useState(false)
|
||||
const [progress, setProgress] = useState<PackProgressEvent | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = window.electronAPI.onPackProgress((payload) => {
|
||||
setProgress(payload)
|
||||
})
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
const handleExportBook = async (): Promise<void> => {
|
||||
if (!bookId) {
|
||||
showToast(t('backup.noBook'))
|
||||
return
|
||||
}
|
||||
const destPath = await ipcCall(() => window.electronAPI.pack.pickFile('save-novel'))
|
||||
if (!destPath) return
|
||||
setExportingBook(true)
|
||||
setProgress(null)
|
||||
try {
|
||||
await ipcCall(() => window.electronAPI.pack.exportBook(bookId, destPath))
|
||||
showToast(t('backup.exportBookSuccess', { name: bookName ?? '' }))
|
||||
} catch {
|
||||
showToast(t('backup.exportBookFailed'))
|
||||
} finally {
|
||||
setExportingBook(false)
|
||||
setProgress(null)
|
||||
}
|
||||
}
|
||||
|
||||
const progressPct =
|
||||
progress && progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0
|
||||
|
||||
return (
|
||||
<div data-testid="backup-settings-tab">
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: 16, lineHeight: 1.6 }}>
|
||||
{t('backup.desc')}
|
||||
</p>
|
||||
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
|
||||
<span>{t('backup.exportCurrentBook')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="backup-export-book"
|
||||
disabled={!bookId || exportingBook}
|
||||
onClick={() => void handleExportBook()}
|
||||
>
|
||||
{exportingBook ? t('pack.exporting') : t('backup.exportBookBtn')}
|
||||
</button>
|
||||
{!bookId && <small style={{ color: 'var(--text-muted)' }}>{t('backup.openBookHint')}</small>}
|
||||
</div>
|
||||
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
|
||||
<span>{t('backup.exportAllBooks')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="backup-export-all"
|
||||
onClick={() => setExportAllModalOpen(true)}
|
||||
>
|
||||
{t('backup.exportAllBtn')}
|
||||
</button>
|
||||
</div>
|
||||
{progress && (
|
||||
<div className="import-progress" data-testid="backup-progress" style={{ marginTop: 16 }}>
|
||||
<div className="import-progress-fill" style={{ width: `${progressPct}%` }} />
|
||||
<span>
|
||||
{progress.message ??
|
||||
t('pack.progress', { current: progress.current, total: progress.total })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 12, marginTop: 24 }}>
|
||||
{t('backup.formatHint')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
|
||||
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
|
||||
import { TemplateSettingsTab } from '@renderer/components/settings/TemplateSettingsTab'
|
||||
import { BackupSettingsTab } from '@renderer/components/settings/BackupSettingsTab'
|
||||
import { SubmissionPresetTab } from '@renderer/components/settings/SubmissionPresetTab'
|
||||
|
||||
const THEMES: { id: ThemeId; key: string }[] = [
|
||||
@@ -21,7 +22,7 @@ export function SettingsPage(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettingsStore()
|
||||
const [section, setSection] = useState<
|
||||
'general' | 'ai' | 'templates' | 'submission' | 'shortcuts' | 'about'
|
||||
'general' | 'ai' | 'templates' | 'submission' | 'backup' | 'shortcuts' | 'about'
|
||||
>('general')
|
||||
|
||||
return (
|
||||
@@ -62,6 +63,15 @@ export function SettingsPage(): React.JSX.Element {
|
||||
>
|
||||
{t('settings.submission')}
|
||||
</div>
|
||||
<div
|
||||
className={`settings-nav-item ${section === 'backup' ? 'active' : ''}`}
|
||||
onClick={() => setSection('backup')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="settings-nav-backup"
|
||||
>
|
||||
{t('settings.backup')}
|
||||
</div>
|
||||
<div
|
||||
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
|
||||
onClick={() => setSection('shortcuts')}
|
||||
@@ -249,10 +259,11 @@ export function SettingsPage(): React.JSX.Element {
|
||||
{section === 'ai' && <AiSettingsPage />}
|
||||
{section === 'templates' && <TemplateSettingsTab />}
|
||||
{section === 'submission' && <SubmissionPresetTab />}
|
||||
{section === 'backup' && <BackupSettingsTab />}
|
||||
{section === 'shortcuts' && <ShortcutEditor />}
|
||||
{section === 'about' && (
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
{t('app.name')} v1.2.0
|
||||
{t('app.name')} v1.3.0
|
||||
<br />
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
|
||||
@@ -10,6 +10,7 @@ interface AppState {
|
||||
landmarksModalOpen: boolean
|
||||
inspirationModalOpen: boolean
|
||||
importBookModalOpen: boolean
|
||||
exportAllModalOpen: boolean
|
||||
focusMode: boolean
|
||||
namingCheckOpen: boolean
|
||||
toast: string | null
|
||||
@@ -20,6 +21,7 @@ interface AppState {
|
||||
setLandmarksModalOpen: (open: boolean) => void
|
||||
setInspirationModalOpen: (open: boolean) => void
|
||||
setImportBookModalOpen: (open: boolean) => void
|
||||
setExportAllModalOpen: (open: boolean) => void
|
||||
setFocusMode: (on: boolean) => void
|
||||
setNamingCheckOpen: (open: boolean) => void
|
||||
showToast: (message: string) => void
|
||||
@@ -34,6 +36,7 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
landmarksModalOpen: false,
|
||||
inspirationModalOpen: false,
|
||||
importBookModalOpen: false,
|
||||
exportAllModalOpen: false,
|
||||
focusMode: false,
|
||||
namingCheckOpen: false,
|
||||
toast: null,
|
||||
@@ -44,6 +47,7 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
|
||||
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
|
||||
setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }),
|
||||
setExportAllModalOpen: (exportAllModalOpen) => set({ exportAllModalOpen }),
|
||||
setFocusMode: (focusMode) => set({ focusMode }),
|
||||
setNamingCheckOpen: (namingCheckOpen) => set({ namingCheckOpen }),
|
||||
showToast: (toast) => {
|
||||
|
||||
Vendored
+13
@@ -59,6 +59,7 @@ import type {
|
||||
ImportExecuteParams,
|
||||
ImportSplitMode
|
||||
} from './types'
|
||||
import type { PackImportStrategy, PackImportReport, PackProgressEvent } from './novel-pack'
|
||||
|
||||
export interface ElectronAPI {
|
||||
window: {
|
||||
@@ -445,6 +446,17 @@ export interface ElectronAPI {
|
||||
copyClipboard: (text: string) => Promise<IpcResult<void>>
|
||||
saveTxt: (text: string, defaultName: string) => Promise<IpcResult<string | null>>
|
||||
}
|
||||
pack: {
|
||||
pickFile: (
|
||||
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
|
||||
) => Promise<IpcResult<string | null>>
|
||||
exportBook: (bookId: string, destPath: string) => Promise<IpcResult<void>>
|
||||
importBook: (filePath: string) => Promise<IpcResult<string>>
|
||||
exportAll: (destPath: string) => Promise<IpcResult<void>>
|
||||
importAll: (filePath: string, strategy: PackImportStrategy) => Promise<IpcResult<PackImportReport>>
|
||||
estimateExportAll: () => Promise<IpcResult<{ bytes: number; needsConfirm: boolean }>>
|
||||
cancel: () => Promise<IpcResult<void>>
|
||||
}
|
||||
bridge: {
|
||||
get: (
|
||||
bookId: string,
|
||||
@@ -471,6 +483,7 @@ export interface ElectronAPI {
|
||||
onPomodoroTick: (callback: (state: PomodoroState) => void) => () => void
|
||||
onGoalNotification: (callback: (payload: GoalNotificationPayload) => void) => () => void
|
||||
onImportProgress: (callback: (payload: { current: number; total: number }) => void) => () => void
|
||||
onPackProgress: (callback: (payload: PackProgressEvent) => void) => () => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
Reference in New Issue
Block a user