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

778 lines
20 KiB
Go

// library.go implements the file library subsystem.
//
// This replaces LCOM.C from the original TAG-BBS. The original stored
// library metadata in Library.Keys files (arrays of Library_Data structs)
// and the actual files on disk in per-library directories. File transfer
// used XMODEM over serial (TRANSFER.C).
//
// Our version stores metadata in SQLite and keeps files on disk in the
// library's FilePath directory. The catalog browsing experience is fully
// preserved: three-level navigation matching the message board pattern
// (library selection → per-library commands → between-file prompts).
//
// File transfer: the original's XMODEM is specific to serial. Over
// telnet, the standard is ZMODEM (via external sz/rz tools). This step
// implements full catalog browsing and file metadata management. Actual
// binary transfer (ZMODEM integration) is noted as a future enhancement.
// Text files can be viewed inline.
package menu
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/urit/urit/internal/models"
"github.com/urit/urit/internal/session"
)
// cmdLibrary is the entry point for the file library subsystem.
// Replaces LCom() from LCOM.C — the top-level library selection menu.
func cmdLibrary(ctx *Context) error {
libs, err := ctx.Store.ListLibraries()
if err != nil {
ctx.Sess.WriteString(" Error loading libraries.\r\n")
return nil
}
// Filter to those the user can access
var visible []*models.Library
for _, l := range libs {
if l.CanDownload(ctx.User.SecLibrary) || l.CanUpload(ctx.User.SecLibrary) {
visible = append(visible, l)
}
}
if len(visible) == 0 {
ctx.Sess.WriteString(" No file libraries available.\r\n\r\n")
return nil
}
ctx.Sess.NewLine()
for {
ctx.Sess.CheckTime()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("N>ew S>ome A>ll L>ist Q>uit\r\n")
ctx.Sess.Printf("%s Library> ", ctx.Cfg.System.Name)
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return nil
}
switch toUpper(ch) {
case 'L':
ctx.Sess.WriteString("List\r\n\r\n")
libList(ctx, visible)
case 'A':
ctx.Sess.WriteString("All Libraries\r\n\r\n")
for i, l := range visible {
ctx.Sess.Printf("[%d] %s\r\n", i+1, l.Name)
quit := libPrompt(ctx, l, i+1)
if quit {
goto done
}
}
ctx.Sess.WriteString("Completed visiting ALL\r\n")
case 'N':
ctx.Sess.WriteString("New Files\r\n\r\n")
found := false
for i, l := range visible {
if !l.CanDownload(ctx.User.SecLibrary) {
continue
}
if ctx.User.LastOn != nil && l.LatestFile != nil &&
!l.LatestFile.After(*ctx.User.LastOn) {
continue
}
found = true
ctx.Sess.Printf("\r\n[%d] %s\r\n", i+1, l.Name)
libReadNew(ctx, l)
quit := libPrompt(ctx, l, i+1)
if quit {
goto done
}
}
if !found {
ctx.Sess.WriteString("Nothing new.\r\n")
}
ctx.Sess.WriteString("Completed visiting NEW\r\n")
case 'S':
ctx.Sess.WriteString("Some Libraries\r\n\r\n")
for i, l := range visible {
ctx.Sess.Printf("Visit [%d] %s? ", i+1, l.Name)
ych, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return nil
}
switch toUpper(ych) {
case 'Y':
ctx.Sess.WriteString("Yes\r\n")
quit := libPrompt(ctx, l, i+1)
if quit {
goto done
}
case 'Q':
ctx.Sess.WriteString("Quit\r\n")
goto done
default:
ctx.Sess.WriteString("No\r\n")
}
}
ctx.Sess.WriteString("Completed visiting SOME\r\n")
case 'Q', '\x1b':
ctx.Sess.WriteString("Quit\r\n")
goto done
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
libList(ctx, visible)
}
}
done:
return nil
}
// libList displays the list of visible libraries with file counts.
func libList(ctx *Context, libs []*models.Library) {
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.WriteString("File Libraries\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 55) + "\r\n")
for i, l := range libs {
access := ""
if l.CanDownload(ctx.User.SecLibrary) && l.CanUpload(ctx.User.SecLibrary) {
access = "DL/UL"
} else if l.CanDownload(ctx.User.SecLibrary) {
access = "DL "
} else if l.CanUpload(ctx.User.SecLibrary) {
access = " UL"
}
latest := "no files"
if l.LatestFile != nil {
latest = l.LatestFile.Format("Jan 02")
}
ctx.Sess.Printf(" [%d] %-20s %3d files %s %s\r\n",
i+1, l.Name, l.FileCount, access, latest)
}
ctx.Sess.NewLine()
}
// libPrompt is the per-library command loop.
// Replaces Lib_Prompt() from LCOM.C.
// Returns true if the user wants to return to the main menu (@).
func libPrompt(ctx *Context, lib *models.Library, num int) bool {
canDL := lib.CanDownload(ctx.User.SecLibrary)
canUL := lib.CanUpload(ctx.User.SecLibrary)
for {
ctx.Sess.CheckTime()
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
opts := ""
if canDL {
opts += "N>ew L>ist C>atalog D>ownload "
}
if canUL {
opts += "U>pload "
}
if ctx.User.SecStatus >= 150 {
opts += "R>emove "
}
opts += "Q>uit @>Main"
ctx.Sess.WriteString(opts + "\r\n")
ctx.Sess.Printf("[%d] %s> ", num, lib.Name)
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return true
}
switch toUpper(ch) {
case 'N':
if !canDL {
continue
}
ctx.Sess.WriteString("New\r\n")
libReadNew(ctx, lib)
case 'L':
if !canDL {
continue
}
ctx.Sess.WriteString("List\r\n")
libListFrontend(ctx, lib)
case 'C':
if !canDL {
continue
}
ctx.Sess.WriteString("Catalog\r\n")
libCatalog(ctx, lib)
case 'D', 'I':
if !canDL {
continue
}
ctx.Sess.WriteString("Download\r\n")
libDownload(ctx, lib)
case 'U':
if !canUL {
continue
}
ctx.Sess.WriteString("Upload\r\n")
libUpload(ctx, lib)
case 'R':
if ctx.User.SecStatus < 150 {
continue
}
ctx.Sess.WriteString("Remove\r\n")
libRemove(ctx, lib)
case 'Q':
ctx.Sess.WriteString("Quit\r\n")
return false
case '@':
ctx.Sess.WriteString("Main Menu\r\n")
return true
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.WriteString(" N - List new files since last login\r\n")
ctx.Sess.WriteString(" L - List files by number range\r\n")
ctx.Sess.WriteString(" C - Quick catalog (names only)\r\n")
ctx.Sess.WriteString(" D - Download a file\r\n")
ctx.Sess.WriteString(" U - Upload a file\r\n")
if ctx.User.SecStatus >= 150 {
ctx.Sess.WriteString(" R - Remove a file entry\r\n")
}
ctx.Sess.WriteString(" Q - Return to library selection\r\n")
ctx.Sess.WriteString(" @ - Return to main menu\r\n")
}
}
}
// libReadNew lists files added since the user's last login.
// Replaces Lib_ReadNew() from LCOM.C.
func libReadNew(ctx *Context, lib *models.Library) {
if lib.FileCount == 0 {
ctx.Sess.WriteString(" No files in this library.\r\n")
return
}
files, err := ctx.Store.ListLibraryFiles(lib.ID, 0, lib.MaxFiles)
if err != nil {
ctx.Sess.WriteString(" Error loading files.\r\n")
return
}
// Filter to new files
var newFiles []*models.LibraryFile
for _, f := range files {
if ctx.User.LastOn == nil || f.CreatedAt.After(*ctx.User.LastOn) {
newFiles = append(newFiles, f)
}
}
if len(newFiles) == 0 {
ctx.Sess.WriteString(" Nothing new.\r\n")
return
}
ctx.Sess.Printf(" %d new file(s)\r\n\r\n", len(newFiles))
libReadSequence(ctx, lib, newFiles)
}
// libListFrontend prompts for a range and lists files with full details.
// Replaces Lib_Read_Frontend() from LCOM.C.
func libListFrontend(ctx *Context, lib *models.Library) {
if lib.FileCount == 0 {
ctx.Sess.WriteString(" No files in this library.\r\n")
return
}
ctx.Sess.Printf(" List FROM [1-%d]: ", lib.FileCount)
fromStr, err := ctx.Sess.ReadLine("1", 5, inputTimeout)
if err != nil {
return
}
from := parseIntDefault(fromStr, 1)
if from < 1 || from > lib.FileCount {
ctx.Sess.Printf(" Invalid. Range is 1-%d\r\n", lib.FileCount)
return
}
ctx.Sess.Printf(" List TO [%d-%d]: ", from, lib.FileCount)
toStr, err := ctx.Sess.ReadLine(fmt.Sprintf("%d", lib.FileCount), 5, inputTimeout)
if err != nil {
return
}
to := parseIntDefault(toStr, lib.FileCount)
if to < from || to > lib.FileCount {
ctx.Sess.Printf(" Invalid. Range is %d-%d\r\n", from, lib.FileCount)
return
}
files, err := ctx.Store.ListLibraryFiles(lib.ID, from-1, to-from+1)
if err != nil {
ctx.Sess.WriteString(" Error loading files.\r\n")
return
}
libReadSequence(ctx, lib, files)
}
// libCatalog shows a quick filename-only listing.
// Replaces Lib_Catalog() from LCOM.C.
func libCatalog(ctx *Context, lib *models.Library) {
if lib.FileCount == 0 {
ctx.Sess.WriteString(" No files in this library.\r\n")
return
}
files, err := ctx.Store.ListLibraryFiles(lib.ID, 0, lib.MaxFiles)
if err != nil {
ctx.Sess.WriteString(" Error loading files.\r\n")
return
}
ctx.Sess.NewLine()
var lines []string
for i, f := range files {
lines = append(lines, fmt.Sprintf(" [%d] %-30s %s %s",
i+1, f.Filename, formatSize(f.FileSize),
f.CreatedAt.Format("Jan 02")))
}
ctx.Sess.Paginate(strings.Join(lines, "\n"), idleTimeout)
ctx.Sess.NewLine()
}
// libReadSequence displays files in order with between-file prompts.
// Replaces Lib_Read() from LCOM.C.
func libReadSequence(ctx *Context, lib *models.Library, files []*models.LibraryFile) {
if len(files) == 0 {
return
}
idx := 0
for idx >= 0 && idx < len(files) {
f := files[idx]
libDisplayFile(ctx, lib, f, idx+1, len(files))
action := libBetweenPrompt(ctx, lib, f, idx+1)
switch action {
case libActNext:
idx++
case libActAgain:
// stay
case libActLast:
if idx > 0 {
idx--
}
case libActQuit:
return
case libActMainMenu:
return
}
}
}
// libDisplayFile renders a single file entry.
// Replaces Send_Message() (the library version) from LCOM.C.
func libDisplayFile(ctx *Context, lib *models.Library, f *models.LibraryFile, num, total int) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.Printf("File Number: [%d] of [%d]\r\n", num, total)
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.Printf(" Filename: %s\r\n", f.Filename)
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.Printf(" Origin: %s [%d]\r\n", f.Uploader, f.UploaderID)
ctx.Sess.Printf(" Size: %s\r\n", formatSize(f.FileSize))
ctx.Sess.Printf(" Validated: %s\r\n", f.CreatedAt.Format("Mon Jan 02 15:04:05 2006"))
ctx.Sess.Printf(" Downloads: %d\r\n", f.Downloads)
if f.Description != "" {
ctx.Sess.NewLine()
ctx.Sess.WriteString(f.Description + "\r\n")
} else {
ctx.Sess.WriteString(" No description available.\r\n")
}
}
type libAction int
const (
libActNext libAction = iota
libActAgain
libActLast
libActQuit
libActMainMenu
)
// libBetweenPrompt shows navigation options between file entries.
// Replaces Between_Lib_Prompt() from LCOM.C.
func libBetweenPrompt(ctx *Context, lib *models.Library, f *models.LibraryFile, num int) libAction {
for {
ctx.Sess.CheckTime()
ctx.Sess.Color(session.AnsiFgCyan)
opts := "N>ext A>gain L>ast S>tats D>ownload Q>uit @>Main"
if ctx.User.SecStatus >= 150 {
opts += " R>emove"
}
ctx.Sess.WriteString("\r\n" + opts + "\r\n")
ctx.Sess.WriteString("File> ")
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return libActQuit
}
switch toUpper(ch) {
case 'N', 'C', '\r':
ctx.Sess.WriteString("Next\r\n")
return libActNext
case 'A':
ctx.Sess.WriteString("Again\r\n")
return libActAgain
case 'L':
ctx.Sess.WriteString("Last\r\n")
return libActLast
case 'S':
ctx.Sess.WriteString("Statistics\r\n")
libShowStats(ctx, lib, f)
case 'D':
ctx.Sess.WriteString("Download\r\n")
libDoDownload(ctx, lib, f)
return libActNext
case 'R':
if ctx.User.SecStatus < 150 {
continue
}
ctx.Sess.WriteString("Remove\r\n")
yes, err := ctx.Sess.Confirm(" Remove this file entry? ", inputTimeout)
if err != nil {
return libActQuit
}
if yes {
ctx.Store.DeleteLibraryFile(f.ID)
if updated, err := ctx.Store.GetLibrary(lib.ID); err == nil {
*lib = *updated
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.WriteString(" File entry removed.\r\n")
ctx.Sess.WriteString(" (The file itself remains on disk.)\r\n")
ctx.Sess.Color(session.AnsiReset)
}
return libActNext
case 'Q':
ctx.Sess.WriteString("Quit\r\n")
return libActQuit
case '@':
ctx.Sess.WriteString("Main Menu\r\n")
return libActMainMenu
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.WriteString(" N/Enter - Next file\r\n")
ctx.Sess.WriteString(" A - View again\r\n")
ctx.Sess.WriteString(" L - Previous file\r\n")
ctx.Sess.WriteString(" S - File statistics\r\n")
ctx.Sess.WriteString(" D - Download file\r\n")
ctx.Sess.WriteString(" Q - Return to library menu\r\n")
ctx.Sess.WriteString(" @ - Return to main menu\r\n")
}
}
}
// libShowStats displays detailed OS-level stats for a file.
// Replaces the 'S' (statistics / report()) command from LCOM.C.
func libShowStats(ctx *Context, lib *models.Library, f *models.LibraryFile) {
fullPath := filepath.Join(lib.FilePath, f.Filename)
info, err := os.Stat(fullPath)
if err != nil {
ctx.Sess.Printf(" File not found on disk: %s\r\n", fullPath)
return
}
ctx.Sess.NewLine()
ctx.Sess.Printf(" Filename: %s\r\n", f.Filename)
ctx.Sess.Printf(" On disk: %s\r\n", fullPath)
ctx.Sess.Printf(" Size: %s (%d bytes)\r\n", formatSize(info.Size()), info.Size())
ctx.Sess.Printf(" Modified: %s\r\n", info.ModTime().Format("Mon Jan 02 15:04:05 2006"))
ctx.Sess.Printf(" DL count: %d\r\n", f.Downloads)
ctx.Sess.NewLine()
}
// libDoDownload handles a file download request.
//
// The original used XMODEM over serial (Xmodem_Send from TRANSFER.C).
// Over telnet, binary transfer requires ZMODEM or similar. For now:
// - Text files (.txt, .nfo, .diz, .ans): displayed inline
// - Binary files: show path and size, note ZMODEM needed
//
// Future enhancement: integrate external sz (ZMODEM send) tool.
func libDoDownload(ctx *Context, lib *models.Library, f *models.LibraryFile) {
fullPath := filepath.Join(lib.FilePath, f.Filename)
info, err := os.Stat(fullPath)
if err != nil {
ctx.Sess.WriteString(" File not found on disk.\r\n")
return
}
ext := strings.ToLower(filepath.Ext(f.Filename))
isText := ext == ".txt" || ext == ".nfo" || ext == ".diz" ||
ext == ".ans" || ext == ".asc" || ext == ".doc" ||
ext == ".1st" || ext == ".me" || ext == ""
if isText && info.Size() < 64*1024 {
// Small text files — display inline (like a bulletin)
data, err := os.ReadFile(fullPath)
if err != nil {
ctx.Sess.WriteString(" Error reading file.\r\n")
return
}
ctx.Sess.NewLine()
ctx.Sess.Paginate(string(data), idleTimeout)
ctx.Sess.NewLine()
// Increment download counter
f.Downloads++
ctx.User.Downloads++
ctx.Store.UpdateUser(ctx.User)
} else {
// Binary or large file — inform the user
ctx.Sess.NewLine()
ctx.Sess.Printf(" File: %s (%s)\r\n", f.Filename, formatSize(info.Size()))
ctx.Sess.Printf(" Path: %s\r\n", fullPath)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightYellow)
ctx.Sess.WriteString(" Binary file transfer requires a ZMODEM-capable terminal.\r\n")
ctx.Sess.WriteString(" (ZMODEM integration coming in a future update.)\r\n")
ctx.Sess.Color(session.AnsiReset)
}
}
// libDownload prompts for a file number and initiates download.
// Replaces Lib_Immediate() from LCOM.C.
func libDownload(ctx *Context, lib *models.Library) {
if lib.FileCount == 0 {
ctx.Sess.WriteString(" No files in this library.\r\n")
return
}
ctx.Sess.Printf(" Download which [1-%d]: ", lib.FileCount)
numStr, err := ctx.Sess.ReadLine("", 5, inputTimeout)
if err != nil {
return
}
num := parseIntDefault(numStr, 0)
if num < 1 || num > lib.FileCount {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
files, err := ctx.Store.ListLibraryFiles(lib.ID, num-1, 1)
if err != nil || len(files) == 0 {
ctx.Sess.WriteString(" File not found.\r\n")
return
}
libDoDownload(ctx, lib, files[0])
}
// libUpload registers a new file in the library.
// Replaces Lib_Upload() from LCOM.C.
//
// The original received the file via XMODEM, then stored the metadata.
// Our version records the metadata and expects the sysop to place the
// actual file in the library directory (or, for future ZMODEM, receive
// the file into that directory).
func libUpload(ctx *Context, lib *models.Library) {
if lib.FileCount >= lib.MaxFiles {
ctx.Sess.WriteString(" Library is full.\r\n")
return
}
ctx.Sess.NewLine()
// Filename
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Filename (Q to quit): ")
ctx.Sess.Color(session.AnsiReset)
filename, err := ctx.Sess.ReadLine("", 30, inputTimeout)
if err != nil {
return
}
filename = strings.TrimSpace(filename)
if filename == "" || strings.EqualFold(filename, "Q") {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
// Security check: no path traversal
// Matches original's check for : / * characters
if strings.ContainsAny(filename, ":/\\*?\"<>|") {
ctx.Sess.WriteString(" Filename cannot contain special characters (: / \\ * ? \" < > |).\r\n")
return
}
// Check for duplicate — replaces Lib_Crossref()
existing, _ := ctx.Store.ListLibraryFiles(lib.ID, 0, lib.MaxFiles)
for _, f := range existing {
if strings.EqualFold(f.Filename, filename) {
ctx.Sess.WriteString(" A file with that name already exists.\r\n")
return
}
}
ctx.Sess.WriteString(" Filename accepted.\r\n\r\n")
// Description
ctx.Sess.WriteString("Description (79 chars): ")
desc, err := ctx.Sess.ReadLine("", 79, inputTimeout)
if err != nil {
return
}
// Uploader name — sysops can override
uploader := ctx.User.Name
if ctx.User.SecStatus >= 150 {
ctx.Sess.WriteString("Name to use: ")
nameInput, err := ctx.Sess.ReadLine(uploader, 30, inputTimeout)
if err != nil {
return
}
nameInput = strings.TrimSpace(nameInput)
if nameInput != "" {
uploader = nameInput
}
}
// Check if file actually exists on disk
fullPath := filepath.Join(lib.FilePath, filename)
var fileSize int64
if info, err := os.Stat(fullPath); err == nil {
fileSize = info.Size()
ctx.Sess.Printf(" File found on disk: %s\r\n", formatSize(fileSize))
} else {
ctx.Sess.Color(session.AnsiFgBrightYellow)
ctx.Sess.WriteString(" File not yet on disk.\r\n")
ctx.Sess.Printf(" Place it at: %s\r\n", fullPath)
ctx.Sess.Color(session.AnsiReset)
}
// Save the entry
entry := &models.LibraryFile{
LibraryID: lib.ID,
Filename: filename,
Description: strings.TrimSpace(desc),
UploaderID: ctx.User.ID,
Uploader: uploader,
FileSize: fileSize,
}
if err := ctx.Store.CreateLibraryFile(entry); err != nil {
ctx.Sess.WriteString(" Error saving file entry.\r\n")
return
}
ctx.User.Uploads++
ctx.Store.UpdateUser(ctx.User)
// Refresh library
if updated, err := ctx.Store.GetLibrary(lib.ID); err == nil {
*lib = *updated
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" File entry #%d created.\r\n", entry.ID)
ctx.Sess.Color(session.AnsiReset)
}
// libRemove deletes a file entry from the library.
// Replaces Lib_Delete_Frontend() from LCOM.C.
func libRemove(ctx *Context, lib *models.Library) {
if lib.FileCount == 0 {
ctx.Sess.WriteString(" No files to remove.\r\n")
return
}
ctx.Sess.Printf(" Remove which [1-%d] (0 to cancel): ", lib.FileCount)
numStr, err := ctx.Sess.ReadLine("", 5, inputTimeout)
if err != nil {
return
}
num := parseIntDefault(numStr, 0)
if num < 1 || num > lib.FileCount {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
files, err := ctx.Store.ListLibraryFiles(lib.ID, num-1, 1)
if err != nil || len(files) == 0 {
ctx.Sess.WriteString(" File not found.\r\n")
return
}
f := files[0]
ctx.Sess.Printf(" Remove \"%s\" by %s? ", f.Filename, f.Uploader)
yes, err := ctx.Sess.Confirm("", inputTimeout)
if err != nil || !yes {
ctx.Sess.WriteString(" Not removed.\r\n")
return
}
ctx.Store.DeleteLibraryFile(f.ID)
if updated, err := ctx.Store.GetLibrary(lib.ID); err == nil {
*lib = *updated
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.WriteString(" File entry removed.\r\n")
ctx.Sess.WriteString(" (The file itself remains on disk.)\r\n")
ctx.Sess.Color(session.AnsiReset)
}
// formatSize formats a byte count as a human-readable string.
func formatSize(bytes int64) string {
switch {
case bytes >= 1024*1024:
return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
case bytes >= 1024:
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
default:
return fmt.Sprintf("%d B", bytes)
}
}