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.
- package/README.md +227 -0
- package/docs/commands/alias.md +42 -0
- package/docs/commands/ban.md +26 -0
- package/docs/commands/close.md +25 -0
- package/docs/commands/connect.md +26 -0
- package/docs/commands/deop.md +24 -0
- package/docs/commands/devoice.md +24 -0
- package/docs/commands/disconnect.md +26 -0
- package/docs/commands/help.md +28 -0
- package/docs/commands/ignore.md +47 -0
- package/docs/commands/items.md +95 -0
- package/docs/commands/join.md +25 -0
- package/docs/commands/kb.md +26 -0
- package/docs/commands/kick.md +25 -0
- package/docs/commands/log.md +82 -0
- package/docs/commands/me.md +24 -0
- package/docs/commands/mode.md +29 -0
- package/docs/commands/msg.md +26 -0
- package/docs/commands/nick.md +24 -0
- package/docs/commands/notice.md +24 -0
- package/docs/commands/op.md +24 -0
- package/docs/commands/part.md +25 -0
- package/docs/commands/quit.md +24 -0
- package/docs/commands/reload.md +19 -0
- package/docs/commands/script.md +126 -0
- package/docs/commands/server.md +61 -0
- package/docs/commands/set.md +37 -0
- package/docs/commands/topic.md +24 -0
- package/docs/commands/unalias.md +22 -0
- package/docs/commands/unban.md +25 -0
- package/docs/commands/unignore.md +25 -0
- package/docs/commands/voice.md +25 -0
- package/docs/commands/whois.md +24 -0
- package/docs/commands/wii.md +23 -0
- package/package.json +38 -0
- package/src/app/App.tsx +205 -0
- package/src/core/commands/docs.ts +183 -0
- package/src/core/commands/execution.ts +114 -0
- package/src/core/commands/help-formatter.ts +185 -0
- package/src/core/commands/helpers.ts +168 -0
- package/src/core/commands/index.ts +7 -0
- package/src/core/commands/parser.ts +33 -0
- package/src/core/commands/registry.ts +1394 -0
- package/src/core/commands/types.ts +19 -0
- package/src/core/config/defaults.ts +66 -0
- package/src/core/config/loader.ts +209 -0
- package/src/core/constants.ts +20 -0
- package/src/core/init.ts +32 -0
- package/src/core/irc/antiflood.ts +244 -0
- package/src/core/irc/client.ts +145 -0
- package/src/core/irc/events.ts +1031 -0
- package/src/core/irc/formatting.ts +132 -0
- package/src/core/irc/ignore.ts +84 -0
- package/src/core/irc/index.ts +2 -0
- package/src/core/irc/netsplit.ts +292 -0
- package/src/core/scripts/api.ts +240 -0
- package/src/core/scripts/event-bus.ts +82 -0
- package/src/core/scripts/index.ts +26 -0
- package/src/core/scripts/manager.ts +154 -0
- package/src/core/scripts/types.ts +256 -0
- package/src/core/state/selectors.ts +39 -0
- package/src/core/state/sorting.ts +30 -0
- package/src/core/state/store.ts +242 -0
- package/src/core/storage/crypto.ts +78 -0
- package/src/core/storage/db.ts +107 -0
- package/src/core/storage/index.ts +80 -0
- package/src/core/storage/query.ts +204 -0
- package/src/core/storage/types.ts +37 -0
- package/src/core/storage/writer.ts +130 -0
- package/src/core/theme/index.ts +3 -0
- package/src/core/theme/loader.ts +45 -0
- package/src/core/theme/parser.ts +518 -0
- package/src/core/theme/renderer.tsx +25 -0
- package/src/index.tsx +17 -0
- package/src/types/config.ts +126 -0
- package/src/types/index.ts +107 -0
- package/src/types/irc-framework.d.ts +569 -0
- package/src/types/theme.ts +37 -0
- package/src/ui/ErrorBoundary.tsx +42 -0
- package/src/ui/chat/ChatView.tsx +39 -0
- package/src/ui/chat/MessageLine.tsx +92 -0
- package/src/ui/hooks/useStatusbarColors.ts +23 -0
- package/src/ui/input/CommandInput.tsx +273 -0
- package/src/ui/layout/AppLayout.tsx +126 -0
- package/src/ui/layout/TopicBar.tsx +46 -0
- package/src/ui/sidebar/BufferList.tsx +55 -0
- package/src/ui/sidebar/NickList.tsx +96 -0
- package/src/ui/splash/SplashScreen.tsx +100 -0
- package/src/ui/statusbar/StatusLine.tsx +205 -0
- package/themes/.gitkeep +0 -0
- package/themes/default.theme +57 -0
- 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,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
|
+
}
|