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