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:
+30
-7
@@ -1,7 +1,25 @@
|
||||
import type { SqliteDb } from './types'
|
||||
import schemaV1 from './schema-v1.sql?raw'
|
||||
import schemaV2 from './schema-v2.sql?raw'
|
||||
|
||||
const CURRENT_VERSION = 1
|
||||
const CURRENT_VERSION = 2
|
||||
|
||||
function tryAlter(db: SqliteDb, sql: string): void {
|
||||
try {
|
||||
db.exec(sql)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (!msg.includes('duplicate column')) throw err
|
||||
}
|
||||
}
|
||||
|
||||
function applyV2(db: SqliteDb): void {
|
||||
tryAlter(db, "ALTER TABLE chapters ADD COLUMN publish_status TEXT NOT NULL DEFAULT 'draft'")
|
||||
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN pov_character_id TEXT DEFAULT NULL')
|
||||
tryAlter(db, "ALTER TABLE chapters ADD COLUMN summary TEXT DEFAULT ''")
|
||||
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN story_time TEXT DEFAULT NULL')
|
||||
db.exec(schemaV2)
|
||||
}
|
||||
|
||||
export function migrate(db: SqliteDb): void {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
|
||||
@@ -11,12 +29,17 @@ export function migrate(db: SqliteDb): void {
|
||||
)`)
|
||||
|
||||
const row = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number | null }
|
||||
const current = row?.v ?? 0
|
||||
let 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'
|
||||
)
|
||||
if (current < 1) {
|
||||
db.exec(schemaV1)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(1, 'initial schema')
|
||||
current = 1
|
||||
}
|
||||
|
||||
if (current < 2) {
|
||||
applyV2(db)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(2, 'P2 schema')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { Bookmark, LandmarkType } from '../../../shared/types'
|
||||
import type { SqliteDb } from '../types'
|
||||
|
||||
function mapRow(row: Record<string, unknown>): Bookmark {
|
||||
return {
|
||||
id: row.id as string,
|
||||
chapterId: row.chapter_id as string,
|
||||
position: row.position as number,
|
||||
label: row.label as string,
|
||||
landmarkType: row.landmark_type as LandmarkType,
|
||||
resolved: Boolean(row.resolved),
|
||||
createdAt: row.created_at as string
|
||||
}
|
||||
}
|
||||
|
||||
export class BookmarkRepository {
|
||||
constructor(private db: SqliteDb) {}
|
||||
|
||||
create(
|
||||
chapterId: string,
|
||||
position: number,
|
||||
label: string,
|
||||
landmarkType: LandmarkType = 'todo'
|
||||
): Bookmark {
|
||||
const id = randomUUID()
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO bookmarks (id, chapter_id, position, label, landmark_type)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(id, chapterId, position, label, landmarkType)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
get(id: string): Bookmark | null {
|
||||
const row = this.db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
return row ? mapRow(row) : null
|
||||
}
|
||||
|
||||
list(chapterId?: string, resolved?: boolean): Bookmark[] {
|
||||
let sql = 'SELECT * FROM bookmarks WHERE 1=1'
|
||||
const params: unknown[] = []
|
||||
if (chapterId !== undefined) {
|
||||
sql += ' AND chapter_id = ?'
|
||||
params.push(chapterId)
|
||||
}
|
||||
if (resolved !== undefined) {
|
||||
sql += ' AND resolved = ?'
|
||||
params.push(resolved ? 1 : 0)
|
||||
}
|
||||
sql += ' ORDER BY created_at DESC'
|
||||
return (this.db.prepare(sql).all(...params) as Record<string, unknown>[]).map(mapRow)
|
||||
}
|
||||
|
||||
listByChapter(chapterId: string): Bookmark[] {
|
||||
return this.list(chapterId)
|
||||
}
|
||||
|
||||
update(
|
||||
id: string,
|
||||
patch: Partial<{ label: string; position: number; landmarkType: LandmarkType }>
|
||||
): Bookmark {
|
||||
const existing = this.get(id)
|
||||
if (!existing) throw new Error('Bookmark not found')
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE bookmarks SET label = ?, position = ?, landmark_type = ? WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
patch.label ?? existing.label,
|
||||
patch.position ?? existing.position,
|
||||
patch.landmarkType ?? existing.landmarkType,
|
||||
id
|
||||
)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
resolve(id: string, resolved: boolean): Bookmark {
|
||||
const existing = this.get(id)
|
||||
if (!existing) throw new Error('Bookmark not found')
|
||||
this.db.prepare('UPDATE bookmarks SET resolved = ? WHERE id = ?').run(resolved ? 1 : 0, id)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { InspirationEntry, SettingType } from '../../../shared/types'
|
||||
import type { SqliteDb } from '../types'
|
||||
import { stripHtml } from '../../services/word-count'
|
||||
import type { OutlineRepository } from './outline.repo'
|
||||
import type { SettingRepository } from './setting.repo'
|
||||
|
||||
function mapRow(row: Record<string, unknown>): InspirationEntry {
|
||||
let tags: string[] = []
|
||||
try {
|
||||
tags = JSON.parse((row.tags as string) || '[]') as string[]
|
||||
} catch {
|
||||
tags = []
|
||||
}
|
||||
return {
|
||||
id: row.id as string,
|
||||
title: row.title as string,
|
||||
content: row.content as string,
|
||||
tags,
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string
|
||||
}
|
||||
}
|
||||
|
||||
export class InspirationRepository {
|
||||
constructor(private db: SqliteDb) {}
|
||||
|
||||
create(title = '', content = '', tags: string[] = []): InspirationEntry {
|
||||
const id = randomUUID()
|
||||
this.db
|
||||
.prepare(`INSERT INTO inspiration (id, title, content, tags) VALUES (?, ?, ?, ?)`)
|
||||
.run(id, title, content, JSON.stringify(tags))
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
get(id: string): InspirationEntry | null {
|
||||
const row = this.db.prepare('SELECT * FROM inspiration WHERE id = ?').get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
return row ? mapRow(row) : null
|
||||
}
|
||||
|
||||
list(): InspirationEntry[] {
|
||||
return (
|
||||
this.db.prepare('SELECT * FROM inspiration ORDER BY updated_at DESC').all() as Record<string, unknown>[]
|
||||
).map(mapRow)
|
||||
}
|
||||
|
||||
update(
|
||||
id: string,
|
||||
patch: Partial<{ title: string; content: string; tags: string[] }>
|
||||
): InspirationEntry {
|
||||
const existing = this.get(id)
|
||||
if (!existing) throw new Error('Inspiration not found')
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE inspiration SET title = ?, content = ?, tags = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
patch.title ?? existing.title,
|
||||
patch.content ?? existing.content,
|
||||
JSON.stringify(patch.tags ?? existing.tags),
|
||||
id
|
||||
)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare('DELETE FROM inspiration WHERE id = ?').run(id)
|
||||
}
|
||||
|
||||
convertToOutline(id: string, outlineRepo: OutlineRepository) {
|
||||
const item = this.get(id)
|
||||
if (!item) throw new Error('Inspiration not found')
|
||||
|
||||
const outline = outlineRepo.create(null, item.title || '未命名灵感', outlineRepo.nextSortOrder(null))
|
||||
outlineRepo.update(outline.id, { description: stripHtml(item.content) })
|
||||
this.delete(id)
|
||||
return outlineRepo.get(outline.id)!
|
||||
}
|
||||
|
||||
convertToSetting(id: string, type: SettingType, settingRepo: SettingRepository) {
|
||||
const item = this.get(id)
|
||||
if (!item) throw new Error('Inspiration not found')
|
||||
|
||||
const setting = settingRepo.create(type, item.title || '未命名设定', stripHtml(item.content))
|
||||
this.delete(id)
|
||||
return setting
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { SettingEntry, SettingType } from '../../../shared/types'
|
||||
import type { SqliteDb } from '../types'
|
||||
|
||||
function mapRow(row: Record<string, unknown>, chapterIds: string[]): SettingEntry {
|
||||
let properties: Record<string, unknown> = {}
|
||||
try {
|
||||
properties = JSON.parse((row.properties as string) || '{}') as Record<string, unknown>
|
||||
} catch {
|
||||
properties = {}
|
||||
}
|
||||
return {
|
||||
id: row.id as string,
|
||||
type: row.type as SettingType,
|
||||
name: row.name as string,
|
||||
description: (row.description as string) ?? '',
|
||||
properties,
|
||||
chapterIds,
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string
|
||||
}
|
||||
}
|
||||
|
||||
export class SettingRepository {
|
||||
constructor(private db: SqliteDb) {}
|
||||
|
||||
private loadChapterIds(settingId: string): string[] {
|
||||
return (
|
||||
this.db
|
||||
.prepare('SELECT chapter_id FROM setting_chapter_refs WHERE setting_id = ?')
|
||||
.all(settingId) as { chapter_id: string }[]
|
||||
).map((r) => r.chapter_id)
|
||||
}
|
||||
|
||||
create(type: SettingType, name: string, description = '', properties: Record<string, unknown> = {}): SettingEntry {
|
||||
const id = randomUUID()
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO settings (id, type, name, description, properties)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(id, type, name, description, JSON.stringify(properties))
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
get(id: string): SettingEntry | null {
|
||||
const row = this.db.prepare('SELECT * FROM settings WHERE id = ?').get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
return row ? mapRow(row, this.loadChapterIds(id)) : null
|
||||
}
|
||||
|
||||
list(type?: SettingType): SettingEntry[] {
|
||||
const rows = type
|
||||
? (this.db.prepare('SELECT * FROM settings WHERE type = ? ORDER BY name').all(type) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[])
|
||||
: (this.db.prepare('SELECT * FROM settings ORDER BY type, name').all() as Record<string, unknown>[])
|
||||
return rows.map((row) => mapRow(row, this.loadChapterIds(row.id as string)))
|
||||
}
|
||||
|
||||
update(
|
||||
id: string,
|
||||
patch: Partial<{ name: string; description: string; type: SettingType; properties: Record<string, unknown> }>
|
||||
): SettingEntry {
|
||||
const existing = this.get(id)
|
||||
if (!existing) throw new Error('Setting not found')
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE settings SET
|
||||
type = ?, name = ?, description = ?, properties = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
patch.type ?? existing.type,
|
||||
patch.name ?? existing.name,
|
||||
patch.description ?? existing.description,
|
||||
JSON.stringify(patch.properties ?? existing.properties),
|
||||
id
|
||||
)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare('DELETE FROM settings WHERE id = ?').run(id)
|
||||
}
|
||||
|
||||
syncChapterRefs(settingId: string, chapterIds: string[]): SettingEntry {
|
||||
if (!this.get(settingId)) throw new Error('Setting not found')
|
||||
|
||||
this.db.prepare('DELETE FROM setting_chapter_refs WHERE setting_id = ?').run(settingId)
|
||||
const insert = this.db.prepare(
|
||||
'INSERT INTO setting_chapter_refs (setting_id, chapter_id) VALUES (?, ?)'
|
||||
)
|
||||
for (const chapterId of chapterIds) {
|
||||
insert.run(settingId, chapterId)
|
||||
}
|
||||
return this.get(settingId)!
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { Snapshot, SnapshotType } from '../../../shared/types'
|
||||
import type { SqliteDb } from '../types'
|
||||
|
||||
function mapRow(row: Record<string, unknown>): Snapshot {
|
||||
return {
|
||||
id: row.id as string,
|
||||
chapterId: row.chapter_id as string,
|
||||
type: row.type as SnapshotType,
|
||||
name: (row.name as string) ?? '',
|
||||
content: row.content as string,
|
||||
createdAt: row.created_at as string
|
||||
}
|
||||
}
|
||||
|
||||
export class SnapshotRepository {
|
||||
constructor(private db: SqliteDb) {}
|
||||
|
||||
create(chapterId: string, type: SnapshotType, content: string, name = ''): Snapshot {
|
||||
const id = randomUUID()
|
||||
this.db
|
||||
.prepare(`INSERT INTO snapshots (id, chapter_id, type, name, content) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(id, chapterId, type, name, content)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
get(id: string): Snapshot | null {
|
||||
const row = this.db.prepare('SELECT * FROM snapshots WHERE id = ?').get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
return row ? mapRow(row) : null
|
||||
}
|
||||
|
||||
list(chapterId: string): Snapshot[] {
|
||||
return (
|
||||
this.db
|
||||
.prepare('SELECT * FROM snapshots WHERE chapter_id = ? ORDER BY created_at DESC')
|
||||
.all(chapterId) as Record<string, unknown>[]
|
||||
).map(mapRow)
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare('DELETE FROM snapshots WHERE id = ?').run(id)
|
||||
}
|
||||
|
||||
pruneAuto(chapterId: string, max: number): void {
|
||||
const autos = (
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT id FROM snapshots WHERE chapter_id = ? AND type = 'auto' ORDER BY created_at ASC`
|
||||
)
|
||||
.all(chapterId) as { id: string }[]
|
||||
)
|
||||
const excess = autos.length - max
|
||||
if (excess <= 0) return
|
||||
for (let i = 0; i < excess; i++) {
|
||||
this.delete(autos[i].id)
|
||||
}
|
||||
}
|
||||
|
||||
prunePersist(chapterId: string, max: number): void {
|
||||
const items = (
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT id FROM snapshots WHERE chapter_id = ? AND type = 'persist' ORDER BY created_at ASC`
|
||||
)
|
||||
.all(chapterId) as { id: string }[]
|
||||
)
|
||||
const excess = items.length - max
|
||||
if (excess <= 0) return
|
||||
for (let i = 0; i < excess; i++) {
|
||||
this.delete(items[i].id)
|
||||
}
|
||||
}
|
||||
|
||||
restore(snapshotId: string): string {
|
||||
const snap = this.get(snapshotId)
|
||||
if (!snap) throw new Error('Snapshot not found')
|
||||
return snap.content
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
CREATE TABLE IF NOT EXISTS outline_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
expected_word_count INTEGER DEFAULT NULL,
|
||||
chapter_id TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (parent_id) REFERENCES outline_items(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
properties TEXT DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS setting_chapter_refs (
|
||||
setting_id TEXT NOT NULL,
|
||||
chapter_id TEXT NOT NULL,
|
||||
PRIMARY KEY (setting_id, chapter_id),
|
||||
FOREIGN KEY (setting_id) REFERENCES settings(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inspiration (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
chapter_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT DEFAULT '',
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id TEXT PRIMARY KEY,
|
||||
chapter_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
landmark_type TEXT NOT NULL DEFAULT 'todo',
|
||||
resolved INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
|
||||
source_kind,
|
||||
source_id,
|
||||
title,
|
||||
body,
|
||||
tokenize='unicode61'
|
||||
);
|
||||
Reference in New Issue
Block a user