// 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) }