kokoirc 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +227 -0
  2. package/docs/commands/alias.md +42 -0
  3. package/docs/commands/ban.md +26 -0
  4. package/docs/commands/close.md +25 -0
  5. package/docs/commands/connect.md +26 -0
  6. package/docs/commands/deop.md +24 -0
  7. package/docs/commands/devoice.md +24 -0
  8. package/docs/commands/disconnect.md +26 -0
  9. package/docs/commands/help.md +28 -0
  10. package/docs/commands/ignore.md +47 -0
  11. package/docs/commands/items.md +95 -0
  12. package/docs/commands/join.md +25 -0
  13. package/docs/commands/kb.md +26 -0
  14. package/docs/commands/kick.md +25 -0
  15. package/docs/commands/log.md +82 -0
  16. package/docs/commands/me.md +24 -0
  17. package/docs/commands/mode.md +29 -0
  18. package/docs/commands/msg.md +26 -0
  19. package/docs/commands/nick.md +24 -0
  20. package/docs/commands/notice.md +24 -0
  21. package/docs/commands/op.md +24 -0
  22. package/docs/commands/part.md +25 -0
  23. package/docs/commands/quit.md +24 -0
  24. package/docs/commands/reload.md +19 -0
  25. package/docs/commands/script.md +126 -0
  26. package/docs/commands/server.md +61 -0
  27. package/docs/commands/set.md +37 -0
  28. package/docs/commands/topic.md +24 -0
  29. package/docs/commands/unalias.md +22 -0
  30. package/docs/commands/unban.md +25 -0
  31. package/docs/commands/unignore.md +25 -0
  32. package/docs/commands/voice.md +25 -0
  33. package/docs/commands/whois.md +24 -0
  34. package/docs/commands/wii.md +23 -0
  35. package/package.json +38 -0
  36. package/src/app/App.tsx +205 -0
  37. package/src/core/commands/docs.ts +183 -0
  38. package/src/core/commands/execution.ts +114 -0
  39. package/src/core/commands/help-formatter.ts +185 -0
  40. package/src/core/commands/helpers.ts +168 -0
  41. package/src/core/commands/index.ts +7 -0
  42. package/src/core/commands/parser.ts +33 -0
  43. package/src/core/commands/registry.ts +1394 -0
  44. package/src/core/commands/types.ts +19 -0
  45. package/src/core/config/defaults.ts +66 -0
  46. package/src/core/config/loader.ts +209 -0
  47. package/src/core/constants.ts +20 -0
  48. package/src/core/init.ts +32 -0
  49. package/src/core/irc/antiflood.ts +244 -0
  50. package/src/core/irc/client.ts +145 -0
  51. package/src/core/irc/events.ts +1031 -0
  52. package/src/core/irc/formatting.ts +132 -0
  53. package/src/core/irc/ignore.ts +84 -0
  54. package/src/core/irc/index.ts +2 -0
  55. package/src/core/irc/netsplit.ts +292 -0
  56. package/src/core/scripts/api.ts +240 -0
  57. package/src/core/scripts/event-bus.ts +82 -0
  58. package/src/core/scripts/index.ts +26 -0
  59. package/src/core/scripts/manager.ts +154 -0
  60. package/src/core/scripts/types.ts +256 -0
  61. package/src/core/state/selectors.ts +39 -0
  62. package/src/core/state/sorting.ts +30 -0
  63. package/src/core/state/store.ts +242 -0
  64. package/src/core/storage/crypto.ts +78 -0
  65. package/src/core/storage/db.ts +107 -0
  66. package/src/core/storage/index.ts +80 -0
  67. package/src/core/storage/query.ts +204 -0
  68. package/src/core/storage/types.ts +37 -0
  69. package/src/core/storage/writer.ts +130 -0
  70. package/src/core/theme/index.ts +3 -0
  71. package/src/core/theme/loader.ts +45 -0
  72. package/src/core/theme/parser.ts +518 -0
  73. package/src/core/theme/renderer.tsx +25 -0
  74. package/src/index.tsx +17 -0
  75. package/src/types/config.ts +126 -0
  76. package/src/types/index.ts +107 -0
  77. package/src/types/irc-framework.d.ts +569 -0
  78. package/src/types/theme.ts +37 -0
  79. package/src/ui/ErrorBoundary.tsx +42 -0
  80. package/src/ui/chat/ChatView.tsx +39 -0
  81. package/src/ui/chat/MessageLine.tsx +92 -0
  82. package/src/ui/hooks/useStatusbarColors.ts +23 -0
  83. package/src/ui/input/CommandInput.tsx +273 -0
  84. package/src/ui/layout/AppLayout.tsx +126 -0
  85. package/src/ui/layout/TopicBar.tsx +46 -0
  86. package/src/ui/sidebar/BufferList.tsx +55 -0
  87. package/src/ui/sidebar/NickList.tsx +96 -0
  88. package/src/ui/splash/SplashScreen.tsx +100 -0
  89. package/src/ui/statusbar/StatusLine.tsx +205 -0
  90. package/themes/.gitkeep +0 -0
  91. package/themes/default.theme +57 -0
  92. package/tsconfig.json +19 -0
