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 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); -- 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 query := ` SELECT record_type, date, address, COALESCE(contact_name, '') as contact_name, id, body, type, 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(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 var mediaData []byte // Call fields var duration, presentation sql.NullInt64 err := rows.Scan(&recordType, &dateUnix, &address, &contactName, &id, &body, &itemType, &readInt, &threadID, &subject, &mediaType, &mediaData, &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, MediaData: mediaData, 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) } // Don't load media data - it will be fetched on demand via /api/media // Clear MediaData to save memory in response msg.MediaData = nil 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 } // 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, '', '', '...', 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) (*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) 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{}) ([]HourlyDistribution, error) { query := ` SELECT CAST(strftime('%H', date, 'unixepoch', 'localtime') 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 }