feat: deliver P2 v0.2.0 with unified editor, search, and snapshots
Implement schema v2 migration, outline/setting/inspiration CRUD, unified TipTap editing, reference panel with mentions, FTS global search, chapter version history, writing landmarks, word frequency analysis, light theme contrast fixes, and full unit/E2E test coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { OutlineItem, OutlineStatus } from '../../../shared/types'
|
||||
import type { SqliteDb } from '../types'
|
||||
|
||||
function mapRow(row: Record<string, unknown>): OutlineItem {
|
||||
return {
|
||||
id: row.id as string,
|
||||
parentId: (row.parent_id as string | null) ?? null,
|
||||
title: row.title as string,
|
||||
description: (row.description as string) ?? '',
|
||||
status: row.status as OutlineStatus,
|
||||
expectedWordCount: (row.expected_word_count as number | null) ?? null,
|
||||
chapterId: (row.chapter_id as string | null) ?? null,
|
||||
sortOrder: row.sort_order as number,
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string
|
||||
}
|
||||
}
|
||||
|
||||
function buildTree(items: OutlineItem[]): OutlineItem[] {
|
||||
const byParent = new Map<string | null, OutlineItem[]>()
|
||||
for (const item of items) {
|
||||
const key = item.parentId
|
||||
if (!byParent.has(key)) byParent.set(key, [])
|
||||
byParent.get(key)!.push({ ...item, children: [] })
|
||||
}
|
||||
const attach = (parentId: string | null): OutlineItem[] => {
|
||||
const nodes = byParent.get(parentId) ?? []
|
||||
nodes.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
for (const node of nodes) {
|
||||
node.children = attach(node.id)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
return attach(null)
|
||||
}
|
||||
|
||||
export class OutlineRepository {
|
||||
constructor(private db: SqliteDb) {}
|
||||
|
||||
create(parentId: string | null, title: string, sortOrder: number): OutlineItem {
|
||||
const id = randomUUID()
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO outline_items (id, parent_id, title, sort_order)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(id, parentId, title, sortOrder)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
get(id: string): OutlineItem | null {
|
||||
const row = this.db.prepare('SELECT * FROM outline_items WHERE id = ?').get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
return row ? mapRow(row) : null
|
||||
}
|
||||
|
||||
listFlat(): OutlineItem[] {
|
||||
return (
|
||||
this.db.prepare('SELECT * FROM outline_items ORDER BY sort_order').all() as Record<string, unknown>[]
|
||||
).map(mapRow)
|
||||
}
|
||||
|
||||
listTree(): OutlineItem[] {
|
||||
return buildTree(this.listFlat())
|
||||
}
|
||||
|
||||
update(
|
||||
id: string,
|
||||
patch: Partial<{
|
||||
title: string
|
||||
description: string
|
||||
status: OutlineStatus
|
||||
expectedWordCount: number | null
|
||||
chapterId: string | null
|
||||
sortOrder: number
|
||||
}>
|
||||
): OutlineItem {
|
||||
const existing = this.get(id)
|
||||
if (!existing) throw new Error('Outline item not found')
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE outline_items SET
|
||||
title = ?, description = ?, status = ?,
|
||||
expected_word_count = ?, chapter_id = ?, sort_order = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
patch.title ?? existing.title,
|
||||
patch.description ?? existing.description,
|
||||
patch.status ?? existing.status,
|
||||
patch.expectedWordCount !== undefined ? patch.expectedWordCount : existing.expectedWordCount,
|
||||
patch.chapterId !== undefined ? patch.chapterId : existing.chapterId,
|
||||
patch.sortOrder ?? existing.sortOrder,
|
||||
id
|
||||
)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare('DELETE FROM outline_items WHERE id = ?').run(id)
|
||||
}
|
||||
|
||||
move(id: string, parentId: string | null, sortOrder: number): OutlineItem {
|
||||
const existing = this.get(id)
|
||||
if (!existing) throw new Error('Outline item not found')
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE outline_items SET parent_id = ?, sort_order = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
)
|
||||
.run(parentId, sortOrder, id)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
listUncovered(): OutlineItem[] {
|
||||
return (
|
||||
this.db
|
||||
.prepare('SELECT * FROM outline_items WHERE chapter_id IS NULL ORDER BY sort_order')
|
||||
.all() as Record<string, unknown>[]
|
||||
).map(mapRow)
|
||||
}
|
||||
|
||||
nextSortOrder(parentId: string | null): number {
|
||||
const row = parentId
|
||||
? (this.db
|
||||
.prepare('SELECT MAX(sort_order) AS maxOrder FROM outline_items WHERE parent_id = ?')
|
||||
.get(parentId) as { maxOrder: number | null })
|
||||
: (this.db
|
||||
.prepare('SELECT MAX(sort_order) AS maxOrder FROM outline_items WHERE parent_id IS NULL')
|
||||
.get() as { maxOrder: number | null })
|
||||
return (row?.maxOrder ?? -1) + 1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user