feat(w4): ship v1.5.0 timeline, character arc, and compliance

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-08 17:40:42 +08:00
parent c5c0b7329b
commit cb6b4c3731
51 changed files with 1528 additions and 42 deletions
+8 -1
View File
@@ -9,7 +9,9 @@ import schemaV7 from './schema-v7.sql?raw'
import schemaV8 from './schema-v8.sql?raw'
import schemaV9 from './schema-v9.sql?raw'
const CURRENT_VERSION = 9
import schemaV10 from './schema-v10.sql?raw'
const CURRENT_VERSION = 10
function tryAlter(db: SqliteDb, sql: string): void {
try {
@@ -98,4 +100,9 @@ export function migrate(db: SqliteDb): void {
db.exec(schemaV9)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(9, 'W1 foundation')
}
if (current < 10) {
db.exec(schemaV10)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(10, 'W4 timeline')
}
}
+61
View File
@@ -0,0 +1,61 @@
import { randomUUID } from 'crypto'
import type { TimelineEntry } from '../../../shared/timeline'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): TimelineEntry {
return {
id: row.id as string,
kind: 'manual',
title: row.title as string,
storyTime: (row.story_time as string) ?? null,
sortOrder: row.sort_order as number,
notes: (row.notes as string) ?? ''
}
}
export class TimelineRepository {
constructor(private db: SqliteDb) {}
listManual(): TimelineEntry[] {
return (
this.db
.prepare('SELECT * FROM timeline_events ORDER BY sort_order ASC, created_at ASC')
.all() as Record<string, unknown>[]
).map(mapRow)
}
upsert(input: {
id?: string
title: string
storyTime?: string | null
sortOrder?: number
notes?: string
}): TimelineEntry {
const id = input.id ?? randomUUID()
const existing = this.db.prepare('SELECT id FROM timeline_events WHERE id = ?').get(id)
if (existing) {
this.db
.prepare(
`UPDATE timeline_events SET title = ?, story_time = ?, sort_order = ?, notes = ? WHERE id = ?`
)
.run(
input.title,
input.storyTime ?? null,
input.sortOrder ?? 0,
input.notes ?? '',
id
)
} else {
this.db
.prepare(
`INSERT INTO timeline_events (id, title, story_time, sort_order, notes) VALUES (?, ?, ?, ?, ?)`
)
.run(id, input.title, input.storyTime ?? null, input.sortOrder ?? 0, input.notes ?? '')
}
return mapRow(this.db.prepare('SELECT * FROM timeline_events WHERE id = ?').get(id) as Record<string, unknown>)
}
delete(id: string): void {
this.db.prepare('DELETE FROM timeline_events WHERE id = ?').run(id)
}
}
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS timeline_events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
story_time TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+13
View File
@@ -0,0 +1,13 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { ArcService } from '../../services/arc.service'
import type { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerArcHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.ARC_GET_FOR_CHARACTER,
(_e, { bookId, settingId }: { bookId: string; settingId: string }) =>
wrap(() => new ArcService(registry.getDb(bookId)).getForCharacter(settingId))
)
}
@@ -0,0 +1,21 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { ComplianceService } from '../../services/compliance.service'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
export function registerComplianceHandlers(settings: GlobalSettingsService): void {
const svc = new ComplianceService(settings)
ipcMain.handle(IPC.COMPLIANCE_GET_WORDS, () => wrap(() => svc.getWords()))
ipcMain.handle(IPC.COMPLIANCE_SET_WORDS, (_e, { words }: { words: string[] }) =>
wrap(() => {
svc.setWords(words)
})
)
ipcMain.handle(IPC.COMPLIANCE_CHECK_TEXT, (_e, { text }: { text: string }) =>
wrap(() => svc.scanText(text))
)
}
+28
View File
@@ -0,0 +1,28 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { TimelineUpsertManualInput } from '../../../shared/timeline'
import { TimelineService } from '../../services/timeline.service'
import type { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerTimelineHandlers(registry: BookRegistryService): void {
ipcMain.handle(IPC.TIMELINE_LIST, (_e, { bookId }: { bookId: string }) =>
wrap(() => new TimelineService(registry.getDb(bookId)).list())
)
ipcMain.handle(
IPC.TIMELINE_UPSERT,
(_e, { bookId, input }: { bookId: string; input: TimelineUpsertManualInput }) =>
wrap(() => new TimelineService(registry.getDb(bookId)).upsertManual(input))
)
ipcMain.handle(IPC.TIMELINE_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) =>
wrap(() => {
new TimelineService(registry.getDb(bookId)).deleteManual(id)
})
)
ipcMain.handle(IPC.TIMELINE_CONFLICTS, (_e, { bookId }: { bookId: string }) =>
wrap(() => new TimelineService(registry.getDb(bookId)).detectConflicts())
)
}
+6
View File
@@ -36,6 +36,9 @@ import { registerGraphHandlers } from './handlers/graph.handler'
import { registerAnalyticsHandlers } from './handlers/analytics.handler'
import { registerBridgeHandlers } from './handlers/bridge.handler'
import { registerExportHandlers } from './handlers/export.handler'
import { registerTimelineHandlers } from './handlers/timeline.handler'
import { registerArcHandlers } from './handlers/arc.handler'
import { registerComplianceHandlers } from './handlers/compliance.handler'
import { AiClientService } from '../services/ai-client.service'
import { NetworkMonitorService } from '../services/network-monitor.service'
@@ -93,6 +96,9 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
registerAnalyticsHandlers(books, writingLogs, writingSessionRepo, pomodoroDailyRepo)
registerBridgeHandlers(books, aiClient)
registerExportHandlers(books, settings)
registerTimelineHandlers(books)
registerArcHandlers(books)
registerComplianceHandlers(settings)
return { settings, books, shortcuts: shortcutManager }
}
+47
View File
@@ -0,0 +1,47 @@
import type { CharacterArcNode } from '../../shared/arc'
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
import { SettingRepository } from '../db/repositories/setting.repo'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import type { SqliteDb } from '../db/types'
export class ArcService {
constructor(private db: SqliteDb) {}
getForCharacter(settingId: string): CharacterArcNode[] {
const setting = new SettingRepository(this.db).get(settingId)
if (!setting || setting.type !== 'character') return []
const knowledge = new KnowledgeRepository(this.db)
.list()
.filter((k) => k.type === 'character' && k.title.includes(setting.name))
const chapterRepo = new ChapterRepository(this.db)
const chapters = new Map(chapterRepo.list().map((c) => [c.id, c]))
const nodes: CharacterArcNode[] = knowledge
.map((k) => {
const chapterId = k.lastMentionChapterId ?? k.sourceChapterId
const chapter = chapterId ? chapters.get(chapterId) : undefined
return {
id: k.id,
title: k.title,
content: k.content,
chapterId: chapterId ?? null,
chapterTitle: chapter?.title ?? null,
updatedAt: k.updatedAt
}
})
.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt))
if (nodes.length === 0 && setting.description.trim()) {
nodes.push({
id: `setting:${setting.id}`,
title: '角色设定',
content: setting.description.replace(/<[^>]+>/g, '').slice(0, 200),
chapterId: null,
chapterTitle: null,
updatedAt: setting.updatedAt
})
}
return nodes
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { ComplianceMatch, ComplianceScanResult } from '../../shared/compliance'
import { BUILTIN_COMPLIANCE_WORDS } from '../../shared/compliance'
import type { GlobalSettingsService } from './global-settings'
export class ComplianceService {
constructor(private settings: GlobalSettingsService) {}
getWords(): string[] {
const custom = this.settings.get().complianceWords ?? []
return [...BUILTIN_COMPLIANCE_WORDS, ...custom.filter(Boolean)]
}
setWords(words: string[]): void {
this.settings.update({ complianceWords: words })
}
scanText(text: string): ComplianceScanResult {
const words = this.getWords()
const matches: ComplianceMatch[] = []
const lower = text.toLowerCase()
for (const word of words) {
const w = word.trim()
if (!w) continue
let start = 0
while (start < lower.length) {
const idx = lower.indexOf(w.toLowerCase(), start)
if (idx < 0) break
matches.push({ word: w, index: idx, length: w.length })
start = idx + w.length
}
}
matches.sort((a, b) => a.index - b.index)
return { matches, text }
}
applyReplacements(text: string, replacements: Record<string, string>): string {
let out = text
for (const [from, to] of Object.entries(replacements)) {
if (!from) continue
out = out.split(from).join(to)
}
return out
}
}
+7 -1
View File
@@ -38,7 +38,10 @@ function defaults(): GlobalSettings {
enableGoalNotifications: true,
chapterTemplates: [],
submissionPresets: [],
defaultSubmissionPresetId: 'builtin-webnovel'
defaultSubmissionPresetId: 'builtin-webnovel',
enableComplianceCheck: false,
complianceWords: [],
customSlashCommands: []
}
}
@@ -70,6 +73,9 @@ export class GlobalSettingsService {
chapterTemplates: raw.chapterTemplates ?? [],
submissionPresets: raw.submissionPresets ?? [],
defaultSubmissionPresetId: raw.defaultSubmissionPresetId ?? 'builtin-webnovel',
enableComplianceCheck: raw.enableComplianceCheck ?? false,
complianceWords: raw.complianceWords ?? [],
customSlashCommands: raw.customSlashCommands ?? [],
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
}
+133
View File
@@ -0,0 +1,133 @@
import type { Chapter, SettingEntry } from '../../shared/types'
import type { TimelineConflict, TimelineEntry } from '../../shared/timeline'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { SettingRepository } from '../db/repositories/setting.repo'
import { TimelineRepository } from '../db/repositories/timeline.repo'
import type { SqliteDb } from '../db/types'
function parseRelativeDays(storyTime: string): number | null {
const m = storyTime.trim().match(/^\+(\d+)\s*([dDmMyY])$/)
if (!m) return null
const n = Number(m[1])
const unit = m[2].toLowerCase()
if (unit === 'y') return n * 365
if (unit === 'm') return n * 30
return n
}
function parseAbsoluteYear(storyTime: string): number | null {
const digits = storyTime.replace(/\D/g, '')
if (!digits) return null
const year = Number(digits)
return Number.isFinite(year) ? year : null
}
function resolveTimelinePosition(chapter: Chapter, prev?: Chapter): number {
if (!chapter.storyTime) return chapter.sortOrder
const rel = parseRelativeDays(chapter.storyTime)
if (rel != null && prev) {
const prevPos = resolveTimelinePosition(prev)
return prevPos + rel
}
const abs = parseAbsoluteYear(chapter.storyTime)
if (abs != null) return abs
return chapter.sortOrder
}
function birthYearFromSetting(setting: SettingEntry): number | null {
const props = setting.properties ?? {}
const birthDate = props.birthDate
if (typeof birthDate === 'string' || typeof birthDate === 'number') {
const year = parseAbsoluteYear(String(birthDate))
if (year != null) return year
}
const age = props.age
if (typeof age === 'number') return null
if (typeof age === 'string' && age.trim()) {
const n = Number(age.replace(/\D/g, ''))
return Number.isFinite(n) ? null : null
}
return null
}
function ageAtPosition(birthYear: number, position: number): number {
return Math.max(0, Math.floor(position - birthYear))
}
export class TimelineService {
constructor(private db: SqliteDb) {}
list(): TimelineEntry[] {
const chapters = new ChapterRepository(this.db).list().sort((a, b) => a.sortOrder - b.sortOrder)
const chapterEntries: TimelineEntry[] = chapters.map((ch) => ({
id: `chapter:${ch.id}`,
kind: 'chapter',
chapterId: ch.id,
title: ch.title,
storyTime: ch.storyTime ?? null,
sortOrder: ch.sortOrder
}))
const manual = new TimelineRepository(this.db).listManual()
const merged = [...chapterEntries, ...manual]
merged.sort((a, b) => {
const chA = chapters.find((c) => c.id === a.chapterId)
const chB = chapters.find((c) => c.id === b.chapterId)
const idxA = chA ? chapters.indexOf(chA) : -1
const idxB = chB ? chapters.indexOf(chB) : -1
const prevA = idxA > 0 ? chapters[idxA - 1] : undefined
const prevB = idxB > 0 ? chapters[idxB - 1] : undefined
const posA = chA ? resolveTimelinePosition(chA, prevA) : a.sortOrder + 10_000
const posB = chB ? resolveTimelinePosition(chB, prevB) : b.sortOrder + 10_000
return posA - posB || a.sortOrder - b.sortOrder
})
return merged
}
upsertManual(input: {
id?: string
title: string
storyTime?: string | null
sortOrder?: number
notes?: string
}): TimelineEntry {
return new TimelineRepository(this.db).upsert(input)
}
deleteManual(id: string): void {
new TimelineRepository(this.db).delete(id)
}
detectConflicts(): TimelineConflict[] {
const chapters = new ChapterRepository(this.db).list().sort((a, b) => a.sortOrder - b.sortOrder)
const characters = new SettingRepository(this.db)
.list()
.filter((s) => s.type === 'character')
const conflicts: TimelineConflict[] = []
for (const character of characters) {
const birthYear = birthYearFromSetting(character)
if (birthYear == null) continue
let lastAge: number | null = null
let lastChapter: Chapter | null = null
for (let i = 0; i < chapters.length; i++) {
const ch = chapters[i]!
const prev = i > 0 ? chapters[i - 1] : undefined
const position = resolveTimelinePosition(ch, prev)
const age = ageAtPosition(birthYear, position)
if (lastAge != null && age < lastAge && ch.storyTime) {
conflicts.push({
characterId: character.id,
characterName: character.name,
chapterId: ch.id,
chapterTitle: ch.title,
message: `${character.name} 年龄从 ${lastAge} 变为 ${age}(时间倒退)`
})
}
lastAge = age
lastChapter = ch
}
if (!lastChapter) continue
}
return conflicts
}
}
+26
View File
@@ -66,6 +66,9 @@ import type {
PackProgressEvent
} from '../shared/novel-pack'
import type { ExportMatrixParams } from '../shared/export-matrix'
import type { TimelineEntry, TimelineConflict, TimelineUpsertManualInput } from '../shared/timeline'
import type { CharacterArcNode } from '../shared/arc'
import type { ComplianceScanResult } from '../shared/compliance'
const electronAPI = {
window: {
@@ -609,6 +612,29 @@ const electronAPI = {
export: (params: ExportMatrixParams): Promise<IpcResult<string | null>> =>
ipcRenderer.invoke(IPC.EXPORT_MATRIX_EXPORT, params)
},
timeline: {
list: (bookId: string): Promise<IpcResult<TimelineEntry[]>> =>
ipcRenderer.invoke(IPC.TIMELINE_LIST, { bookId }),
upsert: (
bookId: string,
input: TimelineUpsertManualInput
): Promise<IpcResult<TimelineEntry>> => ipcRenderer.invoke(IPC.TIMELINE_UPSERT, { bookId, input }),
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.TIMELINE_DELETE, { bookId, id }),
conflicts: (bookId: string): Promise<IpcResult<TimelineConflict[]>> =>
ipcRenderer.invoke(IPC.TIMELINE_CONFLICTS, { bookId })
},
arc: {
getForCharacter: (bookId: string, settingId: string): Promise<IpcResult<CharacterArcNode[]>> =>
ipcRenderer.invoke(IPC.ARC_GET_FOR_CHARACTER, { bookId, settingId })
},
compliance: {
getWords: (): Promise<IpcResult<string[]>> => ipcRenderer.invoke(IPC.COMPLIANCE_GET_WORDS),
setWords: (words: string[]): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.COMPLIANCE_SET_WORDS, { words }),
checkText: (text: string): Promise<IpcResult<ComplianceScanResult>> =>
ipcRenderer.invoke(IPC.COMPLIANCE_CHECK_TEXT, { text })
},
pack: {
pickFile: (
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
+2
View File
@@ -20,6 +20,7 @@ import { InspirationModal } from '@renderer/components/inspiration/InspirationMo
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
import { ExportAllModal } from '@renderer/components/export/ExportAllModal'
import { ReadModeOverlay } from '@renderer/components/read/ReadModeOverlay'
import { TimelineModal } from '@renderer/components/timeline/TimelineModal'
import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
import { useSearchStore } from '@renderer/stores/useSearchStore'
@@ -213,6 +214,7 @@ function AppInner(): React.JSX.Element {
<InspirationModal />
<ImportBookModal />
<ExportAllModal />
<TimelineModal />
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
{toast && <div className="toast">{toast}</div>}
</div>
+3 -1
View File
@@ -6,6 +6,7 @@ import { useAppStore } from '@renderer/stores/useAppStore'
import { useAiStore } from '@renderer/stores/useAiStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { applySlashCommand } from '@renderer/lib/ai-slash-commands'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { insertToEditor } from '@renderer/lib/editor-commands'
import { ipcCall } from '@renderer/lib/ipc-client'
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
@@ -29,6 +30,7 @@ const QUICK_CMDS = ['/续写', '/扩写', '/润色', '/总结', '/纠错']
export function AiPanel(): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
const customSlashCommands = useSettingsStore((s) => s.customSlashCommands)
const bookId = useBookStore((s) => s.currentBookId)
const sessions = useAiStore((s) => s.sessions)
const activeSessionId = useAiStore((s) => s.activeSessionId)
@@ -90,7 +92,7 @@ export function AiPanel(): React.JSX.Element {
const handleSend = (): void => {
const text = input.trim()
if (!text || sending || disabled) return
const { display, prompt } = applySlashCommand(text, t)
const { display, prompt } = applySlashCommand(text, t, customSlashCommands)
setInput('')
void sendMessage(display, prompt !== display ? prompt : undefined)
}
+46 -14
View File
@@ -11,6 +11,9 @@ import { useBookStore } from '@renderer/stores/useBookStore'
import { flushEditSession, registerEditorFlush, useEditStore } from '@renderer/stores/useEditStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { SettingRelationshipsEditor } from '@renderer/components/setting/SettingRelationshipsEditor'
import { CharacterArcPanel } from '@renderer/components/setting/CharacterArcPanel'
import { ComplianceHighlight, compliancePluginKey } from '@renderer/extensions/compliance-highlight'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { LandmarkMark } from '@renderer/extensions/landmark-mark'
import { SettingMention } from '@renderer/extensions/mention'
import { registerHighlightToken, registerInsertLandmark, registerEditorInsert, registerEditorGetText } from '@renderer/lib/editor-commands'
@@ -90,20 +93,22 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
const [mentionFrom, setMentionFrom] = useState(0)
const content = useTargetContent(target)
const isChapter = target?.kind === 'chapter'
const complianceEnabled = useSettingsStore((s) => s.enableComplianceCheck === true)
const extensions = useMemo(
() =>
isChapter
? [
StarterKit,
Underline,
LandmarkMark,
SettingMention,
Placeholder.configure({ placeholder: t('editor.placeholder') })
]
: [simplifiedKit, Placeholder.configure({ placeholder: t('editor.placeholder') })],
[isChapter, t]
)
const extensions = useMemo(() => {
if (!isChapter) {
return [simplifiedKit, Placeholder.configure({ placeholder: t('editor.placeholder') })]
}
const chapterExt = [
StarterKit,
Underline,
LandmarkMark,
SettingMention,
Placeholder.configure({ placeholder: t('editor.placeholder') })
]
if (complianceEnabled) chapterExt.push(ComplianceHighlight)
return chapterExt
}, [isChapter, t, complianceEnabled])
const mentionCandidates = useMemo(() => {
const q = mentionQuery.toLowerCase()
@@ -316,6 +321,30 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
return () => registerEditorInsert(null, () => {})
}, [editor, bookId, target])
useEffect(() => {
if (!editor || !complianceEnabled || target?.kind !== 'chapter') return
let words: string[] = []
let timer: ReturnType<typeof setTimeout> | undefined
const apply = (): void => {
const tr = editor.state.tr
tr.setMeta(compliancePluginKey, words)
editor.view.dispatch(tr)
}
void ipcCall(() => window.electronAPI.compliance.getWords()).then((w) => {
words = w
apply()
})
const onUpdate = (): void => {
clearTimeout(timer)
timer = setTimeout(apply, 300)
}
editor.on('update', onUpdate)
return () => {
editor.off('update', onUpdate)
clearTimeout(timer)
}
}, [editor, complianceEnabled, target?.kind])
useEffect(() => {
if (!editor) return
if (!target) {
@@ -368,7 +397,10 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
<div className="editor-content-wrap">
<EditorContent editor={editor} />
{settingEntry?.type === 'character' && bookId && (
<SettingRelationshipsEditor entry={settingEntry} bookId={bookId} />
<>
<CharacterArcPanel entry={settingEntry} bookId={bookId} />
<SettingRelationshipsEditor entry={settingEntry} bookId={bookId} />
</>
)}
{mentionOpen && mentionCandidates.length > 0 && (
<div className="mention-dropdown" data-testid="mention-dropdown">
@@ -126,6 +126,7 @@ export function EditorLayout(): React.JSX.Element {
const [templateModalOpen, setTemplateModalOpen] = useState(false)
const [chapterMetaOpen, setChapterMetaOpen] = useState(false)
const openReadMode = useReadModeStore((s) => s.openForVolume)
const setTimelineModalOpen = useAppStore((s) => s.setTimelineModalOpen)
const [volumeMenu, setVolumeMenu] = useState<{ volumeId: string; x: number; y: number } | null>(
null
)
@@ -163,6 +164,13 @@ export function EditorLayout(): React.JSX.Element {
useEffect(() => {
if (!selectedChapterId) return
if (
target?.kind === 'setting' ||
target?.kind === 'outline' ||
target?.kind === 'inspiration'
) {
return
}
if (target?.kind === 'chapter' && target.id === selectedChapterId) return
setTarget({ kind: 'chapter', id: selectedChapterId })
}, [selectedChapterId, setTarget, target?.kind, target?.id])
@@ -405,6 +413,11 @@ export function EditorLayout(): React.JSX.Element {
className={`chapter-item ${target?.kind === 'chapter' && target.id === ch.id ? 'active' : ''} ${dragOverChapterId === ch.id ? 'drag-over' : ''}`}
draggable
data-testid={`chapter-item-${ch.id}`}
title={
ch.summary?.trim() ||
ch.content.replace(/<[^>]+>/g, '').trim().slice(0, 80) ||
undefined
}
onDragStart={(e) => {
e.dataTransfer.setData('application/x-bilin-chapter', ch.id)
e.dataTransfer.effectAllowed = 'move'
@@ -499,6 +512,14 @@ export function EditorLayout(): React.JSX.Element {
>
+ {t('template.quickNew')}
</button>
<button
type="button"
data-testid="open-timeline"
style={{ marginTop: 6 }}
onClick={() => setTimelineModalOpen(true)}
>
🕐 {t('timeline.open')}
</button>
<button
type="button"
data-testid="chapter-read-mode"
@@ -1,9 +1,16 @@
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
import { useEditStore } from '@renderer/stores/useEditStore'
type RefItem = {
id: string
kind: 'setting' | 'outline' | 'inspiration'
title: string
preview: string
}
export function ReferencePanel(): React.JSX.Element {
const { t } = useTranslation()
const open = useReferenceStore((s) => s.open)
@@ -18,6 +25,7 @@ export function ReferencePanel(): React.JSX.Element {
const outlines = useBookStore((s) => s.outlines)
const settings = useBookStore((s) => s.settings)
const inspirations = useBookStore((s) => s.inspirations)
const [detailItem, setDetailItem] = useState<RefItem | null>(null)
const items = useMemo(() => {
const q = query.trim().toLowerCase()
@@ -90,13 +98,14 @@ export function ReferencePanel(): React.JSX.Element {
{items.map((item) => (
<div
key={item.id}
className="ref-item"
className={`ref-item ${detailItem?.id === item.id ? 'active' : ''}`}
draggable
onDragStart={(e) => {
const plain = item.preview.replace(/<[^>]+>/g, '').slice(0, 200)
e.dataTransfer.setData('text/plain', plain)
}}
onClick={() => void switchTarget({ kind: item.kind, id: item.id })}
onClick={() => setDetailItem(item)}
onDoubleClick={() => void switchTarget({ kind: item.kind, id: item.id })}
role="button"
tabIndex={0}
data-testid={`ref-item-${item.id}`}
@@ -108,6 +117,26 @@ export function ReferencePanel(): React.JSX.Element {
</div>
))}
</div>
{detailItem && (
<div className="ref-detail-drawer" data-testid="reference-detail-drawer">
<div className="ref-detail-header">
<strong>{detailItem.title}</strong>
<button type="button" className="btn" onClick={() => setDetailItem(null)}>
</button>
</div>
<div className="ref-detail-body">
{detailItem.preview.replace(/<[^>]+>/g, '')}
</div>
<button
type="button"
className="btn btn-primary"
onClick={() => void switchTarget({ kind: detailItem.kind, id: detailItem.id })}
>
{t('reference.openInEditor')}
</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { CharacterArcNode } from '@shared/arc'
import type { SettingEntry } from '@shared/types'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { ipcCall } from '@renderer/lib/ipc-client'
interface CharacterArcPanelProps {
entry: SettingEntry
bookId: string
}
export function CharacterArcPanel({ entry, bookId }: CharacterArcPanelProps): React.JSX.Element {
const { t } = useTranslation()
const switchTarget = useEditStore((s) => s.switchTarget)
const updateSettingLocal = useBookStore((s) => s.updateSettingLocal)
const [nodes, setNodes] = useState<CharacterArcNode[]>([])
const [birthDate, setBirthDate] = useState(String(entry.properties?.birthDate ?? ''))
useEffect(() => {
setBirthDate(String(entry.properties?.birthDate ?? ''))
void ipcCall(() => window.electronAPI.arc.getForCharacter(bookId, entry.id)).then(setNodes)
}, [bookId, entry.id, entry.properties?.birthDate, entry.updatedAt])
const saveBirthDate = async (): Promise<void> => {
const updated = await ipcCall(() =>
window.electronAPI.setting.update(bookId, entry.id, {
properties: { ...entry.properties, birthDate: birthDate.trim() || undefined }
})
)
updateSettingLocal(updated)
}
return (
<div className="character-arc-panel" data-testid="character-arc-panel">
<h4>{t('arc.title')}</h4>
<label className="form-label">{t('arc.birthDate')}</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input
className="form-control"
data-testid="character-birth-date"
value={birthDate}
onChange={(e) => setBirthDate(e.target.value)}
placeholder="732"
/>
<button type="button" className="btn" onClick={() => void saveBirthDate()}>
{t('dialog.save')}
</button>
</div>
{nodes.length === 0 ? (
<p className="text-muted">{t('arc.empty')}</p>
) : (
<ul className="arc-node-list" data-testid="arc-node-list">
{nodes.map((node) => (
<li key={node.id} className="arc-node-item" data-testid={`arc-node-${node.id}`}>
<div className="arc-node-title">{node.title}</div>
<div className="arc-node-content">{node.content.slice(0, 120)}</div>
{node.chapterId && (
<button
type="button"
className="btn"
onClick={() => void switchTarget({ kind: 'chapter', id: node.chapterId! })}
>
{node.chapterTitle ?? t('arc.jumpChapter')}
</button>
)}
</li>
))}
</ul>
)}
</div>
)
}
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import type { AiBackend, AiConfig } from '@shared/types'
import type { AiBackend, AiConfig, CustomSlashCommand } from '@shared/types'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import { ipcCall } from '@renderer/lib/ipc-client'
@@ -22,6 +22,29 @@ export function AiSettingsPage(): React.JSX.Element {
void settings.update({ aiConfig: { ...aiConfig, ...patch } })
}
const customSlashCommands = settings.customSlashCommands ?? []
const updateSlashCommands = (next: CustomSlashCommand[]): void => {
void settings.update({ customSlashCommands: next })
}
const addSlashCommand = (): void => {
updateSlashCommands([
...customSlashCommands,
{ id: crypto.randomUUID(), name: '/自定义', template: '' }
])
}
const patchSlashCommand = (id: string, patch: Partial<CustomSlashCommand>): void => {
updateSlashCommands(
customSlashCommands.map((cmd) => (cmd.id === id ? { ...cmd, ...patch } : cmd))
)
}
const removeSlashCommand = (id: string): void => {
updateSlashCommands(customSlashCommands.filter((cmd) => cmd.id !== id))
}
const handleTest = async (): Promise<void> => {
setTesting(true)
try {
@@ -125,6 +148,41 @@ export function AiSettingsPage(): React.JSX.Element {
</p>
)}
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
<span>{t('ai.settings.customSlashCommands')}</span>
<div className="custom-slash-list" data-testid="custom-slash-commands">
{customSlashCommands.map((cmd) => (
<div key={cmd.id} className="custom-slash-row">
<input
className="form-control"
data-testid={`custom-slash-name-${cmd.id}`}
value={cmd.name}
onChange={(e) => patchSlashCommand(cmd.id, { name: e.target.value })}
placeholder={t('ai.settings.customSlashName')}
/>
<input
className="form-control"
data-testid={`custom-slash-template-${cmd.id}`}
value={cmd.template}
onChange={(e) => patchSlashCommand(cmd.id, { template: e.target.value })}
placeholder={t('ai.settings.customSlashTemplate')}
/>
<button type="button" className="btn" onClick={() => removeSlashCommand(cmd.id)}>
{t('dialog.delete')}
</button>
</div>
))}
<button
type="button"
className="btn"
data-testid="custom-slash-add"
onClick={addSlashCommand}
>
{t('ai.settings.customSlashAdd')}
</button>
</div>
</div>
<div className="setting-item">
<span />
<button
@@ -234,6 +234,39 @@ export function SettingsPage(): React.JSX.Element {
{t('settings.goalNotifications')}
</label>
</div>
<div className="setting-item">
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
data-testid="settings-compliance-check"
checked={settings.enableComplianceCheck === true}
onChange={(e) =>
void settings.update({ enableComplianceCheck: e.target.checked })
}
/>
{t('settings.complianceCheck')}
</label>
</div>
{settings.enableComplianceCheck && (
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
<span>{t('settings.complianceWords')}</span>
<textarea
className="form-control form-control--wide"
data-testid="settings-compliance-words"
rows={3}
value={(settings.complianceWords ?? []).join('\n')}
onChange={(e) =>
void settings.update({
complianceWords: e.target.value
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
})
}
placeholder={t('settings.complianceWordsHint')}
/>
</div>
)}
<div className="setting-item">
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
@@ -276,7 +309,7 @@ export function SettingsPage(): React.JSX.Element {
{section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v1.4.0
{t('app.name')} v1.5.0
<br />
{t('app.tagline')}
</p>
@@ -0,0 +1,125 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { TimelineConflict, TimelineEntry } from '@shared/timeline'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { ipcCall } from '@renderer/lib/ipc-client'
export function TimelineModal(): React.JSX.Element | null {
const { t } = useTranslation()
const open = useAppStore((s) => s.timelineModalOpen)
const setOpen = useAppStore((s) => s.setTimelineModalOpen)
const bookId = useBookStore((s) => s.currentBookId)
const settings = useBookStore((s) => s.settings)
const switchTarget = useEditStore((s) => s.switchTarget)
const [entries, setEntries] = useState<TimelineEntry[]>([])
const [conflicts, setConflicts] = useState<TimelineConflict[]>([])
const [loading, setLoading] = useState(false)
const conflictChapterIds = useMemo(
() => new Set(conflicts.map((c) => c.chapterId)),
[conflicts]
)
useEffect(() => {
if (!open || !bookId) return
setLoading(true)
void Promise.all([
ipcCall(() => window.electronAPI.timeline.list(bookId)),
ipcCall(() => window.electronAPI.timeline.conflicts(bookId))
])
.then(([list, conf]) => {
setEntries(list)
setConflicts(conf)
})
.finally(() => setLoading(false))
}, [open, bookId])
const handleJump = async (entry: TimelineEntry): Promise<void> => {
if (!entry.chapterId) return
await switchTarget({ kind: 'chapter', id: entry.chapterId })
setOpen(false)
}
const handleAddEvent = async (): Promise<void> => {
if (!bookId) return
const title = window.prompt(t('timeline.eventTitlePrompt'))
if (!title?.trim()) return
const storyTime = window.prompt(t('timeline.storyTimePrompt')) ?? ''
await ipcCall(() =>
window.electronAPI.timeline.upsert(bookId, {
title: title.trim(),
storyTime: storyTime.trim() || null
})
)
const list = await ipcCall(() => window.electronAPI.timeline.list(bookId))
setEntries(list)
}
if (!open) return null
return (
<div className="modal-overlay" data-testid="timeline-modal" role="dialog" aria-modal="true">
<div className="modal-content modal-content--wide">
<div className="modal-header">
<h3>{t('timeline.title')}</h3>
<button type="button" className="modal-close" onClick={() => setOpen(false)}>
</button>
</div>
<div className="modal-body">
{loading ? (
<p>{t('cockpit.loading')}</p>
) : (
<div className="timeline-list" data-testid="timeline-list">
{entries.map((entry) => {
const conflict = conflicts.find((c) => c.chapterId === entry.chapterId)
const isConflict = entry.chapterId && conflictChapterIds.has(entry.chapterId)
return (
<div
key={entry.id}
className={`timeline-item ${isConflict ? 'timeline-item--conflict' : ''}`}
data-testid={`timeline-item-${entry.id}`}
>
<div className="timeline-item-main">
<strong>{entry.title}</strong>
<span className="timeline-item-meta">
{entry.storyTime ? entry.storyTime : t('timeline.noTime')}
{entry.kind === 'manual' ? ` · ${t('timeline.manual')}` : ''}
</span>
</div>
{conflict && (
<div className="timeline-conflict-msg" data-testid="timeline-conflict-msg">
{conflict.message}
</div>
)}
{entry.chapterId && (
<button type="button" className="btn" onClick={() => void handleJump(entry)}>
{t('timeline.jumpChapter')}
</button>
)}
</div>
)
})}
{entries.length === 0 && <p className="text-muted">{t('timeline.empty')}</p>}
</div>
)}
{settings.filter((s) => s.type === 'character').length > 0 && (
<p className="text-muted" style={{ marginTop: 12, fontSize: 12 }}>
{t('timeline.characterHint')}
</p>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn" onClick={() => void handleAddEvent()}>
{t('timeline.addEvent')}
</button>
<button type="button" className="btn btn-primary" onClick={() => setOpen(false)}>
{t('dialog.cancel')}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,68 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
export const compliancePluginKey = new PluginKey('complianceHighlight')
function buildDecorations(doc: { descendants: (f: (node: { isText?: boolean; text?: string | null }, pos: number) => void) => void }, words: string[]): DecorationSet {
if (words.length === 0) return DecorationSet.empty
const decorations: Decoration[] = []
doc.descendants((node, pos) => {
if (!node.isText || !node.text) return
const text = node.text
for (const word of words) {
const w = word.trim()
if (!w) continue
let start = 0
while (start < text.length) {
const idx = text.indexOf(w, start)
if (idx < 0) break
decorations.push(
Decoration.inline(pos + idx, pos + idx + w.length, {
class: 'compliance-underline',
'data-testid': 'compliance-mark'
})
)
start = idx + w.length
}
}
})
return DecorationSet.create(doc as never, decorations)
}
export const ComplianceHighlight = Extension.create({
name: 'complianceHighlight',
addProseMirrorPlugins() {
return [
new Plugin({
key: compliancePluginKey,
state: {
init() {
return DecorationSet.empty
},
apply(tr, set) {
const words = tr.getMeta(compliancePluginKey) as string[] | undefined
if (words) {
return buildDecorations(tr.doc, words)
}
return set.map(tr.mapping, tr.doc)
}
},
props: {
decorations(state) {
return compliancePluginKey.getState(state) ?? DecorationSet.empty
}
}
})
]
}
})
export function setComplianceWords(
view: { dispatch: (tr: { doc: unknown; steps: unknown[]; getMeta: (k: PluginKey) => unknown; setMeta: (k: PluginKey, v: unknown) => void }) => void; state: { tr: unknown } },
words: string[]
): void {
const tr = view.state.tr as { setMeta: (k: PluginKey, v: unknown) => void; doc: unknown; steps: unknown[]; getMeta: (k: PluginKey) => unknown }
tr.setMeta(compliancePluginKey, words)
view.dispatch(tr)
}
+20 -4
View File
@@ -1,4 +1,5 @@
import type { TFunction } from 'i18next'
import type { CustomSlashCommand } from '@shared/types'
const SLASH_KEYS: Record<string, string> = {
'/续写': 'ai.slash.continue',
@@ -8,15 +9,30 @@ const SLASH_KEYS: Record<string, string> = {
'/纠错': 'ai.slash.proofread'
}
export function resolveSlashCommand(input: string, t: TFunction): string | null {
const token = input.trim().split(/\s+/)[0]
function normalizeToken(input: string): string {
const token = input.trim().split(/\s+/)[0] ?? ''
return token.startsWith('/') ? token : `/${token}`
}
export function resolveSlashCommand(
input: string,
t: TFunction,
custom?: CustomSlashCommand[]
): string | null {
const token = normalizeToken(input)
const customMatch = custom?.find((c) => normalizeToken(c.name) === token)
if (customMatch?.template.trim()) return customMatch.template
const key = SLASH_KEYS[token]
if (!key) return null
return t(key)
}
export function applySlashCommand(input: string, t: TFunction): { display: string; prompt: string } {
export function applySlashCommand(
input: string,
t: TFunction,
custom?: CustomSlashCommand[]
): { display: string; prompt: string } {
const display = input.trim()
const resolved = resolveSlashCommand(display, t)
const resolved = resolveSlashCommand(display, t, custom)
return { display, prompt: resolved ?? display }
}
+4
View File
@@ -11,6 +11,7 @@ interface AppState {
inspirationModalOpen: boolean
importBookModalOpen: boolean
exportAllModalOpen: boolean
timelineModalOpen: boolean
focusMode: boolean
namingCheckOpen: boolean
toast: string | null
@@ -22,6 +23,7 @@ interface AppState {
setInspirationModalOpen: (open: boolean) => void
setImportBookModalOpen: (open: boolean) => void
setExportAllModalOpen: (open: boolean) => void
setTimelineModalOpen: (open: boolean) => void
setFocusMode: (on: boolean) => void
setNamingCheckOpen: (open: boolean) => void
showToast: (message: string) => void
@@ -37,6 +39,7 @@ export const useAppStore = create<AppState>((set) => ({
inspirationModalOpen: false,
importBookModalOpen: false,
exportAllModalOpen: false,
timelineModalOpen: false,
focusMode: false,
namingCheckOpen: false,
toast: null,
@@ -48,6 +51,7 @@ export const useAppStore = create<AppState>((set) => ({
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }),
setExportAllModalOpen: (exportAllModalOpen) => set({ exportAllModalOpen }),
setTimelineModalOpen: (timelineModalOpen) => set({ timelineModalOpen }),
setFocusMode: (focusMode) => set({ focusMode }),
setNamingCheckOpen: (namingCheckOpen) => set({ namingCheckOpen }),
showToast: (toast) => {
+3
View File
@@ -35,6 +35,9 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
readModeFontTheme: 'serif',
readModeDeviceWidth: 'desktop',
enableFocusAmbientSound: false,
enableComplianceCheck: false,
complianceWords: [] as string[],
customSlashCommands: [] as { id: string; name: string; template: string }[],
loaded: false,
load: async () => {
const data = await ipcCall(() => window.electronAPI.settings.get())
+101
View File
@@ -2473,3 +2473,104 @@
.read-mode-font-eye-care .read-mode-chapter-title {
color: #3d3428;
}
.timeline-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.timeline-item {
padding: 12px 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
}
.timeline-item--conflict {
border-color: #e05252;
background: rgba(224, 82, 82, 0.08);
}
.timeline-item-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.timeline-item-meta {
font-size: 12px;
color: var(--text-muted);
}
.timeline-conflict-msg {
margin-top: 8px;
font-size: 12px;
color: #e05252;
}
.character-arc-panel {
margin-top: 16px;
padding: 14px;
border-top: 1px solid var(--border-color);
}
.arc-node-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.arc-node-item {
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-secondary);
}
.arc-node-title {
font-weight: 600;
margin-bottom: 4px;
}
.arc-node-content {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 8px;
}
.compliance-underline {
text-decoration: underline wavy #e05252;
text-underline-offset: 3px;
}
.ref-detail-drawer {
position: absolute;
top: 0;
right: 0;
width: min(360px, 90%);
height: 100%;
background: var(--bg-primary);
border-left: 1px solid var(--border-color);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.08);
padding: 16px;
z-index: 5;
overflow-y: auto;
}
.custom-slash-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.custom-slash-row {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: 8px;
align-items: center;
}
+8
View File
@@ -0,0 +1,8 @@
export interface CharacterArcNode {
id: string
title: string
content: string
chapterId: string | null
chapterTitle: string | null
updatedAt: string
}
+12
View File
@@ -0,0 +1,12 @@
export interface ComplianceMatch {
word: string
index: number
length: number
}
export interface ComplianceScanResult {
matches: ComplianceMatch[]
text: string
}
export const BUILTIN_COMPLIANCE_WORDS = ['违禁', '暴力描写']
+17
View File
@@ -61,6 +61,9 @@ import type {
} from './types'
import type { PackImportStrategy, PackImportReport, PackProgressEvent } from './novel-pack'
import type { ExportMatrixParams } from './export-matrix'
import type { TimelineEntry, TimelineConflict, TimelineUpsertManualInput } from './timeline'
import type { CharacterArcNode } from './arc'
import type { ComplianceScanResult } from './compliance'
export interface ElectronAPI {
window: {
@@ -450,6 +453,20 @@ export interface ElectronAPI {
exportMatrix: {
export: (params: ExportMatrixParams) => Promise<IpcResult<string | null>>
}
timeline: {
list: (bookId: string) => Promise<IpcResult<TimelineEntry[]>>
upsert: (bookId: string, input: TimelineUpsertManualInput) => Promise<IpcResult<TimelineEntry>>
delete: (bookId: string, id: string) => Promise<IpcResult<void>>
conflicts: (bookId: string) => Promise<IpcResult<TimelineConflict[]>>
}
arc: {
getForCharacter: (bookId: string, settingId: string) => Promise<IpcResult<CharacterArcNode[]>>
}
compliance: {
getWords: () => Promise<IpcResult<string[]>>
setWords: (words: string[]) => Promise<IpcResult<void>>
checkText: (text: string) => Promise<IpcResult<ComplianceScanResult>>
}
pack: {
pickFile: (
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
+9 -1
View File
@@ -160,5 +160,13 @@ export const IPC = {
PACK_ESTIMATE_EXPORT_ALL: 'pack:estimateExportAll',
PACK_CANCEL: 'pack:cancel',
PACK_NEEDS_CONFIRM: 'pack:needsConfirm',
PACK_PROGRESS: 'pack:progress'
PACK_PROGRESS: 'pack:progress',
TIMELINE_LIST: 'timeline:list',
TIMELINE_UPSERT: 'timeline:upsert',
TIMELINE_DELETE: 'timeline:delete',
TIMELINE_CONFLICTS: 'timeline:conflicts',
ARC_GET_FOR_CHARACTER: 'arc:getForCharacter',
COMPLIANCE_GET_WORDS: 'compliance:getWords',
COMPLIANCE_SET_WORDS: 'compliance:setWords',
COMPLIANCE_CHECK_TEXT: 'compliance:checkText'
} as const
+27
View File
@@ -0,0 +1,27 @@
export type TimelineEventKind = 'chapter' | 'manual'
export interface TimelineEntry {
id: string
kind: TimelineEventKind
chapterId?: string | null
title: string
storyTime: string | null
sortOrder: number
notes?: string
}
export interface TimelineConflict {
characterId: string
characterName: string
chapterId: string
chapterTitle: string
message: string
}
export interface TimelineUpsertManualInput {
id?: string
title: string
storyTime?: string | null
sortOrder?: number
notes?: string
}
+10 -1
View File
@@ -212,7 +212,7 @@ export interface SubmissionPreset {
titleFormat: string
indentParagraphs: boolean
includeAuthorsNote: boolean
paragraphGap: 'single' | 'double'
sensitiveReplacements?: Record<string, string>
}
export type ImportSplitMode = 'auto' | 'paragraph' | 'regex'
@@ -292,6 +292,15 @@ export interface GlobalSettings {
readModeFontTheme?: 'serif' | 'sans' | 'eye-care'
readModeDeviceWidth?: 'phone' | 'tablet' | 'desktop'
enableFocusAmbientSound?: boolean
enableComplianceCheck?: boolean
complianceWords?: string[]
customSlashCommands?: CustomSlashCommand[]
}
export interface CustomSlashCommand {
id: string
name: string
template: string
}
export interface BookMeta {