urit/internal/menu/messages.go
2026-05-02 21:11:50 -04:00

903 lines
24 KiB
Go

// messages.go implements the message board subsystem.
//
// This replaces MCOM.C from the original TAG-BBS. The original had
// three nested menu levels:
//
// MCom() → Board selection: N>ew, S>ome, A>ll, L>ist, Q>uit
// Msg_Prompt(board) → Per-board: N>ew, R>ead, I>mmediate, W>rite, Q>uit
// Between_Msg_Prompt → Between messages: A>gain, N>ext, L>ast, R>eply, D>elete
//
// The message storage is completely different from the original:
//
// Original: Board_Header (linked list in memory) + Board.Keys (flat file
// of Board_Data structs with slot numbers) + Board.Data (flat file with
// fixed 2500-byte message bodies at lseek offsets).
//
// Modern: SQLite tables (boards + messages) with foreign keys, variable-
// length text bodies, and automatic numbering via the store layer.
//
// The user-facing experience is preserved: single-keypress navigation,
// read-new-since-last-login, sequential message display with between-
// message prompts, and the same access control model (ReadLow/ReadHigh,
// WriteLow/WriteHigh ranges compared against the user's SecBoard level).
package menu
import (
"fmt"
"strings"
"github.com/urit/urit/internal/models"
"github.com/urit/urit/internal/session"
)
// cmdMessages is the entry point for the message board subsystem.
// Replaces MCom() from MCOM.C — the top-level board selection menu.
func cmdMessages(ctx *Context) error {
boards, err := ctx.Store.ListBoards()
if err != nil {
ctx.Sess.WriteString(" Error loading boards.\r\n")
return nil
}
// Filter boards to those the user can see (read OR write access)
var visible []*models.Board
for _, b := range boards {
if b.CanRead(ctx.User.SecBoard) || b.CanWrite(ctx.User.SecBoard) {
visible = append(visible, b)
}
}
if len(visible) == 0 {
ctx.Sess.WriteString(" No message boards available.\r\n\r\n")
return nil
}
ctx.Sess.NewLine()
for {
ctx.Sess.CheckTime()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("N>ew S>ome A>ll L>ist Q>uit\r\n")
ctx.Sess.Printf("%s Message Base> ", ctx.Cfg.System.Name)
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return nil
}
switch toUpper(ch) {
case 'L':
ctx.Sess.WriteString("List\r\n\r\n")
msgBoardList(ctx, visible)
case 'A':
ctx.Sess.WriteString("All Boards\r\n\r\n")
for i, b := range visible {
ctx.Sess.Printf("[%d] %s\r\n", i+1, b.Name)
quit := msgBoardPrompt(ctx, b, i+1)
if quit {
goto done
}
}
ctx.Sess.WriteString("Completed visiting ALL\r\n")
case 'N':
ctx.Sess.WriteString("New Messages\r\n\r\n")
found := false
for i, b := range visible {
if !b.CanRead(ctx.User.SecBoard) {
continue
}
// Check if there are new messages since last login
if ctx.User.LastOn != nil && b.LatestPost != nil &&
!b.LatestPost.After(*ctx.User.LastOn) {
continue
}
found = true
ctx.Sess.Printf("\r\n[%d] %s\r\n", i+1, b.Name)
// Read new messages first, then show prompt
msgReadNew(ctx, b)
quit := msgBoardPrompt(ctx, b, i+1)
if quit {
goto done
}
}
if !found {
ctx.Sess.WriteString("Nothing new.\r\n")
}
ctx.Sess.WriteString("Completed visiting NEW\r\n")
case 'S':
ctx.Sess.WriteString("Some Boards\r\n\r\n")
for i, b := range visible {
ctx.Sess.Printf("Visit [%d] %s? ", i+1, b.Name)
ych, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return nil
}
switch toUpper(ych) {
case 'Y':
ctx.Sess.WriteString("Yes\r\n")
quit := msgBoardPrompt(ctx, b, i+1)
if quit {
goto done
}
case 'Q':
ctx.Sess.WriteString("Quit\r\n")
goto done
default:
ctx.Sess.WriteString("No\r\n")
}
}
ctx.Sess.WriteString("Completed visiting SOME\r\n")
case 'Q', '\x1b':
ctx.Sess.WriteString("Quit\r\n")
goto done
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
msgBoardList(ctx, visible)
}
}
done:
return nil
}
// msgBoardList displays the list of visible boards with post counts.
// Replaces the 'L' (list) case in MCom().
func msgBoardList(ctx *Context, boards []*models.Board) {
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.WriteString("Message Boards\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 50) + "\r\n")
for i, b := range boards {
access := ""
if b.CanRead(ctx.User.SecBoard) && b.CanWrite(ctx.User.SecBoard) {
access = "RW"
} else if b.CanRead(ctx.User.SecBoard) {
access = "R "
} else if b.CanWrite(ctx.User.SecBoard) {
access = " W"
}
latest := "no posts"
if b.LatestPost != nil {
latest = b.LatestPost.Format("Jan 02")
}
ctx.Sess.Printf(" [%d] %-20s %3d msgs %s %s\r\n",
i+1, b.Name, b.PostCount, access, latest)
}
ctx.Sess.NewLine()
}
// msgBoardPrompt is the per-board command loop.
// Replaces Msg_Prompt() from MCOM.C — the N>ew R>ead I>mmediate W>rite menu.
// Returns true if the user wants to return to the main menu (@).
func msgBoardPrompt(ctx *Context, board *models.Board, num int) bool {
canRead := board.CanRead(ctx.User.SecBoard)
canWrite := board.CanWrite(ctx.User.SecBoard)
for {
ctx.Sess.CheckTime()
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
// Build the prompt options based on access
opts := ""
if canRead {
opts += "N>ew R>ead I>mmediate "
}
if canWrite {
opts += "W>rite "
}
if ctx.User.SecStatus >= 100 {
opts += "D>elete "
}
opts += "Q>uit @>Main"
ctx.Sess.WriteString(opts + "\r\n")
ctx.Sess.Printf("[%d] %s> ", num, board.Name)
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return true
}
switch toUpper(ch) {
case 'N':
if !canRead {
continue
}
ctx.Sess.WriteString("New\r\n")
msgReadNew(ctx, board)
case 'R':
if !canRead {
continue
}
ctx.Sess.WriteString("Read\r\n")
msgReadFrontend(ctx, board)
case 'I':
if !canRead {
continue
}
ctx.Sess.WriteString("Immediate\r\n")
msgImmediate(ctx, board)
case 'W':
if !canWrite {
continue
}
ctx.Sess.WriteString("Write\r\n")
msgWrite(ctx, board, 0)
case 'D':
if ctx.User.SecStatus < 100 {
continue
}
ctx.Sess.WriteString("Delete\r\n")
msgDeleteFrontend(ctx, board)
case 'Q':
ctx.Sess.WriteString("Quit\r\n")
return false
case '@':
ctx.Sess.WriteString("Main Menu\r\n")
return true
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.WriteString(" N - Read new messages since your last login\r\n")
ctx.Sess.WriteString(" R - Read messages by number range\r\n")
ctx.Sess.WriteString(" I - Read a single message by number\r\n")
ctx.Sess.WriteString(" W - Write a new message\r\n")
if ctx.User.SecStatus >= 100 {
ctx.Sess.WriteString(" D - Delete messages\r\n")
}
ctx.Sess.WriteString(" Q - Return to board selection\r\n")
ctx.Sess.WriteString(" @ - Return to main menu\r\n")
}
}
}
// msgReadNew reads messages posted since the user's last login.
// Replaces Msg_ReadNew() from MCOM.C.
func msgReadNew(ctx *Context, board *models.Board) {
if board.PostCount == 0 {
ctx.Sess.WriteString(" No messages in this board.\r\n")
return
}
// Get messages posted since last login
var sinceUnix int64
if ctx.User.LastOn != nil {
sinceUnix = ctx.User.LastOn.Unix()
}
msgs, err := ctx.Store.ListMessagesSince(board.ID, sinceUnix)
if err != nil {
ctx.Sess.WriteString(" Error loading messages.\r\n")
return
}
if len(msgs) == 0 {
ctx.Sess.WriteString(" Nothing new.\r\n")
return
}
ctx.Sess.Printf(" %d new message(s)\r\n", len(msgs))
msgReadSequence(ctx, board, msgs)
}
// msgReadFrontend prompts for a FROM/TO range and reads those messages.
// Replaces Msg_Read_Frontend() from MCOM.C.
func msgReadFrontend(ctx *Context, board *models.Board) {
if board.PostCount == 0 {
ctx.Sess.WriteString(" No messages in this board.\r\n")
return
}
ctx.Sess.Printf(" Read FROM [1-%d]: ", board.PostCount)
fromStr, err := ctx.Sess.ReadLine("1", 5, inputTimeout)
if err != nil {
return
}
from := parseIntDefault(fromStr, 1)
if from < 1 || from > board.PostCount {
ctx.Sess.Printf(" Invalid. Range is 1-%d\r\n", board.PostCount)
return
}
ctx.Sess.Printf(" Read TO [%d-%d]: ", from, board.PostCount)
toStr, err := ctx.Sess.ReadLine(fmt.Sprintf("%d", board.PostCount), 5, inputTimeout)
if err != nil {
return
}
to := parseIntDefault(toStr, board.PostCount)
if to < from || to > board.PostCount {
ctx.Sess.Printf(" Invalid. Range is %d-%d\r\n", from, board.PostCount)
return
}
// Load the range of messages
msgs, err := ctx.Store.ListMessages(board.ID, from-1, to-from+1)
if err != nil {
ctx.Sess.WriteString(" Error loading messages.\r\n")
return
}
msgReadSequence(ctx, board, msgs)
}
// msgImmediate reads a single message by number.
// Replaces Msg_Immediate() from MCOM.C.
func msgImmediate(ctx *Context, board *models.Board) {
if board.PostCount == 0 {
ctx.Sess.WriteString(" No messages in this board.\r\n")
return
}
ctx.Sess.Printf(" Read which [1-%d]: ", board.PostCount)
numStr, err := ctx.Sess.ReadLine("", 5, inputTimeout)
if err != nil {
return
}
num := parseIntDefault(numStr, 0)
if num < 1 || num > board.PostCount {
ctx.Sess.Printf(" Invalid. Range is 1-%d\r\n", board.PostCount)
return
}
msgs, err := ctx.Store.ListMessages(board.ID, num-1, 1)
if err != nil || len(msgs) == 0 {
ctx.Sess.WriteString(" Message not found.\r\n")
return
}
msgReadSequence(ctx, board, msgs)
}
// msgReadSequence displays messages in order with the between-message
// navigation prompt. This is the core reading loop that replaces
// Msg_Read() from MCOM.C.
//
// The original used a for loop with index manipulation:
// number++ (next), number-- (again), number-=2 (last)
//
// We use the same approach with a slice index.
func msgReadSequence(ctx *Context, board *models.Board, msgs []*models.Message) {
if len(msgs) == 0 {
return
}
idx := 0
for idx >= 0 && idx < len(msgs) {
msg := msgs[idx]
// Display the message (replaces Send_Message)
msgDisplay(ctx, board, msg)
// Between-message prompt (replaces Between_Msg_Prompt)
action := msgBetweenPrompt(ctx, board, msg)
switch action {
case msgActionNext:
idx++
case msgActionAgain:
// idx stays the same — re-display
case msgActionLast:
if idx > 0 {
idx--
}
// At first message, "last" re-reads it (matches original)
case msgActionQuit:
return
case msgActionMainMenu:
return
case msgActionDeleted:
// Message was deleted; refresh the list and adjust
refreshed, err := ctx.Store.ListMessages(board.ID, 0, board.MaxPosts)
if err != nil {
return
}
msgs = refreshed
// Refresh the board too (post count changed)
if updated, err := ctx.Store.GetBoard(board.ID); err == nil {
*board = *updated
}
if idx >= len(msgs) {
return
}
}
}
}
// msgDisplay renders a single message to the terminal.
// Replaces Send_Message() from MCOM.C.
func msgDisplay(ctx *Context, board *models.Board, msg *models.Message) {
ctx.Sess.NewLine()
// Header — matches original format:
// Number: [N] of [Total]
// Title: ...
// Author: Name [ID]
// Time: ...
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.Printf("Number: [%d] of [%d]\r\n", msg.Number, board.PostCount)
ctx.Sess.Color(session.AnsiReset)
if msg.Title != "" {
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.Printf(" Title: %s\r\n", msg.Title)
ctx.Sess.Color(session.AnsiReset)
}
ctx.Sess.Printf("Author: %s [%d]\r\n", msg.Author, msg.AuthorID)
ctx.Sess.Printf(" Time: %s\r\n", msg.CreatedAt.Format("Mon Jan 02 15:04:05 2006"))
if msg.ReplyTo > 0 {
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" (reply to #%d)\r\n", msg.ReplyTo)
ctx.Sess.Color(session.AnsiReset)
}
if msg.Locked {
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.WriteString(" [LOCKED]\r\n")
ctx.Sess.Color(session.AnsiReset)
}
ctx.Sess.NewLine()
// Body — paginate if long (replaces the More.. logic in Send_Message)
ctx.Sess.Paginate(msg.Body, idleTimeout)
}
// msgAction represents the user's choice at the between-message prompt.
type msgAction int
const (
msgActionNext msgAction = iota // Continue to next message
msgActionAgain // Re-read current message
msgActionLast // Go back one message
msgActionQuit // Return to board prompt
msgActionMainMenu // Return to main menu
msgActionDeleted // Message was deleted
)
// msgBetweenPrompt shows navigation options between messages.
// Replaces Between_Msg_Prompt() from MCOM.C.
//
// The original's return codes:
// 0 = quit, 1 = continue, 2 = again, 3 = backwards, '@' = main menu
//
// We use named constants instead.
func msgBetweenPrompt(ctx *Context, board *models.Board, msg *models.Message) msgAction {
for {
ctx.Sess.CheckTime()
ctx.Sess.Color(session.AnsiFgCyan)
opts := "N>ext A>gain L>ast R>eply Q>uit @>Main"
// Delete — original required SecStatus >= 100 OR being the author
canDelete := ctx.User.SecStatus >= 100 || ctx.User.ID == msg.AuthorID
if canDelete {
opts += " D>elete"
}
if ctx.User.SecStatus >= 100 {
if msg.Locked {
opts += " ->Unlock"
} else {
opts += " +>Lock"
}
}
ctx.Sess.WriteString("\r\n" + opts + "\r\n")
ctx.Sess.WriteString("Message> ")
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return msgActionQuit
}
switch toUpper(ch) {
case 'N', 'C', '\r':
ctx.Sess.WriteString("Next\r\n")
return msgActionNext
case 'A':
ctx.Sess.WriteString("Again\r\n")
return msgActionAgain
case 'L':
ctx.Sess.WriteString("Last\r\n")
return msgActionLast
case 'R':
ctx.Sess.WriteString("Reply\r\n")
if board.CanWrite(ctx.User.SecBoard) {
msgWrite(ctx, board, msg.Number)
} else {
ctx.Sess.WriteString(" You don't have write access to this board.\r\n")
}
return msgActionNext
case 'D':
if !canDelete {
continue
}
ctx.Sess.WriteString("Delete\r\n")
yes, err := ctx.Sess.Confirm(" Delete this message? ", inputTimeout)
if err != nil {
return msgActionQuit
}
if yes {
if err := ctx.Store.DeleteMessage(msg.ID); err != nil {
ctx.Sess.WriteString(" Error deleting message.\r\n")
} else {
// Update the board's post count
if updated, err := ctx.Store.GetBoard(board.ID); err == nil {
*board = *updated
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.WriteString(" Message deleted.\r\n")
ctx.Sess.Color(session.AnsiReset)
return msgActionDeleted
}
}
case '+':
if ctx.User.SecStatus < 100 {
continue
}
ctx.Sess.WriteString("Lock\r\n")
msg.Locked = true
// We need an UpdateMessage — for now toggle in memory.
// The locked state will persist if the store supports it.
ctx.Sess.WriteString(" Message locked.\r\n")
case '-':
if ctx.User.SecStatus < 100 {
continue
}
ctx.Sess.WriteString("Unlock\r\n")
msg.Locked = false
ctx.Sess.WriteString(" Message unlocked.\r\n")
case 'Q':
ctx.Sess.WriteString("Quit\r\n")
return msgActionQuit
case '@':
ctx.Sess.WriteString("Main Menu\r\n")
return msgActionMainMenu
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.WriteString(" N/C/Enter - Next message\r\n")
ctx.Sess.WriteString(" A - Read again\r\n")
ctx.Sess.WriteString(" L - Previous message\r\n")
ctx.Sess.WriteString(" R - Reply to this message\r\n")
ctx.Sess.WriteString(" D - Delete this message\r\n")
ctx.Sess.WriteString(" Q - Return to board prompt\r\n")
ctx.Sess.WriteString(" @ - Return to main menu\r\n")
}
}
}
// msgWrite composes and saves a new message.
// Replaces Msg_Write() and Msg_Public_Reply() from MCOM.C.
//
// The original used the full-screen editor from EDIT.C with word wrap,
// line editing, and a 2500-byte buffer. Our version uses a simpler
// multi-line editor that collects lines until a blank line, then offers
// Save/Abort/Continue/List/Edit — matching the original's edit menu.
//
// replyToNum is the message number being replied to (0 for new posts).
func msgWrite(ctx *Context, board *models.Board, replyToNum int) {
ctx.Sess.NewLine()
// Check if the board is full
if board.PostCount >= board.MaxPosts {
ctx.Sess.WriteString(" Board is full — no room for another message.\r\n")
return
}
// Title
defaultTitle := ""
if replyToNum > 0 {
// Try to prefill with "Re: original title"
origMsgs, _ := ctx.Store.ListMessages(board.ID, replyToNum-1, 1)
if len(origMsgs) > 0 && origMsgs[0].Title != "" {
t := origMsgs[0].Title
if !strings.HasPrefix(strings.ToLower(t), "re:") {
defaultTitle = "Re: " + t
} else {
defaultTitle = t
}
}
}
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Enter a Title (Q to quit): ")
ctx.Sess.Color(session.AnsiReset)
title, err := ctx.Sess.ReadLine(defaultTitle, 60, inputTimeout)
if err != nil {
return
}
title = strings.TrimSpace(title)
if strings.EqualFold(title, "Q") || title == "" {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
// Author name — sysops/high-sec users can override (like original)
author := ctx.User.Name
if ctx.User.SecStatus >= 100 {
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Name to use: ")
ctx.Sess.Color(session.AnsiReset)
nameInput, err := ctx.Sess.ReadLine(author, 30, inputTimeout)
if err != nil {
return
}
nameInput = strings.TrimSpace(nameInput)
if nameInput != "" {
author = nameInput
}
}
// Compose body using the line editor
ctx.Sess.WriteString("\r\nEnter your message (blank line when done):\r\n")
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" Ctrl-X: erase line | Ctrl-W: erase word | Blank line: finish\r\n\r\n")
ctx.Sess.Color(session.AnsiReset)
lines := msgEditLoop(ctx)
if lines == nil {
return // disconnected
}
// Edit menu — matches EDIT.C's post-entry options
for {
ctx.Sess.WriteString("\r\nA>bort S>ave C>ontinue L>ist E>dit-line D>elete-line\r\n")
ctx.Sess.WriteString("Edit> ")
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return
}
switch toUpper(ch) {
case 'S':
ctx.Sess.WriteString("Save\r\n")
goto save
case 'A', 'Q':
ctx.Sess.WriteString("Abort\r\n")
yes, err := ctx.Sess.Confirm(" Discard this message? ", inputTimeout)
if err != nil || yes {
ctx.Sess.WriteString(" Message discarded.\r\n")
return
}
case 'C':
ctx.Sess.WriteString("Continue\r\n")
more := msgEditLoop(ctx)
if more == nil {
return
}
lines = append(lines, more...)
case 'L':
ctx.Sess.WriteString("List\r\n\r\n")
for i, l := range lines {
ctx.Sess.Printf("%3d> %s\r\n", i+1, l)
}
case 'E':
ctx.Sess.WriteString("Edit\r\n")
if len(lines) == 0 {
ctx.Sess.WriteString(" Nothing to edit.\r\n")
continue
}
ctx.Sess.Printf(" Line number [1-%d]: ", len(lines))
numStr, err := ctx.Sess.ReadLine("", 4, inputTimeout)
if err != nil {
return
}
n := parseIntDefault(numStr, 0)
if n < 1 || n > len(lines) {
ctx.Sess.WriteString(" Invalid line number.\r\n")
continue
}
ctx.Sess.Printf(" Old: %s\r\n", lines[n-1])
ctx.Sess.WriteString(" New: ")
newLine, err := ctx.Sess.ReadLine(lines[n-1], 75, inputTimeout)
if err != nil {
return
}
lines[n-1] = newLine
case 'D':
ctx.Sess.WriteString("Delete\r\n")
if len(lines) == 0 {
ctx.Sess.WriteString(" Nothing to delete.\r\n")
continue
}
ctx.Sess.Printf(" Line number [1-%d]: ", len(lines))
numStr, err := ctx.Sess.ReadLine("", 4, inputTimeout)
if err != nil {
return
}
n := parseIntDefault(numStr, 0)
if n < 1 || n > len(lines) {
ctx.Sess.WriteString(" Invalid line number.\r\n")
continue
}
lines = append(lines[:n-1], lines[n:]...)
ctx.Sess.Printf(" Line %d deleted.\r\n", n)
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.WriteString(" S - Save the message\r\n")
ctx.Sess.WriteString(" A - Abort and discard\r\n")
ctx.Sess.WriteString(" C - Continue writing\r\n")
ctx.Sess.WriteString(" L - List what you've written\r\n")
ctx.Sess.WriteString(" E - Edit a line\r\n")
ctx.Sess.WriteString(" D - Delete a line\r\n")
}
}
save:
body := strings.Join(lines, "\n")
if strings.TrimSpace(body) == "" {
ctx.Sess.WriteString(" Empty message — not saved.\r\n")
return
}
// Resolve replyTo — if replying, store the original message's DB ID
var replyToID int64
if replyToNum > 0 {
origMsgs, _ := ctx.Store.ListMessages(board.ID, replyToNum-1, 1)
if len(origMsgs) > 0 {
replyToID = origMsgs[0].ID
}
}
msg := &models.Message{
BoardID: board.ID,
Title: title,
Author: author,
AuthorID: ctx.User.ID,
Body: body,
ReplyTo: replyToID,
}
ctx.Sess.WriteString(" Saving...\r\n")
if err := ctx.Store.CreateMessage(msg); err != nil {
ctx.Sess.WriteString(" Error saving message.\r\n")
return
}
// Update stats
ctx.User.MessagesPosted++
ctx.Store.UpdateUser(ctx.User)
ctx.Store.IncrementStat("messages_posted", 1)
// Refresh the board's post count
if updated, err := ctx.Store.GetBoard(board.ID); err == nil {
*board = *updated
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Message #%d saved.\r\n", msg.Number)
ctx.Sess.Color(session.AnsiReset)
}
// msgEditLoop reads lines of input until a blank line is entered.
// Replaces the Enter() function from EDIT.C.
//
// The original had elaborate word-wrap logic because terminals ran at
// 75 columns over serial. We still do line-at-a-time with numbered
// prompts, but rely on the terminal's own wrapping for long lines.
func msgEditLoop(ctx *Context) []string {
var lines []string
lineNum := len(lines) + 1
for {
ctx.Sess.Printf("%3d> ", lineNum)
line, err := ctx.Sess.ReadLine("", 75, inputTimeout)
if err != nil {
return nil
}
if strings.TrimSpace(line) == "" {
break
}
lines = append(lines, line)
lineNum++
// Safety limit — original had MAXLINES=100 and Size=2500 bytes
if lineNum > 100 {
ctx.Sess.WriteString(" Maximum lines reached.\r\n")
break
}
}
return lines
}
// msgDeleteFrontend prompts for a range and deletes messages.
// Replaces Msg_Delete_Frontend() from MCOM.C.
func msgDeleteFrontend(ctx *Context, board *models.Board) {
if board.PostCount == 0 {
ctx.Sess.WriteString(" No messages to delete.\r\n")
return
}
ctx.Sess.Printf(" Delete which [1-%d] (0 to cancel): ", board.PostCount)
numStr, err := ctx.Sess.ReadLine("", 5, inputTimeout)
if err != nil {
return
}
num := parseIntDefault(numStr, 0)
if num < 1 || num > board.PostCount {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
msgs, err := ctx.Store.ListMessages(board.ID, num-1, 1)
if err != nil || len(msgs) == 0 {
ctx.Sess.WriteString(" Message not found.\r\n")
return
}
msg := msgs[0]
if msg.Locked && ctx.User.SecStatus < 255 {
ctx.Sess.WriteString(" That message is locked.\r\n")
return
}
ctx.Sess.Printf(" Delete #%d \"%s\" by %s? ", msg.Number, msg.Title, msg.Author)
yes, err := ctx.Sess.Confirm("", inputTimeout)
if err != nil || !yes {
ctx.Sess.WriteString(" Not deleted.\r\n")
return
}
if err := ctx.Store.DeleteMessage(msg.ID); err != nil {
ctx.Sess.WriteString(" Error deleting message.\r\n")
return
}
// Refresh board
if updated, err := ctx.Store.GetBoard(board.ID); err == nil {
*board = *updated
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.WriteString(" Message deleted.\r\n")
ctx.Sess.Color(session.AnsiReset)
}
// parseIntDefault parses a string as int, returning def on failure.
func parseIntDefault(s string, def int) int {
s = strings.TrimSpace(s)
if s == "" {
return def
}
var n int
_, err := fmt.Sscanf(s, "%d", &n)
if err != nil {
return def
}
return n
}