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

478 lines
13 KiB
Go

// sysop_libraries.go implements file library management for the sysop menu.
//
// The original TAG-BBS managed libraries through GENERATE config files.
// Each library had a name, disk path, upload/download security ranges,
// and a maximum file count. Our version provides full CRUD from the
// sysop menu.
//
// Libraries are slightly more complex than boards or bulletins because
// they have both an upload and download security range, plus an on-disk
// file path where actual uploaded files are stored.
package menu
import (
"fmt"
"path/filepath"
"strconv"
"strings"
"github.com/urit/urit/internal/models"
"github.com/urit/urit/internal/session"
)
// sysopLibraryMenu is the library management sub-menu.
func sysopLibraryMenu(ctx *Context) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgYellow, session.AnsiBold)
ctx.Sess.WriteString("Library 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 libraries\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [C] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Create library\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [E] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Edit library\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [D] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Delete library\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.AnsiFgYellow)
ctx.Sess.WriteString("Libraries> ")
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")
sysopListLibraries(ctx)
case 'C':
ctx.Sess.WriteString("Create\r\n")
sysopCreateLibrary(ctx)
case 'E':
ctx.Sess.WriteString("Edit\r\n")
sysopEditLibraryPrompt(ctx)
case 'D':
ctx.Sess.WriteString("Delete\r\n")
sysopDeleteLibraryPrompt(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)
}
}
}
// sysopListLibraries displays all libraries with their configuration.
func sysopListLibraries(ctx *Context) {
libs, err := ctx.Store.ListLibraries()
if err != nil {
ctx.Sess.WriteString(" Error loading libraries.\r\n")
return
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" %-4s %-16s %5s %-11s %-11s %s\r\n",
"ID", "Name", "Files", "Upload", "Download", "Max")
ctx.Sess.WriteString(strings.Repeat("─", 68) + "\r\n")
ctx.Sess.Color(session.AnsiReset)
if len(libs) == 0 {
ctx.Sess.WriteString(" (no libraries)\r\n")
return
}
for _, l := range libs {
ctx.Sess.Printf(" %-4d %-16s %5d %3d — %-3d %3d — %-3d %d\r\n",
l.ID, truncate(l.Name, 16), l.FileCount,
l.UploadLow, l.UploadHigh,
l.DownloadLow, l.DownloadHigh, l.MaxFiles)
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" %d library(ies)\r\n", len(libs))
ctx.Sess.Color(session.AnsiReset)
}
// sysopCreateLibrary walks through creating a new library.
func sysopCreateLibrary(ctx *Context) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgYellow, session.AnsiBold)
ctx.Sess.WriteString("Create Library\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 — directory where uploaded files will be stored.
// Default to a subdirectory next to the database file.
dataDir := filepath.Dir(ctx.Cfg.Storage.SQLitePath) + "/"
defaultPath := dataDir +
strings.ToLower(strings.ReplaceAll(name, " ", "-")) + "/"
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
}
// Upload access range
uploadLow, uploadHigh := sysopReadRange(ctx, "Upload access", 1, 255)
// Download access range
downloadLow, downloadHigh := sysopReadRange(ctx, "Download access", 0, 255)
// Max files
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Max files [200]: ")
ctx.Sess.Color(session.AnsiReset)
maxStr, err := ctx.Sess.ReadLine("", 6, inputTimeout)
if err != nil {
return
}
maxFiles := 200
if s := strings.TrimSpace(maxStr); s != "" {
if v, err := strconv.Atoi(s); err == nil && v > 0 && v <= 10000 {
maxFiles = v
}
}
lib := &models.Library{
Name: name,
FilePath: filePath,
UploadLow: uploadLow,
UploadHigh: uploadHigh,
DownloadLow: downloadLow,
DownloadHigh: downloadHigh,
MaxFiles: maxFiles,
}
if err := ctx.Store.CreateLibrary(lib); 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(" Library created: %s (ID #%d)\r\n", lib.Name, lib.ID)
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Path: %s\r\n", lib.FilePath)
ctx.Sess.Color(session.AnsiReset)
}
// sysopEditLibraryPrompt asks for a library ID and enters the edit loop.
func sysopEditLibraryPrompt(ctx *Context) {
sysopListLibraries(ctx)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Library 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
}
sysopEditLibrary(ctx, id)
}
// sysopEditLibrary is the edit loop for a single library.
func sysopEditLibrary(ctx *Context, id int64) {
lib, err := ctx.Store.GetLibrary(id)
if err != nil || lib == nil {
ctx.Sess.WriteString(" Library not found.\r\n")
return
}
dirty := false
for {
sysopDisplayLibrary(ctx, lib, 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.UpdateLibrary(lib); 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(" Library #%d saved.\r\n", lib.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(lib.Name, 30, inputTimeout)
if err != nil {
return
}
name = strings.TrimSpace(name)
if name != "" && name != lib.Name {
lib.Name = name
dirty = true
}
case 'B': // FilePath
ctx.Sess.WriteString("Path\r\n")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" File path: ")
ctx.Sess.Color(session.AnsiReset)
fp, err := ctx.Sess.ReadLine(lib.FilePath, 80, inputTimeout)
if err != nil {
return
}
fp = strings.TrimSpace(fp)
if fp != "" && fp != lib.FilePath {
lib.FilePath = fp
dirty = true
}
case 'C': // UploadLow
ctx.Sess.WriteString("Upload Low\r\n")
if v, ok := sysopReadInt(ctx, "UploadLow", lib.UploadLow, 0, 255); ok {
lib.UploadLow = v
dirty = true
}
case 'D': // UploadHigh
ctx.Sess.WriteString("Upload High\r\n")
if v, ok := sysopReadInt(ctx, "UploadHigh", lib.UploadHigh, 0, 255); ok {
lib.UploadHigh = v
dirty = true
}
case 'E': // DownloadLow
ctx.Sess.WriteString("Download Low\r\n")
if v, ok := sysopReadInt(ctx, "DownloadLow", lib.DownloadLow, 0, 255); ok {
lib.DownloadLow = v
dirty = true
}
case 'F': // DownloadHigh
ctx.Sess.WriteString("Download High\r\n")
if v, ok := sysopReadInt(ctx, "DownloadHigh", lib.DownloadHigh, 0, 255); ok {
lib.DownloadHigh = v
dirty = true
}
case 'G': // MaxFiles
ctx.Sess.WriteString("Max Files\r\n")
if v, ok := sysopReadInt(ctx, "MaxFiles", lib.MaxFiles, 1, 10000); ok {
lib.MaxFiles = v
dirty = true
}
}
}
}
// sysopDisplayLibrary renders the library detail screen for editing.
func sysopDisplayLibrary(ctx *Context, lib *models.Library, dirty bool) {
ctx.Sess.ClearScreen()
ctx.Sess.Color(session.AnsiFgYellow, session.AnsiBold)
ctx.Sess.Printf(" Library #%d", lib.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", lib.Name)
sysopField(ctx, "B", "File Path", lib.FilePath)
ctx.Sess.NewLine()
// Upload access range
sysopFieldInt(ctx, "C", "Upload Low", lib.UploadLow)
sysopFieldInt(ctx, "D", "Upload High", lib.UploadHigh)
ctx.Sess.NewLine()
// Download access range
sysopFieldInt(ctx, "E", "Download Low", lib.DownloadLow)
sysopFieldInt(ctx, "F", "Download High", lib.DownloadHigh)
ctx.Sess.NewLine()
// Capacity and usage
sysopFieldInt(ctx, "G", "Max Files", lib.MaxFiles)
sysopField(ctx, " ", "Current Files",
fmt.Sprintf("%d", lib.FileCount))
ctx.Sess.NewLine()
// Metadata
if lib.LatestFile != nil {
sysopField(ctx, " ", "Latest File",
lib.LatestFile.Format("Jan 02, 2006 3:04 PM"))
} else {
sysopField(ctx, " ", "Latest File", "none")
}
sysopField(ctx, " ", "Created",
lib.CreatedAt.Format("Jan 02, 2006 3:04 PM"))
ctx.Sess.NewLine()
// Access summary
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Upload: %s Download: %s\r\n",
describeAccess(lib.UploadLow, lib.UploadHigh),
describeAccess(lib.DownloadLow, lib.DownloadHigh))
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.NewLine()
// Footer
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" 1=Save ESC=Cancel A-G=Edit fields\r\n")
ctx.Sess.Color(session.AnsiReset)
}
// sysopDeleteLibraryPrompt asks for a library ID and deletes it with confirmation.
func sysopDeleteLibraryPrompt(ctx *Context) {
sysopListLibraries(ctx)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Delete library 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
}
lib, err := ctx.Store.GetLibrary(id)
if err != nil || lib == nil {
ctx.Sess.WriteString(" Library not found.\r\n")
return
}
fileCount, _ := ctx.Store.CountLibraryFiles(lib.ID)
ctx.Sess.Color(session.AnsiFgRed, session.AnsiBold)
ctx.Sess.Printf("\r\n Delete library: %s (#%d)\r\n", lib.Name, lib.ID)
ctx.Sess.Color(session.AnsiFgRed)
if fileCount > 0 {
ctx.Sess.Printf(" This will remove %d file record(s) from the database.\r\n", fileCount)
ctx.Sess.WriteString(" Actual files on disk will NOT be deleted.\r\n")
}
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.DeleteLibrary(lib.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(" Library %s deleted.\r\n", lib.Name)
if lib.FilePath != "" {
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Files in %s were not removed from disk.\r\n", lib.FilePath)
}
ctx.Sess.Color(session.AnsiReset)
}