feat: add SQLite database layer with contacts, conversations, and messages tables
This commit is contained in:
+284
@@ -0,0 +1,284 @@
|
||||
;;; noise-db.el --- SQLite database for Noise signal client -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; Manages the local SQLite database for contacts, conversations, and messages.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
|
||||
(defcustom noise-db-file (expand-file-name "noise/noise.db" user-emacs-directory)
|
||||
"Path to the Noise SQLite database file."
|
||||
:type 'file
|
||||
:group 'noise)
|
||||
|
||||
(defvar noise-db--connection nil
|
||||
"The active SQLite database connection.")
|
||||
|
||||
(defun noise-db-init ()
|
||||
"Initialize the Noise database.
|
||||
Creates the database file and tables if they do not exist. Idempotent."
|
||||
(noise-db--open)
|
||||
(noise-db--create-schema))
|
||||
|
||||
(defun noise-db--open ()
|
||||
"Open (or return existing) database connection."
|
||||
(unless (and noise-db--connection
|
||||
(sqlitep noise-db--connection))
|
||||
(let ((dir (file-name-directory noise-db-file)))
|
||||
(unless (file-exists-p dir)
|
||||
(make-directory dir t)))
|
||||
(setq noise-db--connection (sqlite-open noise-db-file))))
|
||||
|
||||
(defun noise-db--close ()
|
||||
"Close the database connection if open."
|
||||
(when (and noise-db--connection (sqlitep noise-db--connection))
|
||||
(sqlite-close noise-db--connection)
|
||||
(setq noise-db--connection nil)))
|
||||
|
||||
(defun noise-db--create-schema ()
|
||||
"Create all required tables if they do not exist."
|
||||
(noise-db--exec
|
||||
"CREATE TABLE IF NOT EXISTS contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
color TEXT DEFAULT '',
|
||||
profile_name TEXT DEFAULT '',
|
||||
last_fetch INTEGER DEFAULT 0
|
||||
)")
|
||||
(noise-db--exec
|
||||
"CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('direct', 'group')),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
last_message_preview TEXT DEFAULT '',
|
||||
last_message_time INTEGER DEFAULT 0,
|
||||
last_message_sender TEXT DEFAULT '',
|
||||
unread_count INTEGER DEFAULT 0,
|
||||
is_archived INTEGER DEFAULT 0,
|
||||
expiration_timer INTEGER DEFAULT 0
|
||||
)")
|
||||
(noise-db--exec
|
||||
"CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
timestamp INTEGER NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
body TEXT DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT 'text',
|
||||
receipt_state TEXT DEFAULT '',
|
||||
has_attachment INTEGER DEFAULT 0,
|
||||
attachment_path TEXT DEFAULT ''
|
||||
)")
|
||||
(noise-db--exec
|
||||
"CREATE TABLE IF NOT EXISTS conversation_members (
|
||||
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (conversation_id, contact_id)
|
||||
)")
|
||||
(noise-db--exec
|
||||
"CREATE INDEX IF NOT EXISTS idx_messages_conversation_time
|
||||
ON messages(conversation_id, timestamp)")
|
||||
(noise-db--exec
|
||||
"CREATE INDEX IF NOT EXISTS idx_conversations_time
|
||||
ON conversations(last_message_time)"))
|
||||
|
||||
(defun noise-db--exec (sql &rest args)
|
||||
"Execute SQL with ARGS and return nil."
|
||||
(sqlite-execute noise-db--connection sql args))
|
||||
|
||||
(defvar noise-db--column-cache (make-hash-table :test 'equal)
|
||||
"Cache of table name to column list mappings.")
|
||||
|
||||
(defun noise-db--query (sql &rest args)
|
||||
"Execute SQL query with ARGS and return all rows as a list of hash tables.
|
||||
Each row is a hash table with column names as keys.
|
||||
For `count(*)` style queries, falls back to raw list rows."
|
||||
(let ((rows (sqlite-select noise-db--connection sql args)))
|
||||
(when rows
|
||||
(let ((columns (noise-db--column-names sql)))
|
||||
(if columns
|
||||
(mapcar (lambda (row)
|
||||
(let ((ht (make-hash-table :test 'equal :size (length columns))))
|
||||
(seq-do-indexed
|
||||
(lambda (val idx)
|
||||
(puthash (nth idx columns) val ht))
|
||||
row)
|
||||
ht))
|
||||
rows)
|
||||
rows)))))
|
||||
|
||||
(defun noise-db--column-names (sql)
|
||||
"Extract column names from a SELECT SQL statement.
|
||||
For `SELECT *` queries, uses PRAGMA table_info to get column names."
|
||||
(let ((case-fold-search nil))
|
||||
(cond
|
||||
;; SELECT c.* FROM conversations c ...
|
||||
((string-match "SELECT[[:space:]]+DISTINCT[[:space:]]+c\\.\\*" sql)
|
||||
(noise-db--table-columns "conversations"))
|
||||
;; SELECT * FROM tablename
|
||||
((string-match "SELECT[[:space:]]+\\*[[:space:]]+FROM[[:space:]]+\\([^[:space:]]+\\)" sql)
|
||||
(noise-db--table-columns (match-string 1 sql)))
|
||||
;; SELECT explicit columns, FROM
|
||||
((string-match "SELECT[[:space:]]+\\(.*?\\)[[:space:]]+FROM" sql)
|
||||
(noise-db--parse-column-list (match-string 1 sql))))))
|
||||
|
||||
(defun noise-db--parse-column-list (cols-str)
|
||||
"Parse a comma-separated column list into a list of column names."
|
||||
(mapcar (lambda (c)
|
||||
(let ((trimmed (string-trim c)))
|
||||
(cond
|
||||
;; "col AS alias"
|
||||
((string-match "[Aa][Ss][[:space:]]+\\([^ ,]+\\)$" trimmed)
|
||||
(match-string 1 trimmed))
|
||||
;; "table.col"
|
||||
((string-match "\\.\\([^ ]+\\)$" trimmed)
|
||||
(match-string 1 trimmed))
|
||||
(t trimmed))))
|
||||
(split-string cols-str ",")))
|
||||
|
||||
(defun noise-db--table-columns (table-name)
|
||||
"Get column names for TABLE-NAME using PRAGMA table_info."
|
||||
(or (gethash table-name noise-db--column-cache)
|
||||
(let ((info (sqlite-select noise-db--connection
|
||||
(format "PRAGMA table_info(%s)" table-name))))
|
||||
(when info
|
||||
(let ((cols (mapcar (lambda (row) (nth 1 row)) info)))
|
||||
(puthash table-name cols noise-db--column-cache)
|
||||
cols)))))
|
||||
|
||||
(cl-defun noise-db-upsert-contact (id &key name color profile-name)
|
||||
"Insert or update a contact with ID."
|
||||
(noise-db--exec
|
||||
"INSERT INTO contacts (id, name, color, profile_name, last_fetch)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = COALESCE(NULLIF(?, ''), name),
|
||||
color = COALESCE(NULLIF(?, ''), color),
|
||||
profile_name = COALESCE(NULLIF(?, ''), profile_name),
|
||||
last_fetch = ?"
|
||||
id (or name "") (or color "") (or profile-name "") (floor (float-time))
|
||||
(or name "") (or color "") (or profile-name "") (floor (float-time))))
|
||||
|
||||
(defun noise-db-get-contact (id)
|
||||
"Get a single contact by ID. Returns nil if not found."
|
||||
(car (noise-db--query "SELECT * FROM contacts WHERE id = ?" id)))
|
||||
|
||||
(defun noise-db-get-all-contacts ()
|
||||
"Get all contacts ordered by name."
|
||||
(noise-db--query "SELECT * FROM contacts ORDER BY name COLLATE NOCASE"))
|
||||
|
||||
(cl-defun noise-db-upsert-conversation (id &key type name members last-message-preview
|
||||
last-message-time last-message-sender
|
||||
unread-count is-archived expiration-timer)
|
||||
"Insert or update a conversation with ID.
|
||||
MEMBERS is a list of contact IDs for the conversation."
|
||||
(let ((existing (noise-db-get-conversation id)))
|
||||
(if existing
|
||||
(noise-db--exec
|
||||
"UPDATE conversations SET
|
||||
name = COALESCE(NULLIF(?, ''), name),
|
||||
last_message_preview = COALESCE(NULLIF(?, ''), last_message_preview),
|
||||
last_message_time = COALESCE(?, last_message_time),
|
||||
last_message_sender = COALESCE(NULLIF(?, ''), last_message_sender),
|
||||
unread_count = COALESCE(?, unread_count),
|
||||
is_archived = COALESCE(?, is_archived),
|
||||
expiration_timer = COALESCE(?, expiration_timer)
|
||||
WHERE id = ?"
|
||||
(or name "") (or last-message-preview "")
|
||||
(or last-message-time (gethash "last_message_time" existing))
|
||||
(or last-message-sender "")
|
||||
unread-count is-archived expiration-timer
|
||||
id)
|
||||
(noise-db--exec
|
||||
"INSERT INTO conversations (id, type, name, last_message_preview, last_message_time,
|
||||
last_message_sender, unread_count, is_archived, expiration_timer)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
id (or type "direct") (or name "") (or last-message-preview "")
|
||||
(or last-message-time 0) (or last-message-sender "")
|
||||
(or unread-count 0) (or is-archived 0) (or expiration-timer 0))))
|
||||
(when members
|
||||
(noise-db--exec "DELETE FROM conversation_members WHERE conversation_id = ?" id)
|
||||
(dolist (member-id members)
|
||||
(noise-db--exec
|
||||
"INSERT OR IGNORE INTO conversation_members (conversation_id, contact_id) VALUES (?, ?)"
|
||||
id member-id))))
|
||||
|
||||
(defun noise-db-get-conversation (id)
|
||||
"Get a single conversation by ID. Returns nil if not found."
|
||||
(car (noise-db--query "SELECT * FROM conversations WHERE id = ?" id)))
|
||||
|
||||
(defun noise-db-get-all-conversations ()
|
||||
"Get all conversations ordered by last message time descending."
|
||||
(noise-db--query
|
||||
"SELECT * FROM conversations ORDER BY last_message_time DESC"))
|
||||
|
||||
(cl-defun noise-db-insert-message (conversation-id &key timestamp source body type
|
||||
receipt-state has-attachment attachment-path)
|
||||
"Insert a message into the messages table."
|
||||
(noise-db--exec
|
||||
"INSERT INTO messages (conversation_id, timestamp, source, body, type, receipt_state,
|
||||
has_attachment, attachment_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
conversation-id timestamp (or source "")
|
||||
(or body "") (or type "text") (or receipt-state "")
|
||||
(if has-attachment 1 0) (or attachment-path ""))
|
||||
(let ((preview (or body ""))
|
||||
(sender (or source "")))
|
||||
(noise-db--exec
|
||||
"UPDATE conversations
|
||||
SET last_message_preview = ?,
|
||||
last_message_time = ?,
|
||||
last_message_sender = ?
|
||||
WHERE id = ?"
|
||||
preview timestamp sender conversation-id)))
|
||||
|
||||
(defun noise-db-get-messages (conversation-id &rest kwargs)
|
||||
"Get messages for CONVERSATION-ID, ordered by timestamp.
|
||||
Keyword arguments: :limit N, :offset N."
|
||||
(let* ((limit (plist-get kwargs :limit))
|
||||
(offset (plist-get kwargs :offset))
|
||||
(sql-str "SELECT * FROM messages WHERE conversation_id = ? ORDER BY timestamp")
|
||||
(sql-params (list conversation-id)))
|
||||
(when limit
|
||||
(setq sql-str (concat sql-str " LIMIT ?"))
|
||||
(setq sql-params (nconc sql-params (list limit))))
|
||||
(when offset
|
||||
(setq sql-str (concat sql-str " OFFSET ?"))
|
||||
(setq sql-params (nconc sql-params (list offset))))
|
||||
(apply #'noise-db--query sql-str sql-params)))
|
||||
|
||||
(defun noise-db-get-conversation-members (conversation-id)
|
||||
"Get member contact IDs for CONVERSATION-ID."
|
||||
(noise-db--query
|
||||
"SELECT contact_id FROM conversation_members WHERE conversation_id = ?"
|
||||
conversation-id))
|
||||
|
||||
(defun noise-db-conversations-for-contact (contact-id)
|
||||
"Get all conversations that include CONTACT-ID."
|
||||
(noise-db--query
|
||||
"SELECT c.* FROM conversations c
|
||||
JOIN conversation_members cm ON c.id = cm.conversation_id
|
||||
WHERE cm.contact_id = ?
|
||||
ORDER BY c.last_message_time DESC"
|
||||
contact-id))
|
||||
|
||||
(defun noise-db-search-contacts (query)
|
||||
"Search contacts by name or phone number matching QUERY."
|
||||
(noise-db--query
|
||||
"SELECT * FROM contacts WHERE name LIKE ? OR id LIKE ? ORDER BY name COLLATE NOCASE"
|
||||
(concat "%" query "%") (concat "%" query "%")))
|
||||
|
||||
(defun noise-db-search-conversations (query)
|
||||
"Search conversations by name or member names matching QUERY."
|
||||
(noise-db--query
|
||||
"SELECT DISTINCT c.* FROM conversations c
|
||||
LEFT JOIN conversation_members cm ON c.id = cm.conversation_id
|
||||
LEFT JOIN contacts co ON cm.contact_id = co.id
|
||||
WHERE c.name LIKE ? OR co.name LIKE ?
|
||||
ORDER BY c.last_message_time DESC"
|
||||
(concat "%" query "%") (concat "%" query "%")))
|
||||
|
||||
(provide 'noise-db)
|
||||
;;; noise-db.el ends here
|
||||
Reference in New Issue
Block a user