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

503 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// sysop_boards.go implements board management for the sysop menu.
//
// The original TAG-BBS had no in-BBS board management — boards were
// configured in text files (s:Tag_Boards) and created by running the
// GENERATE program. Each board entry had a name, disk location, read/write
// security ranges, and a maximum post count.
//
// Our version provides full CRUD from the sysop menu:
// L List all boards
// C Create a new board
// E Edit an existing board (name, security, max posts)
// D Delete a board (with confirmation and message count warning)
// Q Return to sysop menu
//
// The edit flow follows the same pattern as the user editor:
// display → keypress → modify field in memory → redisplay → save/cancel.
package menu
import (
"fmt"
"strconv"
"strings"
"github.com/urit/urit/internal/models"
"github.com/urit/urit/internal/session"
)
// sysopBoardMenu is the board management sub-menu.
func sysopBoardMenu(ctx *Context) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.WriteString("Board Management\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [L] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("List boards\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [C] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Create board\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [E] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Edit board\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [D] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Delete board\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [Q] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Return\r\n")
ctx.Sess.Color(session.AnsiReset)
for {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.WriteString("Boards> ")
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return
}
switch toUpper(ch) {
case 'L':
ctx.Sess.WriteString("List\r\n")
sysopListBoards(ctx)
case 'C':
ctx.Sess.WriteString("Create\r\n")
sysopCreateBoard(ctx)
case 'E':
ctx.Sess.WriteString("Edit\r\n")
sysopEditBoardPrompt(ctx)
case 'D':
ctx.Sess.WriteString("Delete\r\n")
sysopDeleteBoardPrompt(ctx)
case 'Q', '\x1b':
ctx.Sess.WriteString("Return\r\n")
return
case '?':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" L=List C=Create E=Edit D=Delete Q=Quit\r\n")
ctx.Sess.Color(session.AnsiReset)
}
}
}
// sysopListBoards displays all boards with their configuration.
func sysopListBoards(ctx *Context) {
boards, err := ctx.Store.ListBoards()
if err != nil {
ctx.Sess.WriteString(" Error loading boards.\r\n")
return
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" %-4s %-20s %5s %-11s %-11s %s\r\n",
"ID", "Name", "Posts", "Read", "Write", "Max")
ctx.Sess.WriteString(strings.Repeat("─", 68) + "\r\n")
ctx.Sess.Color(session.AnsiReset)
if len(boards) == 0 {
ctx.Sess.WriteString(" (no boards)\r\n")
return
}
for _, b := range boards {
ctx.Sess.Printf(" %-4d %-20s %5d %3d — %-3d %3d — %-3d %d\r\n",
b.ID, truncate(b.Name, 20), b.PostCount,
b.ReadLow, b.ReadHigh, b.WriteLow, b.WriteHigh, b.MaxPosts)
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" %d board(s)\r\n", len(boards))
ctx.Sess.Color(session.AnsiReset)
}
// sysopCreateBoard walks through creating a new board.
// In the original, boards were created by adding entries to s:Tag_Boards
// and re-running GENERATE. Here the sysop does it interactively.
func sysopCreateBoard(ctx *Context) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.WriteString("Create Board\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 30) + "\r\n")
// Name
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Name: ")
ctx.Sess.Color(session.AnsiReset)
name, err := ctx.Sess.ReadLine("", 30, inputTimeout)
if err != nil {
return
}
name = strings.TrimSpace(name)
if name == "" {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
// Read access range
readLow, readHigh := sysopReadRange(ctx, "Read access", 0, 255)
// Write access range
writeLow, writeHigh := sysopReadRange(ctx, "Write access", 1, 255)
// Max posts
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Max posts [200]: ")
ctx.Sess.Color(session.AnsiReset)
maxStr, err := ctx.Sess.ReadLine("", 6, inputTimeout)
if err != nil {
return
}
maxPosts := 200
if s := strings.TrimSpace(maxStr); s != "" {
if v, err := strconv.Atoi(s); err == nil && v > 0 && v <= 10000 {
maxPosts = v
}
}
board := &models.Board{
Name: name,
ReadLow: readLow,
ReadHigh: readHigh,
WriteLow: writeLow,
WriteHigh: writeHigh,
MaxPosts: maxPosts,
}
if err := ctx.Store.CreateBoard(board); err != nil {
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.Printf(" Error: %v\r\n", err)
ctx.Sess.Color(session.AnsiReset)
return
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Board created: %s (ID #%d)\r\n", board.Name, board.ID)
ctx.Sess.Color(session.AnsiReset)
}
// sysopEditBoardPrompt asks for a board ID and enters the edit loop.
func sysopEditBoardPrompt(ctx *Context) {
// Show the board list first so the sysop can see IDs
sysopListBoards(ctx)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Board ID: ")
ctx.Sess.Color(session.AnsiReset)
input, err := ctx.Sess.ReadLine("", 10, inputTimeout)
if err != nil {
return
}
input = strings.TrimSpace(input)
if input == "" {
return
}
id, err := strconv.ParseInt(input, 10, 64)
if err != nil || id < 1 {
ctx.Sess.WriteString(" Invalid board ID.\r\n")
return
}
sysopEditBoard(ctx, id)
}
// sysopEditBoard is the edit loop for a single board.
// Follows the same display → keypress → edit → redisplay pattern
// as the user account editor.
func sysopEditBoard(ctx *Context, id int64) {
board, err := ctx.Store.GetBoard(id)
if err != nil || board == nil {
ctx.Sess.WriteString(" Board not found.\r\n")
return
}
dirty := false
for {
// Display
sysopDisplayBoard(ctx, board, dirty)
// Wait for keypress
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return
}
switch toUpper(ch) {
case '1': // Save
ctx.Sess.WriteString("Save\r\n")
if err := ctx.Store.UpdateBoard(board); err != nil {
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.Printf(" Error: %v\r\n", err)
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.ReadKey(idleTimeout)
continue
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Board #%d saved.\r\n", board.ID)
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.ReadKey(idleTimeout)
return
case '\x1b': // ESC — Cancel
ctx.Sess.WriteString("Cancel\r\n")
if dirty {
ctx.Sess.Color(session.AnsiFgYellow)
ctx.Sess.WriteString(" Unsaved changes! Discard? [Y/N] ")
ctx.Sess.Color(session.AnsiReset)
confirm, err := ctx.Sess.ReadKey(inputTimeout)
if err != nil {
return
}
if toUpper(confirm) != 'Y' {
ctx.Sess.WriteString("No\r\n")
continue
}
ctx.Sess.WriteString("Yes\r\n")
}
return
case 'A': // Name
ctx.Sess.WriteString("Name\r\n")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" New name: ")
ctx.Sess.Color(session.AnsiReset)
name, err := ctx.Sess.ReadLine(board.Name, 30, inputTimeout)
if err != nil {
return
}
name = strings.TrimSpace(name)
if name != "" && name != board.Name {
board.Name = name
dirty = true
}
case 'B': // ReadLow
ctx.Sess.WriteString("Read Low\r\n")
if v, ok := sysopReadInt(ctx, "ReadLow", board.ReadLow, 0, 255); ok {
board.ReadLow = v
dirty = true
}
case 'C': // ReadHigh
ctx.Sess.WriteString("Read High\r\n")
if v, ok := sysopReadInt(ctx, "ReadHigh", board.ReadHigh, 0, 255); ok {
board.ReadHigh = v
dirty = true
}
case 'D': // WriteLow
ctx.Sess.WriteString("Write Low\r\n")
if v, ok := sysopReadInt(ctx, "WriteLow", board.WriteLow, 0, 255); ok {
board.WriteLow = v
dirty = true
}
case 'E': // WriteHigh
ctx.Sess.WriteString("Write High\r\n")
if v, ok := sysopReadInt(ctx, "WriteHigh", board.WriteHigh, 0, 255); ok {
board.WriteHigh = v
dirty = true
}
case 'F': // MaxPosts
ctx.Sess.WriteString("Max Posts\r\n")
if v, ok := sysopReadInt(ctx, "MaxPosts", board.MaxPosts, 1, 10000); ok {
board.MaxPosts = v
dirty = true
}
}
}
}
// sysopDisplayBoard renders the board detail screen for editing.
func sysopDisplayBoard(ctx *Context, board *models.Board, dirty bool) {
ctx.Sess.ClearScreen()
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.Printf(" Board #%d", board.ID)
if dirty {
ctx.Sess.Color(session.AnsiFgYellow)
ctx.Sess.WriteString(" *")
}
ctx.Sess.WriteString("\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 50) + "\r\n")
ctx.Sess.NewLine()
sysopField(ctx, "A", "Name", board.Name)
ctx.Sess.NewLine()
// Read access range — mirrors the original's READ: low,high
sysopFieldInt(ctx, "B", "Read Low", board.ReadLow)
sysopFieldInt(ctx, "C", "Read High", board.ReadHigh)
ctx.Sess.NewLine()
// Write access range — mirrors the original's WRITE: low,high
sysopFieldInt(ctx, "D", "Write Low", board.WriteLow)
sysopFieldInt(ctx, "E", "Write High", board.WriteHigh)
ctx.Sess.NewLine()
// Capacity and usage
sysopFieldInt(ctx, "F", "Max Posts", board.MaxPosts)
sysopField(ctx, " ", "Current Posts",
fmt.Sprintf("%d", board.PostCount))
ctx.Sess.NewLine()
// Metadata
if board.LatestPost != nil {
sysopField(ctx, " ", "Latest Post",
board.LatestPost.Format("Jan 02, 2006 3:04 PM"))
} else {
sysopField(ctx, " ", "Latest Post", "none")
}
sysopField(ctx, " ", "Created",
board.CreatedAt.Format("Jan 02, 2006 3:04 PM"))
ctx.Sess.NewLine()
// Access summary — helpful at-a-glance description
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Read: %s Write: %s\r\n",
describeAccess(board.ReadLow, board.ReadHigh),
describeAccess(board.WriteLow, board.WriteHigh))
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.NewLine()
// Footer
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" 1=Save ESC=Cancel A-F=Edit fields\r\n")
ctx.Sess.Color(session.AnsiReset)
}
// sysopDeleteBoardPrompt asks for a board ID and deletes it with confirmation.
func sysopDeleteBoardPrompt(ctx *Context) {
// Show the board list first
sysopListBoards(ctx)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Delete board ID: ")
ctx.Sess.Color(session.AnsiReset)
input, err := ctx.Sess.ReadLine("", 10, inputTimeout)
if err != nil {
return
}
input = strings.TrimSpace(input)
if input == "" {
return
}
id, err := strconv.ParseInt(input, 10, 64)
if err != nil || id < 1 {
ctx.Sess.WriteString(" Invalid board ID.\r\n")
return
}
board, err := ctx.Store.GetBoard(id)
if err != nil || board == nil {
ctx.Sess.WriteString(" Board not found.\r\n")
return
}
// Warn about message count
msgCount, _ := ctx.Store.CountMessages(board.ID)
ctx.Sess.Color(session.AnsiFgRed, session.AnsiBold)
ctx.Sess.Printf("\r\n Delete board: %s (#%d)\r\n", board.Name, board.ID)
ctx.Sess.Color(session.AnsiFgRed)
if msgCount > 0 {
ctx.Sess.Printf(" This will also delete %d message(s).\r\n", msgCount)
}
ctx.Sess.WriteString(" Are you sure? [Y/N] ")
ctx.Sess.Color(session.AnsiReset)
confirm, err := ctx.Sess.ReadKey(inputTimeout)
if err != nil {
return
}
if toUpper(confirm) != 'Y' {
ctx.Sess.WriteString("No\r\n")
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
ctx.Sess.WriteString("Yes\r\n")
if err := ctx.Store.DeleteBoard(board.ID); err != nil {
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.Printf(" Error: %v\r\n", err)
ctx.Sess.Color(session.AnsiReset)
return
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Board %s deleted.\r\n", board.Name)
ctx.Sess.Color(session.AnsiReset)
}
// --- Helpers ---
// sysopReadRange prompts for a low/high security range pair.
// Returns the entered values, defaulting to the provided defaults.
func sysopReadRange(ctx *Context, label string, defaultLow, defaultHigh int) (int, int) {
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.Printf(" %s low [%d]: ", label, defaultLow)
ctx.Sess.Color(session.AnsiReset)
lowStr, err := ctx.Sess.ReadLine("", 5, inputTimeout)
if err != nil {
return defaultLow, defaultHigh
}
low := defaultLow
if s := strings.TrimSpace(lowStr); s != "" {
if v, err := strconv.Atoi(s); err == nil && v >= 0 && v <= 255 {
low = v
}
}
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.Printf(" %s high [%d]: ", label, defaultHigh)
ctx.Sess.Color(session.AnsiReset)
highStr, err := ctx.Sess.ReadLine("", 5, inputTimeout)
if err != nil {
return low, defaultHigh
}
high := defaultHigh
if s := strings.TrimSpace(highStr); s != "" {
if v, err := strconv.Atoi(s); err == nil && v >= 0 && v <= 255 {
high = v
}
}
return low, high
}
// describeAccess returns a human-readable summary of a security range.
func describeAccess(low, high int) string {
if low == 0 && high == 255 {
return "everyone"
}
if low == high {
return fmt.Sprintf("level %d only", low)
}
if low == 0 {
return fmt.Sprintf("up to %d", high)
}
if high == 255 {
return fmt.Sprintf("%d+", low)
}
return fmt.Sprintf("%d%d", low, high)
}