Initial commit

main
mio 2024-06-29 20:38:42 +00:00
commit 8afe7720db
Signed by: mio
GPG Key ID: 8D39604FCEDEB6F0
17 changed files with 1705 additions and 0 deletions

3
.gitignore vendored 100644
View File

@ -0,0 +1,3 @@
lamium
lm
todo.txt

76
cli.go 100644
View File

@ -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()
}
}

79
config.go 100644
View File

@ -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
}

5
go.mod 100644
View File

@ -0,0 +1,5 @@
module lamium
go 1.22.4
require golang.org/x/image v0.18.0

2
go.sum 100644
View File

@ -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=

364
image/image.go 100644
View File

@ -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
}

9
main.go 100644
View File

@ -0,0 +1,9 @@
package main
import (
"os"
)
func main() {
parseArgs(os.Args)
}

59
readme.md 100644
View File

@ -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)

View File

@ -0,0 +1,668 @@
// 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{
"&lt;b&gt;": "<b>",
"&lt;/b&gt;": "</b>",
"&lt;blockquote&gt;": "<blockquote>",
"&lt;/blockquote&gt;": "</blockquote>",
"&lt;br&gt;": "<br>",
"&lt;code&gt;": "<code>",
"&lt;/code&gt;": "</code>",
"&lt;del&gt;": "<del>",
"&lt;/del&gt;": "</del>",
"&lt;dd&gt;": "<dd>",
"&lt;/dd&gt;": "</dd>",
"&lt;dl&gt;": "<dl>",
"&lt;/dl&gt;": "</dl>",
"&lt;dt&gt;": "<dt>",
"&lt;/dt&gt;": "</dt>",
"&lt;em&gt;": "<em>",
"&lt;/em&gt;": "</em>",
"&lt;h1&gt;": "<h1>",
"&lt;/h1&gt;": "</h1>",
"&lt;h2&gt;": "<h2>",
"&lt;/h2&gt;": "</h2>",
"&lt;h3&gt;": "<h3>",
"&lt;/h3&gt;": "</h3>",
"&lt;i&gt;": "<i>",
"&lt;/i&gt;": "</i>",
"&lt;ol&gt;": "<ol>",
"&lt;/ol&gt;": "</ol>",
"&lt;p&gt;": "<p>",
"&lt;/p&gt;": "</p>",
"&lt;strong&gt;": "<strong>",
"&lt;/strong&gt;": "</strong>",
"&lt;s&gt;": "<s>",
"&lt;/s&gt;": "</s>",
"&lt;u&gt;": "<u>",
"&lt;/u&gt;": "</u>",
"&lt;ul&gt;": "<ul>",
"&lt;/ul&gt;": "</ul>",
}
// Supported markup syntax regexp for text files and the HTML tag
// translations.
MarkupTxt = []map[string]string{
{`^\*\*`: "<strong>"},
{`\*\*$`: "</strong>"},
{`\*\*\n`: "</strong>\n"},
{`\t\*\*`: "\t<strong>"},
{`\*\*\t`: "</strong>\t"},
{`[[:space:]]\*\*`: " <strong>"},
{`\*\*[[:space:]]`: "</strong> "},
{`^\*`: "<em>"},
{`\*$`: "</em>"},
{`\*\n`: "</em>\n"},
{`\t\*`: "\t<em>"},
{`\*\t`: "</em>\t"},
{`[[:space:]]\*`: " <em>"},
{`\*[[:space:]]`: "</em> "},
{`^__`: "<u>"},
{`__$`: "</u>"},
{`__\n`: "</u>\n"},
{`\t__`: "\t<u>"},
{`__\t`: "</u>\t"},
{`[[:space:]]__`: " <u>"},
{`__[[:space:]]`: "</u> "},
{`^\~\~`: "<s>"},
{`\~\~$`: "</s>"},
{`\~\~\n`: "</s>\n"},
{`\t\~\~`: "\t<s>"},
{`\~\~\t`: "</s>\t"},
{`[[:space:]]\~\~`: " <s>"},
{`\~\~[[:space:]]`: "</s> "},
{`\[url=`: `<a href="`},
{`\[\/url\]`: "</a>"},
{`\[h1\]`: "<h1>"},
{`\[\/h1\]`: "</h1>"},
{`\[h2\]`: "<h2>"},
{`\[\/h2\]`: "</h2>"},
{`\[h3\]`: "<h3>"},
{`\[\/h3\]`: "</h3>"},
{`\]`: `">`},
{"\n\n": "</p>\n<p>"},
{" \n": "<br>"},
}
// 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": `<a class="` + PartVar["linkClass"] + `" href="` +
PartVar["link"] + `"><img class="` + PartVar["thumbClass"] +
`" src="` + PartVar["thumb"] + `"></a>` + "\n",
"lightbox": `<iframe class="` + PartVar["iframeClass"] + `" id="` +
PartVar["id"] + `" src="` + PartVar["link"] + `"></iframe>
<nav class="nav">
<a class="prev" href="` + PartVar["navPrev"] + `">&larr;</a>
<a class="close" href="#">&#9747;</a>
<a class="next" href="` + PartVar["navNext"] + `">&rarr;</a>` +
`</nav>` + "\n",
"nav": `<a class="link" href="` + PartVar["navPrev"] + `">&larr;</a>
<a class="link" href="` + PartVar["navTop"] + `">&uarr;</a>
<a class="link" href="` + PartVar["navNext"] + `">&rarr;</a>`,
}
// 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 = "<p>" + strings.TrimRight(htmlStr, "\n") + "</p>"
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, filepath.Ext(path)) &&
filepath.Ext(path) != "" {
// 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 if ext == ".html" {
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)
}
}

