Compare commits

...

7 Commits

Author SHA1 Message Date
luxick 1d8dfdb1da Force external links into new tabs 2026-04-29 13:17:42 +02:00
luxick 6c268aa829 Adjust anchor icon style 2026-04-28 17:49:00 +02:00
luxick 73a8b4f78f Improve sections 2026-04-28 17:43:20 +02:00
luxick 1f7cfd637a Use placeholder svg 2026-04-23 20:58:33 +02:00
luxick 02a1482789 Add thumbnailing for photo grids 2026-04-23 20:43:05 +02:00
luxick 60b514eae7 Prefill Empty pages 2026-04-23 14:34:07 +02:00
luxick dedeeb77a8 Migrate movie info downloader 2026-04-23 14:20:53 +02:00
18 changed files with 662 additions and 32 deletions
+1
View File
@@ -1,5 +1,6 @@
.claude/
wiki/
cache/
# Binaries
datascape
+11
View File
@@ -0,0 +1,11 @@
(function () {
document.querySelectorAll('.content h1, .content h2, .content h3, .content h4, .content h5, .content h6').forEach(function (h) {
if (!h.id) return;
var a = document.createElement('a');
a.href = '#' + h.id;
a.className = 'heading-anchor';
a.setAttribute('aria-label', 'Link to this section');
a.textContent = '#';
h.insertBefore(a, h.firstChild);
});
}());
+1 -1
View File
@@ -1,7 +1,7 @@
{{if .Photos}}
<div class="photo-grid">
{{range .Photos}}
<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
{{end}}
</div>
{{end}}
+1 -1
View File
@@ -7,7 +7,7 @@
{{if .Photos}}
<div class="photo-grid">
{{range .Photos}}
<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
{{end}}
</div>
{{end}}
+8 -2
View File
@@ -1,7 +1,13 @@
<h2 id="months">Months</h2>
<h2 id="months">Monate</h2>
{{range .Months}}
<h3 id="{{.ID}}">
<a href="{{.URL}}">{{.Name}}</a>
{{if .PhotoCount}}<span class="muted">({{.PhotoCount}} photos)</span>{{end}}
</h3>
{{if .Photos}}
<div class="photo-grid">
{{range .Photos}}
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
{{end}}
</div>
{{end}}
{{end}}
+6 -1
View File
@@ -58,6 +58,7 @@
var T = EditorTables;
var L = EditorLists;
var D = EditorDates;
var M = EditorMovie;
var actions = {
save: function () { form.submit(); },
@@ -100,6 +101,7 @@
tbldeleterow: function () { applyTableOp(T.deleteRow); },
dateiso: function () { insertAtCursor(D.isoDate()); },
datelong: function () { insertAtCursor(D.longDate()); },
movie: function () { M.run(textarea); },
};
// --- Keyboard shortcut registration ---
@@ -121,7 +123,10 @@
document.addEventListener('keydown', function (e) {
if (!e.altKey || !e.shiftKey) return;
var action = keyMap[e.key];
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
// on US, "!" on DE), so fall back to e.code for digit rows.
var key = /^Digit[0-9]$/.test(e.code) ? e.code.slice(5) : e.key;
var action = keyMap[key];
if (action) {
e.preventDefault();
action();
+121
View File
@@ -0,0 +1,121 @@
window.EditorMovie = (function () {
'use strict';
// OMDb API key. Shipped to the browser; acceptable for a single-user LAN tool.
var OMDB_API_KEY = 'c906744f';
var BEGIN = '<!-- BEGIN MOVIE -->';
var END = '<!-- END MOVIE -->';
function firstHeading(text) {
var m = text.match(/^#{1,6}\s+(.+?)\s*$/m);
return m ? m[1].trim() : '';
}
function parseTitleYear(raw) {
var m = raw.match(/^(.+?)\s*\((\d{4})\)\s*$/);
return m ? { title: m[1].trim(), year: m[2] } : { title: raw.trim(), year: null };
}
function safe(v) { return (!v || v === 'N/A') ? '' : String(v); }
function esc(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function buildBlock(m) {
var out = [BEGIN, '<aside class="movie-info">'];
if (m.Poster && m.Poster !== 'N/A') {
out.push('<img class="movie-poster" src="' + esc(m.Poster) +
'" alt="' + esc(safe(m.Title)) + ' poster">');
}
out.push('<table>');
[
['Title', m.Title],
['Year', m.Year],
['Runtime', m.Runtime],
['Genre', m.Genre],
['Director', m.Director],
['Cast', m.Actors],
['Plot', m.Plot],
].forEach(function (r) {
out.push('<tr><th>' + r[0] + '</th><td>' + esc(safe(r[1])) + '</td></tr>');
});
out.push('</table>', '</aside>', END);
return out.join('\n');
}
function insertOrReplace(ta, markup) {
var t = ta.value || '';
var b = t.indexOf(BEGIN);
var e = t.indexOf(END);
if (b !== -1 && e !== -1 && e > b) {
ta.value = t.slice(0, b) + markup + t.slice(e + END.length);
} else {
var h = t.match(/^#{1,6}\s+.+?\s*$/m);
if (h) {
var idx = t.indexOf(h[0]) + h[0].length;
ta.value = t.slice(0, idx) + '\n\n' + markup + t.slice(idx);
} else {
ta.value = markup + (t ? '\n\n' + t : '');
}
}
ta.dispatchEvent(new Event('input'));
}
function fetchMovie(title, year) {
var url = 'https://www.omdbapi.com/?apikey=' + encodeURIComponent(OMDB_API_KEY) +
'&type=movie&t=' + encodeURIComponent(title);
if (year) url += '&y=' + encodeURIComponent(year);
return fetch(url).then(function (r) { return r.json(); });
}
function showMessage(title, msg) {
openModal({ title: title, body: msg, confirm: { label: 'OK' } });
}
function run(textarea) {
if (!OMDB_API_KEY) {
showMessage('Movie import', 'OMDb API key is not set. Edit assets/editor/movie.js.');
return;
}
var input = document.createElement('input');
input.type = 'text';
input.className = 'modal-input';
input.placeholder = 'Title, optionally with (YYYY)';
input.value = firstHeading(textarea.value || '');
openModal({
title: 'Import movie',
body: input,
confirm: {
label: 'IMPORT',
onConfirm: function () {
var raw = input.value.trim();
if (!raw) return;
var parsed = parseTitleYear(raw);
closeModal();
fetchMovie(parsed.title, parsed.year)
.then(function (data) {
if (!data || data.Response === 'False') {
showMessage('Not found',
(data && data.Error) || 'Movie not found.');
return;
}
insertOrReplace(textarea, buildBlock(data));
})
.catch(function () {
showMessage('Import failed', 'OMDb lookup failed.');
});
},
},
});
}
return { run: run };
})();
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"
fill="none" stroke="#cfcfcf" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
<rect x="1" y="2" width="14" height="12"/>
<path d="M1 11l4-4 3 3 2-2 5 5"/>
<rect x="10" y="4" width="2" height="2" fill="#cfcfcf" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 328 B

+4
View File
@@ -62,12 +62,15 @@
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool dropdown" data-action="tbldrop" title="Table (T)">T▾</button>
<button type="button" class="btn btn-tool dropdown" data-action="datedrop" title="Insert date (D/W)">D▾</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="movie" data-key="V" title="Import movie (V)">MV</button>
</div>
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
</form>
<script src="/_/editor/lists.js"></script>
<script src="/_/editor/tables.js"></script>
<script src="/_/editor/dates.js"></script>
<script src="/_/editor/movie.js"></script>
<script src="/_/editor.js"></script>
{{else}}
{{if .Content}}
@@ -78,6 +81,7 @@
{{end}}
{{if or .Content .SpecialContent}}
<script src="/_/content.js"></script>
<script src="/_/anchors.js"></script>
<script src="/_/toc.js"></script>
{{end}}
{{if .Content}}
+35
View File
@@ -234,6 +234,15 @@ main {
max-width: 100%;
}
.content a.heading-anchor {
color: var(--text-muted);
margin-right: 0.4em;
font-weight: normal;
}
.content a.heading-anchor:hover {
color: var(--primary-hover);
}
/* === File listing === */
.listing {
border: 1px solid var(--secondary);
@@ -374,6 +383,7 @@ textarea {
height: 140px;
object-fit: cover;
display: block;
background: var(--bg-panel) url("/_/icons/thumb-placeholder.svg") center/2rem no-repeat;
}
/* === Empty state === */
@@ -584,6 +594,31 @@ hr {
word-break: break-all;
}
/* === Movie info box === */
.movie-info {
margin: 0.75rem 0;
}
.movie-info::after {
content: "";
display: block;
clear: both;
}
.movie-info .movie-poster {
float: right;
max-width: 200px;
margin: 0 0 0.75rem 1rem;
}
.movie-info table {
width: auto;
}
@media (max-width: 600px) {
.movie-info .movie-poster {
float: none;
display: block;
margin: 0 auto 0.75rem;
}
}
/* === Diary Calendar === */
.diary-cal {
position: fixed;
+1 -1
View File
@@ -21,7 +21,7 @@
var a = document.createElement("a");
a.href = "#" + h.id;
var clone = h.cloneNode(true);
clone.querySelectorAll(".btn, .muted").forEach(function (el) { el.remove(); });
clone.querySelectorAll(".btn, .muted, .heading-anchor").forEach(function (el) { el.remove(); });
a.textContent = clone.textContent.trim();
li.appendChild(a);
list.appendChild(li);
+51 -24
View File
@@ -283,16 +283,17 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
type diaryPhoto struct {
Date time.Time
Name string
URL string
Date time.Time
Name string
URL string
ThumbURL string
}
type diaryMonthSummary struct {
ID string
Name string
URL string
PhotoCount int
ID string
Name string
URL string
Photos []diaryPhoto
}
type diaryDaySection struct {
@@ -376,10 +377,16 @@ func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto {
if err != nil {
continue
}
photoURL := path.Join(yearURLPath, url.PathEscape(name))
thumb := photoURL
if hasThumbnail(name) {
thumb = thumbURL(photoURL, 300)
}
photos = append(photos, diaryPhoto{
Date: t,
Name: name,
URL: path.Join(yearURLPath, url.PathEscape(name)),
Date: t,
Name: name,
URL: photoURL,
ThumbURL: thumb,
})
}
return photos
@@ -394,32 +401,52 @@ func renderDiaryYear(fsPath, urlPath string) template.HTML {
photos := yearPhotos(fsPath, urlPath)
entries, err := os.ReadDir(fsPath)
if err != nil {
return ""
}
var months []diaryMonthSummary
// Collect month numbers from both subdirectories and photo filenames so
// years that contain only photos (no diary entries) still list months.
monthSet := map[int]bool{}
monthDirs := map[int]string{}
entries, _ := os.ReadDir(fsPath)
for _, e := range entries {
if !e.IsDir() {
continue
}
monthNum, err := strconv.Atoi(e.Name())
if err != nil || monthNum < 1 || monthNum > 12 {
n, err := strconv.Atoi(e.Name())
if err != nil || n < 1 || n > 12 {
continue
}
count := 0
monthSet[n] = true
monthDirs[n] = e.Name()
}
for _, p := range photos {
if p.Date.Year() == year {
monthSet[int(p.Date.Month())] = true
}
}
monthNums := make([]int, 0, len(monthSet))
for m := range monthSet {
monthNums = append(monthNums, m)
}
sort.Ints(monthNums)
var months []diaryMonthSummary
for _, monthNum := range monthNums {
var monthPhotos []diaryPhoto
for _, p := range photos {
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
count++
monthPhotos = append(monthPhotos, p)
}
}
monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC)
dirName, ok := monthDirs[monthNum]
if !ok {
dirName = fmt.Sprintf("%02d", monthNum)
}
months = append(months, diaryMonthSummary{
ID: monthDate.Format("2006-01"),
Name: monthDate.Format("January 2006"),
URL: path.Join(urlPath, e.Name()) + "/",
PhotoCount: count,
ID: monthDate.Format("2006-01"),
Name: fmt.Sprintf("%s %d", germanMonths[monthDate.Month()], year),
URL: path.Join(urlPath, dirName) + "/",
Photos: monthPhotos,
})
}
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
type extLinksTransformer struct{}
func (extLinksTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
link, ok := n.(*ast.Link)
if !ok {
return ast.WalkContinue, nil
}
if isExternalURL(string(link.Destination)) {
link.SetAttribute([]byte("target"), []byte("_blank"))
link.SetAttribute([]byte("rel"), []byte("noopener noreferrer"))
}
return ast.WalkContinue, nil
})
}
func isExternalURL(dest string) bool {
if strings.HasPrefix(dest, "//") {
return true
}
i := strings.Index(dest, ":")
if i <= 0 {
return false
}
for _, c := range dest[:i] {
if !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') &&
!(c >= '0' && c <= '9') && c != '+' && c != '-' && c != '.' {
return false
}
}
return true
}
type extLinksExt struct{}
func newExtLinksExt() goldmark.Extender { return &extLinksExt{} }
func (e *extLinksExt) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(extLinksTransformer{}, 999),
))
}
+27 -1
View File
@@ -41,6 +41,7 @@ var pageTypeHandlers []pageTypeHandler
func main() {
addr := flag.String("addr", ":8080", "listen address")
wikiDir := flag.String("dir", "./wiki", "wiki root directory")
cacheDir := flag.String("cache", "./cache", "thumbnail cache directory")
user := flag.String("user", "", "basic auth username (empty = no auth)")
pass := flag.String("pass", "", "basic auth password")
flag.Parse()
@@ -53,6 +54,14 @@ func main() {
log.Fatal(err)
}
thumbCacheDir, err = filepath.Abs(*cacheDir)
if err != nil {
log.Fatal(err)
}
if err := os.MkdirAll(thumbCacheDir, 0755); err != nil {
log.Fatal(err)
}
initMarkdown(root)
authKey, err := loadOrCreateAuthKey(root)
@@ -86,6 +95,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if strings.HasPrefix(r.URL.Path, thumbURLPrefix+"/") {
h.handleThumb(w, r)
return
}
urlPath := path.Clean("/" + r.URL.Path)
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
@@ -187,6 +201,8 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
if sectionIndex < len(sections) {
rawContent = string(sections[sectionIndex])
}
} else if editMode && rawContent == "" && urlPath != "/" {
rawContent = "# " + pageTitle(urlPath) + "\n\n"
}
data := pageData{
@@ -227,6 +243,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
}
content := r.FormValue("content")
indexPath := filepath.Join(fsPath, "index.md")
redirectTarget := urlPath
// If a section index was submitted, splice the edited section back into
// the full file rather than replacing the whole document.
@@ -242,6 +259,15 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
sections[sectionIndex] = []byte(content)
}
content = string(joinSections(sections))
// Section index ≥ 1 is a heading-anchored section. Redirect to its
// anchor so the user lands on the section they just saved, even if
// the heading text changed.
if sectionIndex >= 1 {
ids := headingIDs([]byte(content))
if sectionIndex-1 < len(ids) {
redirectTarget = urlPath + "#" + ids[sectionIndex-1]
}
}
}
if strings.TrimSpace(content) == "" {
@@ -259,7 +285,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
return
}
}
http.Redirect(w, r, urlPath, http.StatusSeeOther)
http.Redirect(w, r, redirectTarget, http.StatusSeeOther)
}
// readPageSettings parses a .page-settings file in dir.
+1 -1
View File
@@ -22,7 +22,7 @@ var md goldmark.Markdown
// targets against the filesystem.
func initMarkdown(root string) {
md = goldmark.New(
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root)),
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root), newExtLinksExt()),
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
goldmark.WithRendererOptions(html.WithUnsafe()),
)
+26
View File
@@ -3,6 +3,9 @@ package main
import (
"bytes"
"regexp"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
)
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
@@ -25,6 +28,29 @@ func splitSections(raw []byte) [][]byte {
return sections
}
// headingIDs returns the auto-generated id of every heading in raw markdown,
// in document order. The kth heading (1-indexed) corresponds to section k from
// splitSections. Uses the package-level goldmark parser so duplicate-id
// numbering matches what the renderer emits.
func headingIDs(raw []byte) []string {
doc := md.Parser().Parse(text.NewReader(raw))
var ids []string
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if _, ok := n.(*ast.Heading); ok {
if v, ok := n.AttributeString("id"); ok {
if b, ok := v.([]byte); ok {
ids = append(ids, string(b))
}
}
}
return ast.WalkContinue, nil
})
return ids
}
// joinSections reassembles sections produced by splitSections.
// Inserts a newline between sections when a non-empty section lacks a
// trailing newline, so an edited section cannot inline the next heading.
+224
View File
@@ -0,0 +1,224 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// Thumbnailer produces a thumbnail of a source file. Implementations register
// themselves in init() by appending to thumbnailers. The first registered
// handler whose CanHandle returns true is used.
type Thumbnailer interface {
CanHandle(ext string) bool
Generate(src io.Reader, dst io.Writer, width int) error
}
var thumbnailers []Thumbnailer
// thumbCacheDir is set from the -cache flag at startup.
var thumbCacheDir string
const thumbURLPrefix = "/_thumb"
// Cache is content-addressed: the cache path is derived from the SHA-256 of
// the source file. Renames and moves reuse the same cache entry; overwriting
// a file with new content produces a new digest and regenerates.
var (
thumbLocks = map[string]*sync.Mutex{}
thumbLocksMu sync.Mutex
digestCache = map[string]digestEntry{}
digestCacheMu sync.Mutex
)
// digestEntry remembers the digest of a source file so repeated requests do
// not re-hash the whole file. The (mtime, size) pair invalidates the cache
// when the file is overwritten in place.
type digestEntry struct {
mtime time.Time
size int64
hex string
}
func findThumbnailer(name string) Thumbnailer {
ext := strings.ToLower(filepath.Ext(name))
if ext == "" {
return nil
}
for _, t := range thumbnailers {
if t.CanHandle(ext) {
return t
}
}
return nil
}
// hasThumbnail reports whether a file name has a registered thumbnailer.
func hasThumbnail(name string) bool {
return findThumbnailer(name) != nil
}
// thumbURL builds a thumbnail URL for a wiki file. filePath must be URL-style
// (slash-separated, leading slash), as already used on page links.
func thumbURL(filePath string, width int) string {
return thumbURLPrefix + filePath + "?w=" + strconv.Itoa(width)
}
func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.URL.Path, thumbURLPrefix)
if raw == "" || raw == "/" {
http.NotFound(w, r)
return
}
decoded, err := url.PathUnescape(raw)
if err != nil {
http.Error(w, "bad path", http.StatusBadRequest)
return
}
cleanPath := path.Clean(decoded)
srcFS := filepath.Join(h.root, filepath.FromSlash(cleanPath))
rel, err := filepath.Rel(h.root, srcFS)
if err != nil || strings.HasPrefix(rel, "..") {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
srcInfo, err := os.Stat(srcFS)
if err != nil || srcInfo.IsDir() {
http.NotFound(w, r)
return
}
t := findThumbnailer(srcFS)
if t == nil {
http.NotFound(w, r)
return
}
width := 300
if s := r.URL.Query().Get("w"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 2000 {
width = n
}
}
digest, data, err := sourceDigest(srcFS, srcInfo)
if err != nil {
log.Printf("thumb digest %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return
}
cacheFS := filepath.Join(thumbCacheDir, digest[:2], fmt.Sprintf("%s.%d.jpg", digest, width))
if _, err := os.Stat(cacheFS); err == nil {
serveThumb(w, r, cacheFS)
return
}
lock := thumbLock(cacheFS)
lock.Lock()
defer lock.Unlock()
if _, err := os.Stat(cacheFS); err == nil {
serveThumb(w, r, cacheFS)
return
}
var src io.Reader
if data != nil {
src = bytes.NewReader(data)
} else {
f, err := os.Open(srcFS)
if err != nil {
log.Printf("thumb open %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return
}
defer f.Close()
src = f
}
if err := generateThumb(t, src, cacheFS, width); err != nil {
log.Printf("thumb %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return
}
serveThumb(w, r, cacheFS)
}
// sourceDigest returns the SHA-256 hex digest of a source file's content.
// On a cache hit (path + mtime + size unchanged) the returned data is nil,
// so the caller knows to open the file itself. On a miss the file is read
// once and the contents are returned for the caller to reuse.
func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
digestCacheMu.Lock()
d, ok := digestCache[srcFS]
digestCacheMu.Unlock()
if ok && d.mtime.Equal(info.ModTime()) && d.size == info.Size() {
return d.hex, nil, nil
}
data, err := os.ReadFile(srcFS)
if err != nil {
return "", nil, err
}
sum := sha256.Sum256(data)
h := hex.EncodeToString(sum[:])
digestCacheMu.Lock()
digestCache[srcFS] = digestEntry{mtime: info.ModTime(), size: info.Size(), hex: h}
digestCacheMu.Unlock()
return h, data, nil
}
func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) {
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, cacheFS)
}
func generateThumb(t Thumbnailer, src io.Reader, cacheFS string, width int) error {
if err := os.MkdirAll(filepath.Dir(cacheFS), 0755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(cacheFS), ".thumb-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if err := t.Generate(src, tmp, width); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return err
}
return os.Rename(tmpName, cacheFS)
}
func thumbLock(key string) *sync.Mutex {
thumbLocksMu.Lock()
defer thumbLocksMu.Unlock()
m, ok := thumbLocks[key]
if !ok {
m = &sync.Mutex{}
thumbLocks[key] = m
}
return m
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"image"
"image/color"
_ "image/gif"
"image/jpeg"
_ "image/png"
"io"
)
func init() {
thumbnailers = append(thumbnailers, &imageThumbnailer{})
}
type imageThumbnailer struct{}
func (it *imageThumbnailer) CanHandle(ext string) bool {
switch ext {
case ".jpg", ".jpeg", ".png", ".gif":
return true
}
return false
}
func (it *imageThumbnailer) Generate(src io.Reader, dst io.Writer, width int) error {
img, _, err := image.Decode(src)
if err != nil {
return err
}
return jpeg.Encode(dst, resizeBox(img, width), &jpeg.Options{Quality: 80})
}
// resizeBox downsamples src to the requested width using a box filter.
// Aspect ratio is preserved. Upscaling is a no-op (returns src unchanged).
// Each source pixel is visited exactly once; alpha is discarded.
func resizeBox(src image.Image, width int) image.Image {
b := src.Bounds()
srcW, srcH := b.Dx(), b.Dy()
if srcW <= width {
return src
}
dstW := width
dstH := srcH * width / srcW
if dstH < 1 {
dstH = 1
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for y := 0; y < dstH; y++ {
sy0 := y * srcH / dstH
sy1 := (y + 1) * srcH / dstH
if sy1 == sy0 {
sy1 = sy0 + 1
}
for x := 0; x < dstW; x++ {
sx0 := x * srcW / dstW
sx1 := (x + 1) * srcW / dstW
if sx1 == sx0 {
sx1 = sx0 + 1
}
var r, g, bl, n uint64
for sy := sy0; sy < sy1; sy++ {
for sx := sx0; sx < sx1; sx++ {
sr, sg, sb, _ := src.At(b.Min.X+sx, b.Min.Y+sy).RGBA()
r += uint64(sr >> 8)
g += uint64(sg >> 8)
bl += uint64(sb >> 8)
n++
}
}
dst.SetRGBA(x, y, color.RGBA{
R: uint8(r / n),
G: uint8(g / n),
B: uint8(bl / n),
A: 255,
})
}
}
return dst
}