feat: 实现笔临 P0/P1 Electron 桌面应用 v0.1.0
交付写作核心(TipTap、自动保存、分卷分章)、Onboarding、7 主题与双语 i18n、快捷键设置;主进程使用 node:sqlite;补全 Vitest 与 Playwright E2E;统一 design/ 目录并移除 desigin 拼写错误路径。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import { migrate } from './migrate'
|
||||
import type { SqliteDb } from './types'
|
||||
|
||||
const pools = new Map<string, SqliteDb>()
|
||||
|
||||
export function openBookDb(dbPath: string): SqliteDb {
|
||||
const existing = pools.get(dbPath)
|
||||
if (existing) return existing
|
||||
|
||||
const dir = dirname(dbPath)
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
|
||||
const db = new DatabaseSync(dbPath)
|
||||
migrate(db)
|
||||
pools.set(dbPath, db)
|
||||
return db
|
||||
}
|
||||
|
||||
export function closeAllBookDbs(): void {
|
||||
for (const db of pools.values()) db.close()
|
||||
pools.clear()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { SqliteDb } from './types'
|
||||
import schemaV1 from './schema-v1.sql?raw'
|
||||
|
||||
const CURRENT_VERSION = 1
|
||||
|
||||
export function migrate(db: SqliteDb): void {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
description TEXT
|
||||
)`)
|
||||
|
||||
const row = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number | null }
|
||||
const current = row?.v ?? 0
|
||||
if (current >= CURRENT_VERSION) return
|
||||
|
||||
db.exec(schemaV1)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(
|
||||
CURRENT_VERSION,
|
||||
'initial schema'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { countWords } from '../../services/word-count'
|
||||
import type { Chapter, ChapterStatus } from '../../../shared/types'
|
||||
import type { SqliteDb } from '../types'
|
||||
function mapRow(row: Record<string, unknown>): Chapter {
|
||||
return {
|
||||
id: row.id as string,
|
||||
volumeId: row.volume_id as string | null,
|
||||
title: row.title as string,
|
||||
content: row.content as string,
|
||||
status: row.status as ChapterStatus,
|
||||
wordCount: row.word_count as number,
|
||||
sortOrder: row.sort_order as number,
|
||||
cursorOffset: (row.cursor_offset as number) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
export class ChapterRepository {
|
||||
constructor(private db: SqliteDb) {}
|
||||
create(volumeId: string, title: string, sortOrder: number, content = ''): Chapter {
|
||||
const id = randomUUID()
|
||||
const wordCount = countWords(content)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO chapters (id, volume_id, title, content, word_count, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(id, volumeId, title, content, wordCount, sortOrder)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
get(id: string): Chapter | null {
|
||||
const row = this.db.prepare('SELECT * FROM chapters WHERE id = ?').get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
return row ? mapRow(row) : null
|
||||
}
|
||||
|
||||
list(): Chapter[] {
|
||||
return (
|
||||
this.db.prepare('SELECT * FROM chapters ORDER BY sort_order').all() as Record<string, unknown>[]
|
||||
).map(mapRow)
|
||||
}
|
||||
|
||||
listByVolume(volumeId: string): Chapter[] {
|
||||
return (
|
||||
this.db
|
||||
.prepare('SELECT * FROM chapters WHERE volume_id = ? ORDER BY sort_order')
|
||||
.all(volumeId) as Record<string, unknown>[]
|
||||
).map(mapRow)
|
||||
}
|
||||
|
||||
update(
|
||||
id: string,
|
||||
patch: Partial<{ title: string; content: string; status: ChapterStatus; cursorOffset: number }>
|
||||
): Chapter {
|
||||
const existing = this.get(id)
|
||||
if (!existing) throw new Error('Chapter not found')
|
||||
|
||||
const content = patch.content ?? existing.content
|
||||
const wordCount = countWords(content)
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE chapters SET
|
||||
title = ?, content = ?, status = ?, cursor_offset = ?,
|
||||
word_count = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
patch.title ?? existing.title,
|
||||
content,
|
||||
patch.status ?? existing.status,
|
||||
patch.cursorOffset ?? existing.cursorOffset,
|
||||
wordCount,
|
||||
id
|
||||
)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare('DELETE FROM chapters WHERE id = ?').run(id)
|
||||
}
|
||||
|
||||
nextSortOrder(volumeId: string): number {
|
||||
const row = this.db
|
||||
.prepare('SELECT MAX(sort_order) AS maxOrder FROM chapters WHERE volume_id = ?')
|
||||
.get(volumeId) as { maxOrder: number | null }
|
||||
return (row?.maxOrder ?? -1) + 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { Volume } from '../../../shared/types'
|
||||
import type { SqliteDb } from '../types'
|
||||
function mapRow(row: Record<string, unknown>): Volume {
|
||||
return {
|
||||
id: row.id as string,
|
||||
name: row.name as string,
|
||||
description: row.description as string,
|
||||
sortOrder: row.sort_order as number
|
||||
}
|
||||
}
|
||||
|
||||
export class VolumeRepository {
|
||||
constructor(private db: SqliteDb) {}
|
||||
create(name: string, sortOrder: number): Volume {
|
||||
const id = randomUUID()
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO volumes (id, name, sort_order) VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(id, name, sortOrder)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
get(id: string): Volume | null {
|
||||
const row = this.db.prepare('SELECT * FROM volumes WHERE id = ?').get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
return row ? mapRow(row) : null
|
||||
}
|
||||
|
||||
list(): Volume[] {
|
||||
return (
|
||||
this.db.prepare('SELECT * FROM volumes ORDER BY sort_order').all() as Record<string, unknown>[]
|
||||
).map(mapRow)
|
||||
}
|
||||
|
||||
update(id: string, patch: { name?: string; description?: string }): Volume {
|
||||
const existing = this.get(id)
|
||||
if (!existing) throw new Error('Volume not found')
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE volumes SET name = ?, description = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
)
|
||||
.run(patch.name ?? existing.name, patch.description ?? existing.description, id)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare('DELETE FROM volumes WHERE id = ?').run(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS volumes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chapters (
|
||||
id TEXT PRIMARY KEY,
|
||||
volume_id TEXT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
word_count INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
cursor_offset INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (volume_id) REFERENCES volumes(id) ON DELETE SET NULL
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
export type SqliteDb = DatabaseSync
|
||||
Reference in New Issue
Block a user