improve IRC channel continuity and formatting

This commit is contained in:
Andy Dixon
2026-08-13 17:35:32 +01:00
parent 696661bfa9
commit 49064a845c
11 changed files with 601 additions and 42 deletions
@@ -0,0 +1,15 @@
-- Migration 006: Remember the channels open for each saved IRC profile.
CREATE TABLE IF NOT EXISTS irc_channel_state (
profile_id TEXT NOT NULL,
room_name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (profile_id, room_name)
);
CREATE INDEX IF NOT EXISTS idx_irc_channel_state_profile_position
ON irc_channel_state (profile_id, position);
INSERT INTO schema_version (version, applied_at)
VALUES (6, datetime('now'));
+57
View File
@@ -33,6 +33,15 @@ func makeSSHProfile(name, host string) *models.Profile {
return &p
}
func makeIRCProfile(name, host string) *models.Profile {
p := models.NewProfile(models.ProtocolIRC)
p.DisplayName = name
p.Host = host
p.Username = "nexus"
p.IRC.Nick = "nexus"
return &p
}
// ---------------------------------------------------------------------------
// Profile CRUD
// ---------------------------------------------------------------------------
@@ -104,6 +113,54 @@ func TestDeleteProfileNotFound(t *testing.T) {
}
}
func TestIRCLastRoomsRoundTripReplaceAndDelete(t *testing.T) {
store := setupTestStore(t)
p := makeIRCProfile("IRC Network", "irc.example.com")
if err := store.CreateProfile(p); err != nil {
t.Fatalf("CreateProfile() error = %v", err)
}
if err := store.SaveIRCLastRooms(p.ID, []string{" #Go-Nuts ", "#nexus", "#go-nuts", ""}); err != nil {
t.Fatalf("SaveIRCLastRooms() error = %v", err)
}
rooms, err := store.GetIRCLastRooms(p.ID)
if err != nil {
t.Fatalf("GetIRCLastRooms() error = %v", err)
}
if got, want := strings.Join(rooms, ","), "#Go-Nuts,#nexus"; got != want {
t.Fatalf("GetIRCLastRooms() = %q, want %q", got, want)
}
if err := store.SaveIRCLastRooms(p.ID, []string{"#new-room"}); err != nil {
t.Fatalf("SaveIRCLastRooms(replace) error = %v", err)
}
rooms, err = store.GetIRCLastRooms(p.ID)
if err != nil {
t.Fatalf("GetIRCLastRooms(replaced) error = %v", err)
}
if got, want := strings.Join(rooms, ","), "#new-room"; got != want {
t.Fatalf("GetIRCLastRooms(replaced) = %q, want %q", got, want)
}
if err := store.DeleteProfile(p.ID); err != nil {
t.Fatalf("DeleteProfile() error = %v", err)
}
rooms, err = store.GetIRCLastRooms(p.ID)
if err != nil {
t.Fatalf("GetIRCLastRooms(after delete) error = %v", err)
}
if len(rooms) != 0 {
t.Fatalf("GetIRCLastRooms(after delete) = %v, want empty", rooms)
}
}
func TestSaveIRCLastRoomsRejectsEmptyProfileID(t *testing.T) {
store := setupTestStore(t)
if err := store.SaveIRCLastRooms(" ", []string{"#room"}); err == nil {
t.Fatal("SaveIRCLastRooms() accepted an empty profile ID")
}
}
// ---------------------------------------------------------------------------
// List and search
// ---------------------------------------------------------------------------
+87 -1
View File
@@ -215,7 +215,16 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
// DeleteProfile removes a profile by ID.
func (s *Store) DeleteProfile(id string) error {
res, err := s.db.Exec("DELETE FROM profiles WHERE id = ?", id)
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("storage: failed to begin profile deletion: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec("DELETE FROM irc_channel_state WHERE profile_id = ?", id); err != nil {
return fmt.Errorf("storage: failed to delete IRC channel state: %w", err)
}
res, err := tx.Exec("DELETE FROM profiles WHERE id = ?", id)
if err != nil {
return fmt.Errorf("storage: failed to delete profile: %w", err)
}
@@ -223,6 +232,9 @@ func (s *Store) DeleteProfile(id string) error {
if n == 0 {
return fmt.Errorf("storage: profile %q not found", id)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("storage: failed to commit profile deletion: %w", err)
}
return nil
}
@@ -550,6 +562,80 @@ func (s *Store) ListAuditEntries(limit int) ([]*models.AuditEntry, error) {
return entries, rows.Err()
}
// ---------------------------------------------------------------------------
// IRC channel state
// ---------------------------------------------------------------------------
// GetIRCLastRooms returns the channels that were open the last time the given
// saved IRC profile was connected.
func (s *Store) GetIRCLastRooms(profileID string) ([]string, error) {
rows, err := s.db.Query(`SELECT room_name FROM irc_channel_state
WHERE profile_id = ? ORDER BY position, room_name`, profileID)
if err != nil {
return nil, fmt.Errorf("storage: failed to get IRC channel state: %w", err)
}
defer rows.Close()
rooms := make([]string, 0)
for rows.Next() {
var room string
if err := rows.Scan(&room); err != nil {
return nil, fmt.Errorf("storage: failed to scan IRC channel state: %w", err)
}
rooms = append(rooms, room)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("storage: failed to read IRC channel state: %w", err)
}
return rooms, nil
}
// SaveIRCLastRooms atomically replaces the remembered channel set for a saved
// IRC profile. Empty names and case-insensitive duplicates are discarded.
func (s *Store) SaveIRCLastRooms(profileID string, rooms []string) error {
profileID = strings.TrimSpace(profileID)
if profileID == "" {
return fmt.Errorf("storage: IRC channel state requires a profile ID")
}
normalized := make([]string, 0, len(rooms))
seen := make(map[string]struct{}, len(rooms))
for _, room := range rooms {
room = strings.TrimSpace(room)
if room == "" {
continue
}
key := strings.ToLower(room)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
normalized = append(normalized, room)
}
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("storage: failed to begin IRC channel state update: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec("DELETE FROM irc_channel_state WHERE profile_id = ?", profileID); err != nil {
return fmt.Errorf("storage: failed to clear IRC channel state: %w", err)
}
now := time.Now().Format(time.RFC3339)
for position, room := range normalized {
if _, err := tx.Exec(`INSERT INTO irc_channel_state
(profile_id, room_name, position, updated_at) VALUES (?, ?, ?, ?)`,
profileID, room, position, now); err != nil {
return fmt.Errorf("storage: failed to save IRC channel state: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("storage: failed to commit IRC channel state: %w", err)
}
return nil
}
// ---------------------------------------------------------------------------
// Preferences (key-value)
// ---------------------------------------------------------------------------