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

400 lines
11 KiB
Go

// sysop_bulletins.go implements bulletin management for the sysop menu.
//
// The original TAG-BBS managed bulletins through GENERATE config files.
// Each bulletin had a name, a display file (ANSI/text), and read
// security range. Our version provides full CRUD from the sysop menu.
//
// Bulletins are simpler than boards or libraries — they're just pointers
// to screen files with access control. There's no post count or file
// tracking, so the edit flow is straightforward.
package menu
import (
"strconv"
"strings"
"github.com/urit/urit/internal/models"
"github.com/urit/urit/internal/session"
)
// sysopBulletinMenu is the bulletin management sub-menu.
func sysopBulletinMenu(ctx *Context) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgMagenta, session.AnsiBold)
ctx.Sess.WriteString("Bulletin 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 bulletins\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [C] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Create bulletin\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [E] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Edit bulletin\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [D] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Delete bulletin\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.AnsiFgMagenta)
ctx.Sess.WriteString("Bulletins> ")
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")
sysopListBulletins(ctx)
case 'C':
ctx.Sess.WriteString("Create\r\n")
sysopCreateBulletin(ctx)
case 'E':
ctx.Sess.WriteString("Edit\r\n")
sysopEditBulletinPrompt(ctx)
case 'D':
ctx.Sess.WriteString("Delete\r\n")
sysopDeleteBulletinPrompt(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)
}
}
}
// sysopListBulletins displays all bulletins.
func sysopListBulletins(ctx *Context) {
bulletins, err := ctx.Store.ListBulletins()
if err != nil {
ctx.Sess.WriteString(" Error loading bulletins.\r\n")
return
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" %-4s %-20s %-24s %s\r\n",
"ID", "Name", "File", "Access")
ctx.Sess.WriteString(strings.Repeat("─", 60) + "\r\n")
ctx.Sess.Color(session.AnsiReset)
if len(bulletins) == 0 {
ctx.Sess.WriteString(" (no bulletins)\r\n")
return
}
for _, b := range bulletins {
ctx.Sess.Printf(" %-4d %-20s %-24s %s\r\n",
b.ID, truncate(b.Name, 20),
truncate(b.FilePath, 24),
describeAccess(b.ReadLow, b.ReadHigh))
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" %d bulletin(s)\r\n", len(bulletins))
ctx.Sess.Color(session.AnsiReset)
}
// sysopCreateBulletin walks through creating a new bulletin.
func sysopCreateBulletin(ctx *Context) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgMagenta, session.AnsiBold)
ctx.Sess.WriteString("Create Bulletin\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
}
// File path — the ANSI/text file to display
defaultPath := ctx.Cfg.System.Screens + "bulletin-" +
strings.ToLower(strings.ReplaceAll(name, " ", "-")) + ".ans"
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.Printf(" File path [%s]: ", defaultPath)
ctx.Sess.Color(session.AnsiReset)
filePath, err := ctx.Sess.ReadLine("", 80, inputTimeout)
if err != nil {
return
}
filePath = strings.TrimSpace(filePath)
if filePath == "" {
filePath = defaultPath
}
// Read access
readLow, readHigh := sysopReadRange(ctx, "Read access", 0, 255)
b := &models.Bulletin{
Name: name,
FilePath: filePath,
ReadLow: readLow,
ReadHigh: readHigh,
}
if err := ctx.Store.CreateBulletin(b); 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(" Bulletin created: %s (ID #%d)\r\n", b.Name, b.ID)
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" File: %s\r\n", b.FilePath)
ctx.Sess.Color(session.AnsiReset)
}
// sysopEditBulletinPrompt asks for a bulletin ID and enters the edit loop.
func sysopEditBulletinPrompt(ctx *Context) {
sysopListBulletins(ctx)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Bulletin 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 ID.\r\n")
return
}
sysopEditBulletin(ctx, id)
}
// sysopEditBulletin is the edit loop for a single bulletin.
func sysopEditBulletin(ctx *Context, id int64) {
b, err := ctx.Store.GetBulletin(id)
if err != nil || b == nil {
ctx.Sess.WriteString(" Bulletin not found.\r\n")
return
}
dirty := false
for {
sysopDisplayBulletin(ctx, b, dirty)
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.UpdateBulletin(b); 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(" Bulletin #%d saved.\r\n", b.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(b.Name, 30, inputTimeout)
if err != nil {
return
}
name = strings.TrimSpace(name)
if name != "" && name != b.Name {
b.Name = name
dirty = true
}
case 'B': // FilePath
ctx.Sess.WriteString("File\r\n")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" File path: ")
ctx.Sess.Color(session.AnsiReset)
fp, err := ctx.Sess.ReadLine(b.FilePath, 80, inputTimeout)
if err != nil {
return
}
fp = strings.TrimSpace(fp)
if fp != "" && fp != b.FilePath {
b.FilePath = fp
dirty = true
}
case 'C': // ReadLow
ctx.Sess.WriteString("Read Low\r\n")
if v, ok := sysopReadInt(ctx, "ReadLow", b.ReadLow, 0, 255); ok {
b.ReadLow = v
dirty = true
}
case 'D': // ReadHigh
ctx.Sess.WriteString("Read High\r\n")
if v, ok := sysopReadInt(ctx, "ReadHigh", b.ReadHigh, 0, 255); ok {
b.ReadHigh = v
dirty = true
}
}
}
}
// sysopDisplayBulletin renders the bulletin detail screen for editing.
func sysopDisplayBulletin(ctx *Context, b *models.Bulletin, dirty bool) {
ctx.Sess.ClearScreen()
ctx.Sess.Color(session.AnsiFgMagenta, session.AnsiBold)
ctx.Sess.Printf(" Bulletin #%d", b.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", b.Name)
sysopField(ctx, "B", "File Path", b.FilePath)
ctx.Sess.NewLine()
sysopFieldInt(ctx, "C", "Read Low", b.ReadLow)
sysopFieldInt(ctx, "D", "Read High", b.ReadHigh)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Access: %s\r\n", describeAccess(b.ReadLow, b.ReadHigh))
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" 1=Save ESC=Cancel A-D=Edit fields\r\n")
ctx.Sess.Color(session.AnsiReset)
}
// sysopDeleteBulletinPrompt asks for a bulletin ID and deletes it.
func sysopDeleteBulletinPrompt(ctx *Context) {
sysopListBulletins(ctx)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Delete bulletin 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 ID.\r\n")
return
}
b, err := ctx.Store.GetBulletin(id)
if err != nil || b == nil {
ctx.Sess.WriteString(" Bulletin not found.\r\n")
return
}
ctx.Sess.Color(session.AnsiFgRed, session.AnsiBold)
ctx.Sess.Printf("\r\n Delete bulletin: %s (#%d)?\r\n", b.Name, b.ID)
ctx.Sess.Color(session.AnsiFgRed)
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.DeleteBulletin(b.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(" Bulletin %s deleted.\r\n", b.Name)
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Note: the file %s was not removed from disk.\r\n", b.FilePath)
ctx.Sess.Color(session.AnsiReset)
}