400 lines
11 KiB
Go
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)
|
|
}
|