Initial commit
commit
2a081b0a2d
|
@ -0,0 +1,3 @@
|
|||
lamium
|
||||
lm
|
||||
todo.txt
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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=
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
parseArgs(os.Args)
|
||||
}
|
|
@ -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 of images (a "set") to the directory, including any image captions as .txt 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)
|
|
@ -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>",
|
||||
"</b>": "</b>",
|
||||
"<blockquote>": "<blockquote>",
|
||||
"</blockquote>": "</blockquote>",
|
||||
"<br>": "<br>",
|
||||
"<code>": "<code>",
|
||||
"</code>": "</code>",
|
||||
"<del>": "<del>",
|
||||
"</del>": "</del>",
|
||||
"<dd>": "<dd>",
|
||||
"</dd>": "</dd>",
|
||||
"<dl>": "<dl>",
|
||||
"</dl>": "</dl>",
|
||||
"<dt>": "<dt>",
|
||||
"</dt>": "</dt>",
|
||||
"<em>": "<em>",
|
||||
"</em>": "</em>",
|
||||
"<h1>": "<h1>",
|
||||
"</h1>": "</h1>",
|
||||
"<h2>": "<h2>",
|
||||
"</h2>": "</h2>",
|
||||
"<h3>": "<h3>",
|
||||
"</h3>": "</h3>",
|
||||
"<i>": "<i>",
|
||||
"</i>": "</i>",
|
||||
"<ol>": "<ol>",
|
||||
"</ol>": "</ol>",
|
||||
"<p>": "<p>",
|
||||
"</p>": "</p>",
|
||||
"<strong>": "<strong>",
|
||||
"</strong>": "</strong>",
|
||||
"<s>": "<s>",
|
||||
"</s>": "</s>",
|
||||
"<u>": "<u>",
|
||||
"</u>": "</u>",
|
||||
"<ul>": "<ul>",
|
||||
"</ul>": "</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> "},
|
||||
{"\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"] + `">←</a>
|
||||
<a class="close" href="#">☓</a>
|
||||
<a class="next" href="` + PartVar["navNext"] + `">→</a>` +
|
||||
`</nav>` + "\n",
|
||||
"nav": `<a class="link" href="` + PartVar["navPrev"] + `">←</a>
|
||||
<a class="link" href="` + PartVar["navTop"] + `">↑</a>
|
||||
<a class="link" href="` + PartVar["navNext"] + `">→</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, 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)
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,4 @@
|
|||
<header class="title">{{ text_title }}</header>
|
||||
<section class="content">
|
||||
{{ text_content }}
|
||||
</section>
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
|
@ -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)
|
||||
}
|
Loading…
Reference in New Issue