Files

1239 lines
35 KiB
Go

package internal
import (
"database/sql"
"fmt"
"log/slog"
"os"
"regexp"
"strings"
"sync"
"time"
_ "github.com/mattn/go-sqlite3"
)
var db *sql.DB
// userDBs stores per-user database connections (keyed by user ID)
var userDBs = make(map[string]*sql.DB)
var userDBsMutex sync.RWMutex
// UseWALMode controls whether WAL journal mode is enabled for databases
var UseWALMode bool
// truncateString truncates a string to maxLen characters for logging
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// SanitizeUsername converts a username to a safe filesystem name
func SanitizeUsername(username string) string {
// Convert to lowercase
safe := strings.ToLower(username)
// Replace spaces and special characters with underscores
reg := regexp.MustCompile(`[^a-z0-9]+`)
safe = reg.ReplaceAllString(safe, "_")
// Remove leading/trailing underscores
safe = strings.Trim(safe, "_")
// Ensure it's not empty
if safe == "" {
safe = "user"
}
return safe
}
func InitDB(filepath string) error {
var err error
db, err = sql.Open("sqlite3", filepath)
if err != nil {
return err
}
if err = db.Ping(); err != nil {
return err
}
// Set busy timeout for better concurrent access
_, err = db.Exec("PRAGMA busy_timeout=5000;")
if err != nil {
return fmt.Errorf("failed to set busy timeout: %w", err)
}
// Enable WAL mode if requested (better for concurrent reads during writes)
if UseWALMode {
_, err = db.Exec("PRAGMA journal_mode=WAL;")
if err != nil {
return fmt.Errorf("failed to enable WAL mode: %w", err)
}
}
createTableSQL := `
-- Unified table for SMS messages, MMS messages, and call logs
-- record_type: 1 = SMS, 2 = MMS, 3 = call
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_type INTEGER NOT NULL DEFAULT 1,
address TEXT NOT NULL,
body TEXT,
type INTEGER NOT NULL,
date INTEGER NOT NULL,
read INTEGER DEFAULT 0,
thread_id INTEGER,
subject TEXT,
media_type TEXT,
media_data BLOB,
protocol INTEGER,
status INTEGER,
service_center TEXT,
sub_id INTEGER,
contact_name TEXT,
sender TEXT,
content_type TEXT,
read_report INTEGER,
read_status INTEGER,
message_id TEXT,
message_size INTEGER,
message_type INTEGER,
sim_slot INTEGER,
addresses TEXT,
duration INTEGER,
presentation INTEGER,
subscription_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_address ON messages(address);
CREATE INDEX IF NOT EXISTS idx_date ON messages(date);
CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id);
CREATE INDEX IF NOT EXISTS idx_record_type ON messages(record_type);
CREATE INDEX IF NOT EXISTS idx_record_type_date ON messages(record_type, date);
CREATE INDEX IF NOT EXISTS idx_address_date ON messages(address, date);
-- Create unique constraints for idempotent imports
-- record_type differentiates SMS (1), MMS (2), and calls (3)
CREATE UNIQUE INDEX IF NOT EXISTS idx_message_unique ON messages(record_type, address, date, type, COALESCE(body, ''), COALESCE(content_type, ''), COALESCE(message_id, ''), COALESCE(duration, 0));
-- Create FTS5 virtual table for full-text search of messages
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
message_id UNINDEXED,
address UNINDEXED,
body,
contact_name UNINDEXED,
date UNINDEXED,
content='messages',
content_rowid='id'
);
-- Create triggers to keep FTS table in sync
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date)
VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date);
END;
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date)
VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date);
END;
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date)
VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date);
INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date)
VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date);
END;
`
_, err = db.Exec(createTableSQL)
if err != nil {
return err
}
slog.Info("Database initialized successfully")
return nil
}
// InitUserDB initializes a database for a specific user
func InitUserDB(userID string, filepath string) error {
userDB, err := sql.Open("sqlite3", filepath)
if err != nil {
return err
}
if err = userDB.Ping(); err != nil {
return err
}
// Set busy timeout for better concurrent access
_, err = userDB.Exec("PRAGMA busy_timeout=5000;")
if err != nil {
return fmt.Errorf("failed to set busy timeout: %w", err)
}
// Enable WAL mode if requested (better for concurrent reads during writes)
if UseWALMode {
_, err = userDB.Exec("PRAGMA journal_mode=WAL;")
if err != nil {
return fmt.Errorf("failed to enable WAL mode: %w", err)
}
}
createTableSQL := `
-- Unified table for SMS messages, MMS messages, and call logs
-- record_type: 1 = SMS, 2 = MMS, 3 = call
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_type INTEGER NOT NULL DEFAULT 1,
address TEXT NOT NULL,
body TEXT,
type INTEGER NOT NULL,
date INTEGER NOT NULL,
read INTEGER DEFAULT 0,
thread_id INTEGER,
subject TEXT,
media_type TEXT,
media_data BLOB,
protocol INTEGER,
status INTEGER,
service_center TEXT,
sub_id INTEGER,
contact_name TEXT,
sender TEXT,
content_type TEXT,
read_report INTEGER,
read_status INTEGER,
message_id TEXT,
message_size INTEGER,
message_type INTEGER,
sim_slot INTEGER,
addresses TEXT,
duration INTEGER,
presentation INTEGER,
subscription_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_address ON messages(address);
CREATE INDEX IF NOT EXISTS idx_date ON messages(date);
CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id);
CREATE INDEX IF NOT EXISTS idx_record_type ON messages(record_type);
CREATE INDEX IF NOT EXISTS idx_record_type_date ON messages(record_type, date);
CREATE INDEX IF NOT EXISTS idx_address_date ON messages(address, date);
-- record_type differentiates SMS (1), MMS (2), and calls (3)
CREATE UNIQUE INDEX IF NOT EXISTS idx_message_unique ON messages(record_type, address, date, type, COALESCE(body, ''), COALESCE(content_type, ''), COALESCE(message_id, ''), COALESCE(duration, 0));
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
message_id UNINDEXED,
address UNINDEXED,
body,
contact_name UNINDEXED,
date UNINDEXED,
content='messages',
content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date)
VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date);
END;
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date)
VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date);
END;
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date)
VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date);
INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date)
VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date);
END;
`
_, err = userDB.Exec(createTableSQL)
if err != nil {
return err
}
// Store in map
userDBsMutex.Lock()
userDBs[userID] = userDB
userDBsMutex.Unlock()
slog.Info("User database initialized", "user_id", userID, "path", filepath)
return nil
}
// GetUserDB retrieves the database connection for a specific user, creating it if it doesn't exist
func GetUserDB(userID string, username string) (*sql.DB, error) {
userDBsMutex.RLock()
userDB, exists := userDBs[userID]
userDBsMutex.RUnlock()
if !exists {
// Database not in cache, try to open or create it
dbPathPrefix := os.Getenv("DB_PATH_PREFIX")
if dbPathPrefix == "" {
dbPathPrefix = "."
}
// Use UUID as database filename instead of sanitized username
filepath := fmt.Sprintf("%s/sbv_%s.db", dbPathPrefix, userID)
// InitUserDB will create the database if it doesn't exist
if err := InitUserDB(userID, filepath); err != nil {
return nil, fmt.Errorf("failed to initialize user database: %w", err)
}
userDBsMutex.RLock()
userDB = userDBs[userID]
userDBsMutex.RUnlock()
}
return userDB, nil
}
func InsertMessage(userDB *sql.DB, msg *Message) error {
// Convert addresses slice to JSON string
var addressesJSON string
if len(msg.Addresses) > 0 {
addresses := strings.Join(msg.Addresses, ",")
addressesJSON = addresses
}
// Determine record type: 1 = SMS, 2 = MMS
// MMS messages have ContentType set (e.g., 'application/vnd.wap.multipart.related')
// SMS messages do not have ContentType
recordType := 1 // Default to SMS
if msg.ContentType != "" {
recordType = 2 // MMS
}
query := `
INSERT INTO messages (
record_type, address, body, type, date, read, thread_id, subject, media_type, media_data,
protocol, status, service_center, sub_id, contact_name, sender,
content_type, read_report, read_status, message_id, message_size, message_type, sim_slot, addresses
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING
`
result, err := userDB.Exec(query,
recordType, // record_type: 1 = SMS, 2 = MMS
msg.Address,
msg.Body,
msg.Type,
msg.Date.Unix(),
msg.Read,
msg.ThreadID,
msg.Subject,
msg.MediaType,
msg.MediaData,
msg.Protocol,
msg.Status,
msg.ServiceCenter,
msg.SubID,
msg.ContactName,
msg.Sender,
msg.ContentType,
msg.ReadReport,
msg.ReadStatus,
msg.MessageID,
msg.MessageSize,
msg.MessageType,
msg.SimSlot,
addressesJSON,
)
if err != nil {
slog.Debug("InsertMessage: Error inserting message", "error", err)
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
msg.ID = id
return nil
}
func InsertCallLog(userDB *sql.DB, call *CallLog) error {
query := `
INSERT INTO messages (record_type, address, type, date, duration, presentation, subscription_id, contact_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING
`
result, err := userDB.Exec(query,
3, // record_type: 3 = call
call.Number,
call.Type,
call.Date.Unix(),
call.Duration,
call.Presentation,
call.SubscriptionID,
call.ContactName,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
call.ID = id
return nil
}
// InsertCallLogBatch inserts multiple call logs in a single transaction for better performance
func InsertCallLogBatch(userDB *sql.DB, calls []CallLog) error {
if len(calls) == 0 {
return nil
}
tx, err := userDB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT INTO messages (record_type, address, type, date, duration, presentation, subscription_id, contact_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING
`)
if err != nil {
return err
}
defer stmt.Close()
for i := range calls {
_, err := stmt.Exec(
3, // record_type: 3 = call
calls[i].Number,
calls[i].Type,
calls[i].Date.Unix(),
calls[i].Duration,
calls[i].Presentation,
calls[i].SubscriptionID,
calls[i].ContactName,
)
if err != nil {
return err
}
}
return tx.Commit()
}
func GetConversations(userDB *sql.DB, startDate, endDate *time.Time) ([]Conversation, error) {
// Build a query that groups all activity (messages and calls) by address
query := `
SELECT
address,
MAX(COALESCE(contact_name, '')) as contact_name,
(
SELECT COALESCE(subject, '')
FROM messages m2
WHERE m2.address = messages.address
AND m2.subject IS NOT NULL
AND m2.subject != ''
ORDER BY date DESC
LIMIT 1
) as subject,
(
SELECT
CASE
WHEN record_type = 1 THEN body -- SMS
WHEN record_type = 2 THEN body -- MMS
WHEN record_type = 3 AND type = 1 THEN 'Incoming call'
WHEN record_type = 3 AND type = 2 THEN 'Outgoing call'
WHEN record_type = 3 AND type = 3 THEN 'Missed call'
WHEN record_type = 3 AND type = 4 THEN 'Voicemail'
WHEN record_type = 3 AND type = 5 THEN 'Rejected call'
WHEN record_type = 3 AND type = 6 THEN 'Refused call'
ELSE 'Call'
END
FROM messages m3
WHERE m3.address = messages.address
ORDER BY date DESC
LIMIT 1
) as last_message,
MAX(date) as last_date,
COUNT(*) as activity_count
FROM messages
WHERE 1=1
`
args := []interface{}{}
if startDate != nil {
query += " AND date >= ?"
args = append(args, startDate.Unix())
}
if endDate != nil {
query += " AND date <= ?"
args = append(args, endDate.Unix())
}
query += `
GROUP BY address
ORDER BY last_date DESC
`
rows, err := userDB.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
conversations := []Conversation{}
for rows.Next() {
var c Conversation
var lastDateUnix int64
var subject sql.NullString
err := rows.Scan(&c.Address, &c.ContactName, &subject, &c.LastMessage, &lastDateUnix, &c.MessageCount)
if err != nil {
return nil, err
}
c.LastDate = time.Unix(lastDateUnix, 0)
c.Subject = subject.String
c.Type = "conversation" // Changed from "message" or "call" to indicate it's a merged conversation
conversations = append(conversations, c)
}
return conversations, nil
}
func formatCallType(callType int) string {
switch callType {
case 1:
return "Incoming call"
case 2:
return "Outgoing call"
case 3:
return "Missed call"
case 4:
return "Voicemail"
case 5:
return "Rejected call"
case 6:
return "Refused call"
default:
return "Call"
}
}
func GetMessages(userDB *sql.DB, address string, startDate, endDate *time.Time) ([]Message, error) {
query := `
SELECT id, address, body, type, date, read, thread_id,
COALESCE(subject, ''), COALESCE(media_type, ''), COALESCE(media_data, ''),
COALESCE(protocol, 0), COALESCE(status, 0), COALESCE(service_center, ''),
COALESCE(sub_id, 0), COALESCE(contact_name, ''), COALESCE(sender, ''),
COALESCE(content_type, ''), COALESCE(read_report, 0), COALESCE(read_status, 0),
COALESCE(message_id, ''), COALESCE(message_size, 0), COALESCE(message_type, 0),
COALESCE(sim_slot, 0), COALESCE(addresses, '')
FROM messages
WHERE record_type IN (1, 2) AND address = ? -- 1 = SMS, 2 = MMS
`
args := []interface{}{address}
if startDate != nil {
query += " AND date >= ?"
args = append(args, startDate.Unix())
}
if endDate != nil {
query += " AND date <= ?"
args = append(args, endDate.Unix())
}
query += " ORDER BY date ASC"
slog.Debug("GetMessages: executing query", "address", address)
slog.Debug("GetMessages: SQL query", "query", query)
slog.Debug("GetMessages: query arguments", "args", args)
rows, err := userDB.Query(query, args...)
if err != nil {
slog.Debug("GetMessages: Query error", "error", err)
return nil, err
}
defer rows.Close()
messages := []Message{}
for rows.Next() {
var m Message
var dateUnix int64
var readInt int
var addressesStr string
err := rows.Scan(&m.ID, &m.Address, &m.Body, &m.Type, &dateUnix,
&readInt, &m.ThreadID, &m.Subject, &m.MediaType, &m.MediaData,
&m.Protocol, &m.Status, &m.ServiceCenter, &m.SubID, &m.ContactName, &m.Sender,
&m.ContentType, &m.ReadReport, &m.ReadStatus, &m.MessageID,
&m.MessageSize, &m.MessageType, &m.SimSlot, &addressesStr)
if err != nil {
return nil, err
}
m.Date = time.Unix(dateUnix, 0)
m.Read = readInt == 1
// Parse addresses from comma-separated string
if addressesStr != "" {
m.Addresses = strings.Split(addressesStr, ",")
}
// Don't load media data - it will be fetched on demand via /api/media
// Clear MediaData to save memory in response
m.MediaData = nil
slog.Debug("GetMessages: Message", "id", m.ID, "address", m.Address, "media_type", m.MediaType, "body", truncateString(m.Body, 50))
messages = append(messages, m)
}
slog.Debug("GetMessages: Returning messages", "count", len(messages), "address", address)
return messages, nil
}
func GetCallLogs(userDB *sql.DB, number string, startDate, endDate *time.Time) ([]CallLog, error) {
query := `
SELECT id, address, duration, date, type,
COALESCE(presentation, 0), COALESCE(subscription_id, ''), COALESCE(contact_name, '')
FROM messages
WHERE record_type = 3 AND address = ? -- 3 = call
`
args := []interface{}{number}
if startDate != nil {
query += " AND date >= ?"
args = append(args, startDate.Unix())
}
if endDate != nil {
query += " AND date <= ?"
args = append(args, endDate.Unix())
}
query += " ORDER BY date ASC"
rows, err := userDB.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
calls := []CallLog{}
for rows.Next() {
var c CallLog
var dateUnix int64
err := rows.Scan(&c.ID, &c.Number, &c.Duration, &dateUnix, &c.Type,
&c.Presentation, &c.SubscriptionID, &c.ContactName)
if err != nil {
return nil, err
}
c.Date = time.Unix(dateUnix, 0)
calls = append(calls, c)
}
return calls, nil
}
func GetAllCalls(userDB *sql.DB, startDate, endDate *time.Time, limit, offset int) ([]CallLog, error) {
query := `
SELECT id, address, duration, date, type,
COALESCE(presentation, 0), COALESCE(subscription_id, ''), COALESCE(contact_name, '')
FROM messages
WHERE record_type = 3 -- 3 = call
`
args := []interface{}{}
if startDate != nil {
query += " AND date >= ?"
args = append(args, startDate.Unix())
}
if endDate != nil {
query += " AND date <= ?"
args = append(args, endDate.Unix())
}
query += " ORDER BY date ASC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := userDB.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
calls := []CallLog{}
for rows.Next() {
var c CallLog
var dateUnix int64
err := rows.Scan(&c.ID, &c.Number, &c.Duration, &dateUnix, &c.Type,
&c.Presentation, &c.SubscriptionID, &c.ContactName)
if err != nil {
return nil, err
}
c.Date = time.Unix(dateUnix, 0)
calls = append(calls, c)
}
return calls, nil
}
func GetActivity(userDB *sql.DB, startDate, endDate *time.Time, limit, offset int) ([]ActivityItem, error) {
return GetActivityByAddress(userDB, "", startDate, endDate, limit, offset)
}
func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time, limit, offset int) ([]ActivityItem, error) {
var activities []ActivityItem
// Query from unified table — media_data is intentionally excluded; fetched on-demand via /api/media
query := `
SELECT record_type, date, address, COALESCE(contact_name, '') as contact_name,
id, body, type, read, thread_id, COALESCE(subject, ''),
COALESCE(media_type, ''),
COALESCE(protocol, 0), COALESCE(status, 0), COALESCE(service_center, ''),
COALESCE(sub_id, 0), COALESCE(content_type, ''), COALESCE(read_report, 0),
COALESCE(read_status, 0), COALESCE(message_id, ''), COALESCE(message_size, 0),
COALESCE(message_type, 0), COALESCE(sim_slot, 0), COALESCE(addresses, ''),
COALESCE(duration, 0), COALESCE(presentation, 0), COALESCE(subscription_id, ''),
COALESCE(sender, '')
FROM messages
WHERE 1=1
`
args := []interface{}{}
if address != "" {
query += " AND address = ?"
args = append(args, address)
}
if startDate != nil {
query += " AND date >= ?"
args = append(args, startDate.Unix())
}
if endDate != nil {
query += " AND date <= ?"
args = append(args, endDate.Unix())
}
query += " ORDER BY date ASC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
slog.Debug("GetActivityByAddress: executing query", "address", address, "limit", limit, "offset", offset)
slog.Debug("GetActivityByAddress: SQL query", "query", query)
slog.Debug("GetActivityByAddress: query arguments", "args", args)
rows, err := userDB.Query(query, args...)
if err != nil {
slog.Debug("GetActivityByAddress: Query error", "error", err)
return nil, err
}
defer rows.Close()
for rows.Next() {
var recordType int64
var dateUnix int64
var address, contactName string
// Shared fields
var id sql.NullInt64
var itemType sql.NullInt64 // type field - used for both message type and call type
// Message fields
var body, subject, mediaType, serviceCenter, contentType, messageID, subscriptionID, addressesStr, sender sql.NullString
var readInt, threadID, protocol, status, subID, readReport, readStatus, messageSize, messageTypeField, simSlot sql.NullInt64
// Call fields
var duration, presentation sql.NullInt64
err := rows.Scan(&recordType, &dateUnix, &address, &contactName,
&id, &body, &itemType, &readInt, &threadID, &subject,
&mediaType,
&protocol, &status, &serviceCenter,
&subID, &contentType, &readReport,
&readStatus, &messageID, &messageSize,
&messageTypeField, &simSlot, &addressesStr,
&duration, &presentation, &subscriptionID, &sender)
if err != nil {
return nil, err
}
var activityTypeStr string
if recordType == 1 || recordType == 2 {
// 1 = SMS, 2 = MMS
activityTypeStr = "message"
} else if recordType == 3 {
// 3 = call
activityTypeStr = "call"
}
activity := ActivityItem{
Type: activityTypeStr,
Date: time.Unix(dateUnix, 0),
Address: address,
ContactName: contactName,
}
if (recordType == 1 || recordType == 2) && id.Valid {
// Handle SMS (1) and MMS (2)
msg := &Message{
ID: id.Int64,
Address: address,
Body: body.String,
Date: time.Unix(dateUnix, 0),
ThreadID: int(threadID.Int64),
Subject: subject.String,
MediaType: mediaType.String,
Protocol: int(protocol.Int64),
Status: int(status.Int64),
ServiceCenter: serviceCenter.String,
SubID: int(subID.Int64),
ContactName: contactName,
ContentType: contentType.String,
ReadReport: int(readReport.Int64),
ReadStatus: int(readStatus.Int64),
MessageID: messageID.String,
MessageSize: int(messageSize.Int64),
MessageType: int(messageTypeField.Int64),
SimSlot: int(simSlot.Int64),
Sender: sender.String,
}
if itemType.Valid {
msg.Type = int(itemType.Int64)
}
if readInt.Valid {
msg.Read = readInt.Int64 == 1
}
// Parse addresses from comma-separated string
slog.Debug("GetActivityByAddress: addressesStr raw", "id", id.Int64, "valid", addressesStr.Valid, "value", addressesStr.String)
if addressesStr.Valid && addressesStr.String != "" {
msg.Addresses = strings.Split(addressesStr.String, ",")
slog.Debug("GetActivityByAddress: addresses split result", "id", id.Int64, "count", len(msg.Addresses), "values", msg.Addresses)
} else if strings.Contains(address, ",") {
// Fallback: If addresses field is empty but address contains commas,
// this is a group conversation - parse the address field
msg.Addresses = strings.Split(address, ",")
slog.Debug("GetActivityByAddress: addresses from address field", "id", id.Int64, "count", len(msg.Addresses), "values", msg.Addresses)
}
slog.Debug("GetActivityByAddress: Message", "id", msg.ID, "address", msg.Address, "type", msg.Type, "sender", msg.Sender, "addresses", msg.Addresses, "media_type", msg.MediaType, "body", truncateString(msg.Body, 50))
activity.Message = msg
} else if recordType == 3 && id.Valid {
// Handle calls (3)
call := &CallLog{
ID: id.Int64,
Number: address,
Duration: int(duration.Int64),
Date: time.Unix(dateUnix, 0),
Type: int(itemType.Int64),
Presentation: int(presentation.Int64),
SubscriptionID: subscriptionID.String,
ContactName: contactName,
}
slog.Debug("GetActivityByAddress: Call", "id", call.ID, "number", call.Number, "type", call.Type, "duration", call.Duration)
activity.Call = call
}
activities = append(activities, activity)
}
slog.Debug("GetActivityByAddress: Returning activities", "count", len(activities), "address", address)
return activities, nil
}
// CountActivityByAddress returns the total number of activity rows for a given address and date range
func CountActivityByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time) (int, error) {
query := `SELECT COUNT(*) FROM messages WHERE 1=1`
args := []interface{}{}
if address != "" {
query += " AND address = ?"
args = append(args, address)
}
if startDate != nil {
query += " AND date >= ?"
args = append(args, startDate.Unix())
}
if endDate != nil {
query += " AND date <= ?"
args = append(args, endDate.Unix())
}
var count int
err := userDB.QueryRow(query, args...).Scan(&count)
return count, err
}
// GetMediaByAddress fetches only media items (images/videos) for a specific address
func GetMediaByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time) ([]Message, error) {
query := `
SELECT id, address, COALESCE(body, '') as body, date,
COALESCE(contact_name, '') as contact_name, COALESCE(media_type, '') as media_type,
read, thread_id
FROM messages
WHERE record_type IN (1, 2)
AND media_type IS NOT NULL
AND media_type != ''
AND (media_type LIKE 'image/%' OR media_type LIKE 'video/%')
`
args := []interface{}{}
if address != "" {
query += " AND address = ?"
args = append(args, address)
}
if startDate != nil {
query += " AND date >= ?"
args = append(args, startDate.Unix())
}
if endDate != nil {
query += " AND date <= ?"
args = append(args, endDate.Unix())
}
query += " ORDER BY date DESC"
rows, err := userDB.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var mediaItems []Message
for rows.Next() {
var m Message
var dateUnix int64
var readInt int64
err := rows.Scan(&m.ID, &m.Address, &m.Body, &dateUnix, &m.ContactName, &m.MediaType, &readInt, &m.ThreadID)
if err != nil {
return nil, err
}
m.Date = time.Unix(dateUnix, 0)
m.Read = readInt == 1
mediaItems = append(mediaItems, m)
}
return mediaItems, nil
}
func GetMessageMedia(userDB *sql.DB, messageID string) ([]byte, string, error) {
query := `
SELECT COALESCE(media_data, ''), COALESCE(media_type, '')
FROM messages
WHERE id = ? AND record_type IN (1, 2) -- 1 = SMS, 2 = MMS
`
slog.Debug("GetMessageMedia: Fetching media", "message_id", messageID)
slog.Debug("GetMessageMedia: SQL query", "query", query)
var mediaData []byte
var mediaType string
err := userDB.QueryRow(query, messageID).Scan(&mediaData, &mediaType)
if err != nil {
slog.Debug("GetMessageMedia: Error scanning row", "message_id", messageID, "error", err)
return nil, "", err
}
slog.Debug("GetMessageMedia: Found media", "media_type", mediaType, "data_length", len(mediaData), "message_id", messageID)
if len(mediaData) == 0 || mediaType == "" {
slog.Debug("GetMessageMedia: No media found", "message_id", messageID)
return nil, "", fmt.Errorf("no media found")
}
// Convert HEIC to JPEG if needed
if isHEICContentType(mediaType) {
convertedData, err := convertHEICtoJPEG(mediaData)
if err != nil {
slog.Error("Failed to convert HEIC to JPEG", "message_id", messageID, "error", err)
// Return original if conversion fails
return mediaData, mediaType, nil
}
return convertedData, "image/jpeg", nil
}
// Convert unsupported video formats (3GP, etc.) to MP4 if needed
if needsVideoConversion(mediaType) {
slog.Info("Converting video to MP4", "from_type", mediaType, "message_id", messageID)
convertedData, err := convertVideoToMP4(mediaData)
if err != nil {
slog.Error("Failed to convert video to MP4", "message_id", messageID, "error", err)
// Return original if conversion fails
return mediaData, mediaType, nil
}
slog.Info("Successfully converted video to MP4", "message_id", messageID)
return convertedData, "video/mp4", nil
}
// Convert unsupported audio formats (AMR, etc.) to MP3 if needed
if needsAudioConversion(mediaType) {
slog.Info("Converting audio to MP3", "from_type", mediaType, "message_id", messageID)
convertedData, err := convertAudioToMP3(mediaData)
if err != nil {
slog.Error("Failed to convert audio to MP3", "message_id", messageID, "error", err)
return mediaData, mediaType, nil
}
slog.Info("Successfully converted audio to MP3", "message_id", messageID)
return convertedData, "audio/mpeg", nil
}
return mediaData, mediaType, nil
}
func GetDateRange(userDB *sql.DB) (time.Time, time.Time, error) {
var minDate, maxDate int64
// Get min/max from unified messages table
query := "SELECT MIN(date), MAX(date) FROM messages"
var min, max sql.NullInt64
err := userDB.QueryRow(query).Scan(&min, &max)
if err != nil && err != sql.ErrNoRows {
return time.Time{}, time.Time{}, err
}
if !min.Valid || !max.Valid {
return time.Time{}, time.Time{}, fmt.Errorf("no data available")
}
minDate = min.Int64
maxDate = max.Int64
return time.Unix(minDate, 0), time.Unix(maxDate, 0), nil
}
// SearchResult represents a message search result
type SearchResult struct {
MessageID int64 `json:"message_id"`
Address string `json:"address"`
ContactName string `json:"contact_name"`
Body string `json:"body"`
Date time.Time `json:"date"`
Snippet string `json:"snippet"`
}
// SearchMessages performs full-text search on message contents
func SearchMessages(userDB *sql.DB, query string, limit int) ([]SearchResult, error) {
if query == "" {
return []SearchResult{}, nil
}
sqlQuery := `
SELECT
m.id,
m.address,
COALESCE(m.contact_name, ''),
m.body,
m.date,
snippet(messages_fts, 2, '<mark>', '</mark>', '...', 50) as snippet
FROM messages_fts
JOIN messages m ON messages_fts.rowid = m.id
WHERE messages_fts MATCH ?
ORDER BY rank
LIMIT ?
`
rows, err := userDB.Query(sqlQuery, query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
results := []SearchResult{}
for rows.Next() {
var r SearchResult
var dateUnix int64
err := rows.Scan(&r.MessageID, &r.Address, &r.ContactName, &r.Body, &dateUnix, &r.Snippet)
if err != nil {
return nil, err
}
r.Date = time.Unix(dateUnix, 0)
results = append(results, r)
}
return results, nil
}
// GetAnalytics retrieves analytics data for the Summary tab
func GetAnalytics(userDB *sql.DB, startDate, endDate *time.Time, topN int, tzOffsetMinutes int) (*AnalyticsResponse, error) {
analytics := &AnalyticsResponse{}
// Build date filter
dateFilter := ""
args := []interface{}{}
if startDate != nil {
dateFilter += " AND date >= ?"
args = append(args, startDate.Unix())
}
if endDate != nil {
dateFilter += " AND date <= ?"
args = append(args, endDate.Unix())
}
// 1. Get summary statistics
if err := getSummaryStats(userDB, dateFilter, args, analytics); err != nil {
return nil, err
}
// 2. Get top contacts
topContacts, err := getTopContacts(userDB, dateFilter, args, topN)
if err != nil {
return nil, err
}
analytics.TopContacts = topContacts
// 3. Get hourly distribution
hourly, err := getHourlyDistribution(userDB, dateFilter, args, tzOffsetMinutes)
if err != nil {
return nil, err
}
analytics.HourlyDistribution = hourly
// 4. Get daily trend
daily, err := getDailyTrend(userDB, dateFilter, args)
if err != nil {
return nil, err
}
analytics.DailyTrend = daily
return analytics, nil
}
func getSummaryStats(userDB *sql.DB, dateFilter string, args []interface{}, analytics *AnalyticsResponse) error {
query := `
SELECT
COUNT(*) as total,
SUM(CASE WHEN record_type = 1 THEN 1 ELSE 0 END) as sms_count,
SUM(CASE WHEN record_type = 2 THEN 1 ELSE 0 END) as mms_count,
SUM(CASE WHEN record_type = 3 THEN 1 ELSE 0 END) as call_count,
SUM(CASE WHEN record_type IN (1,2) AND type = 2 THEN 1 ELSE 0 END) as sent,
SUM(CASE WHEN record_type IN (1,2) AND type = 1 THEN 1 ELSE 0 END) as received,
SUM(CASE WHEN record_type = 3 AND type = 1 THEN 1 ELSE 0 END) as incoming_calls,
SUM(CASE WHEN record_type = 3 AND type = 2 THEN 1 ELSE 0 END) as outgoing_calls,
SUM(CASE WHEN record_type = 3 AND type = 3 THEN 1 ELSE 0 END) as missed_calls,
COALESCE(SUM(CASE WHEN record_type = 3 THEN duration ELSE 0 END), 0) as total_duration,
COALESCE(AVG(CASE WHEN record_type IN (1,2) AND body IS NOT NULL AND body != '' THEN LENGTH(body) END), 0) as avg_length
FROM messages
WHERE 1=1 ` + dateFilter
return userDB.QueryRow(query, args...).Scan(
&analytics.TotalMessages,
&analytics.TotalSMS,
&analytics.TotalMMS,
&analytics.TotalCalls,
&analytics.TotalSent,
&analytics.TotalReceived,
&analytics.IncomingCalls,
&analytics.OutgoingCalls,
&analytics.MissedCalls,
&analytics.TotalCallDuration,
&analytics.AvgMessageLength,
)
}
func getTopContacts(userDB *sql.DB, dateFilter string, args []interface{}, limit int) ([]TopContact, error) {
query := `
SELECT
address,
MAX(COALESCE(contact_name, '')) as contact_name,
COUNT(*) as message_count,
SUM(CASE WHEN type = 2 THEN 1 ELSE 0 END) as sent_count,
SUM(CASE WHEN type = 1 THEN 1 ELSE 0 END) as received_count
FROM messages
WHERE record_type IN (1, 2) ` + dateFilter + `
GROUP BY address
ORDER BY message_count DESC
LIMIT ?`
queryArgs := append(args, limit)
rows, err := userDB.Query(query, queryArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
var contacts []TopContact
for rows.Next() {
var c TopContact
if err := rows.Scan(&c.Address, &c.ContactName, &c.MessageCount, &c.SentCount, &c.ReceivedCount); err != nil {
return nil, err
}
contacts = append(contacts, c)
}
return contacts, nil
}
func getHourlyDistribution(userDB *sql.DB, dateFilter string, args []interface{}, tzOffsetMinutes int) ([]HourlyDistribution, error) {
tzModifier := fmt.Sprintf("%+d minutes", tzOffsetMinutes)
query := `
SELECT
CAST(strftime('%H', date, 'unixepoch', '` + tzModifier + `') AS INTEGER) as hour,
COUNT(*) as count
FROM messages
WHERE record_type IN (1, 2) ` + dateFilter + `
GROUP BY hour
ORDER BY hour`
rows, err := userDB.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
// Initialize all 24 hours with 0
hourMap := make(map[int]int)
for i := 0; i < 24; i++ {
hourMap[i] = 0
}
for rows.Next() {
var hour, count int
if err := rows.Scan(&hour, &count); err != nil {
return nil, err
}
hourMap[hour] = count
}
// Convert to slice
result := make([]HourlyDistribution, 24)
for i := 0; i < 24; i++ {
result[i] = HourlyDistribution{Hour: i, Count: hourMap[i]}
}
return result, nil
}
func getDailyTrend(userDB *sql.DB, dateFilter string, args []interface{}) ([]DailyCount, error) {
query := `
SELECT
strftime('%Y-%m-%d', date, 'unixepoch', 'localtime') as day,
COUNT(*) as count
FROM messages
WHERE record_type IN (1, 2) ` + dateFilter + `
GROUP BY day
ORDER BY day`
rows, err := userDB.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var trend []DailyCount
for rows.Next() {
var d DailyCount
if err := rows.Scan(&d.Date, &d.Count); err != nil {
return nil, err
}
trend = append(trend, d)
}
return trend, nil
}