503 lines
14 KiB
Go
503 lines
14 KiB
Go
// 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)
|
||
}
|