View File

@ -0,0 +1,9 @@
<nav class="image-nav">
{{ nav }}
</nav>
<figure class="image-fig">
<img class="img" src="{{ image_path }}" title="{{ image_title }}">
<figcaption class="caption">
{{ image_caption }}
</figcaption>
</figure>

View File

@ -0,0 +1,12 @@
<section class="set {{ set_name }}">
<header class="title">{{ set_title }}</header>
<div class="desc">
{{ set_desc }}
</div>
<div class="thumbs">
{{ set }}
</div>
<div class="lightbox">
{{ set_lightbox }}
</div>
</section>

View File

@ -0,0 +1,25 @@
<section class="set {{ set_set1_name }}">
<header class="title">{{ set_set1_title }}</header>
<div class="desc">
{{ set_set1_desc }}
</div>
<div class="thumbs">
{{ set_set1 }}
</div>
<div class="lightbox">
{{ set_set1_lightbox }}
</div>
</section>
<section class="set {{ set_set2_name }}">
<header class="title">{{ set_set2_title }}</header>
<div class="desc">
{{ set_set2_desc }}
</div>
<div class="thumbs">
{{ set_set2 }}
</div>
<div class="lightbox">
{{ set_set2_lightbox }}
</div>
</section>

View File

@ -0,0 +1,4 @@
<header class="title">{{ text_title }}</header>
<section class="content">
{{ text_content }}
</section>

View File

@ -0,0 +1,41 @@
: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));
}

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ index_title }}</title>
<link rel="stylesheet" href="{{ index_relpath }}/album.css">
<link rel="stylesheet" href="{{ index_relpath }}/lightbox.css">
</head>
<body>
<main>
{{ index_content }}
</main>
</body>
</html>

View File

@ -0,0 +1,103 @@
: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: 60vh;
}
.image-fig .caption {
color: var(--col-caption);
margin: 0 auto;
max-width: 60%;
}
.image-fig .caption a {
color: var(--col-link);
}
.image-fig .caption a:visited {
color: var(--col-link-visited);
}
.image-fig .caption h1 {
font-size: 1.4rem;
}
.image-fig .caption h2 {
font-size: 1.2rem;
}
.image-fig .caption h3 {
font-size: 1rem;
}

233
util/util.go 100644
View File

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