aac51bf183
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>
138 lines
4.2 KiB
TypeScript
138 lines
4.2 KiB
TypeScript
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
|
|
}
|
|
}
|