From 99d38a4a311e4a8a4d97965d88b66d217a786415 Mon Sep 17 00:00:00 2001
From: Kevin Lyda <kevin@lyda.ie>
Date: Thu, 15 May 2025 18:24:40 +0100
Subject: [PATCH] Implement several commands

Implement SEEN, UNSEEN, FIRST and PREV.  Can now parse number
lists used by several commands.
---
 NOTES.md                     | 18 ++++----
 folders/messages.go          | 56 +++++++++++++++++++++++
 repl/args.go                 | 43 ++++++++++++++++++
 repl/command.go              |  4 +-
 repl/messages.go             | 88 +++++++++++++++++++++++++++++++-----
 repl/show.go                 |  2 +-
 storage/connection.go        |  4 ++
 storage/display.go           | 27 +++++++++++
 storage/messages.sql.go      | 79 +++++++++++++++++++++++++++-----
 storage/queries/messages.sql | 17 ++++++-
 storage/version.go           | 53 ++++++++++++++++++++++
 this/this.go                 | 13 +++++-
 12 files changed, 366 insertions(+), 38 deletions(-)
 create mode 100644 repl/args.go
 create mode 100644 storage/version.go

diff --git a/NOTES.md b/NOTES.md
index 89823c4..46182bb 100644
--- a/NOTES.md
+++ b/NOTES.md
@@ -30,14 +30,10 @@ repl.commands?
   * Run [godoc](http://localhost:6060/) and then review where the help text is lacking.
   * ~~Move to a storage layer.~~
   * this.Folder should be a storage.Folder.
-  * Implement batch jobs
-     * ~~Have install populate the database with some test data.~~
-     * ~~reboot~~
-     * ~~expire~~
   * Implement each command.
     * Next: folder commands - ~~CREATE~~, ~~REMOVE~~, MODIFY, ~~INDEX~~, ~~SELECT~~
-    * Messages: ~~ADD~~, CURRENT, ~~DIRECTORY~~, BACK, CHANGE,
-                FIRST, REMOVE, ~~NEXT~~, ~~READ~~
+    * Messages: ~~ADD~~, ~~CURRENT~~, ~~DIRECTORY~~, ~~BACK~~, CHANGE,
+                ~~FIRST~~, ~~NEXT~~, ~~READ~~, DELETE
     * Messages edit: CHANGE, REPLY, FORWARD
     * Moving messages: COPY, MOVE
     * Compound commands: SET and SHOW - make HELP work for them.
@@ -53,15 +49,12 @@ repl.commands?
     * trigger to limit values for 'visibility'?
   * Add some of the early announcements from the sources - see the
     conversion branch - to the GENERAL folder.
-  * Add a pager
   * Add commands:
     * A way to add / delete ssh keys.
     * A way to manage files?
     * Commands for a local mail system?
     * Commands to connect to Mattermost or mastodon?
     * Commands to manage users.
-  * ~~SHOW VERSION~~
-  * Check db version; notify user if it changes; refuse to write to db if it has.
 
 Done:
 
@@ -72,6 +65,13 @@ Done:
     * Using giu, a [text-editor](https://serge-hulne.medium.com/coding-a-simple-text-editor-in-go-using-giu-quick-and-dirty-b9b97ab41e4a) (needs cgo, no)
     * [bubbletea](https://github.com/charmbracelet/bubbletea) seems to be the tui that's winning
     * Another option is tview - [simpler](https://github.com/rivo/tview).
+  * ~~Implement batch jobs~~
+     * ~~Have install populate the database with some test data.~~
+     * ~~reboot~~
+     * ~~expire~~
+  * ~~Add a pager~~
+  * ~~SHOW VERSION~~
+  * Check db version; notify user if it changes; refuse to write to db if it has.
 
 ## Module links
 
diff --git a/folders/messages.go b/folders/messages.go
index e59c2a9..6eeb8eb 100644
--- a/folders/messages.go
+++ b/folders/messages.go
@@ -60,6 +60,34 @@ func ReadMessage(login, folder string, msgid int64) (*storage.Message, error) {
 	return &msg, nil
 }
 
+// MarkSeen marks a list of messages as seen.
+func MarkSeen(msgids []int64) error {
+	ctx := storage.Context()
+
+	for _, msgid := range msgids {
+		this.Q.SetMessageSeen(ctx, storage.SetMessageSeenParams{
+			Login:  this.User.Login,
+			Folder: this.Folder,
+			Msgid:  msgid,
+		})
+	}
+	return nil
+}
+
+// MarkUnseen marks a list of messages as unseen.
+func MarkUnseen(msgids []int64) error {
+	ctx := storage.Context()
+
+	for _, msgid := range msgids {
+		this.Q.UnsetMessageSeen(ctx, storage.UnsetMessageSeenParams{
+			Login:  this.User.Login,
+			Folder: this.Folder,
+			Msgid:  msgid,
+		})
+	}
+	return nil
+}
+
 // NextMsgid gets the next message id.
 func NextMsgid(login, folder string, msgid int64) int64 {
 	ctx := storage.Context()
@@ -74,6 +102,34 @@ func NextMsgid(login, folder string, msgid int64) int64 {
 	return newid
 }
 
+// PrevMsgid gets the next message id.
+func PrevMsgid(login, folder string, msgid int64) int64 {
+	ctx := storage.Context()
+	newid, err := this.Q.PrevMsgid(ctx, storage.PrevMsgidParams{
+		Folder: folder,
+		Login:  login,
+		ID:     msgid,
+	})
+	if err != nil {
+		return 0
+	}
+	return newid
+}
+
+// FirstMessage gets the first message in a folder.
+func FirstMessage(folder string) int64 {
+	ctx := storage.Context()
+	first, err := this.Q.NextMsgidIgnoringSeen(ctx,
+		storage.NextMsgidIgnoringSeenParams{
+			Folder: folder,
+			ID:     0,
+		})
+	if err != nil {
+		return 0
+	}
+	return first
+}
+
 // ListMessages lists messages.
 func ListMessages(folder string) ([]storage.Message, error) {
 	ctx := storage.Context()
diff --git a/repl/args.go b/repl/args.go
new file mode 100644
index 0000000..dd88455
--- /dev/null
+++ b/repl/args.go
@@ -0,0 +1,43 @@
+package repl
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+)
+
+// ParseNumberList takes a string with a number list
+// like "1,8,3-6,10,20-30" and returns a number list like
+// []int64{1,8,3,4,5,6,10,20,21,...,30}.  Well, also an error since
+// somethings can go wrong here.
+func ParseNumberList(input string) ([]int64, error) {
+	var result []int64
+	segments := strings.Split(input, ",")
+
+	for _, segment := range segments {
+		segment = strings.TrimSpace(segment)
+		if strings.Contains(segment, "-") {
+			// Handle range
+			parts := strings.SplitN(segment, "-", 2)
+			if len(parts) != 2 {
+				return nil, fmt.Errorf("invalid range format: %s", segment)
+			}
+			start, err1 := strconv.ParseInt(parts[0], 10, 64)
+			end, err2 := strconv.ParseInt(parts[1], 10, 64)
+			if err1 != nil || err2 != nil || start > end {
+				return nil, fmt.Errorf("invalid range: %s", segment)
+			}
+			for i := start; i <= end; i++ {
+				result = append(result, i)
+			}
+		} else {
+			// Handle single number
+			num, err := strconv.ParseInt(segment, 10, 64)
+			if err != nil {
+				return nil, fmt.Errorf("invalid number: %s", segment)
+			}
+			result = append(result, num)
+		}
+	}
+	return result, nil
+}
diff --git a/repl/command.go b/repl/command.go
index 3053404..83167df 100644
--- a/repl/command.go
+++ b/repl/command.go
@@ -1139,16 +1139,16 @@ automatically  be set  as  being SEEN  when they  are  read.
 If you  have used  the SEEN  command and wish  to disable  the automatic
 marking of messages in regular folders  as SEEN when they are read, type
 the command SEEN/NOREAD. To reenable, simply use the SEEN command again.`,
-		MinArgs: 1,
 		MaxArgs: 1,
+		Action:  ActionSeen,
 	},
 	"UNSEEN": {
 		Description: `Sets the current or message-id message as unseen.
 
   Format:
     UNSEEN [message-number or numbers]`,
-		MinArgs: 1,
 		MaxArgs: 1,
+		Action:  ActionUnseen,
 	},
 	"SELECT": {
 		Description: `Selects a folder of messages.  See HELP Folders for a description  of  a
diff --git a/repl/messages.go b/repl/messages.go
index 4778a6d..9e5f223 100644
--- a/repl/messages.go
+++ b/repl/messages.go
@@ -148,14 +148,36 @@ func ActionAdd(cmd *dclish.Command) error {
 }
 
 // ActionCurrent handles the `CURRENT` command.
-func ActionCurrent(cmd *dclish.Command) error {
-	fmt.Printf("TODO: unimplemented...\n%s\n", cmd.Description)
+func ActionCurrent(_ *dclish.Command) error {
+	// TODO: handle flags.
+	msg, err := folders.ReadMessage(this.User.Login, this.Folder, this.MsgID)
+	if err != nil {
+		return err
+	}
+	lines := strings.Split(msg.String(), "\n")
+	if len(lines) > 10 {
+		lines = lines[:10]
+	}
+	fmt.Printf("%s\n", strings.Join(lines, "\n"))
 	return nil
 }
 
 // ActionBack handles the `BACK` command.
-func ActionBack(cmd *dclish.Command) error {
-	fmt.Printf("TODO: unimplemented...\n%s\n", cmd.Description)
+func ActionBack(_ *dclish.Command) error {
+	// TODO: handle flags.
+	msgid := folders.PrevMsgid(this.User.Login, this.Folder, this.MsgID)
+	if msgid == 0 {
+		fmt.Println("No previous messages")
+		return nil
+	}
+	msg, err := folders.ReadMessage(this.User.Login, this.Folder, msgid)
+	if err != nil {
+		return err
+	}
+	// TODO: pager needs to report if the whole message was read
+	// and only increment if not.
+	pager.Pager(msg.String())
+	this.MsgID = msgid
 	return nil
 }
 
@@ -166,15 +188,24 @@ func ActionChange(cmd *dclish.Command) error {
 }
 
 // ActionFirst handles the `FIRST` command.
-func ActionFirst(cmd *dclish.Command) error {
-	fmt.Printf("TODO: unimplemented...\n%s\n", cmd.Description)
+func ActionFirst(_ *dclish.Command) error {
+	msgid := folders.FirstMessage(this.Folder)
+	if msgid == 0 {
+		fmt.Println("No messages in folder")
+		return nil
+	}
+	this.MsgID = msgid
 	return nil
 }
 
 // ActionNext handles the `NEXT` command.
-func ActionNext(cmd *dclish.Command) error {
+func ActionNext(_ *dclish.Command) error {
 	// TODO: handle flags.
-	msgid := max(folders.NextMsgid(this.User.Login, this.Folder, this.MsgID), this.MsgID)
+	msgid := folders.NextMsgid(this.User.Login, this.Folder, this.MsgID)
+	if msgid == 0 {
+		fmt.Println("No next messages")
+		return nil
+	}
 	msg, err := folders.ReadMessage(this.User.Login, this.Folder, msgid)
 	if err != nil {
 		return err
@@ -182,8 +213,7 @@ func ActionNext(cmd *dclish.Command) error {
 	// TODO: pager needs to report if the whole message was read
 	// and only increment if not.
 	pager.Pager(msg.String())
-	msgid = folders.NextMsgid(this.User.Login, this.Folder, msgid)
-	this.MsgID = max(this.MsgID, msgid)
+	this.MsgID = msgid
 	return nil
 }
 
@@ -192,11 +222,11 @@ func ActionRead(cmd *dclish.Command) error {
 	// TODO: handle flags.
 	msgid := this.MsgID
 	if len(cmd.Args) == 1 {
-		id, err := strconv.Atoi(cmd.Args[0])
+		var err error
+		msgid, err = strconv.ParseInt(cmd.Args[0], 10, 64)
 		if err != nil {
 			return err
 		}
-		msgid = int64(id)
 	}
 	msg, err := folders.ReadMessage(this.User.Login, this.Folder, msgid)
 	if err != nil {
@@ -221,3 +251,37 @@ func ActionForward(cmd *dclish.Command) error {
 	fmt.Printf("TODO: unimplemented...\n%s\n", cmd.Description)
 	return nil
 }
+
+// ActionSeen handles the `SEEN` command.
+func ActionSeen(cmd *dclish.Command) error {
+	var err error
+	msgids := []int64{this.MsgID}
+	if len(cmd.Args) == 1 {
+		msgids, err = ParseNumberList(cmd.Args[0])
+		if err != nil {
+			return err
+		}
+	}
+	err = folders.MarkSeen(msgids)
+	if err != nil {
+		fmt.Printf("ERROR: %s.\n", err)
+	}
+	return nil
+}
+
+// ActionUnseen handles the `UNSEEN` command.
+func ActionUnseen(cmd *dclish.Command) error {
+	var err error
+	msgids := []int64{this.MsgID}
+	if len(cmd.Args) == 1 {
+		msgids, err = ParseNumberList(cmd.Args[0])
+		if err != nil {
+			return err
+		}
+	}
+	err = folders.MarkUnseen(msgids)
+	if err != nil {
+		fmt.Printf("ERROR: %s.\n", err)
+	}
+	return nil
+}
diff --git a/repl/show.go b/repl/show.go
index b3c4df2..6b23e8f 100644
--- a/repl/show.go
+++ b/repl/show.go
@@ -11,7 +11,7 @@ import (
 
 // ActionShowFlags handles the `SHOW FLAGS` command.
 func ActionShowFlags(_ *dclish.Command) error {
-	fmt.Println("TODO: implement ActionShowFlags.")
+	// ctx := storage.Context()
 	return nil
 }
 
diff --git a/storage/connection.go b/storage/connection.go
index 616afd2..d4c9eb5 100644
--- a/storage/connection.go
+++ b/storage/connection.go
@@ -51,6 +51,10 @@ func Open() (*sqlx.DB, error) {
 	if err != nil {
 		return nil, errors.New("bulletin database problem")
 	}
+
+	// Prepare to watch for schema skew - see version.go.
+	InitialiseSkewChecker(db, m)
+
 	return db, nil
 }
 
diff --git a/storage/display.go b/storage/display.go
index 7515534..d01d7f2 100644
--- a/storage/display.go
+++ b/storage/display.go
@@ -33,3 +33,30 @@ func (m *Message) OneLine(expire bool) string {
 	ts := t.Format("2006-05-04 15:02:01")
 	return fmt.Sprintf("%4d %-43s %-12s %-10s\n", m.ID, m.Subject, m.Author, ts)
 }
+
+// String displays a user (mainly used for debugging).
+func (u User) String() string {
+	return fmt.Sprintf("User %s (%s) [a%d, m%d, !%d, d%d] [%s]",
+		u.Login,
+		u.Name,
+		u.Admin,
+		u.Moderator,
+		u.Alert,
+		u.Disabled,
+		u.LastLogin.Format("06-05-04 15:02:01"))
+}
+
+// String displays a folder (mainly used for debugging).
+func (f Folder) String() string {
+	return fmt.Sprintf("Folder %s (%s) [a%d, b%d, n%d, rn%d, sn%d, sys%d, exp%d, v%d]",
+		f.Name,
+		f.Description,
+		f.Always,
+		f.Brief,
+		f.Notify,
+		f.Readnew,
+		f.Shownew,
+		f.System,
+		f.Expire,
+		f.Visibility)
+}
diff --git a/storage/messages.sql.go b/storage/messages.sql.go
index b922e09..ac9ea85 100644
--- a/storage/messages.sql.go
+++ b/storage/messages.sql.go
@@ -41,17 +41,6 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) er
 	return err
 }
 
-const getFirstMessageID = `-- name: GetFirstMessageID :one
-SELECT id FROM messages WHERE folder = ? and id = MIN(id) GROUP BY folder
-`
-
-func (q *Queries) GetFirstMessageID(ctx context.Context, folder string) (int64, error) {
-	row := q.db.QueryRowContext(ctx, getFirstMessageID, folder)
-	var id int64
-	err := row.Scan(&id)
-	return id, err
-}
-
 const nextMsgid = `-- name: NextMsgid :one
 SELECT CAST(COALESCE(MIN(id), 0) AS INT) FROM messages AS m
   WHERE m.folder = ?1 AND m.id > ?2
@@ -71,6 +60,59 @@ func (q *Queries) NextMsgid(ctx context.Context, arg NextMsgidParams) (int64, er
 	return column_1, err
 }
 
+const nextMsgidIgnoringSeen = `-- name: NextMsgidIgnoringSeen :one
+SELECT CAST(MIN(id) AS INT) FROM messages AS m
+  WHERE m.folder = ?1 AND m.id > ?2
+`
+
+type NextMsgidIgnoringSeenParams struct {
+	Folder string
+	ID     int64
+}
+
+func (q *Queries) NextMsgidIgnoringSeen(ctx context.Context, arg NextMsgidIgnoringSeenParams) (int64, error) {
+	row := q.db.QueryRowContext(ctx, nextMsgidIgnoringSeen, arg.Folder, arg.ID)
+	var column_1 int64
+	err := row.Scan(&column_1)
+	return column_1, err
+}
+
+const prevMsgid = `-- name: PrevMsgid :one
+SELECT CAST(COALESCE(MAX(id), 0) AS INT) FROM messages AS m
+  WHERE m.folder = ?1 AND m.id < ?2
+  AND id NOT IN (SELECT id FROM seen AS s WHERE s.folder = ?1 AND s.login = ?3)
+`
+
+type PrevMsgidParams struct {
+	Folder string
+	ID     int64
+	Login  string
+}
+
+func (q *Queries) PrevMsgid(ctx context.Context, arg PrevMsgidParams) (int64, error) {
+	row := q.db.QueryRowContext(ctx, prevMsgid, arg.Folder, arg.ID, arg.Login)
+	var column_1 int64
+	err := row.Scan(&column_1)
+	return column_1, err
+}
+
+const prevMsgidIgnoringSeen = `-- name: PrevMsgidIgnoringSeen :one
+SELECT CAST(MAX(id) AS INT) FROM messages AS m
+  WHERE m.folder = ?1 AND m.id < ?2
+`
+
+type PrevMsgidIgnoringSeenParams struct {
+	Folder string
+	ID     int64
+}
+
+func (q *Queries) PrevMsgidIgnoringSeen(ctx context.Context, arg PrevMsgidIgnoringSeenParams) (int64, error) {
+	row := q.db.QueryRowContext(ctx, prevMsgidIgnoringSeen, arg.Folder, arg.ID)
+	var column_1 int64
+	err := row.Scan(&column_1)
+	return column_1, err
+}
+
 const readMessage = `-- name: ReadMessage :one
 SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages WHERE folder = ? AND id = ?
 `
@@ -112,3 +154,18 @@ func (q *Queries) SetMessageSeen(ctx context.Context, arg SetMessageSeenParams)
 	_, err := q.db.ExecContext(ctx, setMessageSeen, arg.Login, arg.Folder, arg.Msgid)
 	return err
 }
+
+const unsetMessageSeen = `-- name: UnsetMessageSeen :exec
+DELETE FROM seen WHERE login = ? AND folder = ? AND msgid = ?
+`
+
+type UnsetMessageSeenParams struct {
+	Login  string
+	Folder string
+	Msgid  int64
+}
+
+func (q *Queries) UnsetMessageSeen(ctx context.Context, arg UnsetMessageSeenParams) error {
+	_, err := q.db.ExecContext(ctx, unsetMessageSeen, arg.Login, arg.Folder, arg.Msgid)
+	return err
+}
diff --git a/storage/queries/messages.sql b/storage/queries/messages.sql
index 0866f46..52eed05 100644
--- a/storage/queries/messages.sql
+++ b/storage/queries/messages.sql
@@ -8,8 +8,8 @@ INSERT INTO messages (
 -- name: SetMessageSeen :exec
 INSERT INTO seen (login, folder, msgid) VALUES (?, ?, ?);
 
--- name: GetFirstMessageID :one
-SELECT id FROM messages WHERE folder = ? and id = MIN(id) GROUP BY folder;
+-- name: UnsetMessageSeen :exec
+DELETE FROM seen WHERE login = ? AND folder = ? AND msgid = ?;
 
 -- name: ReadMessage :one
 SELECT * FROM messages WHERE folder = ? AND id = ?;
@@ -18,3 +18,16 @@ SELECT * FROM messages WHERE folder = ? AND id = ?;
 SELECT CAST(COALESCE(MIN(id), 0) AS INT) FROM messages AS m
   WHERE m.folder = ?1 AND m.id > ?2
   AND id NOT IN (SELECT id FROM seen AS s WHERE s.folder = ?1 AND s.login = ?3);
+
+-- name: NextMsgidIgnoringSeen :one
+SELECT CAST(MIN(id) AS INT) FROM messages AS m
+  WHERE m.folder = ?1 AND m.id > ?2;
+
+-- name: PrevMsgid :one
+SELECT CAST(COALESCE(MAX(id), 0) AS INT) FROM messages AS m
+  WHERE m.folder = ?1 AND m.id < ?2
+  AND id NOT IN (SELECT id FROM seen AS s WHERE s.folder = ?1 AND s.login = ?3);
+
+-- name: PrevMsgidIgnoringSeen :one
+SELECT CAST(MAX(id) AS INT) FROM messages AS m
+  WHERE m.folder = ?1 AND m.id < ?2;
diff --git a/storage/version.go b/storage/version.go
new file mode 100644
index 0000000..565065b
--- /dev/null
+++ b/storage/version.go
@@ -0,0 +1,53 @@
+package storage
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/golang-migrate/migrate/v4"
+	"github.com/jmoiron/sqlx"
+)
+
+// Skew helps us track database skew.
+var Skew SkewChecker
+
+// SkewChecker helps us track database skew.
+type SkewChecker struct {
+	db     *sqlx.DB
+	migver dbVersion
+}
+
+// InitialiseSkewChecker creates an object to track skew.
+func InitialiseSkewChecker(db *sqlx.DB, m *migrate.Migrate) {
+	version, dirty, err := m.Version()
+	if err != nil {
+		fmt.Printf("ERROR: Schema issue: %s\n", err)
+		os.Exit(1)
+	}
+	Skew = SkewChecker{
+		db: db,
+		migver: dbVersion{
+			Version: version,
+			Dirty:   dirty,
+		},
+	}
+}
+
+type dbVersion struct {
+	Version uint `db:"version"`
+	Dirty   bool `db:"dirty"`
+}
+
+// Safe returns true if the DB hasn't skewed.
+func (skew *SkewChecker) Safe() bool {
+	row := skew.db.QueryRowx("SELECT * FROM schema_migrations")
+	v := &dbVersion{
+		Dirty: true,
+	}
+	row.StructScan(v)
+
+	if skew.migver.Version != v.Version {
+		return false
+	}
+	return !v.Dirty
+}
diff --git a/this/this.go b/this/this.go
index 9ea7e69..c644852 100644
--- a/this/this.go
+++ b/this/this.go
@@ -74,7 +74,18 @@ func StartThis(login, name string) error {
 		return errors.New("User is disabled")
 	}
 	Folder = "GENERAL"
-	MsgID, err = Q.GetFirstMessageID(ctx, Folder)
+	MsgID, err = Q.NextMsgid(ctx, storage.NextMsgidParams{
+		Folder: Folder,
+		ID:     0,
+		Login:  User.Login,
+	})
+	if MsgID == 0 {
+		MsgID, err = Q.NextMsgidIgnoringSeen(ctx, storage.NextMsgidIgnoringSeenParams{
+			Folder: Folder,
+			ID:     0,
+		})
+	}
 
+	fmt.Printf("User: %s\nFolder: %s\nMsgID: %d\n", User, Folder, MsgID)
 	return nil
 }
-- 
GitLab