Introduce file icon support to the file browser. Icons are loaded on startup and mapped based on file name and content type. - Load file icon mappings in the App handler initialization. - Add `HasThumbnail`, `IconURL`, and `IconRetroURL` to the file view. - Update CSS to support displaying file icons alongside thumbnails. - Add retro theme support to swap standard icons with pixelated retro variants when the retro theme is active.
153 lines
4.3 KiB
Go
153 lines
4.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// fileIcon holds the two icon filenames for a file type: the standard (modern)
|
|
// icon and the retro (Win98) icon. The filenames are resolved against
|
|
// static/file-icons/standard and static/file-icons/retro respectively.
|
|
type fileIcon struct {
|
|
Standard string `json:"standard"`
|
|
Retro string `json:"retro"`
|
|
}
|
|
|
|
type iconType struct {
|
|
Mime string `json:"mime"`
|
|
Standard string `json:"standard"`
|
|
Retro string `json:"retro"`
|
|
Extensions []string `json:"extensions"`
|
|
}
|
|
|
|
type iconMapFile struct {
|
|
Default iconType `json:"default"`
|
|
Types []iconType `json:"types"`
|
|
}
|
|
|
|
type mimeRule struct {
|
|
pattern string // exact mime ("application/pdf") or major prefix ("image/")
|
|
prefix bool
|
|
icon fileIcon
|
|
}
|
|
|
|
// fileIconSet is the loaded icon map: an extension lookup plus content-type
|
|
// rules and a fallback. It is built once at startup from icon-map.json.
|
|
type fileIconSet struct {
|
|
byExt map[string]fileIcon
|
|
byMime []mimeRule
|
|
fallback fileIcon
|
|
}
|
|
|
|
// loadFileIcons reads static/file-icons/icon-map.json and indexes it by
|
|
// extension and content type so icons can be assigned at render time.
|
|
func loadFileIcons(staticDir string) (*fileIconSet, error) {
|
|
data, err := os.ReadFile(filepath.Join(staticDir, "file-icons", "icon-map.json"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var raw iconMapFile
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
set := &fileIconSet{
|
|
byExt: make(map[string]fileIcon),
|
|
fallback: fileIcon{Standard: raw.Default.Standard, Retro: raw.Default.Retro},
|
|
}
|
|
if err := validateFileIcon(staticDir, set.fallback); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, t := range raw.Types {
|
|
icon := fileIcon{Standard: t.Standard, Retro: t.Retro}
|
|
if err := validateFileIcon(staticDir, icon); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, ext := range t.Extensions {
|
|
set.byExt[strings.ToLower(strings.TrimPrefix(ext, "."))] = icon
|
|
}
|
|
if t.Mime == "" {
|
|
continue
|
|
}
|
|
if strings.HasSuffix(t.Mime, "/*") {
|
|
set.byMime = append(set.byMime, mimeRule{pattern: strings.TrimSuffix(t.Mime, "*"), prefix: true, icon: icon})
|
|
} else {
|
|
set.byMime = append(set.byMime, mimeRule{pattern: strings.ToLower(t.Mime), icon: icon})
|
|
}
|
|
}
|
|
return set, nil
|
|
}
|
|
|
|
func validateFileIcon(staticDir string, icon fileIcon) error {
|
|
if icon.Standard != "" {
|
|
if err := validateFileIconPath(staticDir, "standard", icon.Standard); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if icon.Retro != "" {
|
|
if err := validateFileIconPath(staticDir, "retro", icon.Retro); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateFileIconPath(staticDir, theme, name string) error {
|
|
if strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") {
|
|
return fmt.Errorf("invalid %s file icon path %q", theme, name)
|
|
}
|
|
path := filepath.Join(staticDir, "file-icons", theme, name)
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return fmt.Errorf("missing %s file icon %q: %w", theme, name, err)
|
|
}
|
|
if info.IsDir() {
|
|
return fmt.Errorf("%s file icon %q is a directory", theme, name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// lookup resolves a file's icon from its name (extension) first, falling back to
|
|
// its content type, then to the default icon. Extension wins because stored
|
|
// content types are often the generic application/octet-stream.
|
|
func (s *fileIconSet) lookup(name, contentType string) fileIcon {
|
|
if s == nil {
|
|
return fileIcon{}
|
|
}
|
|
if ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")); ext != "" {
|
|
if icon, ok := s.byExt[ext]; ok {
|
|
return icon
|
|
}
|
|
}
|
|
|
|
ct := strings.ToLower(strings.TrimSpace(contentType))
|
|
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
|
ct = strings.TrimSpace(ct[:i])
|
|
}
|
|
if ct != "" && ct != "application/octet-stream" {
|
|
for _, rule := range s.byMime { // exact matches first
|
|
if !rule.prefix && rule.pattern == ct {
|
|
return rule.icon
|
|
}
|
|
}
|
|
for _, rule := range s.byMime { // then major-type prefixes
|
|
if rule.prefix && strings.HasPrefix(ct, rule.pattern) {
|
|
return rule.icon
|
|
}
|
|
}
|
|
}
|
|
return s.fallback
|
|
}
|
|
|
|
// fileIconURL builds the /static URL for an icon filename in the given theme
|
|
// directory ("standard" or "retro").
|
|
func fileIconURL(theme, name string) string {
|
|
if name == "" {
|
|
return ""
|
|
}
|
|
return "/static/file-icons/" + theme + "/" + name
|
|
}
|