@@ -0,0 +1,107 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { LOG_DB_PATH } from "@/core/constants"
3
+ import type { LoggingConfig } from "./types"
4
+
5
+ let db: Database | null = null
6
+
7
+ const SCHEMA = `
8
+ CREATE TABLE IF NOT EXISTS messages (
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ msg_id TEXT,
11
+ network TEXT NOT NULL,
12
+ buffer TEXT NOT NULL,
13
+ timestamp INTEGER NOT NULL,
14
+ type TEXT NOT NULL,
15
+ nick TEXT,
16
+ text TEXT NOT NULL,
17
+ highlight INTEGER DEFAULT 0,
18
+ iv BLOB
19
+ );
20
+
21
+ CREATE INDEX IF NOT EXISTS idx_messages_lookup ON messages(network, buffer, timestamp);
22
+ CREATE INDEX IF NOT EXISTS idx_messages_time ON messages(timestamp);
23
+ CREATE INDEX IF NOT EXISTS idx_messages_msg_id ON messages(msg_id);
24
+
25
+ CREATE TABLE IF NOT EXISTS read_markers (
26
+ network TEXT NOT NULL,
27
+ buffer TEXT NOT NULL,
28
+ client TEXT NOT NULL,
29
+ last_read INTEGER NOT NULL,
30
+ PRIMARY KEY (network, buffer, client)
31
+ );
32
+ `
33
+
34
+ const MIGRATION_ADD_MSG_ID = `
35
+ ALTER TABLE messages ADD COLUMN msg_id TEXT;
36
+ CREATE INDEX IF NOT EXISTS idx_messages_msg_id ON messages(msg_id);
37
+ `
38
+
39
+ const MIGRATION_ADD_READ_MARKERS = `
40
+ CREATE TABLE IF NOT EXISTS read_markers (
41
+ network TEXT NOT NULL,
42
+ buffer TEXT NOT NULL,
43
+ client TEXT NOT NULL,
44
+ last_read INTEGER NOT NULL,
45
+ PRIMARY KEY (network, buffer, client)
46
+ );
47
+ `
48
+
49
+ const FTS_SCHEMA = `
50
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
51
+ nick, text, content=messages, content_rowid=id
52
+ );
53
+ `
54
+
55
+ export function openDatabase(config: LoggingConfig): Database {
56
+ if (db) return db
57
+
58
+ db = new Database(LOG_DB_PATH, { create: true })
59
+ db.run("PRAGMA journal_mode=WAL")
60
+ db.run("PRAGMA synchronous=NORMAL")
61
+ db.exec(SCHEMA)
62
+
63
+ // Migrate existing databases: add msg_id column if missing
64
+ const cols = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[]
65
+ if (!cols.some((c) => c.name === "msg_id")) {
66
+ db.exec(MIGRATION_ADD_MSG_ID)
67
+ }
68
+
69
+ // Migrate: add read_markers table if missing
70
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='read_markers'").all()
71
+ if (tables.length === 0) {
72
+ db.exec(MIGRATION_ADD_READ_MARKERS)
73
+ }
74
+
75
+ // FTS5 only in plain text mode (can't index encrypted text)
76
+ if (!config.encrypt) {
77
+ db.exec(FTS_SCHEMA)
78
+ }
79
+
80
+ return db
81
+ }
82
+
83
+ export function getDatabase(): Database | null {
84
+ return db
85
+ }
86
+
87
+ export function closeDatabase(): void {
88
+ if (db) {
89
+ db.close()
90
+ db = null
91
+ }
92
+ }
93
+
94
+ /** Purge messages older than retention_days. Cleans FTS5 index if it exists. */
95
+ export function purgeOldMessages(retentionDays: number, hasFts: boolean): number {
96
+ if (!db || retentionDays <= 0) return 0
97
+ const cutoff = Date.now() - retentionDays * 86400_000
98
+ // Clean FTS index before deleting rows (external content mode requires manual sync)
99
+ if (hasFts) {
100
+ db.run(
101
+ "DELETE FROM messages_fts WHERE rowid IN (SELECT id FROM messages WHERE timestamp < ?)",
102
+ [cutoff],
103
+ )
104
+ }
105
+ const result = db.run("DELETE FROM messages WHERE timestamp < ?", [cutoff])
106
+ return result.changes
107
+ }
@@ -0,0 +1,80 @@
1
+ import type { MessageType } from "@/types"
2
+ import type { LoggingConfig, LogRow, MessageListener } from "./types"
3
+ import { openDatabase, closeDatabase, purgeOldMessages } from "./db"
4
+ import { setQueryConfig } from "./query"
5
+ import { LogWriter } from "./writer"
6
+
7
+ export type { LoggingConfig, LogRow, StoredMessage, ReadMarker, MessageListener } from "./types"
8
+ export { getMessages, searchMessages, getBuffers, getStats } from "./query"
9
+ export { updateReadMarker, getReadMarker, getReadMarkers, getUnreadCount } from "./query"
10
+
11
+ let writer: LogWriter | null = null
12
+ let loggingConfig: LoggingConfig | null = null
13
+
14
+ /** Initialize the storage system. Call after config is loaded, before connections. */
15
+ export async function initStorage(config: LoggingConfig): Promise<void> {
16
+ if (!config.enabled) return
17
+
18
+ loggingConfig = config
19
+ const db = openDatabase(config)
20
+ setQueryConfig(config)
21
+
22
+ // Purge old messages on startup
23
+ if (config.retention_days > 0) {
24
+ const purged = purgeOldMessages(config.retention_days, !config.encrypt)
25
+ if (purged > 0) {
26
+ console.log(`[storage] purged ${purged} messages older than ${config.retention_days} days`)
27
+ }
28
+ }
29
+
30
+ writer = new LogWriter(db, config)
31
+ await writer.init()
32
+ }
33
+
34
+ /** Log a message. Called from store.addMessage(). */
35
+ export function logMessage(
36
+ network: string,
37
+ buffer: string,
38
+ msgId: string,
39
+ type: MessageType,
40
+ text: string,
41
+ nick: string | null,
42
+ highlight: boolean,
43
+ timestamp: Date,
44
+ ): void {
45
+ if (!writer) return
46
+
47
+ const row: LogRow = {
48
+ msg_id: msgId,
49
+ network,
50
+ buffer,
51
+ timestamp: timestamp.getTime(),
52
+ type,
53
+ nick,
54
+ text,
55
+ highlight: highlight ? 1 : 0,
56
+ }
57
+
58
+ writer.enqueue(row)
59
+ }
60
+
61
+ /** Subscribe to real-time message events (for WebSocket push). */
62
+ export function onMessage(listener: MessageListener): () => void {
63
+ if (!writer) return () => {}
64
+ return writer.onMessage(listener)
65
+ }
66
+
67
+ /** Flush pending writes and close the database. Call on shutdown. */
68
+ export async function shutdownStorage(): Promise<void> {
69
+ if (writer) {
70
+ await writer.shutdown()
71
+ writer = null
72
+ }
73
+ closeDatabase()
74
+ loggingConfig = null
75
+ }
76
+
77
+ /** Check if storage is active. */
78
+ export function isStorageActive(): boolean {
79
+ return writer !== null
80
+ }
@@ -0,0 +1,204 @@
1
+ import { getDatabase } from "./db"
2
+ import { LOG_DB_PATH } from "@/core/constants"
3
+ import { decrypt, loadOrCreateKey } from "./crypto"
4
+ import type { StoredMessage, LoggingConfig, ReadMarker } from "./types"
5
+
6
+ let config: LoggingConfig | null = null
7
+
8
+ export function setQueryConfig(cfg: LoggingConfig): void {
9
+ config = cfg
10
+ }
11
+
12
+ interface RawRow {
13
+ id: number
14
+ msg_id: string | null
15
+ network: string
16
+ buffer: string
17
+ timestamp: number
18
+ type: string
19
+ nick: string | null
20
+ text: string
21
+ highlight: number
22
+ iv: Uint8Array | null
23
+ }
24
+
25
+ async function decryptRow(row: RawRow): Promise<StoredMessage> {
26
+ let text = row.text
27
+ if (config?.encrypt && row.iv) {
28
+ const key = await loadOrCreateKey()
29
+ text = await decrypt(row.text, row.iv, key)
30
+ }
31
+ return {
32
+ id: row.id,
33
+ msg_id: row.msg_id ?? "",
34
+ network: row.network,
35
+ buffer: row.buffer,
36
+ timestamp: row.timestamp,
37
+ type: row.type as StoredMessage["type"],
38
+ nick: row.nick,
39
+ text,
40
+ highlight: row.highlight === 1,
41
+ }
42
+ }
43
+
44
+ /** Get messages for a buffer, paginated by timestamp (cursor-based). */
45
+ export async function getMessages(
46
+ network: string,
47
+ buffer: string,
48
+ before?: number,
49
+ limit: number = 100,
50
+ ): Promise<StoredMessage[]> {
51
+ const db = getDatabase()
52
+ if (!db) return []
53
+
54
+ let rows: RawRow[]
55
+ if (before) {
56
+ rows = db.prepare(
57
+ "SELECT * FROM messages WHERE network = ? AND buffer = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT ?"
58
+ ).all(network, buffer, before, limit) as RawRow[]
59
+ } else {
60
+ rows = db.prepare(
61
+ "SELECT * FROM messages WHERE network = ? AND buffer = ? ORDER BY timestamp DESC LIMIT ?"
62
+ ).all(network, buffer, limit) as RawRow[]
63
+ }
64
+
65
+ // Reverse to chronological order
66
+ rows.reverse()
67
+
68
+ const messages: StoredMessage[] = []
69
+ for (const row of rows) {
70
+ messages.push(await decryptRow(row))
71
+ }
72
+ return messages
73
+ }
74
+
75
+ /** Full-text search (plain mode only). */
76
+ export async function searchMessages(
77
+ query: string,
78
+ network?: string,
79
+ buffer?: string,
80
+ limit: number = 50,
81
+ ): Promise<StoredMessage[]> {
82
+ const db = getDatabase()
83
+ if (!db || config?.encrypt) return []
84
+
85
+ let sql = `
86
+ SELECT m.* FROM messages m
87
+ JOIN messages_fts fts ON m.id = fts.rowid
88
+ WHERE messages_fts MATCH ?
89
+ `
90
+ // Wrap in double quotes for literal phrase match (escape internal quotes)
91
+ const safeQuery = `"${query.replace(/"/g, '""')}"`
92
+ const params: any[] = [safeQuery]
93
+
94
+ if (network) {
95
+ sql += " AND m.network = ?"
96
+ params.push(network)
97
+ }
98
+ if (buffer) {
99
+ sql += " AND m.buffer = ?"
100
+ params.push(buffer)
101
+ }
102
+
103
+ sql += " ORDER BY m.timestamp DESC LIMIT ?"
104
+ params.push(limit)
105
+
106
+ const rows = db.prepare(sql).all(...params) as RawRow[]
107
+ rows.reverse()
108
+
109
+ return rows.map((row) => ({
110
+ id: row.id,
111
+ msg_id: row.msg_id ?? "",
112
+ network: row.network,
113
+ buffer: row.buffer,
114
+ timestamp: row.timestamp,
115
+ type: row.type as StoredMessage["type"],
116
+ nick: row.nick,
117
+ text: row.text,
118
+ highlight: row.highlight === 1,
119
+ }))
120
+ }
121
+
122
+ /** List all buffers that have logged messages for a network. */
123
+ export function getBuffers(network: string): string[] {
124
+ const db = getDatabase()
125
+ if (!db) return []
126
+
127
+ const rows = db.prepare(
128
+ "SELECT DISTINCT buffer FROM messages WHERE network = ? ORDER BY buffer"
129
+ ).all(network) as { buffer: string }[]
130
+
131
+ return rows.map((r) => r.buffer)
132
+ }
133
+
134
+ /** Get total message count and database file size. */
135
+ export function getStats(): { messageCount: number; dbSizeBytes: number } | null {
136
+ const db = getDatabase()
137
+ if (!db) return null
138
+
139
+ const row = db.prepare("SELECT COUNT(*) as count FROM messages").get() as { count: number }
140
+ const file = Bun.file(LOG_DB_PATH)
141
+
142
+ return {
143
+ messageCount: row.count,
144
+ dbSizeBytes: file.size,
145
+ }
146
+ }
147
+
148
+ // ─── Read Markers ──────────────────────────────────────────────
149
+
150
+ /** Update (upsert) a read marker for a client viewing a buffer. */
151
+ export function updateReadMarker(network: string, buffer: string, client: string, timestamp: number): void {
152
+ const db = getDatabase()
153
+ if (!db) return
154
+
155
+ db.run(
156
+ `INSERT INTO read_markers (network, buffer, client, last_read)
157
+ VALUES (?, ?, ?, ?)
158
+ ON CONFLICT (network, buffer, client)
159
+ DO UPDATE SET last_read = excluded.last_read`,
160
+ [network, buffer, client, timestamp],
161
+ )
162
+ }
163
+
164
+ /** Get the read marker for a specific client on a buffer. */
165
+ export function getReadMarker(network: string, buffer: string, client: string): number | null {
166
+ const db = getDatabase()
167
+ if (!db) return null
168
+
169
+ const row = db.prepare(
170
+ "SELECT last_read FROM read_markers WHERE network = ? AND buffer = ? AND client = ?"
171
+ ).get(network, buffer, client) as { last_read: number } | null
172
+
173
+ return row?.last_read ?? null
174
+ }
175
+
176
+ /** Get all read markers for a buffer (all clients). */
177
+ export function getReadMarkers(network: string, buffer: string): ReadMarker[] {
178
+ const db = getDatabase()
179
+ if (!db) return []
180
+
181
+ return db.prepare(
182
+ "SELECT * FROM read_markers WHERE network = ? AND buffer = ?"
183
+ ).all(network, buffer) as ReadMarker[]
184
+ }
185
+
186
+ /** Count unread messages for a client on a buffer (messages after their last_read). */
187
+ export function getUnreadCount(network: string, buffer: string, client: string): number {
188
+ const db = getDatabase()
189
+ if (!db) return 0
190
+
191
+ const marker = getReadMarker(network, buffer, client)
192
+ if (marker === null) {
193
+ // Never read — all messages are unread
194
+ const row = db.prepare(
195
+ "SELECT COUNT(*) as count FROM messages WHERE network = ? AND buffer = ?"
196
+ ).get(network, buffer) as { count: number }
197
+ return row.count
198
+ }
199
+
200
+ const row = db.prepare(
201
+ "SELECT COUNT(*) as count FROM messages WHERE network = ? AND buffer = ? AND timestamp > ?"
202
+ ).get(network, buffer, marker) as { count: number }
203
+ return row.count
204
+ }
@@ -0,0 +1,37 @@
1
+ import type { MessageType } from "@/types"
2
+
3
+ // Re-export from canonical config location
4
+ export type { LoggingConfig } from "@/types/config"
5
+
6
+ export interface LogRow {
7
+ msg_id: string // UUID from Message.id — shared identity across TUI/web
8
+ network: string
9
+ buffer: string
10
+ timestamp: number // Unix ms
11
+ type: MessageType
12
+ nick: string | null
13
+ text: string
14
+ highlight: number // 0 or 1
15
+ }
16
+
17
+ export interface StoredMessage {
18
+ id: number
19
+ msg_id: string
20
+ network: string
21
+ buffer: string
22
+ timestamp: number
23
+ type: MessageType
24
+ nick: string | null
25
+ text: string
26
+ highlight: boolean
27
+ }
28
+
29
+ export interface ReadMarker {
30
+ network: string
31
+ buffer: string
32
+ client: string // 'tui' or web session id
33
+ last_read: number // Unix ms timestamp
34
+ }
35
+
36
+ /** Callback for real-time message events (used by WebSocket server). */
37
+ export type MessageListener = (row: LogRow) => void
@@ -0,0 +1,130 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import type { LogRow, LoggingConfig, MessageListener } from "./types"
3
+ import { encrypt, loadOrCreateKey } from "./crypto"
4
+
5
+ const BATCH_SIZE = 50
6
+ const FLUSH_INTERVAL_MS = 1000
7
+
8
+ export class LogWriter {
9
+ private queue: LogRow[] = []
10
+ private timer: ReturnType<typeof setTimeout> | null = null
11
+ private flushing = false
12
+ private db: Database
13
+ private config: LoggingConfig
14
+ private cryptoKey: CryptoKey | null = null
15
+ private hasFts: boolean
16
+ private listeners: MessageListener[] = []
17
+
18
+ constructor(db: Database, config: LoggingConfig) {
19
+ this.db = db
20
+ this.config = config
21
+ this.hasFts = !config.encrypt
22
+ }
23
+
24
+ async init(): Promise<void> {
25
+ if (this.config.encrypt) {
26
+ this.cryptoKey = await loadOrCreateKey()
27
+ }
28
+ }
29
+
30
+ /** Subscribe to new messages (for WebSocket real-time push). */
31
+ onMessage(listener: MessageListener): () => void {
32
+ this.listeners.push(listener)
33
+ return () => {
34
+ this.listeners = this.listeners.filter((l) => l !== listener)
35
+ }
36
+ }
37
+
38
+ enqueue(row: LogRow): void {
39
+ // Filter excluded message types
40
+ if (this.config.exclude_types.includes(row.type)) return
41
+
42
+ // Notify listeners immediately (before batching delay)
43
+ for (const listener of this.listeners) {
44
+ try { listener(row) } catch {}
45
+ }
46
+
47
+ this.queue.push(row)
48
+
49
+ // Start timer on first buffered message
50
+ if (this.queue.length === 1) {
51
+ this.timer = setTimeout(() => {
52
+ this.flush().catch((err) => console.error("[storage] flush error:", err))
53
+ }, FLUSH_INTERVAL_MS)
54
+ }
55
+
56
+ // Flush immediately at batch size
57
+ if (this.queue.length >= BATCH_SIZE) {
58
+ this.flush().catch((err) => console.error("[storage] flush error:", err))
59
+ }
60
+ }
61
+
62
+ async flush(): Promise<void> {
63
+ if (this.timer) {
64
+ clearTimeout(this.timer)
65
+ this.timer = null
66
+ }
67
+
68
+ if (this.queue.length === 0 || this.flushing) return
69
+
70
+ this.flushing = true
71
+ const batch = this.queue.splice(0)
72
+
73
+ const insert = this.db.prepare(
74
+ "INSERT INTO messages (msg_id, network, buffer, timestamp, type, nick, text, highlight, iv) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
75
+ )
76
+
77
+ // Prepare FTS insert if available
78
+ const insertFts = this.hasFts
79
+ ? this.db.prepare(
80
+ "INSERT INTO messages_fts (rowid, nick, text) VALUES (?, ?, ?)"
81
+ )
82
+ : null
83
+
84
+ try {
85
+ // bun:sqlite transactions are sync, but encrypt is async — handle both modes
86
+ if (this.cryptoKey) {
87
+ // Encrypted mode: can't use bun:sqlite transaction wrapper (it's sync)
88
+ this.db.run("BEGIN")
89
+ try {
90
+ for (const row of batch) {
91
+ const encrypted = await encrypt(row.text, this.cryptoKey)
92
+ insert.run(
93
+ row.msg_id, row.network, row.buffer, row.timestamp, row.type,
94
+ row.nick, encrypted.ciphertext, row.highlight, encrypted.iv,
95
+ )
96
+ }
97
+ this.db.run("COMMIT")
98
+ } catch (err) {
99
+ this.db.run("ROLLBACK")
100
+ throw err
101
+ }
102
+ } else {
103
+ // Plain mode: synchronous transaction with FTS5
104
+ const syncTransaction = this.db.transaction((rows: LogRow[]) => {
105
+ for (const row of rows) {
106
+ const result = insert.run(
107
+ row.msg_id, row.network, row.buffer, row.timestamp, row.type,
108
+ row.nick, row.text, row.highlight, null,
109
+ )
110
+ if (insertFts && result.lastInsertRowid) {
111
+ insertFts.run(result.lastInsertRowid, row.nick ?? "", row.text)
112
+ }
113
+ }
114
+ })
115
+ syncTransaction(batch)
116
+ }
117
+ } finally {
118
+ this.flushing = false
119
+ }
120
+
121
+ // If more messages accumulated during flush, flush again
122
+ if (this.queue.length > 0) {
123
+ await this.flush()
124
+ }
125
+ }
126
+
127
+ async shutdown(): Promise<void> {
128
+ await this.flush()
129
+ }
130
+ }
@@ -0,0 +1,3 @@
1
+ export { parseFormatString, resolveAbstractions } from "./parser"
2
+ export { loadTheme } from "./loader"
3
+ export { StyledText } from "./renderer"
@@ -0,0 +1,45 @@
1
+ import { parse as parseTOML } from "smol-toml"
2
+ import type { ThemeFile, ThemeColors } from "@/types/theme"
3
+
4
+ const DEFAULT_COLORS: ThemeColors = {
5
+ bg: "#1a1b26",
6
+ bg_alt: "#16161e",
7
+ border: "#292e42",
8
+ fg: "#a9b1d6",
9
+ fg_muted: "#565f89",
10
+ fg_dim: "#292e42",
11
+ accent: "#7aa2f7",
12
+ cursor: "#7aa2f7",
13
+ }
14
+
15
+ const DEFAULT_THEME: ThemeFile = {
16
+ meta: { name: "Fallback", description: "Minimal fallback theme" },
17
+ colors: DEFAULT_COLORS,
18
+ abstracts: {
19
+ timestamp: "$*",
20
+ msgnick: "$0$1> ",
21
+ ownnick: "$*",
22
+ pubnick: "$*",
23
+ },
24
+ formats: {
25
+ messages: { pubmsg: "$0 $1", own_msg: "$0 $1" },
26
+ events: {},
27
+ sidepanel: { header: "$0", item: "$0. $1", item_selected: "> $0. $1" },
28
+ nicklist: { normal: " $0" },
29
+ },
30
+ }
31
+
32
+ export async function loadTheme(path: string): Promise<ThemeFile> {
33
+ const file = Bun.file(path)
34
+ if (!(await file.exists())) {
35
+ return DEFAULT_THEME
36
+ }
37
+ const text = await file.text()
38
+ const parsed = parseTOML(text) as unknown as Partial<ThemeFile>
39
+ return {
40
+ meta: parsed.meta ?? DEFAULT_THEME.meta,
41
+ colors: { ...DEFAULT_COLORS, ...parsed.colors },
42
+ abstracts: parsed.abstracts ?? DEFAULT_THEME.abstracts,
43
+ formats: parsed.formats ?? DEFAULT_THEME.formats,
44
+ }
45
+ }