From 9d76faad362f9bcd5b344445e569fcb8826c8bee Mon Sep 17 00:00:00 2001 From: mio Date: Sat, 29 Jun 2024 20:38:42 +0000 Subject: [PATCH] Initial commit --- .gitignore | 3 + cli.go | 76 ++++ config.go | 79 ++++ go.mod | 5 + go.sum | 2 + image/image.go | 364 +++++++++++++++ main.go | 9 + readme.md | 59 +++ template/template.go | 658 ++++++++++++++++++++++++++++ template/themes/nettle/_image.html | 9 + template/themes/nettle/_set.html | 12 + template/themes/nettle/_sets.html | 25 ++ template/themes/nettle/_text.html | 4 + template/themes/nettle/album.css | 46 ++ template/themes/nettle/index.html | 13 + template/themes/nettle/lightbox.css | 86 ++++ util/util.go | 233 ++++++++++ 17 files changed, 1683 insertions(+) create mode 100644 .gitignore create mode 100644 cli.go create mode 100644 config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 image/image.go create mode 100644 main.go create mode 100644 readme.md create mode 100644 template/template.go create mode 100644 template/themes/nettle/_image.html create mode 100644 template/themes/nettle/_set.html create mode 100644 template/themes/nettle/_sets.html create mode 100644 template/themes/nettle/_text.html create mode 100644 template/themes/nettle/album.css create mode 100644 template/themes/nettle/index.html create mode 100644 template/themes/nettle/lightbox.css create mode 100644 util/util.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05abb75 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +lamium +lm +todo.txt diff --git a/cli.go b/cli.go new file mode 100644 index 0000000..b68d229 --- /dev/null +++ b/cli.go @@ -0,0 +1,76 @@ +package main + +import ( + "fmt" + + tpl "lamium/template" +) + +var ( + // App information. + app = struct { + name string + cmd string + desc string + version string + configPath string + }{ + name: "Lamium", + cmd: "lm", + desc: "Static web album generator", + version: "0.1.0", + configPath: "lamium.json", + } + + // Command line menu options. + helpOpts = [][]string{ + {"Usage: " + app.cmd + " [option]\n"}, + {"Options:"}, + {"-h", "help", "Show this help message"}, + {"-m", "make", "Make album in the configured directory"}, + {"-n", "new", "Place a new config in the current directory"}, + {"-v", "version", "Show app version"}, + } +) + +// Prints a help message to standard output. +func showHelp() { + for i := range helpOpts { + if len(helpOpts[i]) >= 3 { + fmt.Println(helpOpts[i][0]+", "+helpOpts[i][1], "\t\t"+ + helpOpts[i][2]) + } else { + fmt.Println(helpOpts[i][0]) + } + } +} + +// Prints version information. +func showVersion() { + fmt.Println(app.name, app.version, "—", app.desc) +} + +// Maps command line options to corresponding functions. +func parseArgs(args []string) { + if len(args) > 1 { + switch args[1] { + case "-h", "h", "help": + showHelp() + case "-m", "m", "make": + conf := loadConfig(app.configPath) + index := map[string]string{ + "html": conf.IndexHTML, + "type": conf.IndexType, + "title": conf.IndexTitle, + } + tpl.MakeAlbum(conf.ThemeDir, conf.HTMLDir, index, conf.Sets) + case "-n", "n", "new": + genConfig(app.configPath) + tpl.GenTheme(false) + case "-v", "v", "version": + showVersion() + } + } else { + showHelp() + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..57dfe99 --- /dev/null +++ b/config.go @@ -0,0 +1,79 @@ +package main + +import ( + "encoding/json" + "path/filepath" + + tpl "lamium/template" + util "lamium/util" +) + +type ( + // A Config defines settings for album generation. + Config struct { + HTMLDir string `json:"htmlDir"` + IndexHTML string `json:"indexHTML"` + IndexTitle string `json:"indexTitle"` + IndexType string `json:"indexType"` + ThemeDir string `json:"themeDir"` + Sets []tpl.Set `json:"sets"` + } +) + +var ( + // Example configuration. + sampleConfig = Config{ + HTMLDir: "public_html", + IndexHTML: "index.html", + IndexTitle: "My Album", + IndexType: "text", + ThemeDir: "themes/nettle", + Sets: []tpl.Set{ + { + Name: "set1", + ImageDir: "set1", + IndexHTML: "index.html", + Title: "Set 1", + SortOrder: "modTimeDesc", + CSSClassIframe: "frame", + CSSClassLink: "link", + CSSClassThumb: "thumb", + LightboxOn: true, + LinkType: "page", + ThumbDir: "thumbs", + ThumbWidth: 200, + ThumbHeight: 200, + ThumbPct: 0, + ThumbClip: "square", + ThumbUnifyMode: "height", + }, + }, + } + + // Error messages. + configErr = struct { + configNotLoaded string + jsonNotSaved string + }{ + configNotLoaded: "Error: config could not be loaded:", + jsonNotSaved: "Error: JSON config could not be saved:", + } +) + +// genConfig outputs an example configuration file at path. +func genConfig(path string) { + conf, jsonErr := json.MarshalIndent(sampleConfig, "", " ") + if !util.HasError(jsonErr, configErr.jsonNotSaved+" "+path, true) { + util.MakeDir(filepath.Dir(path)) + util.SaveFile(path, string(conf), true) + } +} + +// loadConfig returns a *Config interface of settings after reading +// the configuration file at path. +func loadConfig(path string) *Config { + contents := util.LoadFile(path, true) + conf := &Config{} + json.Unmarshal([]byte(contents), &conf) + return conf +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ac33147 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module lamium + +go 1.22.4 + +require golang.org/x/image v0.18.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9d0133a --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= diff --git a/image/image.go b/image/image.go new file mode 100644 index 0000000..47ef108 --- /dev/null +++ b/image/image.go @@ -0,0 +1,364 @@ +// Package image uses the official Go image/draw library to provide more options // for image downscaling. It includes functions related to the supported image +// formats. +package image + +import ( + "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + "math" + "os" + "path/filepath" + "slices" + "strings" + + "golang.org/x/image/draw" + + util "lamium/util" +) + +var ( + // Error messages. + err = struct { + imageNotCreated string + imageNotLoaded string + imageNotDecoded string + imageNotEncoded string + }{ + imageNotCreated: "Error: new image not created.", + imageNotLoaded: "Error: image could not be loaded:", + imageNotDecoded: "Error: image could not be decoded:", + imageNotEncoded: "Error: image could not be encoded:", + } + + // Supported image extensions. + imageExt = map[string][]string{ + "gif": {".gif", ".GIF"}, + "jpg": {".jpeg", ".jpg", ".JPEG", ".JPG"}, + "png": {".png", ".PNG"}, + } + + // Image information messages. + msg = struct { + debugSrcImg string + debugSrcPath string + debugNewImg string + imgResize string + }{ + debugSrcImg: "Source image size:", + debugSrcPath: "Image:", + debugNewImg: "New image size:", + imgResize: "Resizing", + } +) + +// IsSupported checks an image's file extension returns true if it is one of the // supported extensions. +func IsSupported(path string) bool { + var exts []string + for _, s := range imageExt { + for _, v := range s { + exts = append(exts, v) + } + } + if slices.Contains(exts, filepath.Ext(path)) { + return true + } else { + return false + } +} + +// CopyImages copies image files from srcPath to destPath, overwriting files at +// the destination if overwrite is set to true. +func CopyImages(srcPath, destPath string, overwrite bool) { + files := util.GetFileList(srcPath, "name", false) + for _, file := range files { + destFile := filepath.Join(destPath, filepath.Base(file)) + if (!util.FileExists(destFile) || (util.FileExists(destFile) && + overwrite)) && IsSupported(file) { + util.CopyFile(file, destFile) + } + } +} + +// GetImageList returns a slice of strings of image files in srcPath. +// +// For sortOrder options, see util.GetFileList. +func GetImageList(srcPath, sortOrder string, includeDirs bool) []string { + files := util.GetFileList(srcPath, sortOrder, includeDirs) + var list []string + for _, file := range files { + if IsSupported(file) { + list = append(list, file) + } + } + return list +} + +// DecodeImage returns an image.Image object from an image path. +func DecodeImage(path string) image.Image { + var ( + img image.Image + decErr error + ) + srcFile, openErr := os.Open(filepath.FromSlash(path)) + defer srcFile.Close() + if !util.HasError(openErr, err.imageNotLoaded+" "+path) { + if slices.Contains(imageExt["jpg"], filepath.Ext(path)) { + img, decErr = jpeg.Decode(srcFile) + } else if slices.Contains(imageExt["png"], filepath.Ext(path)) { + img, decErr = png.Decode(srcFile) + } else if slices.Contains(imageExt["gif"], filepath.Ext(path)) { + img, decErr = gif.Decode(srcFile) + } else { + util.HasError(decErr, err.imageNotDecoded+" "+path) + } + } + util.HasError(decErr, err.imageNotDecoded+" "+path) + return img +} + +// EncodeImage writes an image.Image object as image file path. +func EncodeImage(path string, writer io.Writer, img image.Image) { + var encErr error + if slices.Contains(imageExt["jpg"], filepath.Ext(path)) { + encErr = jpeg.Encode(writer, img, nil) + } else if slices.Contains(imageExt["png"], filepath.Ext(path)) { + encErr = png.Encode(writer, img) + } else if slices.Contains(imageExt["gif"], filepath.Ext(path)) { + encErr = gif.Encode(writer, img, nil) + } else { + util.HasError(encErr, err.imageNotEncoded+" "+path) + } + util.HasError(encErr, err.imageNotEncoded+" "+path) +} + +// CalcImageSize returns the new width and height of image.Image srcImg +// based on pct, clip and unifyMode. +// +// pct is percentage of the original size, an int value between 1-99 +// inclusively. +// +// clip options: square, rect. +// +// unifyMode options with example scenarios if width is 200 and height is 100: +// +// maxSize: images are the maximum size at the longer side. +// An image in landscape would be 200px in width and less in height. +// An image in portrait would have a height of 200px, less in width. +// A square image would have a width and height of 200px. +// +// minSize: images are the minimum size at the shorter side. +// An image in landscape would be 100px in height and more in width. +// An image in portrait would have a width of 100px, more in height. +// A square image would have a width and height of 100px. +// +// width: images have the same width. +// An image in landscape would be 200px wide, less in height. +// Animage in portrait would be 200px wide, more in height +// A square image would have a width and height of 200px. +// +// height: images have the same height. +// An image in landscape would be 100px high and wider than 100px. +// An image in portrait would be 100px high, and narrower than 100px. +// A square image would have a width and height of 100px. +// +// none: images are sized at a percentage of the original image regardless of +// set width and height. The current default is 50% of the original image +// dimensions, e.g. a 600 x 800px source image yields a new 300 x 400px image. +func CalcImageSize(srcImg image.Image, width, height, pct int, + clip string, unifyMode string) (int, int) { + // Constrain width and height to source dimensions. + w, h := width, height + if w > srcImg.Bounds().Dx() || w < 0 { + w = srcImg.Bounds().Dx() + } + if h > srcImg.Bounds().Dy() || h < 0 { + h = srcImg.Bounds().Dy() + } + // Get source aspect ratio. + minSrcSize, maxSrcSize := util.MinMax(srcImg.Bounds().Dx(), + srcImg.Bounds().Dy()) + var ratio = float64(minSrcSize) / float64(maxSrcSize) + switch unifyMode { + case "maxSize": + // Use the higher value of width/height as maximum size. + _, maxSize := util.MinMax(w, h) + if srcImg.Bounds().Dx() > srcImg.Bounds().Dy() { + w = maxSize + h = int(math.Round(float64(w) * ratio)) + } else if srcImg.Bounds().Dy() > srcImg.Bounds().Dx() { + w = int(math.Round(float64(maxSize) * ratio)) + h = maxSize + } else { + w, h = maxSize, maxSize + } + case "minSize": + // Use the lower value of width/height as minimum size. + minSize, _ := util.MinMax(w, h) + if srcImg.Bounds().Dx() > srcImg.Bounds().Dy() { + w = int(math.Round(float64(srcImg.Bounds().Dx()) / + (float64(srcImg.Bounds().Dy()) / float64(h)))) + h = minSize + } else if srcImg.Bounds().Dx() < srcImg.Bounds().Dy() { + w = minSize + h = int(math.Round(float64(srcImg.Bounds().Dy()) / + (float64(srcImg.Bounds().Dx()) / float64(w)))) + } else { + w, h = minSize, minSize + } + case "width": + // Use width as baseline for aspect ratio. + if srcImg.Bounds().Dx() != srcImg.Bounds().Dy() { + h = int(math.Round(float64(srcImg.Bounds().Dy()) / + (float64(srcImg.Bounds().Dx()) / float64(w)))) + } else { + h = w + } + case "height": + // Use height as baseline for aspect ratio. + if srcImg.Bounds().Dx() != srcImg.Bounds().Dy() { + w = int(math.Round(float64(srcImg.Bounds().Dx()) / + (float64(srcImg.Bounds().Dy()) / float64(h)))) + } else { + w = h + } + case "none": + // Derive values as a percentage of source image size. + pc := pct + if pct > 0 || pct < 100 { + pc = 50 + } + if srcImg.Bounds().Dx() > srcImg.Bounds().Dy() { + w = int(math.Round(float64(srcImg.Bounds().Dx()) * + (float64(pc) / 100))) + h = int(math.Round(float64(srcImg.Bounds().Dx()) * ratio * + (float64(pc) / 100))) + } else { + w = int(math.Round(float64(srcImg.Bounds().Dy()) * ratio * + (float64(pc) / 100))) + h = int(math.Round(float64(srcImg.Bounds().Dy()) * + (float64(pc) / 100))) + } + } + + if clip == "square" { + switch unifyMode { + case "minSize": + w, _ = util.MinMax(w, h) + h = w + case "maxSize", "none": + _, w = util.MinMax(w, h) + h = w + case "width": + h = w + case "height": + w = h + } + } + + return w, h +} + +// CalcClipSize returns the new width and height as well as an image.Rectangle +// object for image.Image srcImg based on a set width and height and clip mode +// (square or rect). +func CalcClipSize(srcImg image.Image, width, height int, clip string) (int, + int, image.Rectangle) { + var w, h int + var srcBounds image.Rectangle + if clip == "square" { + // Center the square on rectangular images. + // Use max length for new image size if width and height are + // different. + _, w = util.MinMax(width, height) + h = w + // Get the shorter side. + srcMin, _ := util.MinMax(srcImg.Bounds().Dx(), srcImg.Bounds().Dy()) + x1, y1 := 0, 0 + x2, y2 := srcMin, srcMin + // Shift the start and end values on the longer side of the + // source image's image.Rectangle. + if srcImg.Bounds().Dx() != srcImg.Bounds().Dy() && + srcMin == srcImg.Bounds().Dx() { + y1 = int(math.Round(float64(srcImg.Bounds().Dy()- + srcMin) / float64(2))) + y2 = int(srcMin + y1) + } else if srcImg.Bounds().Dx() != srcImg.Bounds().Dy() && + srcMin == srcImg.Bounds().Dy() { + x1 = int(math.Round(float64(srcImg.Bounds().Dx()- + srcMin) / float64(2))) + x2 = int(srcMin + x1) + } + srcBounds = image.Rect(x1, y1, x2, y2) + } else { + w, h = width, height + srcBounds = srcImg.Bounds() + } + return w, h, srcBounds +} + +// ResizeImage creates a new downscaled image at destPath from source image +// srcPath. +// +// For more details about the pct, clip, and unifyMode parameters, see +// CalcImageSize. +func ResizeImage(srcPath, destPath string, width, height, pct int, clip, + unifyMode string, debug ...bool) { + util.MakeDir(filepath.Dir(destPath), true) + var destFile *os.File + var createErr error + if IsSupported(srcPath) { + destFile, createErr = os.Create(filepath.FromSlash(destPath)) + defer destFile.Close() + } + if !util.HasError(createErr, err.imageNotCreated) && IsSupported(srcPath) { + w, h := width, height + var srcBounds image.Rectangle + srcImg := DecodeImage(srcPath) + w, h = CalcImageSize(srcImg, w, h, pct, clip, unifyMode) + w, h, srcBounds = CalcClipSize(srcImg, w, h, clip) + + destImg := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.BiLinear.Scale(destImg, destImg.Rect, srcImg, srcBounds, draw.Over, + nil) + EncodeImage(destPath, destFile, destImg) + + if slices.Contains(debug, true) { + fmt.Println(msg.debugSrcPath, srcPath) + fmt.Println(msg.debugSrcImg, srcImg.Bounds().Dx(), + srcImg.Bounds().Dy()) + fmt.Println(msg.debugNewImg, w, h) + + } + } +} + +// ResizeImages creates multiple downscaled images at directory destPath from +// source images at directory srcPath. +func ResizeImages(srcPath, destPath string, width, height, pct int, clip string, + unifyMode string, overwrite bool, debug ...bool) { + files := util.GetFileList(srcPath, "name", false) + debugOn := false + if slices.Contains(debug, true) { + debugOn = true + } + for _, file := range files { + destImg := filepath.Join(destPath, filepath.Base(file)) + if !util.FileExists(destImg) || (util.FileExists(destImg) && + overwrite) { + ResizeImage(file, destImg, width, height, pct, clip, unifyMode, + debugOn) + } + } +} + +// GetImageTitle returns a title string based on the image filename in path. +func GetImageTitle(path string) string { + repChars := map[string]string{"-": " ", "_": " "} + str := strings.Replace(util.MultiReplace(filepath.Base(path), repChars), + filepath.Ext(path), "", 1) + return str +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c3b46b3 --- /dev/null +++ b/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "os" +) + +func main() { + parseArgs(os.Args) +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..4fd8fab --- /dev/null +++ b/readme.md @@ -0,0 +1,59 @@ +# Lamium + +A simple HTML album generator. + +Features: + +- Generate rectangular or square image thumbnails +- Default plain theme with CSS lightbox +- Supported image types: .jpg, .png +- Supported image caption files: .txt, .html + + +*This is currently a preview release and may contain issues.* + + +## Build + +- Download the source or `git clone` the repo. + +- Install [Go](https://golang.org), then build: `go build -o lm .` + + +## Usage + +- Make a new album directory and change into it: `mkdir [album] && cd [album]` + +- Add a folder or "set" of images to the directory, including any image caption files. + +- Generate a new sample config and theme: `lm new` + +- Edit the settings in the `.json` config file. + +- Customise the default theme templates as desired. + +- Generate the album: `lm make` + +By default the `make` option will output a `public_html` folder inside the album source directory with the generated HTML files, images and thumbnails. The HTML album can then be uploaded to a web server directory using rsync or another file transfer application. + + +Example album structure: + +``` +album/ + |- set1/ + | |- image1.jpg + | |- image1.txt + | |- image2.jpg + | |- image2.txt + | |- index.txt + |- themes/ + | |- [theme]/ + | |- index.txt + |- lamium.json +``` + + +## License + +[0BSD](https://spdx.org/licenses/0BSD.html) diff --git a/template/template.go b/template/template.go new file mode 100644 index 0000000..f281fc2 --- /dev/null +++ b/template/template.go @@ -0,0 +1,658 @@ +// Package template implements HTML page generation functions from a default +// or custom theme. +package template + +import ( + "embed" + "html" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + + image "lamium/image" + util "lamium/util" +) + +type ( + // A Set is a collection of images. The Set type contains properties + // about the collection such as the source directory for the images + // and how to display them. + // + // JSON tags are included for config file marshalling. + Set struct { + Name string `json:"name"` + ImageDir string `json:"imageDir"` + IndexHTML string `json:"indexHTML"` + Title string `json:"title"` + SortOrder string `json:"sortOrder"` + CSSClassIframe string `json:"cssClassIframe"` + CSSClassLink string `json:"cssClassLink"` + CSSClassThumb string `json:"cssClassThumb"` + LightboxOn bool `json:"lightboxOn"` + LinkType string `json:"linkType"` + ThumbDir string `json:"thumbDir"` + ThumbWidth int `json:"thumbWidth"` + ThumbHeight int `json:"thumbHeight"` + ThumbPct int `json:"thumbPercent"` + ThumbClip string `json:"thumbClip"` + ThumbUnifyMode string `json:"thumbUnifyMode"` + } +) + +var ( + // File extensions for captions. + CaptionExt = map[string]string{ + "html": ".html", + "txt": ".txt", + } + + // Supported HTML markup tags. + MarkupHTML = map[string]string{ + "<b>": "", + "</b>": "", + "<blockquote>": "
", + "</blockquote>": "
", + "<br>": "
", + "<code>": "", + "</code>": "", + "<del>": "", + "</del>": "", + "<dd>": "
", + "</dd>": "
", + "<dl>": "
", + "</dl>": "
", + "<dt>": "
", + "</dt>": "
", + "<em>": "", + "</em>": "", + "<h1>": "

", + "</h1>": "

", + "<h2>": "

", + "</h2>": "

", + "<h3>": "

", + "</h3>": "

", + "<i>": "", + "</i>": "", + "<ol>": "
    ", + "</ol>": "
", + "<p>": "

", + "</p>": "

", + "<strong>": "", + "</strong>": "", + "<s>": "", + "</s>": "", + "<u>": "", + "</u>": "", + "<ul>": "", + } + + // Supported markup syntax regexp for text files and the HTML tag + // translations. + MarkupTxt = []map[string]string{ + {`^\*\*`: ""}, + {`\*\*$`: ""}, + {`\*\*\n`: "\n"}, + {`\t\*\*`: "\t"}, + {`\*\*\t`: "\t"}, + {`[[:space:]]\*\*`: " "}, + {`\*\*[[:space:]]`: " "}, + {`^\*`: ""}, + {`\*$`: ""}, + {`\*\n`: "\n"}, + {`\t\*`: "\t"}, + {`\*\t`: "\t"}, + {`[[:space:]]\*`: " "}, + {`\*[[:space:]]`: " "}, + {`^__`: ""}, + {`__$`: ""}, + {`__\n`: "\n"}, + {`\t__`: "\t"}, + {`__\t`: "\t"}, + {`[[:space:]]__`: " "}, + {`__[[:space:]]`: " "}, + {`^\~\~`: ""}, + {`\~\~$`: ""}, + {`\~\~\n`: "\n"}, + {`\t\~\~`: "\t"}, + {`\~\~\t`: "\t"}, + {`[[:space:]]\~\~`: " "}, + {`\~\~[[:space:]]`: " "}, + {"\n\n": "

\n

"}, + {" \n": "
"}, + } + + // Template variables used in partials. + PartVar = map[string]string{ + "id": "{{ id }}", + "iframeClass": "{{ iframe_class }}", + "link": "{{ link }}", + "linkClass": "{{ link_class }}", + "navNext": "{{ nav_next }}", + "navPrev": "{{ nav_prev }}", + "navTop": "{{ nav_top }}", + "thumb": "{{ thumb }}", + "thumbClass": "{{ thumb_class }}", + "set": "[set]", + } + + // HTML partials that are inserted into templates. + PartHTML = map[string]string{ + "img": `` + "\n", + "lightbox": ` +

` + "\n", + "nav": ` + + `, + } + + // Sample theme directory path relative to the album root. + // + // If changing this value, the go:embed directive for the ThemeFile variable + // also needs to be updated, to store the theme files at compile time. + SampleThemeDir = "themes/nettle" + + // Theme template filenames. + Theme = struct { + Index string `json:"index"` + Image string `json:"_image"` + Set string `json:"_set"` + Sets string `json:"_sets"` + Text string `json:"_text"` + AlbumCSS string `json:"albumCSS"` + LightboxCSS string `json:"lightboxCSS"` + }{ + Index: "index.html", + Image: "_image.html", + Set: "_set.html", + Sets: "_sets.html", + Text: "_text.html", + AlbumCSS: "album.css", + LightboxCSS: "lightbox.css", + } + + // Template variables used in theme files. + ThemeVar = map[string]map[string]string{ + Theme.Index: { + "content": "{{ index_content }}", + "relPath": "{{ index_relpath }}", + "title": "{{ index_title }}", + }, + Theme.Image: { + "caption": "{{ image_caption }}", + "path": "{{ image_path }}", + "title": "{{ image_title }}", + "nav": "{{ nav }}", + }, + Theme.Set: { + "content": "{{ set }}", + "desc": "{{ set_desc }}", + "lightbox": "{{ set_lightbox }}", + "name": "{{ set_name }}", + "title": "{{ set_title }}", + }, + Theme.Sets: { + "content": "{{ set_[set] }}", + "desc": "{{ set_[set]_desc }}", + "lightbox": "{{ set_[set]_lightbox }}", + "name": "{{ set_[set]_name }}", + "title": "{{ set_[set]_title }}", + }, + Theme.Text: { + "content": "{{ text_content }}", + "title": "{{ text_title }}", + }, + } +) + +// Compile-time theme embed directive. +// Ensure this path corresponds with SampleThemeDir. +// +//go:embed themes/nettle/* +var ThemeFile embed.FS + +// GetHTMLFilename returns the value of path with the base filename reset to +// Theme.Index if path contains a conflicting or invalid filename. Otherwise, +// returns a copy of path. +func GetHTMLFilename(path string) string { + str := filepath.Join(filepath.Dir(path), Theme.Index) + if util.ReplaceExt(filepath.Base(path), "") != "" && filepath.Ext(path) == + filepath.Ext(Theme.Index) { + str = path + } + return str +} + +// CheckDup returns a renamed path if a file by the same name already +// exists in the same srcDir and is not a caption text file. If the file path +// does not already exist, returns the original path. +func CheckDup(path, srcDir, ext string) string { + files := util.GetFileList(srcDir, "name", false) + name := util.ReplaceExt(filepath.Base(path), "") + num := strconv.Itoa(len(files) + 1) + newPath := filepath.Join(filepath.Dir(path), name+num+ext) + for _, file := range files { + if util.ReplaceExt(filepath.Base(file), "") == name && + filepath.Base(file) != name+CaptionExt["txt"] { + return newPath + } + } + return path +} + +// GenTheme outputs the theme files to the album directory at the +// SampleThemeDir path. +func GenTheme(exitOnErr bool) { + util.MakeDir(SampleThemeDir, exitOnErr) + files := []string{Theme.Index, Theme.Image, Theme.Set, Theme.Sets, + Theme.Text, Theme.AlbumCSS, Theme.LightboxCSS} + for _, f := range files { + content, _ := ThemeFile.ReadFile(filepath.Join(SampleThemeDir, f)) + util.SaveFile(filepath.Join(SampleThemeDir, f), string(content), + exitOnErr) + } +} + +// GetSetHTML returns an HTML string of image thumbnails for an image set. +// +// linkClass and thumbClass are CSS classes to assign to the links and +// thumbnails respectively for styling. +// +// If imageType is "image", the HTML target links will link directly to +// images instead of their corresponding HTML pages. +// +// If lightboxOn is true, the HTML target links will be set to anchors +// that are image iframe ids. +func GetSetHTML(srcPath, thumbPath, sortOrder, linkType, linkClass, + thumbClass string, lightboxOn, isTopLevel bool) string { + var htmlStr string + files := image.GetImageList(srcPath, sortOrder, false) + imgVars := map[string]string{ + PartVar["linkClass"]: linkClass, + PartVar["thumbClass"]: thumbClass, + } + if linkType == "image" { + for _, file := range files { + // Adjust path for top-level page. + if isTopLevel { + imgVars[PartVar["link"]] = file + } else { + imgVars[PartVar["link"]] = filepath.Base(file) + } + imgVars[PartVar["thumb"]] = filepath.Join(thumbPath, + filepath.Base(file)) + htmlStr += util.MultiReplace(PartHTML["img"], imgVars) + } + } else { + for _, file := range files { + if lightboxOn { + imgVars[PartVar["link"]] = "#" + + util.ReplaceExt(filepath.Base(file), "") + } else if !lightboxOn && isTopLevel { + imgVars[PartVar["link"]] = strings.Replace(file, + filepath.Ext(file), filepath.Ext(Theme.Index), 1) + } else { + imgVars[PartVar["link"]] = filepath.Base( + strings.Replace(file, + filepath.Ext(file), filepath.Ext(Theme.Index), 1)) + } + imgVars[PartVar["thumb"]] = filepath.Join(thumbPath, + filepath.Base(file)) + htmlStr += util.MultiReplace(PartHTML["img"], imgVars) + } + } + return strings.TrimRight(htmlStr, "\n") +} + +// GetSetLBHTML returns an HTML string of image iframes and navigation links for // an image set. +// +// iframeClass is a CSS class to assign to the iframes for styling. +func GetSetLBHTML(srcPath, sortOrder, iframeClass string, + isTopLevel bool) string { + var htmlStr string + files := image.GetImageList(srcPath, sortOrder, false) + lbVars := map[string]string{ + PartVar["iframeClass"]: iframeClass, + } + for f := 0; f < len(files); f++ { + prev, next := "#", "#" + if len(files) > 1 { + if f == 0 { + next = "#" + util.ReplaceExt(filepath.Base(files[f+1]), "") + } else if f == (len(files) - 1) { + prev = "#" + util.ReplaceExt(filepath.Base(files[f-1]), "") + next = "#" + } else { + prev = "#" + util.ReplaceExt(filepath.Base(files[f-1]), "") + next = "#" + util.ReplaceExt(filepath.Base(files[f+1]), "") + } + } + lbVars[PartVar["navNext"]] = next + lbVars[PartVar["navPrev"]] = prev + lbVars[PartVar["id"]] = util.ReplaceExt(filepath.Base(files[f]), "") + if isTopLevel { + lbVars[PartVar["link"]] = strings.Replace(files[f], + filepath.Ext(files[f]), filepath.Ext(Theme.Index), 1) + } else { + lbVars[PartVar["link"]] = filepath.Base(strings.Replace( + files[f], filepath.Ext(files[f]), filepath.Ext(Theme.Index), 1)) + } + htmlStr += util.MultiReplace(PartHTML["lightbox"], lbVars) + } + return strings.TrimRight(htmlStr, "\n") +} + +// TxtToHTML returns an HTML string with supported markup syntax in string str +// replaced with HTML tags. +func TxtToHTML(str string) string { + htmlStr := html.EscapeString(str) + for _, r := range MarkupTxt { + for reg, repl := range r { + re, _ := regexp.Compile(reg) + htmlStr = string(re.ReplaceAll([]byte(htmlStr), []byte(repl))) + } + } + htmlStr = "

" + strings.TrimRight(htmlStr, "\n") + "

" + return htmlStr +} + +// FilterHTML returns an HTML string with some characters escaped +// (see html.EscapeString) and supported markup tags exempted. +func FilterHTML(str string) string { + htmlStr := html.EscapeString(str) + htmlStr = strings.TrimRight(util.MultiReplace(htmlStr, MarkupHTML), "\n") + return htmlStr +} + +// GetCaption returns an HTML string with the contents of an image caption for +// string path, if the caption file exists. If the caption file does not exist, +// returns an empty string. +func GetCaption(path string) string { + for _, ext := range CaptionExt { + capPath := path + if strings.HasSuffix(path, ext) { + // Files. + capPath = strings.Replace(path, filepath.Ext(path), ext, 1) + } else { + // Directories. + capPath += ext + } + if util.FileExists(capPath) { + if ext == ".txt" { + return TxtToHTML(util.LoadFile(capPath)) + } else { + return FilterHTML(util.LoadFile(capPath)) + } + } + } + return "" +} + +// Like GetCaption but for multiple files in a directory at string path, +// GetCaptions a map with image files as keys and caption contents as values. +// +// If titleFallback is true, sets the caption contents to the image title +// if no caption file is found for a given image. +func GetCaptions(path string, titleFallback bool) map[string]string { + caps := make(map[string]string) + files := image.GetImageList(path, "name", false) + // Load caption file if available, fallback to deriving title from the + // filename if title fallback is enabled, or empty string if unknown. + for _, file := range files { + caps[file] = GetCaption(file) + if caps[file] == "" && titleFallback { + caps[file] = image.GetImageTitle(file) + } + } + return caps +} + +// CopyCSS copies the theme CSS files from themePath to destDir. +func CopyCSS(themePath, destDir string) { + util.MakeDir(destDir, true) + util.CopyFile(filepath.Join(themePath, Theme.LightboxCSS), + filepath.Join(destDir, Theme.LightboxCSS)) + util.CopyFile(filepath.Join(themePath, Theme.AlbumCSS), + filepath.Join(destDir, Theme.AlbumCSS)) +} + +// PrepImages copies images in Set set to destDir, and creates a directory of +// thumbnails at destDir. +func PrepImages(destDir string, set Set) { + dsPath := filepath.FromSlash(destDir) + util.MakeDir(filepath.Join(dsPath, set.ImageDir)) + image.CopyImages(set.ImageDir, filepath.Join(dsPath, set.ImageDir), + false) + + util.MakeDir(filepath.Join(dsPath, set.ImageDir, set.ThumbDir)) + image.ResizeImages(filepath.Join(dsPath, set.ImageDir), + filepath.Join(dsPath, set.ImageDir, set.ThumbDir), + set.ThumbWidth, set.ThumbHeight, set.ThumbPct, set.ThumbClip, + set.ThumbUnifyMode, false) +} + +// GetPartial returns an HTML string of partial themeFile from theme themePath +// with template variables partialVars inserted. +func GetPartial(themePath, themeFile string, + partialVars map[string]string) string { + // Replace variables in partial template and trim end of file newlines. + partial := util.LoadFile(filepath.Join(filepath.FromSlash(themePath), + themeFile)) + return strings.TrimRight(util.MultiReplace(partial, partialVars), + "\n") +} + +// MakePage saves a page at destPath with the contents of themeFile from +// themePath and the values from pageVars template variables inserted. +func MakePage(themePath, themeFile, destPath string, + pageVars map[string]string) { + // Replace variables in index template. + dsPath := filepath.FromSlash(destPath) + util.MakeDir(filepath.Dir(dsPath)) + index := util.LoadFile(filepath.Join(filepath.FromSlash(themePath), + themeFile)) + page := util.MultiReplace(index, pageVars) + os.Remove(dsPath) + util.SaveFile(dsPath, page) +} + +// MakeHTMLImagePages saves individual HTML pages for set images at destDir. +func MakeHTMLImagePages(themePath, destDir string, sets []Set) { + for _, set := range sets { + util.MakeDir(filepath.Join(destDir, set.ImageDir)) + caps := GetCaptions(set.ImageDir, true) + files := image.GetImageList(set.ImageDir, set.SortOrder, false) + + PrepImages(destDir, set) + for f := 0; f < len(files); f++ { + nav := "" + // No nav HTML emitted in lightbox mode. + if !set.LightboxOn { + prev, next := "#", "#" + if len(files) > 1 { + if f == 0 { + next = util.ReplaceExt(filepath.Base(files[f+1]), + filepath.Ext(Theme.Index)) + } else if f == (len(files) - 1) { + prev = util.ReplaceExt(filepath.Base(files[f-1]), + filepath.Ext(Theme.Index)) + next = "#" + } else { + prev = util.ReplaceExt(filepath.Base(files[f-1]), + filepath.Ext(Theme.Index)) + next = util.ReplaceExt(filepath.Base(files[f+1]), + filepath.Ext(Theme.Index)) + } + } + navVars := map[string]string{ + PartVar["navNext"]: next, + PartVar["navPrev"]: prev, + PartVar["navTop"]: set.IndexHTML, + } + nav = util.MultiReplace(PartVar["nav"], navVars) + } + partialVars := map[string]string{ + ThemeVar[Theme.Image]["caption"]: caps[files[f]], + ThemeVar[Theme.Image]["path"]: filepath.Base(files[f]), + ThemeVar[Theme.Image]["title"]: image.GetImageTitle(files[f]), + ThemeVar[Theme.Image]["nav"]: nav, + } + partial := GetPartial(themePath, Theme.Image, partialVars) + pageVars := map[string]string{ + ThemeVar[Theme.Index]["content"]: partial, + ThemeVar[Theme.Index]["relPath"]: "..", + ThemeVar[Theme.Index]["title"]: image.GetImageTitle(files[f]), + } + pagePath := filepath.Join(destDir, set.ImageDir, + strings.Replace(filepath.Base(files[f]), + filepath.Ext(files[f]), filepath.Ext(Theme.Index), 1)) + MakePage(themePath, Theme.Index, pagePath, pageVars) + } + } +} + +// MakeHTMLSetPages saves a page displaying each set, if enabled in the set's +// properties, at destDir. +func MakeHTMLSetPages(themePath, destDir string, sets []Set) { + tmPath := filepath.FromSlash(themePath) + dsPath := filepath.FromSlash(destDir) + setHTML := util.LoadFile(filepath.Join(tmPath, Theme.Set)) + for _, set := range sets { + if set.IndexHTML != "" { + // Move images if not already present. + PrepImages(destDir, set) + + // Replace variables in partial template. + setCont := GetSetHTML(set.ImageDir, set.ThumbDir, set.SortOrder, + set.LinkType, set.CSSClassLink, set.CSSClassThumb, + set.LightboxOn, false) + setDesc := GetCaption(filepath.Join(set.ImageDir, set.ImageDir)) + setLB := "" + if set.LightboxOn { + setLB = GetSetLBHTML(set.ImageDir, set.SortOrder, + set.CSSClassIframe, false) + } + partialVars := map[string]string{ + ThemeVar[Theme.Set]["content"]: setCont, + ThemeVar[Theme.Set]["desc"]: setDesc, + ThemeVar[Theme.Set]["lightbox"]: setLB, + ThemeVar[Theme.Set]["name"]: set.Name, + ThemeVar[Theme.Set]["title"]: set.Title, + } + pagePath := GetHTMLFilename(filepath.Join(dsPath, set.ImageDir, + set.IndexHTML)) + pagePath = CheckDup(pagePath, set.ImageDir, + filepath.Ext(Theme.Index)) + + // Remove cached page. + os.Remove(pagePath) + setPage := strings.TrimRight(util.MultiReplace(setHTML, + partialVars), "\n") + // Replace variables in page template. + pageVars := map[string]string{ + ThemeVar[Theme.Index]["content"]: setPage, + ThemeVar[Theme.Index]["relPath"]: "..", + ThemeVar[Theme.Index]["title"]: image.GetImageTitle(set.Title), + } + MakePage(themePath, Theme.Index, pagePath, pageVars) + } + } +} + +// MakeHTMLIndexPage saves a top-level HTML page at destDir. +func MakeHTMLIndexPage(themePath, destDir string, index map[string]string, + sets []Set) { + tmPath := filepath.FromSlash(themePath) + pagePath := GetHTMLFilename(filepath.Join(filepath.FromSlash(destDir), + index["html"])) + var cache []string + + if index["type"] == "sets" { + for _, set := range sets { + setHTML := util.LoadFile(filepath.Join(tmPath, Theme.Sets)) + setCont := GetSetHTML(set.ImageDir, filepath.Join(set.ImageDir, + set.ThumbDir), set.SortOrder, set.LinkType, + set.CSSClassLink, set.CSSClassThumb, set.LightboxOn, true) + setDesc := GetCaption(filepath.Join(set.ImageDir, set.ImageDir)) + setLB := "" + if set.LightboxOn { + setLB = GetSetLBHTML(set.ImageDir, set.SortOrder, + set.CSSClassIframe, true) + } + contVar := strings.Replace(ThemeVar[Theme.Sets]["content"], + PartVar["set"], set.Name, 1) + descVar := strings.Replace(ThemeVar[Theme.Sets]["desc"], + PartVar["set"], set.Name, 1) + lbVar := strings.Replace(ThemeVar[Theme.Sets]["lightbox"], + PartVar["set"], set.Name, 1) + nameVar := strings.Replace(ThemeVar[Theme.Sets]["name"], + PartVar["set"], set.Name, 1) + titleVar := strings.Replace(ThemeVar[Theme.Sets]["title"], + PartVar["set"], set.Name, 1) + partialVars := map[string]string{ + contVar: setCont, + descVar: setDesc, + lbVar: setLB, + nameVar: set.Name, + titleVar: set.Title, + } + + // Remove cached page in the first pass. + // For subsequent sets on the same page, save file directly after + // replacing variables in the file already in the output directory. + if !slices.Contains(cache, pagePath) { + os.Remove(pagePath) + cache = append(cache, pagePath) + setHTML = strings.TrimRight(util.MultiReplace(setHTML, + partialVars), "\n") + pageVars := map[string]string{ + ThemeVar[Theme.Index]["content"]: setHTML, + ThemeVar[Theme.Index]["relPath"]: ".", + ThemeVar[Theme.Index]["title"]: index["title"], + } + MakePage(themePath, Theme.Index, pagePath, pageVars) + } else { + setHTML = util.LoadFile(pagePath) + setHTML = strings.TrimRight(util.MultiReplace(setHTML, partialVars), + "\n") + util.SaveFile(pagePath, setHTML) + } + } + } else { + // Text type. + textHTML := util.LoadFile(filepath.Join(tmPath, Theme.Text)) + content := GetCaption(GetHTMLFilename(index["html"])) + partialVars := map[string]string{ + ThemeVar[Theme.Text]["content"]: content, + ThemeVar[Theme.Text]["title"]: index["title"], + } + partial := strings.TrimRight(util.MultiReplace(textHTML, + partialVars), "\n") + pageVars := map[string]string{ + ThemeVar[Theme.Index]["content"]: partial, + ThemeVar[Theme.Index]["relPath"]: ".", + ThemeVar[Theme.Index]["title"]: index["title"], + } + MakePage(themePath, Theme.Index, pagePath, pageVars) + } +} + +// MakeAlbum generates a directory of album files. +func MakeAlbum(themePath, destDir string, index map[string]string, sets []Set) { + CopyCSS(themePath, destDir) + MakeHTMLImagePages(themePath, destDir, sets) + MakeHTMLSetPages(themePath, destDir, sets) + if index["html"] != "" { + MakeHTMLIndexPage(themePath, destDir, index, sets) + } +} diff --git a/template/themes/nettle/_image.html b/template/themes/nettle/_image.html new file mode 100644 index 0000000..f9fdb75 --- /dev/null +++ b/template/themes/nettle/_image.html @@ -0,0 +1,9 @@ + +
+ +
+ {{ image_caption }} +
+
diff --git a/template/themes/nettle/_set.html b/template/themes/nettle/_set.html new file mode 100644 index 0000000..b85072e --- /dev/null +++ b/template/themes/nettle/_set.html @@ -0,0 +1,12 @@ +
+
{{ set_title }}
+
+ {{ set_desc }} +
+
+ {{ set }} +
+ +
diff --git a/template/themes/nettle/_sets.html b/template/themes/nettle/_sets.html new file mode 100644 index 0000000..b02784a --- /dev/null +++ b/template/themes/nettle/_sets.html @@ -0,0 +1,25 @@ +
+
{{ set_set1_title }}
+
+ {{ set_set1_desc }} +
+
+ {{ set_set1 }} +
+ +
+ +
+
{{ set_set2_title }}
+
+ {{ set_set2_desc }} +
+
+ {{ set_set2 }} +
+ +
diff --git a/template/themes/nettle/_text.html b/template/themes/nettle/_text.html new file mode 100644 index 0000000..9f19702 --- /dev/null +++ b/template/themes/nettle/_text.html @@ -0,0 +1,4 @@ +
{{ text_title }}
+
+ {{ text_content }} +
diff --git a/template/themes/nettle/album.css b/template/themes/nettle/album.css new file mode 100644 index 0000000..1c8e270 --- /dev/null +++ b/template/themes/nettle/album.css @@ -0,0 +1,46 @@ +:root { + --thumb-width: 200px; + --thumbs-row: 4; + + --desc-width: 50rem; + --font: Open Sans, Helvetica Neue, Helvetica, sans-serif; + --col-body-text: rgba(10, 50, 10, 1.0); + --col-set-title: rgba(15, 110, 10, 1.0); + --col-set-desc: rgba(10, 50, 10, 1.0); +} + +body { + color: var(--col-body-text); + font-family: var(--font); + font-size: 1rem; +} + +.set { + margin: 3rem auto 6rem; + text-align: center; +} + +.set .title { + color: var(--col-set-title); + font-size: 2rem; + font-weight: bold; + margin: 1rem auto; + max-width: var(--desc-width); +} +.set .desc { + color: var(--col-set-desc); + margin: 1rem auto 3rem; + max-width: var(--desc-width); +} + +.thumbs { + /* Set font size to hide newline spaces between thumbnails. */ + font-size: 0; + margin: 0 auto; + max-width: calc(var(--thumb-width) * var(--thumbs-row)); +} + +.image-fig .caption { + font-weight: bold; + margin: 1rem auto; +} diff --git a/template/themes/nettle/index.html b/template/themes/nettle/index.html new file mode 100644 index 0000000..f736f5a --- /dev/null +++ b/template/themes/nettle/index.html @@ -0,0 +1,13 @@ + + + + {{ index_title }} + + + + +
+ {{ index_content }} +
+ + diff --git a/template/themes/nettle/lightbox.css b/template/themes/nettle/lightbox.css new file mode 100644 index 0000000..5784355 --- /dev/null +++ b/template/themes/nettle/lightbox.css @@ -0,0 +1,86 @@ +:root { + --nav-close: 3rem; + --nav-width: 10rem; + --v-padding: 4rem; + + --col-caption: rgba(15, 110, 10, 1); + --col-link: rgba(15, 110, 10, 1.0); + --col-link-visited: rgba(20, 145, 10, 1.0); + --col-overlay: rgba(255, 255, 255, 0.95); +} + +body { + overflow-x: hidden; +} + +.lightbox .frame { + background: var(--col-overlay); + border: 0; + display: none; + height: 100%; + left: 0; + opacity: 0; + overflow-x: hidden; + padding: var(--v-padding) 0; + position: fixed; + top: 0; + transition: opacity 0.5s ease; + width: 100%; + z-index: 5; +} +.lightbox .frame:target { + display: block; + opacity: 1; +} + +/* Navigation */ +.lightbox .nav { + display: none; + font-size: 1.5rem; + left: calc(50% - (var(--nav-width) / 2)); + max-width: var(--nav-width); + position: fixed; + top: calc(var(--v-padding) / 2); +} +.lightbox .frame:target + .nav { + display: block; + z-index: 10; +} +/* Disable the prev button for the first item, +and the next button for the last item */ +.lightbox .nav:nth-child(2) .prev, +.lightbox .nav:last-child .next { + opacity: 0; + z-index: -1; +} + +.lightbox .prev, +.lightbox .close, +.lightbox .next { + color: var(--col-link); + position: relative; + text-decoration: none; +} +.lightbox .close { + left: var(--nav-close); +} +.lightbox .next { + left: calc(var(--nav-close) * 2); +} +.lightbox .prev:visited, +.lightbox .close:visited, +.lightbox .next:visited { + color: var(--col-link-visited); +} + +/* iframe page contents */ +.image-nav, .image-fig { + text-align: center; +} +.image-fig .img { + max-width: 90%; + max-height: calc(90vh - (var(--v-padding) * 2)); +} +.image-fig .caption { + color: var(--col-caption); +} diff --git a/util/util.go b/util/util.go new file mode 100644 index 0000000..fe2d1c1 --- /dev/null +++ b/util/util.go @@ -0,0 +1,233 @@ +// Package util includes various helper functions for common +// string manipulation and file handling operations. +package util + +import ( + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" +) + +var ( + // Error messages. + err = struct { + dirNotCreated string + dirNotOpened string + dirNotRead string + fileNotCopied string + fileNotCreated string + fileNotLoaded string + }{ + dirNotCreated: "Error: directory could not be created:", + dirNotOpened: "Error: directory could not be opened:", + dirNotRead: "Error: directory not readable:", + fileNotCopied: "Error: file could not be copied:", + fileNotCreated: "Error: file could not be created.", + fileNotLoaded: "Error: file could not be loaded:", + } +) + +// HasError returns true if error e is a non-empty value and prints +// string msg to standard output, or false otherwise. If exitOnErr is +// true, sends exit signal code 3 instead of a returning a boolean value. +func HasError(e error, msg string, exitOnErr ...bool) bool { + if e != nil { + fmt.Println(msg) + if len(exitOnErr) == 1 { + if exitOnErr[0] { + os.Exit(3) + } + } + return true + } else { + return false + } +} + +// FileExists returns true a file or directory at path exists, or +// false otherwise. +func FileExists(path string) bool { + if _, fileErr := os.Stat(path); fileErr == nil { + return true + } else { + return false + } +} + +// MinMax returns the smaller and larger value of two numbers, +// in that order. +func MinMax(n1, n2 int) (int, int) { + if n1 < n2 { + return n1, n2 + } else { + return n2, n1 + } +} + +// MultiReplace returns a copy of string str after replacing values +// from map of key-value string pairs rep. +func MultiReplace(str string, rep map[string]string) string { + s := str + for k, v := range rep { + s = strings.ReplaceAll(s, k, v) + } + return s +} + +// MutliOrdReplace returns a copy of string str after replacing values +// from rep, a slice of maps containing key-value string pairs. Like +// MultiReplace for multiple maps. +func MultiOrdReplace(str string, rep []map[string]string) string { + s := str + for _, e := range rep { + for k, v := range e { + s = strings.ReplaceAll(s, k, v) + } + } + return s +} + +// ReplaceExt returns a copy of the string path with its file extension +// replaced with string ext. If path has no extension, returns a copy of path +// with ext appended to it. +func ReplaceExt(path, ext string) string { + if filepath.Ext(path) != "" { + return strings.Replace(path, filepath.Ext(path), ext, 1) + } + return path + ext +} + +// ConvertPath returns a copy of string path with Unix shorthand locations +// "~/" and "$HOME" expanded to the full absolute path. +func ConvertPath(path string) string { + home, homeErr := os.UserHomeDir() + if homeErr == nil { + if strings.HasPrefix(path, "~/") { + return strings.Replace(path, "~/", home+string(os.PathSeparator), 1) + } else if strings.HasPrefix(path, "~"+filepath.Base(home)) { + return strings.Replace(path, "~"+filepath.Base(home), home, 1) + } else if strings.HasPrefix(path, "$HOME") { + return strings.Replace(path, "$HOME", home, 1) + } + } + return path +} + +// GetFileList returns a string slice containing a list of files at path. +// +// sortOrder options: modTime, modTimeDesc, name, nameDesc +// +// If includeDirs is true, include directory names in the list. +func GetFileList(path string, sortOrder string, includeDirs bool) []string { + dir, openErr := os.Open(filepath.FromSlash(path)) + HasError(openErr, err.dirNotOpened+" "+path) + files, readErr := dir.ReadDir(0) + HasError(readErr, err.dirNotRead+" "+path) + + var list []string + if strings.Contains(sortOrder, "modTime") { + mapTimes := make(map[string]string) + var times []string + for _, file := range files { + if !file.IsDir() || (file.IsDir() && includeDirs) { + fi, _ := file.Info() + mapTimes[fmt.Sprint(fi.ModTime())] = filepath.Join(path, + fi.Name()) + times = append(times, fmt.Sprint(fi.ModTime())) + } + } + slices.Sort(times) + for _, t := range times { + list = append(list, mapTimes[t]) + } + } else if strings.Contains(sortOrder, "name") { + for _, file := range files { + if !file.IsDir() || (file.IsDir() && includeDirs) { + list = append(list, filepath.Join(path, file.Name())) + } + } + slices.Sort(list) + } + + if strings.Contains(sortOrder, "Desc") { + slices.Reverse(list) + } + return list +} + +// MakeDir returns true if a new directory is created successfully or already +// exists at path, or false otherwise. If exitOnErr is true, sends an +// application exit signal if an error occurs. +func MakeDir(path string, exitOnErr ...bool) bool { + toExit := false + if len(exitOnErr) == 1 { + if exitOnErr[0] == true { + toExit = true + } + } + if !HasError(os.MkdirAll(filepath.FromSlash(path), 0755), err.dirNotCreated+ + " "+path, toExit) { + return true + } else { + return false + } +} + +// CopyFile returns true if a file is copied successfully from srcPath to +// destPath, or false otherwise. +func CopyFile(srcPath, destPath string) bool { + src, srcErr := os.Open(srcPath) + HasError(srcErr, err.fileNotLoaded+" "+srcPath) + defer src.Close() + + dest, destErr := os.Create(destPath) + HasError(destErr, err.fileNotCreated) + defer dest.Close() + + _, copyErr := io.Copy(dest, src) + return HasError(copyErr, err.fileNotCopied+" "+destPath) +} + +// CopyFiles copies files in srcPath to destPath. If overwrite is true, +// copying overwrites files of the same name at destPath. +func CopyFiles(srcPath string, destPath string, overwrite bool) { + files := GetFileList(srcPath, "name", false) + for _, file := range files { + destFile := filepath.Join(destPath, filepath.Base(file)) + if !FileExists(destFile) || (FileExists(destFile) && overwrite) { + CopyFile(file, destFile) + } + } +} + +// SaveFile writes the string contents of str to file at path. If exitOnErr is +// true, sends an application exit signal if an error occurs. +func SaveFile(path string, str string, exitOnErr ...bool) { + fh, fileErr := os.Create(filepath.FromSlash(path)) + toExit := false + if len(exitOnErr) == 1 { + if exitOnErr[0] == true { + toExit = true + } + } + if !HasError(fileErr, err.fileNotCreated, toExit) { + fmt.Fprint(fh, str) + } +} + +// LoadFile returns the contents of path as string. If exitOnErr is true, sends +// an application exit signal if an error occurs. +func LoadFile(path string, exitOnErr ...bool) string { + contents, fileErr := os.ReadFile(filepath.FromSlash(path)) + toExit := false + if len(exitOnErr) == 1 { + if exitOnErr[0] == true { + toExit = true + } + } + HasError(fileErr, err.fileNotLoaded+" "+path, toExit) + return string(contents) +}