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