48 Commits
v1.16 ... v1.18

Author SHA1 Message Date
Alex Schroeder
4eb013a4da Remove link to list from upload.html 2025-08-10 08:59:19 +02:00
Alex Schroeder
e8f6ae0450 Release 1.18 2025-08-10 08:47:04 +02:00
Alex Schroeder
9bf3beb440 Add the new feature to the release docs 2025-08-09 18:21:20 +02:00
Alex Schroeder
cd6809d791 Add -update and -dry-run options to hashtags command 2025-08-09 18:18:11 +02:00
Alex Schroeder
7c5a3860e7 Fix addLinkToPage
It now tries to keep the links sorted.
2025-08-09 18:17:56 +02:00
Alex Schroeder
a7c343decb Be more restrictive about the request methods 2025-08-08 17:16:41 +02:00
Alex Schroeder
18bb5da8c0 Preview requests using GET redirect to view requests 2025-08-08 00:58:50 +02:00
Alex Schroeder
2a0ea791ec Removed unnecessary option description 2025-08-07 22:12:11 +02:00
Alex Schroeder
726586b39d Updated README 2025-08-07 07:11:54 +02:00
Alex Schroeder
8f30704be9 Improve WebDAV documentation 2025-07-16 16:24:36 +02:00
Alex Schroeder
616ae0a1ba Mention how to sudo make install PREFIX= 2025-07-16 11:20:04 +02:00
Alex Schroeder
af86b865bf Delete list, delete and rename actions
In an effort to remove features that can be handled by the web server, the
list, delete and rename actions were removed again.

The sentence linking to the list action from the upload
template ("upload.html") was also deleted.
2025-07-16 11:03:40 +02:00
Alex Schroeder
7110e0af68 Release 1.17 2025-04-26 19:39:17 +02:00
Alex Schroeder
8841372814 Fix typos 2025-04-26 19:36:33 +02:00
Alex Schroeder
fefa283775 Reword oddmu-releases 2025-04-23 08:05:46 +02:00
Alex Schroeder
5a09d65dab Theme fixes 2025-04-23 08:05:33 +02:00
Alex Schroeder
2cf0855994 Add changes link
To transjovian.org and default theme. I must have dropped it at some
point but I think it makes no sense.
2025-04-22 08:26:28 +02:00
Alex Schroeder
f98312e12f Fix communitywiki theme
Add missing templates.
2025-04-22 08:25:11 +02:00
Alex Schroeder
d213ee2815 Add communitywiki.org theme
This is a very minor theme.
2025-04-22 07:59:27 +02:00
Alex Schroeder
0cd09666c6 Don't inherit the color for buttons 2025-04-21 23:20:53 +02:00
Alex Schroeder
bd9364dc09 Change "Skip navigation" to "Skip" in all templates 2025-04-21 16:39:42 +02:00
Alex Schroeder
93fd49bc4c Fix template test
I changed "Skip navigation" to "Skip" in the templates.
2025-04-21 16:36:49 +02:00
Alex Schroeder
300e411960 Make search handler more robust 2025-04-21 16:36:18 +02:00
Alex Schroeder
10cea2bf2c Fix comment 2025-04-21 16:30:09 +02:00
Alex Schroeder
830af140eb Add initial-scale=1.0, user-scalable=no to all templates 2025-04-21 16:28:39 +02:00
Alex Schroeder
c758dd7df7 Fix image inclusion for the search template 2025-04-21 16:25:47 +02:00
Alex Schroeder
969df2aef9 Add pasting of images to chat theme 2025-04-21 16:25:30 +02:00
Alex Schroeder
39f414694c Update alexschroeder.ch theme
Drop the separate CSS file and switch to inline CSS again.
Change the upload page based on the new setup.
Change view.html to pass along filename and pagename.
2025-04-21 16:24:32 +02:00
Alex Schroeder
fa67508692 Update campaignwiki.org theme
Change the upload page based on the new setup.
Change view.html to pass along filename and pagename.
2025-04-21 00:47:02 +02:00
Alex Schroeder
d5696135c1 Use image-1.jpg as the default filename 2025-04-21 00:40:14 +02:00
Alex Schroeder
284fc3094d Update flying-carpet.ch theme
Use .Path instead of .Name in URLs.
Change the upload page based on the new setup.
Change view.html to pass along filename and pagename.
2025-04-21 00:33:23 +02:00
Alex Schroeder
57161bbc98 Updated release info
The link to the upload page needs two query parameters, most likely.
2025-04-21 00:28:08 +02:00
Alex Schroeder
d855d9d91a Update transjovian.org theme
Use .Path instead of .Name in URLs.
Change the upload page based on the new setup.
Change view.html to pass along filename and pagename.
2025-04-21 00:25:19 +02:00
Alex Schroeder
ca85250514 Remove trailing semicolon in standard theme 2025-04-21 00:24:52 +02:00
Alex Schroeder
649fde81fe Add a warning about JavaScript to plain theme 2025-04-21 00:24:19 +02:00
Alex Schroeder
8a47e9c5fe Remove some extra semicolons in a theme 2025-04-21 00:05:34 +02:00
Alex Schroeder
fd9a515e0f Use .Path instead of .Name in list.html
This ensures that files with hash signs or question marks in their
name get handled correctly.
2025-04-21 00:04:32 +02:00
Alex Schroeder
da04c6dc27 Major update of upload.html! 2025-04-21 00:02:28 +02:00
Alex Schroeder
bd2da1414c Remove trailing semicolons in CSS 2025-04-09 21:14:16 +02:00
Alex Schroeder
6d1a5462b4 Make textarea "full size" 2025-04-09 21:10:54 +02:00
Alex Schroeder
3dcaf8aca1 Fix markup 2025-04-08 08:49:28 +02:00
Alex Schroeder
80ce16f873 Improve the documentation of the template changes 2025-04-08 08:41:41 +02:00
Alex Schroeder
41347ad5dc Fix list action
More explicit path encoding for directory and file name.
2025-04-08 08:04:52 +02:00
Alex Schroeder
6a911b2860 Fix handling of hashes in filenames 2025-04-08 00:14:59 +02:00
Alex Schroeder
1d6db77660 Handle files with # in their name 2025-04-06 23:44:57 +02:00
Alex Schroeder
8a8afcb56f List action skips dot files and dot directories 2025-04-06 21:08:36 +02:00
Alex Schroeder
6803b8e90d Update the themes 2025-04-06 20:44:57 +02:00
Alex Schroeder
ff357a4048 Added more steps to the RELEASE file 2025-04-06 13:48:12 +02:00
102 changed files with 1992 additions and 1045 deletions

View File

@@ -16,6 +16,8 @@ help:
@echo " just build it"
@echo make install
@echo " install the files to ~/.local"
@echo sudo make install PREFIX=/usr/local
@echo " install the files to /usr/local"
@echo make upload
@echo " this is how I upgrade my server"
@echo make dist

View File

@@ -1,28 +1,35 @@
# Oddμ: A minimal wiki
This program helps you run a minimal wiki, blog, digital garden, memex
or Zettelkasten. There is no version history.
Oddμ (or Oddmu) helps you run a minimal wiki, blog, digital garden,
memex or Zettelkasten.
It's well suited as a self-hosted, single-user web application, when
there is no need for collaboration on the site itself. Links and email
connect you to the rest of the net. The wiki can be public or private.
Perhaps it just runs on your local machine, unreachable from the
Internet.
Oddμ can be run as a static site generator, processing a directory
with Markdown files, turning them into HTML files. HTML templates
allow the customisation of headers, footers and styling. There are no
plugins.
It's well suited as a secondary medium for a close-knit group:
Oddμ is well suited as a self-hosted, single-user web application,
when there is no need for collaboration on the site itself. Links and
email connect you to the rest of the net. The wiki can be public or
private.
If the site is public, use a regular web server as a proxy to make
people log in before making changes. As there is no version history,
it is not possible to undo vandalism and spam. Only grant write-access
to people you trust.
If the site is private, running on a local machine and unreachable
from the Internet, no such precautions are necessary.
Oddμ is well suited as a secondary medium for a close-knit group:
collaboration and conversation happens elsewhere, in chat, on social
media. The wiki serves as the text repository that results from these
discussions. As there are no logins and no version histories, it is
not possible to undo vandalism and spam. Only allow people you trust
write-access to the site.
It's well suited as a simple static site generator. There are no
plugins.
discussions.
When Oddμ runs as a web server, it serves all the Markdown files
(ending in `.md`) as web pages. These pages can be edited via the web.
Oddmu adds the following extensions to Markdown: local links `[[like
Oddμ adds the following extensions to Markdown: local links `[[like
this]]`, hashtags `#Like_This` and fediverse account links like
`@alex@alexschroeder.ch`.
@@ -42,15 +49,15 @@ available:
[oddmu(1)](https://alexschroeder.ch/view/oddmu/oddmu.1): This man page
has a short introduction to Oddmu, its configuration via templates and
environment variables, plus points to the other man pages.
environment variables, plus pointers to the other man pages.
[oddmu(5)](https://alexschroeder.ch/view/oddmu/oddmu.5): This man page
talks about the Markdown and includes some examples for the
non-standard features such as table markup. It also talks about the
Oddmu extensions to Markdown: wiki links, hashtags and fediverse
account links. Local links must use percent encoding for page names so
there is a section about percent encoding. The man page also explains
how feeds are generated.
talks about Markdown and includes some examples for the non-standard
features such as table markup. It also talks about the Oddmu
extensions to Markdown: wiki links, hashtags and fediverse account
links. Local links must use percent encoding for page names so there
is a section about percent encoding. The man page also explains how
feeds are generated.
[oddmu-releases(7)](https://alexschroeder.ch/view/oddmu/oddmu-releases.7):
This man page lists all the Oddmu versions and their user-visible

View File

@@ -13,4 +13,10 @@ When preparing a new release
5. Tag the release and push the tag to all remotes
6. cd man && make upload
6. cd man && make upload
7. make dist
8. create a new release at https://github.com/kensanata/oddmu/releases
9. upload the four .tar.gz binaries to the GitHub release

View File

@@ -3,16 +3,19 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Add to {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto }
body { hyphens: auto; color: #111; background-color: #ffe; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
form, textarea { box-sizing: border-box; width: 100%; font-size: inherit }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
</style>
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Path}}" method="POST">
<form id="editor" action="/append/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="{{.Language}}" autofocus required></textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Add">

View File

@@ -2,6 +2,7 @@ package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"net/url"
"os"
"regexp"
@@ -34,15 +35,15 @@ It's not `)}
data.Set("body", "barbecue")
assert.Regexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(viewHandler, false),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/add/fire", nil))
assert.NotRegexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(addHandler, true),
assert.HTTPBody(makeHandler(addHandler, true, http.MethodGet),
"GET", "/add/testdata/add/fire", nil))
HTTPRedirectTo(t, makeHandler(appendHandler, true),
HTTPRedirectTo(t, makeHandler(appendHandler, true, http.MethodPost),
"POST", "/append/testdata/add/fire", data, "/view/testdata/add/fire")
assert.Regexp(t, regexp.MustCompile(`not</p>\s*<p>barbecue`),
assert.HTTPBody(makeHandler(viewHandler, false),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/add/fire", nil))
}
@@ -57,7 +58,7 @@ Blue and green and pebbles gray
data := url.Values{}
data.Set("body", "Stand in cold water")
data.Add("notify", "on")
HTTPRedirectTo(t, makeHandler(appendHandler, true),
HTTPRedirectTo(t, makeHandler(appendHandler, true, http.MethodPost),
"POST", "/append/testdata/append/"+today+"-water",
data, "/view/testdata/append/"+today+"-water")
// The changes.md file was created

View File

@@ -3,6 +3,7 @@ package main
import (
"archive/zip"
"github.com/stretchr/testify/assert"
"net/http"
"os"
"strings"
"testing"
@@ -15,7 +16,7 @@ func TestArchive(t *testing.T) {
assert.NoError(t, os.WriteFile("testdata/archive/public/index.md", []byte("# Public\nChurch tower bells ringing\nA cold wind biting my ears\nWalk across the square"), 0644))
assert.NoError(t, os.WriteFile("testdata/archive/secret/index.md", []byte("# Secret\nMany years ago I danced\nSpending nights in clubs and bars\nIt is my secret"), 0644))
os.Setenv("ODDMU_FILTER", "^testdata/archive/secret/")
body := assert.HTTPBody(makeHandler(archiveHandler, true), "GET", "/archive/testdata/data.zip", nil)
body := assert.HTTPBody(makeHandler(archiveHandler, true, http.MethodGet), "GET", "/archive/testdata/data.zip", nil)
r, err := zip.NewReader(strings.NewReader(body), int64(len(body)))
assert.NoError(t, err, "Unzip")
names := []string{}

View File

@@ -154,21 +154,56 @@ func addLink(name string, mandatory bool, link string, re *regexp.Regexp) error
}
}
org := string(p.Body)
addLinkToPage(p, link, re)
// only save if something changed
if string(p.Body) != org {
return p.save()
}
return nil
}
func addLinkToPage(p *Page, link string, re *regexp.Regexp) {
// if a link exists, that's the place to insert the new link (in which case loc[0] and loc[1] differ)
loc := re.FindIndex(p.Body)
// if no link exists, find a good place to insert it
if loc == nil {
// locate the beginning of the list to insert the line
re = regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n`)
loc = re.FindIndex(p.Body)
if loc == nil {
// if no list was found, use the end of the page
m := len(p.Body)
loc = []int{m, m}
} else {
// if a list item was found, use just the beginning as insertion point
loc[1] = loc[0]
// locate the list items
re = regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n?`)
items := re.FindAllIndex(p.Body, -1)
first := false
pos := -1
// skip newer items
for i, it := range items {
// break if the current line is older (earlier in sort order)
stop := string(p.Body[it[0]:it[1]]) < link
// before the first match is always a good insert point
if i == 0 {
pos = it[0]
first = true
}
// if we're not stopping, then after the current item is a good insert point
if !stop {
pos = it[1]
first = false
} else {
break
}
}
// otherwise it's at the end of the list, after the last item
if pos == -1 && len(items) > 0 {
pos = items[len(items)-1][1]
first = false
}
// if no list was found, use the end of the page
if pos == -1 {
pos = len(p.Body)
first = true
}
if first {
p.Body, pos = ensureTwoNewlines(p.Body, pos)
}
// mimic a zero-width match at the insert point
loc = []int{pos, pos}
}
// start with new page content
r := []byte("")
@@ -179,9 +214,27 @@ func addLink(name string, mandatory bool, link string, re *regexp.Regexp) error
// append the rest
r = append(r, p.Body[loc[1]:]...)
p.Body = r
// only save if something changed
if string(p.Body) != org {
return p.save()
}
return nil
}
// ensureTwoNewlines makes sure that the two bytes before pos in buf are newlines. If the are not, newlines are inserted
// and pos is increased. The new buf and pos is returned.
func ensureTwoNewlines(buf []byte, pos int) ([]byte, int) {
var insert []byte
if pos >= 1 && buf[pos-1] != '\n' {
// add two newlines if buf doesn't end with a newline
insert = []byte("\n\n")
} else if pos >= 2 && buf[pos-2] != '\n' {
// add one newline if Body ends with just one newline
insert = []byte("\n")
}
if insert != nil {
r := []byte("")
r = append(r, buf[:pos]...)
r = append(r, insert...)
r = append(r, buf[pos:]...)
buf = r
pos += len(insert)
}
return buf, pos
}

View File

@@ -3,12 +3,67 @@ package main
import (
"github.com/stretchr/testify/assert"
"os"
"regexp"
"testing"
"time"
)
// Note TestEditSaveChanges and TestAddAppendChanges.
func TestAddLinkToPageWithNoList(t *testing.T) {
// no newlines
title := "# Test"
p := &Page{Body: []byte(title)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-08\)\n`)
link := "* [2025-08-08](2025-08-08)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title + "\n\n" + link, string(p.Body))
}
func TestAddLinkToPageWithOlderLink(t *testing.T) {
// one newline
title := "# Test\n"
old := "* [2025-08-08](2025-08-08)\n"
p := &Page{Body: []byte(title + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-10\)\n`)
link := "* [2025-08-10](2025-08-10)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title + "\n" + link + old, string(p.Body))
}
func TestAddLinkToPageBetweenToExistingLinks(t *testing.T) {
title := "# Test\n\n"
new := "* [2025-08-10](2025-08-10)\n"
old := "* [2025-08-08](2025-08-08)\n"
p := &Page{Body: []byte(title + new + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-09\)\n`)
link := "* [2025-08-09](2025-08-09)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title + new + link + old, string(p.Body))
}
func TestAddLinkToPageBetweenToExistingLinks2(t *testing.T) {
title := "# Test\n\n"
new := "* [2025-08-10](2025-08-10)\n* [2025-08-09](2025-08-09)\n"
old := "* [2025-08-07](2025-08-07)\n"
p := &Page{Body: []byte(title + new + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-08\)\n`)
link := "* [2025-08-08](2025-08-08)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title + new + link + old, string(p.Body))
}
func TestAddLinkToPageAtTheEnd(t *testing.T) {
title := "# Test\n\n"
new := "* [2025-08-10](2025-08-10)\n"
old := "* [2025-08-08](2025-08-08)\n"
p := &Page{Body: []byte(title + new + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-07\)\n`)
link := "* [2025-08-07](2025-08-07)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title + new + old + link, string(p.Body))
}
func TestChanges(t *testing.T) {
cleanup(t, "testdata/washing")
today := time.Now().Format(time.DateOnly)
@@ -48,7 +103,8 @@ Home away from home
assert.Contains(t, string(s), line)
s, err = os.ReadFile("testdata/changes/Haiku.md")
assert.NoError(t, err)
assert.Equal(t, intro+line, string(s))
// ensure an empty line when adding at the end of the page
assert.Equal(t, intro+"\n"+line, string(s))
assert.NoFileExists(t, "testdata/changes/Poetry.md")
}

View File

@@ -3,11 +3,11 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
del { background-color: #fab }
ins { background-color: #af8 }
pre { white-space: normal; background-color: white; border: 1px solid #eee; padding: 1ch }

View File

@@ -2,6 +2,7 @@ package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"os"
"testing"
"time"
@@ -24,7 +25,7 @@ Oh so fresh, so warm.`
p.save()
p.Body = []byte(r)
p.save()
body := assert.HTTPBody(makeHandler(diffHandler, true),
body := assert.HTTPBody(makeHandler(diffHandler, true, http.MethodGet),
"GET", "/diff/testdata/diff/bread", nil)
assert.Contains(t, body, `<del>breathe</del>`)
assert.Contains(t, body, `<ins>whisper</ins>`)
@@ -47,7 +48,7 @@ Mispronouncing words`
p.save()
p.Body = []byte(r)
p.save()
body := assert.HTTPBody(makeHandler(diffHandler, true),
body := assert.HTTPBody(makeHandler(diffHandler, true, http.MethodGet),
"GET", "/diff/testdata/diff/coup%20de%20grace", nil)
assert.Contains(t, body, `<del>s</del>`)
assert.Contains(t, body, `<ins>ce</ins>`)

View File

@@ -3,17 +3,20 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<base href="/view/{{.Dir}}">
<title>Editing {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto }
body { hyphens: auto; color: #111; background-color: #ffe; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
form, textarea { box-sizing: border-box; width: 100%; font-size: inherit }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
</style>
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Path}}" method="POST">
<form id="editor" action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="{{.Language}}" autofocus>{{printf "%s" .Body}}</textarea>

View File

@@ -16,24 +16,24 @@ func TestEditSave(t *testing.T) {
data.Set("body", "Hallo!")
// View of the non-existing page redirects to the edit page
HTTPRedirectTo(t, makeHandler(viewHandler, false),
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
// Edit page can be fetched
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
assert.HTTPStatusCode(t, makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/testdata/save/alex", nil, 200)
// Posting to the save URL saves a page
HTTPRedirectTo(t, makeHandler(saveHandler, true),
HTTPRedirectTo(t, makeHandler(saveHandler, true, http.MethodPost),
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
// Page now contains the text
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false),
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/save/alex", nil),
"Hallo!")
// Delete the page and you're sent to the empty page
data.Set("body", "")
HTTPRedirectTo(t, makeHandler(saveHandler, true),
HTTPRedirectTo(t, makeHandler(saveHandler, true, http.MethodPost),
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
// Viewing the non-existing page redirects to the edit page (like in the beginning)
HTTPRedirectTo(t, makeHandler(viewHandler, false),
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
}
@@ -44,7 +44,7 @@ func TestEditSaveChanges(t *testing.T) {
data.Add("notify", "on")
today := time.Now().Format("2006-01-02")
// Posting to the save URL saves a page
HTTPRedirectTo(t, makeHandler(saveHandler, true),
HTTPRedirectTo(t, makeHandler(saveHandler, true, http.MethodPost),
"POST", "/save/testdata/notification/"+today,
data, "/view/testdata/notification/"+today)
// The changes.md file was created
@@ -73,15 +73,15 @@ func TestEditId(t *testing.T) {
cleanup(t, "testdata/id")
data := url.Values{}
data.Set("id", "testdata/id/alex")
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
assert.HTTPStatusCode(t, makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/", data, http.StatusBadRequest,
"No slashes in id")
data.Set("id", ".alex")
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
assert.HTTPStatusCode(t, makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/", data, http.StatusForbidden,
"No hidden files")
data.Set("id", "alex")
assert.Contains(t, assert.HTTPBody(makeHandler(editHandler, true),
assert.Contains(t, assert.HTTPBody(makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/testdata/id/", data),
"Editing testdata/id/alex")
}

View File

@@ -34,10 +34,6 @@ func (*exportCmd) Usage() string {
it:
oddmu export > /tmp/export.rss
Options:
-template "filename" specifies the template to use (default: feed.html)
`
}

View File

@@ -8,13 +8,13 @@ import (
func TestFeed(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index.rss", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/index.rss", nil),
"Welcome to Oddμ")
}
func TestNoFeed(t *testing.T) {
assert.HTTPStatusCode(t,
makeHandler(viewHandler, false), "GET", "/view/no-feed.rss", nil, http.StatusNotFound)
makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/no-feed.rss", nil, http.StatusNotFound)
}
func TestFeedItems(t *testing.T) {
@@ -44,7 +44,7 @@ Writing poems about plants.
* [My Dragon Tree](dragon)`)}
p3.save()
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/feed/plants.rss", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/feed/plants.rss", nil)
assert.Contains(t, body, "<title>Plants</title>")
assert.Contains(t, body, "<title>Cactus</title>")
assert.Contains(t, body, "<title>Dragon</title>")

2
go.mod
View File

@@ -10,6 +10,7 @@ require (
github.com/fsnotify/fsnotify v1.7.0
github.com/gabriel-vasile/mimetype v1.4.3
github.com/gen2brain/heic v0.3.1
github.com/gen2brain/webp v0.5.2
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e
github.com/google/subcommands v1.2.0
github.com/hexops/gotextdiff v1.0.3
@@ -25,7 +26,6 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/ebitengine/purego v0.8.1 // indirect
github.com/gen2brain/webp v0.5.2 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect

6
go.sum
View File

@@ -5,8 +5,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA=
github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
@@ -19,8 +17,6 @@ github.com/gen2brain/heic v0.3.1 h1:ClY5YTdXdIanw7pe9ZVUM9XcsqH6CCCa5CZBlm58qOs=
github.com/gen2brain/heic v0.3.1/go.mod h1:m2sVIf02O7wfO8mJm+PvE91lnq4QYJy2hseUon7So10=
github.com/gen2brain/webp v0.5.2 h1:aYdjbU/2L98m+bqUdkYMOIY93YC+EN3HuZLMaqgMD9U=
github.com/gen2brain/webp v0.5.2/go.mod h1:Nb3xO5sy6MeUAHhru9H3GT7nlOQO5dKRNNlE92CZrJw=
github.com/gomarkdown/markdown v0.0.0-20240930133403-7e0a027d98c5 h1:qIhG9h8tUzKsVHn0iHtWUohq7Ve7btgA8rGp7TvrIHw=
github.com/gomarkdown/markdown v0.0.0-20240930133403-7e0a027d98c5/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e h1:ESHlT0RVZphh4JGBz49I5R6nTdC8Qyc08vU25GQHzzQ=
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
@@ -61,8 +57,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tetratelabs/wazero v1.7.3 h1:PBH5KVahrt3S2AHgEjKu4u+LlDbbk+nsGE3KLucy6Rw=
github.com/tetratelabs/wazero v1.7.3/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y=
github.com/tetratelabs/wazero v1.8.1 h1:NrcgVbWfkWvVc4UtT4LRLDf91PsOzDzefMdwhLfA550=
github.com/tetratelabs/wazero v1.8.1/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=

View File

@@ -5,15 +5,26 @@ import (
"flag"
"fmt"
"github.com/google/subcommands"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
"io"
"os"
"regexp"
"sort"
"strings"
)
type hashtagsCmd struct {
update bool
dryRun bool
}
func (cmd *hashtagsCmd) SetFlags(f *flag.FlagSet) {
f.BoolVar(&cmd.update, "update", false, "create and update hashtag pages")
f.BoolVar(&cmd.dryRun, "dry-run", false, "only report the changes it would make")
}
func (*hashtagsCmd) Name() string { return "hashtags" }
@@ -25,6 +36,9 @@ func (*hashtagsCmd) Usage() string {
}
func (cmd *hashtagsCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if cmd.update {
return hashtagsUpdateCli(os.Stdout, cmd.dryRun)
}
return hashtagsCli(os.Stdout)
}
@@ -57,3 +71,138 @@ func hashtagsCli(w io.Writer) subcommands.ExitStatus {
return subcommands.ExitSuccess
}
// hashtagsUpdateCli runs the hashtags command on the command line and creates and updates the hashtag pages in the
// current directory. That is, pages in subdirectories are skipped! It is used here with an io.Writer for easy testing.
func hashtagsUpdateCli(w io.Writer, dryRun bool) subcommands.ExitStatus {
index.load()
// no locking necessary since this is for the command-line
namesMap := make(map[string]string)
for hashtag, docids := range index.token {
if len(docids) <= 5 {
if dryRun {
fmt.Fprintf(w, "Skipping #%s because there are not enough entries (%d)\n", hashtag, len(docids))
}
continue
}
title, ok := namesMap[hashtag]
if (!ok) {
title = hashtagName(namesMap, hashtag, docids)
namesMap[hashtag] = title
}
pageName := strings.ReplaceAll(title, " ", "_")
h, err := loadPage(pageName)
original := ""
new := false
if err != nil {
new = true
h = &Page{Name: pageName, Body: []byte("# " + title + "\n\n#" + pageName + "\n\nBlog posts:\n\n")}
} else {
original = string(h.Body)
}
for _, docid := range docids {
name := index.documents[docid]
if strings.Contains(name, "/") {
continue
}
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(w, "Loading %s: %s\n", name, err)
return subcommands.ExitFailure
}
if !p.IsBlog() {
continue
}
p.handleTitle(false)
if p.Title == "" {
p.Title = p.Name
}
esc := nameEscape(p.Base())
link := "* [" + p.Title + "](" + esc + ")\n"
// I guess & used to get escaped and now no longer does
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(` + strings.ReplaceAll(esc, "&", "(&|%26)") + `\)\n`)
addLinkToPage(h, link, re)
}
// only save if something changed
if string(h.Body) != original {
if dryRun {
if new {
fmt.Fprintf(w, "Creating %s.md\n", title)
} else {
fmt.Fprintf(w, "Updating %s.md\n", title)
}
fn := h.Name + ".md"
edits := myers.ComputeEdits(span.URIFromPath(fn), original, string(h.Body))
diff := fmt.Sprint(gotextdiff.ToUnified(fn + "~", fn, original, edits))
fmt.Fprint(w, diff)
} else {
err = h.save()
if err != nil {
fmt.Fprintf(w, "Saving hashtag %s failed: %s", hashtag, err)
return subcommands.ExitFailure
}
}
}
}
return subcommands.ExitSuccess
}
// Go through all the documents in the same directory and look for hashtag matches in the rendered HTML in order to
// determine the most likely capitalization.
func hashtagName (namesMap map[string]string, hashtag string, docids []docid) string {
candidate := make(map[string]int)
var mostPopular string
for _, docid := range docids {
name := index.documents[docid]
if strings.Contains(name, "/") {
continue
}
p, err := loadPage(name)
if err != nil {
continue
}
// parsing finds all the hashtags
parser, _ := wikiParser()
doc := markdown.Parse(p.Body, parser)
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
if entering {
switch v := node.(type) {
case *ast.Link:
for _, attr := range v.AdditionalAttributes {
if attr == `class="tag"` {
tagName := []byte("")
ast.WalkFunc(v, func(node ast.Node, entering bool) ast.WalkStatus {
if entering && node.AsLeaf() != nil {
tagName = append(tagName, node.AsLeaf().Literal...)
}
return ast.GoToNext
})
tag := string(tagName[1:])
if strings.EqualFold(hashtag, strings.ReplaceAll(tag, " ", "_")) {
_, ok := candidate[tag]
if ok {
candidate[tag] += 1
} else {
candidate[tag] = 1
}
}
}
}
}
}
return ast.GoToNext
})
count := 0
for key, val := range candidate {
if val > count {
mostPopular = key
count = val
}
}
// shortcut
if count >= 5 {
return mostPopular
}
}
return mostPopular
}

108
list.go
View File

@@ -1,108 +0,0 @@
package main
import (
"io/fs"
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
)
// ListItem is used to display the list of files.
type File struct {
Name, Title string
IsDir, IsUp bool
// Date is the last modification date of the file storing the page. As the pages used by Oddmu are plain
// Markdown files, they don't contain any metadata. Instead, the last modification date of the file is used.
// This makes it work well with changes made to the files outside of Oddmu.
Date string
}
type List struct {
Dir string
Files []File
}
// listHandler uses the "list.html" template to enable file management in a particular directory.
func listHandler(w http.ResponseWriter, r *http.Request, name string) {
files := []File{}
d := filepath.FromSlash(name)
if d == "" {
d = "."
} else if !strings.HasSuffix(d, "/") {
http.Redirect(w, r, "/list/" + nameEscape(name) + "/", http.StatusFound)
return
} else {
it := File{Name: "..", IsUp: true, IsDir: true }
files = append(files, it)
}
err := filepath.Walk(d, func (fp string, fi fs.FileInfo, err error) error {
if err != nil {
return err
}
isDir := false
if fi.IsDir() {
if d == fp {
return nil
}
isDir = true
}
name := filepath.ToSlash(fp)
base := filepath.Base(fp)
title := ""
if !isDir && strings.HasSuffix(name, ".md") {
index.RLock()
defer index.RUnlock()
title = index.titles[name[:len(name)-3]]
} else if isDir {
// even on Windows, this looks like a Unix directory
base += "/"
}
it := File{Name: base, Title: title, Date: fi.ModTime().Format(time.DateTime), IsDir: isDir }
files = append(files, it)
if isDir {
return filepath.SkipDir
}
return nil
})
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
renderTemplate(w, d, "list", &List{Dir: name, Files: files})
}
// deleteHandler deletes the named file and then redirects back to the list
func deleteHandler(w http.ResponseWriter, r *http.Request, name string) {
fn := filepath.FromSlash(name)
err := os.RemoveAll(fn) // and all its children!
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/list/" + nameEscape(path.Dir(name)) + "/", http.StatusFound)
}
// renameHandler renames the named file and then redirects back to the list
func renameHandler(w http.ResponseWriter, r *http.Request, name string) {
fn := filepath.FromSlash(name)
dir := path.Dir(name)
target := path.Join(dir, r.FormValue("name"))
if (isHiddenName(target)) {
http.Error(w, "the target file would be hidden", http.StatusForbidden)
return
}
err := os.Rename(fn, filepath.FromSlash(target))
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/list/" + nameEscape(path.Dir(filepath.ToSlash(target))) + "/", http.StatusFound)
}

View File

@@ -1,59 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Manage Files</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
form { width: 100% }
table { border-collapse: collapse }
th:nth-child(3) { max-width: 3ex; overflow: visible }
td form { display: inline }
td { padding-right: 1ch }
td:last-child { padding-right: 0 }
td:first-child { max-width: 30ch; overflow: hidden }
tr:nth-child(odd) { background-color: #eed }
td:first-child, td:last-child { white-space: nowrap }
</style>
</head>
<body lang="en">
<header>
<a href="#main">Skip navigation</a>
<a href="/view/index">Home</a>
<a href="/archive/{{.Dir}}data.zip" accesskey="z">Zip</a>
<a href="/upload/{{.Dir}}?filename=image-1.jpg" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<button>Go</button>
</form>
</header>
<main>
<h1>Manage Files</h1>
<form id="manage">
<p><mark>Deletions and renamings take effect immediately and there is no undo!</mark></p>
</form>
<table>
<tr>
<th>Name</th>
<th>Title</th>
<th>Delete</th>
<th>Rename</th>
</tr>{{range .Files}}
<tr>
<td>{{if .IsDir}}<a href="/list/{{$.Dir}}{{.Name}}">{{.Name}}</a>{{else}}<a href="/view/{{$.Dir}}{{.Name}}">{{.Name}}</a>{{end}}</td>
<td>{{.Title}}</td>
<td>{{if .IsUp}}{{else}}<button form="manage" formaction="/delete/{{$.Dir}}{{.Name}}" title="Delete {{.Name}}">🗑</button>{{end}}</td>
<td>{{if .IsUp}}{{else}}
<form action="/rename/{{$.Dir}}{{.Name}}">
<input name="name" placeholder="New name"/>
<button title="Rename {{.Name}}"></button>
</form>{{end}}</td>
</tr>{{end}}
</table>
</main>
</body>
</html>

View File

@@ -1,30 +0,0 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"testing"
)
// relies on index.md in the current directory!
func TestListHandler(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/", nil),
"index.md")
}
func TestDeleteHandler(t *testing.T) {
cleanup(t, "testdata/delete")
assert.NoError(t, os.Mkdir("testdata/delete", 0755))
p := &Page{Name: "testdata/delete/haiku", Body: []byte(`# Sunset
Walk the fields outside
See the forest loom above
And an orange sky
`)}
p.save()
list := assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/delete/", nil)
assert.Contains(t, list, `<a href="/view/testdata/delete/haiku.md">haiku.md</a>`)
assert.Contains(t, list, `<td>Sunset</td>`)
assert.Contains(t, list, `<button form="manage" formaction="/delete/testdata/delete/haiku.md" title="Delete haiku.md">`)
}

View File

@@ -27,7 +27,7 @@ html: ${HTML}
%.html: %.md
@echo Making $@
@echo '<!DOCTYPE html>' > $@
@oddmu html $(basename $<) | sed --regexp-extended \
@oddmu html $< | sed --regexp-extended \
-e 's/<a href="(oddmu[a-z.-]*.[1-9])">([^<>]*)<\/a>/<a href="\1.html">\2<\/a>/g' >> $@
md: ${MD}

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-APACHE" "5" "2024-09-25"
.TH "ODDMU-APACHE" "5" "2025-07-16"
.PP
.SH NAME
.PP
@@ -48,7 +48,7 @@ ServerAdmin alex@alexschroeder\&.ch
<VirtualHost *:443>
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(\&.*))?$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
"http://localhost:8080/$1"
</VirtualHost>
.fi
@@ -126,13 +126,13 @@ ServerAdmin alex@alexschroeder\&.ch
ServerName transjovian\&.org
ProxyPassMatch "^/((view|diff|search|archive)/(\&.*))?$"
"http://localhost:8080/$1"
RedirectMatch "^/((edit|save|add|append|upload|drop|list|delete|rename)/(\&.*))?$"
RedirectMatch "^/((edit|save|add|append|upload|drop)/(\&.*))?$"
"https://transjovian\&.org/$1"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(\&.*))?$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
"http://localhost:8080/$1"
</VirtualHost>
.fi
@@ -170,7 +170,7 @@ In that case, you need to use the ProxyPassMatch directive.\&
.PP
.nf
.RS 4
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(\&.*))?$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
.fi
.RE
@@ -189,7 +189,7 @@ A workaround is to add the redirect manually and drop the question-mark:
.nf
.RS 4
RedirectMatch "^/$" "/view/index"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(\&.*))$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))$"
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
.fi
.RE
@@ -234,12 +234,12 @@ htpasswd -D \&.htpasswd berta
.RE
.PP
Modify your site configuration and protect the "/edit/", "/save/", "/add/",
"/append/", "/upload/", "/drop/", "/list/", "/delete/" and "/rename/" URLs with
a password by adding the following to your "<VirtualHost *:443>" section:
"/append/", "/upload/" and "/drop/" URLs with a password by adding the following
to your "<VirtualHost *:443>" section:
.PP
.nf
.RS 4
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename)/">
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
@@ -274,7 +274,7 @@ directory:
.PP
.nf
.RS 4
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename|(view|preview|search|archive)/secret)/">
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|preview|search|archive)/secret)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
@@ -300,9 +300,8 @@ DocumentRoot /home/oddmu
.PP
Make sure that none of the subdirectories look like the wiki paths "/view/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/",
"/list", "/delete/", "/rename/" "/search/" or "/archive/".\& For example, create a
file called "robots.\&txt" containing the following, telling all robots that
they'\&re not welcome.\&
"/search/" or "/archive/".\& For example, create a file called "robots.\&txt"
containing the following, telling all robots that they'\&re not welcome.\&
.PP
.nf
.RS 4
@@ -350,7 +349,7 @@ This requires a valid login by the user "alex" or "berta":
.PP
.nf
.RS 4
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename)/intetebi/">
<LocationMatch "^/(edit|save|add|append|upload|drop)/intetebi/">
Require user alex berta
</LocationMatch>
.fi

View File

@@ -40,7 +40,7 @@ ServerAdmin alex@alexschroeder.ch
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))?$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
</VirtualHost>
```
@@ -106,13 +106,13 @@ ServerAdmin alex@alexschroeder.ch
ServerName transjovian.org
ProxyPassMatch "^/((view|diff|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
RedirectMatch "^/((edit|save|add|append|upload|drop|list|delete|rename)/(.*))?$" \
RedirectMatch "^/((edit|save|add|append|upload|drop)/(.*))?$" \
"https://transjovian.org/$1"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))?$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
</VirtualHost>
```
@@ -144,7 +144,7 @@ You probably want to serve some static files as well (see *Serve static files*).
In that case, you need to use the ProxyPassMatch directive.
```
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))?$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
@@ -159,7 +159,7 @@ A workaround is to add the redirect manually and drop the question-mark:
```
RedirectMatch "^/$" "/view/index"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
@@ -197,11 +197,11 @@ htpasswd -D .htpasswd berta
```
Modify your site configuration and protect the "/edit/", "/save/", "/add/",
"/append/", "/upload/", "/drop/", "/list/", "/delete/" and "/rename/" URLs with
a password by adding the following to your "<VirtualHost \*:443>" section:
"/append/", "/upload/" and "/drop/" URLs with a password by adding the following
to your "<VirtualHost \*:443>" section:
```
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename)/">
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
@@ -234,7 +234,7 @@ You need to configure the web server to prevent access to the "secret/"
directory:
```
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename|(view|preview|search|archive)/secret)/">
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|preview|search|archive)/secret)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
@@ -257,9 +257,8 @@ DocumentRoot /home/oddmu
Make sure that none of the subdirectories look like the wiki paths "/view/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/",
"/list", "/delete/", "/rename/" "/search/" or "/archive/". For example, create a
file called "robots.txt" containing the following, telling all robots that
they're not welcome.
"/search/" or "/archive/". For example, create a file called "robots.txt"
containing the following, telling all robots that they're not welcome.
```
User-agent: *
@@ -302,7 +301,7 @@ password file mentioned above.
This requires a valid login by the user "alex" or "berta":
```
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename)/intetebi/">
<LocationMatch "^/(edit|save|add|append|upload|drop)/intetebi/">
Require user alex berta
</LocationMatch>
```

View File

@@ -5,20 +5,31 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-HASHTAGS" "1" "2024-08-29"
.TH "ODDMU-HASHTAGS" "1" "2025-08-09"
.PP
.SH NAME
.PP
oddmu-hashtags - count the hashtags used
oddmu-hashtags - work with hashtags
.PP
.SH SYNOPSIS
.PP
\fBoddmu hashtags\fR
.PP
\fBoddmu hashtags -update\fR [\fB-dry-run\fR]
.PP
.SH DESCRIPTION
.PP
The "hashtags" subcommand counts all the hashtags used and lists them, separated
by a TAB character.\&
By default, the "hashtags" subcommand counts all the hashtags used and lists
them, separated by a TAB character.\&
.PP
With the \fB-update\fR flag, the hashtag pages are update with links to all the blog
pages having the corresponding tag.\& This only necessary when migrating a
collection of Markdown files.\& Ordinarily, Oddmu maintains the hashtag pages
automatically.\& When writing pages offline, use \fIoddmu-notify\fR(1) to update the
hashtag pages.\&
.PP
Use the \fB-dry-run\fR flag to see what would change with the \fB-update\fR flag without
actually changing any files.\&
.PP
.SH EXAMPLES
.PP
@@ -30,6 +41,22 @@ oddmu hashtags | head -n 11
.fi
.RE
.PP
See what kind of changes Oddmu would suggest:
.PP
.nf
.RS 4
oddmu hashtags -update -dry-run
.fi
.RE
.PP
And then do it:
.PP
.nf
.RS 4
oddmu hashtags -update
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1)

View File

@@ -2,16 +2,27 @@ ODDMU-HASHTAGS(1)
# NAME
oddmu-hashtags - count the hashtags used
oddmu-hashtags - work with hashtags
# SYNOPSIS
*oddmu hashtags*
*oddmu hashtags -update* [*-dry-run*]
# DESCRIPTION
The "hashtags" subcommand counts all the hashtags used and lists them, separated
by a TAB character.
By default, the "hashtags" subcommand counts all the hashtags used and lists
them, separated by a TAB character.
With the *-update* flag, the hashtag pages are update with links to all the blog
pages having the corresponding tag. This only necessary when migrating a
collection of Markdown files. Ordinarily, Oddmu maintains the hashtag pages
automatically. When writing pages offline, use _oddmu-notify_(1) to update the
hashtag pages.
Use the *-dry-run* flag to see what would change with the *-update* flag without
actually changing any files.
# EXAMPLES
@@ -21,6 +32,18 @@ List the top 10 hashtags. This requires 11 lines because of the header line.
oddmu hashtags | head -n 11
```
See what kind of changes Oddmu would suggest:
```
oddmu hashtags -update -dry-run
```
And then do it:
```
oddmu hashtags -update
```
# SEE ALSO
_oddmu_(1)

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-NGINX" "5" "2025-03-16"
.TH "ODDMU-NGINX" "5" "2025-07-16"
.PP
.SH NAME
.PP
@@ -27,7 +27,7 @@ section.\& Add a new \fIlocation\fR section after the existing \fIlocation\fR se
.PP
.nf
.RS 4
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
proxy_pass http://localhost:8080;
}
.fi
@@ -53,7 +53,7 @@ location ~ ^/(view|diff|search)/ {
proxy_pass http://localhost:8080;
}
# password required
location ~ ^/(edit|save|add|append|upload|drop|list|delete|rename|archive)/ {
location ~ ^/(edit|save|add|append|upload|drop|archive)/ {
auth_basic "Oddmu author";
auth_basic_user_file /etc/nginx/conf\&.d/htpasswd;
proxy_pass http://localhost:8080;
@@ -97,7 +97,7 @@ server configuration.\& On a Debian system, that'\&d be in
.PP
.nf
.RS 4
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
proxy_pass http://unix:/run/oddmu/oddmu\&.sock:;
}
.fi

View File

@@ -19,7 +19,7 @@ The site is defined in "/etc/nginx/sites-available/default", in the _server_
section. Add a new _location_ section after the existing _location_ section:
```
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
proxy_pass http://localhost:8080;
}
```
@@ -43,7 +43,7 @@ location ~ ^/(view|diff|search)/ {
proxy_pass http://localhost:8080;
}
# password required
location ~ ^/(edit|save|add|append|upload|drop|list|delete|rename|archive)/ {
location ~ ^/(edit|save|add|append|upload|drop|archive)/ {
auth_basic "Oddmu author";
auth_basic_user_file /etc/nginx/conf.d/htpasswd;
proxy_pass http://localhost:8080;
@@ -81,7 +81,7 @@ server configuration. On a Debian system, that'd be in
"/etc/nginx/sites-available/default".
```
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
proxy_pass http://unix:/run/oddmu/oddmu.sock:;
}
```

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-RELEASES" "7" "2025-04-06"
.TH "ODDMU-RELEASES" "7" "2025-08-10"
.PP
.SH NAME
.PP
@@ -15,14 +15,59 @@ oddmu-releases - what'\&s new?\&
.PP
This page lists user-visible features and template changes to consider.\&
.PP
.SS 1.18 (2025)
.PP
The \fIhashtags\fR gained the option of checking and fixing the hashtag pages by
adding missing links to tagged blog pages.\& See \fIoddmu-hashtags\fR(1) for more.\&
.PP
In an effort to remove features that can be handled by the web server, the
\fIlist\fR, \fIdelete\fR and \fIrename\fR actions were removed again.\& See \fIoddmu-webdav\fR(5)
for a better solution.\&
.PP
You probably need to remove a sentence linking to the list action from the
upload template ("upload.\&html").\&
.PP
.SS 1.17 (2025)
.PP
You need to update the upload template ("upload.\&html").\& Many things have
changed!\& See \fIoddmu-templates\fR(5) for more.\&
.PP
You probably want to ensure that the upload link on the view template
("view.\&html") and others, if you added it, has a \fIfilename\fR and \fIpagename\fR
parameters.\&
.PP
Example:
.PP
.nf
.RS 4
<a href="/upload/{{\&.Dir}}?filename={{\&.Base}}-1\&.jpg&pagename={{\&.Base}}">Upload</a>
.fi
.RE
.PP
You need to change {{.\&Name}} to {{.\&Path}} when it is used in URLs, in the list
template ("list.\&html").\& If you don'\&t do this, file deleting and rename may not
work on files containing a comma, a semicolon, a questionmark or a hash
character.\& This fix was necessary because URLs for files containing a
questionmark or a hash character would end the path at this character and treat
the rest as a query parameter or fragment, respectively.\&
.PP
Updated the example themes.\& Some of my sites got a text area that tries to take
all the vertical space available.\& This is great for monitors in portrait mode.\&
.PP
\fIlist\fR action now skips dot files.\&
.PP
.SS 1.16 (2025)
.PP
Add support for WebP images for uploading and resizing.\&
.PP
You need to change {{.\&Name}} to {{.\&Path}} in HTML templates.\& If you don'\&t do
this, your page names (i.\&e.\& filenames for pages) may not include a comma, a
semicolon or a questionmark.\& This fix was necessary because file uploads of
filenames with non-ASCII characters ended up double-encoded.\&
You need to change {{.\&Name}} to {{.\&Path}} in HTML templates where pages are
concerned.\& If you don'\&t do this, your page names (i.\&e.\& filenames for pages) may
not include a comma, a semicolon, a questionmark or a hash sign.\& This fix was
necessary because file uploads of filenames with non-ASCII characters ended up
double-encoded.\&
.PP
Note that on the "list.\&html" template, {{.\&Name}} refers to file instead of a
page and File.\&Path() isn'\&t implemented, yet.\& This is fixed in the next release.\&
.PP
Improved the example themes.\& The chat theme got better list styling and better
upload functionality with automatic "add" button; the plain theme got rocket

View File

@@ -8,14 +8,57 @@ oddmu-releases - what's new?
This page lists user-visible features and template changes to consider.
## 1.18 (2025)
The _hashtags_ gained the option of checking and fixing the hashtag pages by
adding missing links to tagged blog pages. See _oddmu-hashtags_(1) for more.
In an effort to remove features that can be handled by the web server, the
_list_, _delete_ and _rename_ actions were removed again. See _oddmu-webdav_(5)
for a better solution.
You probably need to remove a sentence linking to the list action from the
upload template ("upload.html").
## 1.17 (2025)
You need to update the upload template ("upload.html"). Many things have
changed! See _oddmu-templates_(5) for more.
You probably want to ensure that the upload link on the view template
("view.html") and others, if you added it, has a _filename_ and _pagename_
parameters.
Example:
```
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg&pagename={{.Base}}">Upload</a>
```
You need to change {{.Name}} to {{.Path}} when it is used in URLs, in the list
template ("list.html"). If you don't do this, file deleting and rename may not
work on files containing a comma, a semicolon, a questionmark or a hash
character. This fix was necessary because URLs for files containing a
questionmark or a hash character would end the path at this character and treat
the rest as a query parameter or fragment, respectively.
Updated the example themes. Some of my sites got a text area that tries to take
all the vertical space available. This is great for monitors in portrait mode.
_list_ action now skips dot files.
## 1.16 (2025)
Add support for WebP images for uploading and resizing.
You need to change {{.Name}} to {{.Path}} in HTML templates. If you don't do
this, your page names (i.e. filenames for pages) may not include a comma, a
semicolon or a questionmark. This fix was necessary because file uploads of
filenames with non-ASCII characters ended up double-encoded.
You need to change {{.Name}} to {{.Path}} in HTML templates where pages are
concerned. If you don't do this, your page names (i.e. filenames for pages) may
not include a comma, a semicolon, a questionmark or a hash sign. This fix was
necessary because file uploads of filenames with non-ASCII characters ended up
double-encoded.
Note that on the "list.html" template, {{.Name}} refers to file instead of a
page and File.Path() isn't implemented, yet. This is fixed in the next release.
Improved the example themes. The chat theme got better list styling and better
upload functionality with automatic "add" button; the plain theme got rocket

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-TEMPLATES" "5" "2024-08-30" "File Formats Manual"
.TH "ODDMU-TEMPLATES" "5" "2025-04-26" "File Formats Manual"
.PP
.SH NAME
.PP
@@ -44,6 +44,29 @@ placeholders.\&
\fIview.\&html\fR uses a \fIpage\fR
.PD
.PP
The following property lists always indicate whether the property is
percent-encoded or not.\& In theory, the html/template package would handle this.\&
The problem is that the package gives special treatment to the semicolon, comma,
question-mark and hash-sign as these are potential separators in a URL.\&
.PP
Consider the following:
.PP
.nf
.RS 4
<a href="{{\&.Name}}">{{\&.Name}}</a>
.fi
.RE
.PP
If \fI.\&Name\fR is "#foo", the html/template package treats it as a URL fragment
inside the attribute instead of a file path that needs to be escaped to
"%23foo".\& The same problem arises if \fI.\&Name\fR is "foo?\&" as the questionmark is
not escaped and therefore treated as the separator between URL path and query
parameters instead of being part of the name.\&
.PP
The consequences for template authors is that the properties that are
percent-encoded must be used in links where as the regular properties must be
used outside of links.\&
.PP
.SS Page
.PP
A page has the following properties:
@@ -51,14 +74,14 @@ A page has the following properties:
\fI{{.\&Title}}\fR is the page title.\& If the page doesn'\&t provide its own title, the
page name is used.\&
.PP
\fI{{.\&Name}}\fR is the page name, escaped for use in URLs.\& More specifically, it is
percent-escaped except for the slashes.\& The page name doesn'\&t include the \fI.\&md\fR
extension.\&
\fI{{.\&Name}}\fR is the page name.\& The page name doesn'\&t include the \fI.\&md\fR extension.\&
.PP
\fI{{.\&Dir}}\fR is the page directory, percent-escaped except for the slashes.\&
\fI{{.\&Path}}\fR is the page name, percent-encoded.\&
.PP
\fI{{.\&Dir}}\fR is the page directory, percent-encoded.\&
.PP
\fI{{.\&Base}}\fR is the basename of the current file (without the directory and
without the \fI.\&md\fR extension), escaped for use in URLs.\&
without the \fI.\&md\fR extension), percent-encoded.\&
.PP
\fI{{.\&Language}}\fR is the suspected language of the page.\& This is used to set the
language on the \fIview.\&html\fR template.\& See "Non-English hyphenation" below.\&
@@ -113,7 +136,7 @@ An item is a page plus a date.\& All the properties of a page can be used (see
.PP
The list contains a directory name and an array of files.\&
.PP
\fI{{.\&Dir}}\fR is the directory name that is being listed.\&
\fI{{.\&Dir}}\fR is the directory name that is being listed, percent-encoded.\&
.PP
\fI{{.\&Files}}\fR is the array of files.\& To refer to them, you need to use a \fI{{range
Files}}\fR\fI{{end}}\fR construct.\&
@@ -123,6 +146,8 @@ Each file has the following attributes:
\fI{{.\&Name}}\fR is the filename.\& The ".\&md" suffix for Markdown files is part of the
name (unlike page names).\&
.PP
\fI{{.\&Path}}\fR is the page name, percent-encoded.\&
.PP
\fI{{.\&Title}}\fR is the page title, if the file in question is a Markdown file.\&
.PP
\fI{{.\&IsDir}}\fR is a boolean used to indicate that this file is a directory.\&
@@ -137,8 +162,7 @@ directory).\& The filename of this file is ".\&.\&".\&
.PP
\fI{{.\&Query}}\fR is the query string.\&
.PP
\fI{{.\&Dir}}\fR is the directory in which the search starts, percent-escaped except
for the slashes.\&
\fI{{.\&Dir}}\fR is the directory in which the search starts, percent-encoded.\&
.PP
\fI{{.\&Previous}}\fR, \fI{{.\&Page}}\fR and \fI{{.\&Next}}\fR are the previous, current and next
page number in the results since doing arithmetics in templates is hard.\& The
@@ -175,23 +199,18 @@ search term that matched.\&
.SS Upload
.PP
\fI{{.\&Dir}}\fR is the directory where the uploaded file ends up, based on the URL
path, percent-escaped except for the slashes.\&
path, percent-encoded.\&
.PP
\fI{{.\&Name}}\fR is the \fIfilename\fR query parameter.\&
\fI{{.\&FileName}}\fR is the \fIfilename\fR query parameter used to suggested a filename.\&
.PP
\fI{{.\&Last}}\fR is the filename of the last file uploaded.\&
\fI{{.\&FilePath}}\fR is the filename, percent-encoded.\&
.PP
\fI{{.\&Actual}}\fR is an array of filenames of all the files uploaded.\& Use {{range
Actual}} … {{.\&}} … {{end}} to loop over all the filenames.\&
\fI{{.\&Name}}\fR is the \fIpagename\fR query parameter used to indicate where to append
links to the files.\&
.PP
\fI{{.\&Base}}\fR is the basename of the first file uploaded (without the directory,
extension and numeric part at the end), escaped for use in URLs.\&
\fI{{.\&Path}}\fR is the page name, percent-encoded.\&
.PP
\fI{{.\&Title}}\fR is the title of the basename, if it exists.\&
.PP
\fI{{.\&Image}}\fR is a boolean to indicate whether the last file uploaded has a file
name indicating an image or not (such as ending in \fI.\&jpg\fR).\& If so, a thumbnail
can be shown by the template, for example.\&
\fI{{.\&Title}}\fR is the title of the page, if it exists.\&
.PP
\fI{{.\&MaxWidth}}\fR is the \fImaxwidth\fR query parameter, i.\&e.\& the value used for the
previous image uploaded.\&
@@ -201,6 +220,22 @@ previous image uploaded.\&
.PP
\fI{{.\&Today}}\fR is the current date, in ISO format.\&
.PP
\fI{{.\&Uploads}}\fR an array of files already uploaded, based on the \fIuploads\fR query
parameter.\& To refer to them, you need to use a \fI{{range .\&Uploads}}\fR\fI{{end}}\fR
construct.\& This is required because the \fIdrop\fR action redirects back to the
\fIupload\fR action, so after saving one or more files, you can upload even more
files.\&
.PP
Each upload has the following attributes:
.PP
\fI{{.\&Name}}\fR is the filename.\&
.PP
\fI{{.\&Path}}\fR is the file name, percent-encoded.\&
.PP
\fI{{.\&Image}}\fR is a boolean to indicate whether the upload is an image or not
(such as ending in \fI.\&jpg\fR).\& If so, a thumbnail can be shown by the template, for
example.\&
.PP
.SS Non-English hyphenation
.PP
Automatic hyphenation by the browser requires two things: The style sheet must
@@ -214,16 +249,16 @@ use a small number of languages or just a single language!\& you can set
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
codes, e.\&g.\& "en" or "en,de,fr,pt".\&
.PP
"view.\&html" is used the template to render a single page and so the language
detected is added to the "html" element.\&
"view.\&html" is used to render a single page and so the language detected is
added to the "html" element.\&
.PP
"search.\&html" is the template used to render search results and so "en" is used
for the "html" element and the language detected for every page in the search
result is added to the "article" element for each snippet.\&
.PP
"edit.\&html" and "add.\&html" are the templates used to edit a page and at that
point, the language isn'\&t known, so "en" is used for the "html" element and no
language is used for the "textarea" element.\&
"edit.\&html" and "add.\&html" are the templates used to edit a page.\& If the page
already exists, its language is used for the "textarea" element.\& If the page is
new, no language is used for the "textarea" element.\&
.PP
.SH EXAMPLES
.PP

View File

@@ -25,6 +25,27 @@ placeholders.
- _upload.html_ uses an _upload_
- _view.html_ uses a _page_
The following property lists always indicate whether the property is
percent-encoded or not. In theory, the html/template package would handle this.
The problem is that the package gives special treatment to the semicolon, comma,
question-mark and hash-sign as these are potential separators in a URL.
Consider the following:
```
<a href="{{.Name}}">{{.Name}}</a>
```
If _.Name_ is "#foo", the html/template package treats it as a URL fragment
inside the attribute instead of a file path that needs to be escaped to
"%23foo". The same problem arises if _.Name_ is "foo?" as the questionmark is
not escaped and therefore treated as the separator between URL path and query
parameters instead of being part of the name.
The consequences for template authors is that the properties that are
percent-encoded must be used in links where as the regular properties must be
used outside of links.
## Page
A page has the following properties:
@@ -32,14 +53,14 @@ A page has the following properties:
_{{.Title}}_ is the page title. If the page doesn't provide its own title, the
page name is used.
_{{.Name}}_ is the page name, escaped for use in URLs. More specifically, it is
percent-escaped except for the slashes. The page name doesn't include the _.md_
extension.
_{{.Name}}_ is the page name. The page name doesn't include the _.md_ extension.
_{{.Dir}}_ is the page directory, percent-escaped except for the slashes.
_{{.Path}}_ is the page name, percent-encoded.
_{{.Dir}}_ is the page directory, percent-encoded.
_{{.Base}}_ is the basename of the current file (without the directory and
without the _.md_ extension), escaped for use in URLs.
without the _.md_ extension), percent-encoded.
_{{.Language}}_ is the suspected language of the page. This is used to set the
language on the _view.html_ template. See "Non-English hyphenation" below.
@@ -89,7 +110,7 @@ _{{.Date}}_ is the date of the last update to the page, in RFC 822 format.
The list contains a directory name and an array of files.
_{{.Dir}}_ is the directory name that is being listed.
_{{.Dir}}_ is the directory name that is being listed, percent-encoded.
_{{.Files}}_ is the array of files. To refer to them, you need to use a _{{range
.Files}}_ … _{{end}}_ construct.
@@ -99,6 +120,8 @@ Each file has the following attributes:
_{{.Name}}_ is the filename. The ".md" suffix for Markdown files is part of the
name (unlike page names).
_{{.Path}}_ is the page name, percent-encoded.
_{{.Title}}_ is the page title, if the file in question is a Markdown file.
_{{.IsDir}}_ is a boolean used to indicate that this file is a directory.
@@ -113,8 +136,7 @@ _{{.Date}}_ is the last modification date of the file.
_{{.Query}}_ is the query string.
_{{.Dir}}_ is the directory in which the search starts, percent-escaped except
for the slashes.
_{{.Dir}}_ is the directory in which the search starts, percent-encoded.
_{{.Previous}}_, _{{.Page}}_ and _{{.Next}}_ are the previous, current and next
page number in the results since doing arithmetics in templates is hard. The
@@ -151,23 +173,18 @@ search term that matched.
## Upload
_{{.Dir}}_ is the directory where the uploaded file ends up, based on the URL
path, percent-escaped except for the slashes.
path, percent-encoded.
_{{.Name}}_ is the _filename_ query parameter.
_{{.FileName}}_ is the _filename_ query parameter used to suggested a filename.
_{{.Last}}_ is the filename of the last file uploaded.
_{{.FilePath}}_ is the filename, percent-encoded.
_{{.Actual}}_ is an array of filenames of all the files uploaded. Use {{range
.Actual}} … {{.}} … {{end}} to loop over all the filenames.
_{{.Name}}_ is the _pagename_ query parameter used to indicate where to append
links to the files.
_{{.Base}}_ is the basename of the first file uploaded (without the directory,
extension and numeric part at the end), escaped for use in URLs.
_{{.Path}}_ is the page name, percent-encoded.
_{{.Title}}_ is the title of the basename, if it exists.
_{{.Image}}_ is a boolean to indicate whether the last file uploaded has a file
name indicating an image or not (such as ending in _.jpg_). If so, a thumbnail
can be shown by the template, for example.
_{{.Title}}_ is the title of the page, if it exists.
_{{.MaxWidth}}_ is the _maxwidth_ query parameter, i.e. the value used for the
previous image uploaded.
@@ -177,6 +194,22 @@ previous image uploaded.
_{{.Today}}_ is the current date, in ISO format.
_{{.Uploads}}_ an array of files already uploaded, based on the _uploads_ query
parameter. To refer to them, you need to use a _{{range .Uploads}}_ … _{{end}}_
construct. This is required because the _drop_ action redirects back to the
_upload_ action, so after saving one or more files, you can upload even more
files.
Each upload has the following attributes:
_{{.Name}}_ is the filename.
_{{.Path}}_ is the file name, percent-encoded.
_{{.Image}}_ is a boolean to indicate whether the upload is an image or not
(such as ending in _.jpg_). If so, a thumbnail can be shown by the template, for
example.
## Non-English hyphenation
Automatic hyphenation by the browser requires two things: The style sheet must
@@ -190,16 +223,16 @@ use a small number of languages or just a single language! you can set t
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
codes, e.g. "en" or "en,de,fr,pt".
"view.html" is used the template to render a single page and so the language
detected is added to the "html" element.
"view.html" is used to render a single page and so the language detected is
added to the "html" element.
"search.html" is the template used to render search results and so "en" is used
for the "html" element and the language detected for every page in the search
result is added to the "article" element for each snippet.
"edit.html" and "add.html" are the templates used to edit a page and at that
point, the language isn't known, so "en" is used for the "html" element and no
language is used for the "textarea" element.
"edit.html" and "add.html" are the templates used to edit a page. If the page
already exists, its language is used for the "textarea" element. If the page is
new, no language is used for the "textarea" element.
# EXAMPLES

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-WEBDAV" "5" "2024-09-25"
.TH "ODDMU-WEBDAV" "5" "2025-07-16"
.PP
.SH NAME
.PP
@@ -14,27 +14,34 @@ oddmu-webdav - how to setup Web-DAV using Apache for Oddmu
.SH DESCRIPTION
.PP
With the Apache Web-DAV module enabled, users can mount the wiki as a remote
file system and edit files using their favourite text editor.\& If you want to
offer users direct file access to the wiki, this can be accomplished via ssh,
sftp or Web-DAV.\&
.PP
The benefit of using the Apache Web-DAV module is that access has to be
configured only once.\&
file system and manage the files using some other tool.\& Using the Apache Web-DAV
module means that the same user accounts can be used as for the regular wiki.\&
.PP
.SH CONFIGURATION
.PP
In the following example, "data" is not an action provided by Oddmu but an
actual directory for Oddmu files.\& In the example below,
"/home/alex/campaignwiki.\&org/data" is both the document root for static files
and the data directory for Oddmu.\& This is the directory where Oddmu needs to
run.\& When users request the "/data" path, authentication is required but the
request is not proxied to Oddmu since the "ProxyPassMatch" directive doesn'\&t
handle "/data".\& Instead, Apache gets to handle it.\& Since "data" is part of all
the "LocationMatch" directives, credentials are required to save (PUT) files.\&
Consider the "campaignwiki.\&org" site in the example below.\& This site offers
users their own wikis.\& Thus:
.PP
"Dav On" enables Web-DAV for the "knochentanz" wiki.\& It is enabled for all the
actions, but since only "/data" is handled by Apache, this has no effect for all
the other actions, allowing us to specify the required users only once.\&
"https://campaignwiki.\&org/" is a regular website with static files.\&
.PP
"https://campaignwiki.\&org/view/index" is one of the requests that gets passed to
a Unix domain socket.\& See "Socket Activation" in \fIoddmu\fR(1).\&
.PP
Some of these actions are protected by basic authentication.\& A valid user is
required to make changes to the site.\& Valid users are "admin" and "alex".\&
.PP
"data" is the Oddmu working directory.\& WebDAV is turned on for this directory.\& A
shortcut has been taken, here: The "data" subdirectory requires authentication
and offers WebDAV access.\& The other paths also require authentication and map to
Oddmu actions.\& The fact that WebDAV access is "enabled" for the Oddmu actions
has no effect.\& The only drawback is that "https://campaignwiki.\&org/data/" now
requires authentication even if only used for reading.\&
.PP
"https://campaignwiki.\&org/view/knochentanz/index" is a separate site called
"knochentanz".\& The only valid user is "knochentanz".\&
.PP
Notice how the \fIarchive\fR action is not available at the top level, only for
subdirectories.\&
.PP
.nf
.RS 4
@@ -48,28 +55,32 @@ MDomain campaignwiki\&.org
<VirtualHost *:443>
ServerAdmin alex@campaignwiki\&.org
ServerName campaignwiki\&.org
# Static HTML, CSS, JavaScript files and so on are saved here\&.
DocumentRoot /home/alex/campaignwiki\&.org
<Directory /home/alex/campaignwiki\&.org>
Options Includes Indexes MultiViews SymLinksIfOwnerMatch
Options Indexes MultiViews SymLinksIfOwnerMatch
AllowOverride All
Require all granted
Require all granted
</Directory>
SSLEngine on
# Any request to the following paths is passed on to the Unix domain socket\&.
ProxyPassMatch
"^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|search|archive/\&.+)/(\&.*))$"
"^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive/\&.+)/(\&.*))$"
"unix:/home/oddmu/campaignwiki\&.sock|http://localhost/$1"
# /archive only for subdirectories
Redirect "/archive/data\&.zip" "/view/archive"
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete)/">
# Making changes to the wiki requires authentication\&.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
Require user admin alex
</LocationMatch>
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete|archive)/knochentanz">
Require user admin alex knochentanz
Dav On
</LocationMatch>
# Making changes to a subdirectory requires different accounts\&.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|archive)/knochentanz">
Require user knochentanz
</LocationMatch>
</VirtualHost>
.fi
.RE
@@ -101,9 +112,13 @@ sudo chmod g+w /home/alex/campaignwiki\&.org/data/knochentanz
Web-DAV clients are often implemented such that they only work with servers that
exactly match their assumptions.\& If you'\&re trying to use \fIgvfs\fR(7), the Windows
File Explorer or the macOS Finder to edit Oddmu pages using Web-DAV, you'\&re on
your own.\&
your own.\& Sometimes it works.\& I'\&ve used Nemo 5.\&6.\&4 to connect to the server and
edited files using gedit 44.\&2.\& But I'\&ve used other file managers and other
editors with WebDAV support and they didn'\&t work very well.\&
.PP
This section has examples sessions using tools that work.\&
On Windows, try third party tools like WinSCP.\&
.PP
This section has examples sessions using command-line tools that work.\&
.PP
.SS cadaver
.PP
@@ -183,6 +198,9 @@ alex@melanobombus ~> echo test >> knochentanz/index\&.md
"Apache Module mod_dav".\&
https://httpd.\&apache.\&org/docs/current/mod/mod_dav.\&html
.PP
"WinSCP"
https://winscp.\&net/
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

View File

@@ -7,27 +7,34 @@ oddmu-webdav - how to setup Web-DAV using Apache for Oddmu
# DESCRIPTION
With the Apache Web-DAV module enabled, users can mount the wiki as a remote
file system and edit files using their favourite text editor. If you want to
offer users direct file access to the wiki, this can be accomplished via ssh,
sftp or Web-DAV.
The benefit of using the Apache Web-DAV module is that access has to be
configured only once.
file system and manage the files using some other tool. Using the Apache Web-DAV
module means that the same user accounts can be used as for the regular wiki.
# CONFIGURATION
In the following example, "data" is not an action provided by Oddmu but an
actual directory for Oddmu files. In the example below,
"/home/alex/campaignwiki.org/data" is both the document root for static files
and the data directory for Oddmu. This is the directory where Oddmu needs to
run. When users request the "/data" path, authentication is required but the
request is not proxied to Oddmu since the "ProxyPassMatch" directive doesn't
handle "/data". Instead, Apache gets to handle it. Since "data" is part of all
the "LocationMatch" directives, credentials are required to save (PUT) files.
Consider the "campaignwiki.org" site in the example below. This site offers
users their own wikis. Thus:
"Dav On" enables Web-DAV for the "knochentanz" wiki. It is enabled for all the
actions, but since only "/data" is handled by Apache, this has no effect for all
the other actions, allowing us to specify the required users only once.
"https://campaignwiki.org/" is a regular website with static files.
"https://campaignwiki.org/view/index" is one of the requests that gets passed to
a Unix domain socket. See "Socket Activation" in _oddmu_(1).
Some of these actions are protected by basic authentication. A valid user is
required to make changes to the site. Valid users are "admin" and "alex".
"data" is the Oddmu working directory. WebDAV is turned on for this directory. A
shortcut has been taken, here: The "data" subdirectory requires authentication
and offers WebDAV access. The other paths also require authentication and map to
Oddmu actions. The fact that WebDAV access is "enabled" for the Oddmu actions
has no effect. The only drawback is that "https://campaignwiki.org/data/" now
requires authentication even if only used for reading.
"https://campaignwiki.org/view/knochentanz/index" is a separate site called
"knochentanz". The only valid user is "knochentanz".
Notice how the _archive_ action is not available at the top level, only for
subdirectories.
```
MDomain campaignwiki.org
@@ -40,28 +47,32 @@ MDomain campaignwiki.org
<VirtualHost *:443>
ServerAdmin alex@campaignwiki.org
ServerName campaignwiki.org
# Static HTML, CSS, JavaScript files and so on are saved here.
DocumentRoot /home/alex/campaignwiki.org
<Directory /home/alex/campaignwiki.org>
Options Includes Indexes MultiViews SymLinksIfOwnerMatch
Options Indexes MultiViews SymLinksIfOwnerMatch
AllowOverride All
Require all granted
Require all granted
</Directory>
SSLEngine on
# Any request to the following paths is passed on to the Unix domain socket.
ProxyPassMatch \
"^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|search|archive/.+)/(.*))$" \
"^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive/.+)/(.*))$" \
"unix:/home/oddmu/campaignwiki.sock|http://localhost/$1"
# /archive only for subdirectories
Redirect "/archive/data.zip" "/view/archive"
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete)/">
# Making changes to the wiki requires authentication.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
Require user admin alex
</LocationMatch>
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete|archive)/knochentanz">
Require user admin alex knochentanz
Dav On
</LocationMatch>
# Making changes to a subdirectory requires different accounts.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|archive)/knochentanz">
Require user knochentanz
</LocationMatch>
</VirtualHost>
```
@@ -88,9 +99,13 @@ sudo chmod g+w /home/alex/campaignwiki.org/data/knochentanz
Web-DAV clients are often implemented such that they only work with servers that
exactly match their assumptions. If you're trying to use _gvfs_(7), the Windows
File Explorer or the macOS Finder to edit Oddmu pages using Web-DAV, you're on
your own.
your own. Sometimes it works. I've used Nemo 5.6.4 to connect to the server and
edited files using gedit 44.2. But I've used other file managers and other
editors with WebDAV support and they didn't work very well.
This section has examples sessions using tools that work.
On Windows, try third party tools like WinSCP.
This section has examples sessions using command-line tools that work.
## cadaver
@@ -164,6 +179,9 @@ _oddmu_(1), _oddmu-apache_(5)
"Apache Module mod_dav".
https://httpd.apache.org/docs/current/mod/mod_dav.html
"WinSCP"
https://winscp.net/
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "1" "2025-03-14"
.TH "ODDMU" "1" "2025-08-09"
.PP
.SH NAME
.PP
@@ -77,12 +77,6 @@ directory:
.IP \(bu 4
\fI/drop/dir/name\fR saves an upload
.IP \(bu 4
\fI/list/dir/\fR lists the files in a directory
.IP \(bu 4
\fI/delete/dir/name\fR deletes a file or directory
.IP \(bu 4
\fI/rename/dir/name?\&name=new\fR renames a file or directory
.IP \(bu 4
\fI/search/dir/?\&q=term\fR to search for a term
.IP \(bu 4
\fI/archive/dir/name.\&zip\fR to download a zip file of a directory
@@ -390,7 +384,7 @@ Oddmu running as a webserver:
.PP
.PD 0
.IP \(bu 4
\fIoddmu-hashtags\fR(1), on how to count the hashtags used
\fIoddmu-hashtags\fR(1), on working with hashtags
.IP \(bu 4
\fIoddmu-html\fR(1), on how to render a page
.IP \(bu 4

View File

@@ -55,9 +55,6 @@ directory:
- _/append/dir/name_ appends an addition to a page
- _/upload/dir/name_ shows a form to upload a file
- _/drop/dir/name_ saves an upload
- _/list/dir/_ lists the files in a directory
- _/delete/dir/name_ deletes a file or directory
- _/rename/dir/name?name=new_ renames a file or directory
- _/search/dir/?q=term_ to search for a term
- _/archive/dir/name.zip_ to download a zip file of a directory
@@ -318,7 +315,7 @@ If you run Oddmu as a web server:
If you run Oddmu as a static site generator or pages offline and sync them with
Oddmu running as a webserver:
- _oddmu-hashtags_(1), on how to count the hashtags used
- _oddmu-hashtags_(1), on working with hashtags
- _oddmu-html_(1), on how to render a page
- _oddmu-list_(1), on how to list pages and titles
- _oddmu-links_(1), on how to list the outgoing links for a page

View File

@@ -71,7 +71,7 @@ func TestManActions(t *testing.T) {
wiki := string(b)
count := 0
// this doesn't match the root handler
re := regexp.MustCompile(`\.HandleFunc\("(/[a-z]+/)", makeHandler\([a-z]+Handler, (true|false)\)\)`)
re := regexp.MustCompile(`\.HandleFunc\("(/[a-z]+/)", makeHandler\([a-z]+Handler, (true|false)(, http\.Method(Get|Post))+\)\)`)
for _, match := range re.FindAllStringSubmatch(wiki, -1) {
count++
var path string

30
page.go
View File

@@ -150,19 +150,24 @@ func (p *Page) IsBlog() bool {
const upperhex = "0123456789ABCDEF"
// Path returns the page name with semicolon, comma and questionmark escaped because html/template doesn't escape those.
// This is suitable for use in HTML templates.
// Path returns the Page.Name with some characters escaped because html/template doesn't escape those. This is suitable
// for use in HTML templates.
func (p *Page) Path() string {
s := p.Name
n := strings.Count(s, ";") + strings.Count(s, ",") + strings.Count(s, "?")
return pathEncode(p.Name)
}
// pathEncode returns the page name with some characters escaped because html/template doesn't escape those. This is
// suitable for use in HTML templates.
func pathEncode(s string) string {
n := strings.Count(s, ";") + strings.Count(s, ",") + strings.Count(s, "?") + strings.Count(s, "#")
if n == 0 {
return p.Name
return s
}
t := make([]byte, len(s) + 2*n)
j := 0
for i := 0; i < len(s); i++ {
switch s[i] {
case ';', ',', '?':
case ';', ',', '?', '#':
t[j] = '%'
t[j+1] = upperhex[s[i]>>4]
t[j+2] = upperhex[s[i]&15]
@@ -172,21 +177,22 @@ func (p *Page) Path() string {
j++
}
}
return string(t);
return string(t)
}
// Dir returns the directory part of the page name. It's either the empty string if the page is in the Oddmu working
// directory, or it ends in a slash. This is used to create the upload link in "view.html", for example.
// Dir returns the directory part of the page name, percent-escaped except for the slashes. It's either the empty string
// if the page is in the Oddmu working directory, or it ends in a slash. This is used to create the upload link in
// "view.html", for example.
func (p *Page) Dir() string {
d := path.Dir(p.Name)
if d == "." {
return ""
}
return d + "/"
return pathEncode(d) + "/"
}
// Base returns the basename of the page name: no directory. This is used to create the upload link in "view.html", for
// example.
// Base returns the basename of the page name: no directory, percent-escaped except for the slashes. This is used to
// create the upload link in "view.html", for example.
func (p *Page) Base() string {
n := path.Base(p.Name)
if n == "." {

View File

@@ -10,8 +10,13 @@ import (
// otherwise the rendered template has garbage bytes at the end. Note also that we need to remove the title from the
// page so that the preview works as intended (and much like the "view.html" template) where as the editing requires the
// page content including the header… which is why it needs to be added in the "preview.html" template. This makes me
// sad.
// sad. While viewing the preview, links will point to the /preview path. In order to handle this, regular GET requests
// are passed on the the {viewHandler}.
func previewHandler(w http.ResponseWriter, r *http.Request, path string) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/view/" + strings.TrimPrefix(path, "/preview/"), http.StatusFound)
return
}
body := strings.ReplaceAll(r.FormValue("body"), "\r", "")
p := &Page{Name: path, Body: []byte(body)}
p.handleTitle(true)

View File

@@ -3,18 +3,18 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<base href="/view/{{.Dir}}">
<title>Preview: {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 12ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
</style>
</head>
<body>

View File

@@ -3,6 +3,7 @@ package main
import (
"github.com/stretchr/testify/assert"
"net/url"
"net/http"
"testing"
)
@@ -12,6 +13,6 @@ func TestPreview(t *testing.T) {
data := url.Values{}
data.Set("body", "**Hallo**!")
r := assert.HTTPBody(makeHandler(previewHandler, false), "POST", "/view/testdata/preview/alex", data)
r := assert.HTTPBody(makeHandler(previewHandler, false, http.MethodGet), "POST", "/view/testdata/preview/alex", data)
assert.Contains(t, r, "<strong>Hallo</strong>!")
}

View File

@@ -300,3 +300,9 @@ func searchHandler(w http.ResponseWriter, r *http.Request, dir string) {
Results: len(items) > 0, More: more}
renderTemplate(w, dir, "search", s)
}
// Path returns the ImageData.Name with some characters escaped because html/template doesn't escape those. This is
// suitable for use in HTML templates.
func (img *ImageData) Path() string {
return pathEncode(img.Name)
}

View File

@@ -3,24 +3,24 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Search for {{.Query}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 20ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 20ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8; }
.image { display: inline-block; margin-right: 1em; max-width: calc(20% - 1em); font-size: small; }
.image img { max-width: 100%; }
.score { font-size: smaller; opacity: 0.8 }
.image { display: inline-block; margin-right: 1em; max-width: calc(20% - 1em); font-size: small }
.image img { max-width: 100% }
</style>
</head>
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="#main">Skip</a>
<a href="/view/index">Home</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>

View File

@@ -3,6 +3,7 @@ package main
import (
"fmt"
"github.com/stretchr/testify/assert"
"net/http"
"net/url"
"slices"
"testing"
@@ -63,15 +64,15 @@ func TestSearch(t *testing.T) {
data := url.Values{}
data.Set("q", "oddμ")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
body := assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/", data)
assert.Contains(t, body, "Welcome")
assert.Contains(t, body, `<span class="score">5</span>`)
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata", data)
body = assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/testdata", data)
assert.NotContains(t, body, "Welcome")
data.Set("q", "'create a new page'")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
body = assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/", data)
assert.Contains(t, body, "Welcome")
}
@@ -158,16 +159,16 @@ Where is lady luck?`)}
data := url.Values{}
data.Set("q", "luck")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
body := assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/", data)
assert.Contains(t, body, "luck")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata", data)
body = assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/testdata", data)
assert.Contains(t, body, "luck")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/dir", data)
body = assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/testdata/dir", data)
assert.Contains(t, body, "luck")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/other", data)
body = assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/testdata/other", data)
assert.Contains(t, body, "No results")
}
@@ -277,7 +278,7 @@ The silence streches.`)}
p.save()
data := url.Values{}
data.Set("q", "look")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
body := assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/testdata/question/", data)
assert.Contains(t, body, "We <b>look</b>")
assert.NotContains(t, body, "Odd?")
assert.Contains(t, body, "Even?")

View File

@@ -3,17 +3,17 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 12ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
</style>
</head>
<body>

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"github.com/stretchr/testify/assert"
"mime/multipart"
"net/http"
"testing"
)
@@ -18,13 +19,12 @@ Memories of cold
`)}
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/snow", nil),
"Skip navigation")
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/templates/snow", nil), "Skip")
// save a new view handler
html := "<body><h1>{{.Title}}</h1>{{.Html}}"
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, err := writer.CreateFormField("name")
field, err := writer.CreateFormField("filename")
assert.NoError(t, err)
field.Write([]byte("view.html"))
file, err := writer.CreateFormFile("file", "test.html")
@@ -33,18 +33,17 @@ Memories of cold
assert.NoError(t, err)
assert.Equal(t, len(html), n)
writer.Close()
HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/testdata/templates/", writer.FormDataContentType(), form)
HTTPUploadLocation(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/templates/", writer.FormDataContentType(), form)
assert.FileExists(t, "view.html", "original view.html still exists")
assert.FileExists(t, "testdata/templates/view.html", "new view.html also exists")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/view.html", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/templates/view.html", nil),
html)
// verify that it works
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/snow", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/templates/snow", nil)
assert.Contains(t, body, "<h1>Snow</h1>")
assert.NotContains(t, body, "Skip")
// verify that the top level still uses the old template
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index", nil),
"Skip navigation")
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/index", nil), "Skip")
}

View File

@@ -1,16 +1,17 @@
download:
rsync --archive --delete --itemize-changes --exclude='*-*' sibirocobombus:alexschroeder.ch/wiki/'*.html' alexschroeder.ch/
rsync --archive sibirocobombus:alexschroeder.ch/css/oddmu-2023.css alexschroeder.ch/oddmu.css
sed --in-place=~ --expression='s/\/css\/oddmu-2023\.css/oddmu.css/' alexschroeder.ch/*.html
rsync --archive --delete --itemize-changes sibirocobombus:flying-carpet.ch/wiki/'*.html' flying-carpet.ch/
rsync --archive --delete --itemize-changes sibirocobombus:campaignwiki.org/data/'*.html' campaignwiki.org/
rsync --archive --delete --itemize-changes sibirocobombus.root:/home/oddmu/'*.html' transjovian.org/
SHELL=/usr/bin/fish
# Manually figure out what needs to change:
# (ediff-directories "alexschroeder.ch" "/ssh:sibirocobombus:alexschroeder.ch/wiki/" "html$")
# (ediff-directories "flying-carpet.ch" "/ssh:sibirocobombus.root|sudo:claudia@sibirocobombus.root:/home/alex/flying-carpet.ch/wiki/" "html$")
# (ediff-directories "flying-carpet.ch" "/ssh:sibirocobombus.root:/home/claudia/flying-carpet.ch/wiki/" "html$") + fix permissions
# (ediff-directories "campaignwiki.org" "/ssh:sibirocobombus:campaignwiki.org/data/" "html$")
# (ediff-directories "communitywiki.org" "/ssh:sibirocobombus:communitywiki.org/data/" "html$")
# (ediff-directories "transjovian.org" "/ssh:sibirocobombus.root:/home/oddmu/" "html$")
upload:
rsync --archive --delete --itemize-changes --exclude=Makefile --exclude='*~' . sibirocobombus:alexschroeder.ch/wiki/oddmu/themes/
# (ediff-directories "communitywiki.org" ".." "html$")
# Upload the theme for the web site. This does not deploy the themes!
upload:
rsync --archive --delete --itemize-changes --exclude=Makefile --exclude='*~' \
. sibirocobombus:alexschroeder.ch/wiki/oddmu/themes/
@echo Updated the templates for the Oddmu site

View File

@@ -2,11 +2,6 @@
At the top there's a text input to quickly create new pages.
This theme comes with an external CSS file. If you plan to use
subdirectories for your site, you need to change the URL of the CSS in
the HTML templates to `/view/oddmu.css` or serve it as a static file
from a `/css` directory.
The CSS switches between light and dark mode based on the visitor's
setup.

View File

@@ -3,9 +3,23 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Add to {{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
<style>
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto }
body { hyphens: auto; color: #111; background-color: #ffe; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
form, textarea { box-sizing: border-box; width: 100%; font-size: inherit }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
@media (prefers-color-scheme: dark) {
body { color: #ffe; background-color: #110 }
pre { background-color: #333 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
input, textarea, button { color: #eeeee8; background-color: #555 }
}
</style>
<script>
window.addEventListener("load", () => {
let t = document.getElementsByTagName('textarea').item(0);
@@ -31,7 +45,7 @@ window.addEventListener("load", () => {
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Path}}" method="POST">
<form id="editor" action="/append/{{.Path}}" method="POST">
<p>Use <tt>Control+I</tt> for italics, <tt>Control+B</tt> for bold, <tt>Control+k</tt> for link.</p>
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="{{.Language}}" autofocus required>{{if .IsBlog}}**{{.Today}}**. {{end}}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>

View File

@@ -3,20 +3,30 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
del { background-color: #fab }
ins { background-color: #af8 }
pre { white-space: normal; background-color: white; border: 1px solid #eee; padding: 1ch }
@media (prefers-color-scheme: dark) {
html { color: #ffe; background-color: #110 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
ins { background-color: #070 }
del { background-color: #f40 }
pre { color: #eeeee8; background-color: #555 }
}
</style>
</head>
<body>
<header>
<a href="/view/{{.Path}}">Back</a>
</header>
<main id="main">
<h1>{{.Title}}</h1>
<p>This is the diff between <a href="/view/{{.Path}}.md~">the backup</a> and <a href="/view/{{.Path}}.md">the current copy</a>.</p>
<pre class="diff">
<h1>{{.Title}}</h1>
<p>This is the diff between <a href="/view/{{.Path}}.md~">the backup</a> and <a href="/view/{{.Path}}.md">the current copy</a>.</p>
<pre>
{{.Diff}}
</pre>
</main>
</pre>
</body>
</html>

View File

@@ -3,10 +3,24 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<base href="/view/{{.Dir}}">
<title>Editing {{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
<style>
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto }
body { hyphens: auto; color: #111; background-color: #ffe; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
form, textarea { box-sizing: border-box; width: 100%; font-size: inherit }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
@media (prefers-color-scheme: dark) {
body { color: #ffe; background-color: #110 }
pre { background-color: #333 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
input, textarea, button { color: #eeeee8; background-color: #555 }
}
</style>
<script>
window.addEventListener("load", () => {
let t = document.getElementsByTagName('textarea').item(0);
@@ -32,7 +46,7 @@ window.addEventListener("load", () => {
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Path}}" method="POST">
<form id="editor" action="/save/{{.Path}}" method="POST">
<p>Use <tt>Control+I</tt> for italics, <tt>Control+B</tt> for bold, <tt>Control+k</tt> for link.</p>
<textarea name="body" rows="20" cols="80" placeholder="# Title

View File

@@ -1,47 +0,0 @@
html { max-width: 80ch; padding: 1ch; margin: auto }
body { hyphens: auto; color: #111; background-color: #ffe }
header a { margin-right: 1ch }
h1 { text-wrap: balance }
footer { border-top: 1px solid #888 }
form, textarea { width: 97%; font-size: inherit }
pre { background-color: #fff; padding: 1ex 2ex; overflow-x: auto }
.diff { font-size: inherit; white-space: normal; overflow-wrap: break-word; background-color: white; border: 1px solid #333; padding: 1ch }
img, video { max-width: 100%; max-height: 90vh; width: auto; height: auto }
.right img { float: right; margin-left: 2em; margin-bottom: 1em; border: 1px solid #111 }
.left img { float: left; margin-right: 2em; margin-bottom: 1em; border: 1px solid #111 }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
del { background-color: #fab }
ins { background-color: #af8 }
#search, #id { max-width: 30ch; width: calc(100% - 23ch) }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8 }
.image { font-size: small; display: inline-block; margin-right: 1em; max-width: calc(20% - 1em); }
#view form { margin-top: 2px }
#view button { width: 6ch }
#view label { display: inline-block; width: 10ch }
#upload label { display: inline-block; width: 15ch }
#upload input[type=text] { width: 30ch }
img.last { max-width: 20% }
hr { border-bottom: 1px }
th { font-weight: normal }
th + th, td + td { padding-left: 1em }
@media (prefers-color-scheme: dark) {
body { color: #eeeee8; background-color: #333 }
footer { border-top: 1px solid #666 }
.diff { background-color: inherit; border: 1px solid #666 }
.right img { border: 1px solid #111 }
.left img { border: 1px solid #111 }
pre { background-color: #000; }
button { background-color: #eee; color: inherit }
del { background-color: #f40 }
ins { background-color: #070 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
img { opacity: .75; transition: opacity .5s ease-in-out; }
img:hover { opacity: 1; }
input, input[type="text"], textarea, button, .diff {
color: #eeeee8; background-color: #555
}
}

View File

@@ -3,13 +3,32 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Search for {{.Query}}</title>
<link href="oddmu.css" rel="stylesheet" />
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 20ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8 }
.image { display: inline-block; margin-right: 1em; max-width: calc(20% - 1em); font-size: small }
.image img { max-width: 100% }
@media (prefers-color-scheme: dark) {
html { color: #ffe; background-color: #110 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
img { opacity: .75; transition: opacity .5s ease-in-out }
img:hover { opacity: 1 }
}
</style>
</head>
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="#main">Skip</a>
<a href="/view/index">Home</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>

View File

@@ -3,17 +3,27 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
h1 { text-wrap: balance }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
@media (prefers-color-scheme: dark) {
html { color: #ffe; background-color: #110 }
footer { border-top: 1px solid #666 }
.right img { border: 1px solid #111 }
.left img { border: 1px solid #111 }
pre { background-color: #333 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
img { opacity: .75; transition: opacity .5s ease-in-out }
img:hover { opacity: 1 }
}
</style>
</head>
<body>

View File

@@ -3,9 +3,23 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Upload File</title>
<link href="oddmu.css" rel="stylesheet" />
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
form, textarea { width: 100% }
label { display: inline-block; width: 20ch }
input [type=text] { width: 30ch }
.upload { max-width: 20% }
@media (prefers-color-scheme: dark) {
html { color: #ffe; background-color: #110 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
input, textarea, button { color: #eeeee8; background-color: #555 }
}
</style>
<script type="text/javascript">
var uploadFiles = {
init: function() {
@@ -41,7 +55,7 @@ var uploadFiles = {
post: function(files) {
let action = document.getElementById('upload').getAttribute('action');
var fd = new FormData();
fd.append("name", document.getElementById('name').value);
fd.append("filename", document.getElementById('filename').value);
fd.append("maxwidth", document.getElementById('maxwidth').value);
fd.append("quality", document.getElementById('quality').value);
for (var i = 0; i < files.length; i++) {
@@ -65,27 +79,24 @@ window.addEventListener('load', uploadFiles.init);
</head>
<body lang="en">
<h1>Upload Files</h1>
{{if ne .Last ""}}
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a>
{{if .Image}}
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"><br>
Links:<tt>{{range .Actual}}<br>![]({{.}}){{end}}</tt>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{end}}
<form id="add" action="/append/{{.Dir}}{{.Base}}" method="POST">
<input type="hidden" name="body" value="{{range .Actual}}![]({{.}})
{{if .Uploads}}
<p>Previous uploads:
<p>{{range .Uploads}}
{{if .Image}}<img class="upload" src="/view/{{$.Dir}}{{.Path}}">{{else}}<a class="upload" href="/view/{{$.Dir}}{{.Path}}">{{end}}{{end}}
<form id="add" action="/append/{{.Dir}}{{.Path}}" method="POST">
<input type="hidden" name="body" value="{{range .Uploads}}{{if .Image}}!{{end}}[{{.Name}}]({{.Path}})
{{end}}">
<p>Append this to <a href="/view/{{.Dir}}{{.Base}}">{{.Title}}</a>?
<input type="submit" value="Add">
<input type="hidden" name="pagename" value="{{.Name}}">
<p>Append it to <a href="/view/{{.Dir}}{{.Path}}">{{.Title}}</a>?
<input type="submit" value="Add">
</form>
{{end}}
<form id="upload" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>When uploading a picture from a phone, its filename is going to be something like IMG_1234.JPG.
Please provide your own filename. End the base name with "-1" to auto-increment.
Use <tt>.jpg</tt>, <tt>.png</tt> or <tt>.webp</tt> as the extension if you want to resize the picture.
<p><label for="name">Filename:</label>
<input id="name" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
<p><label for="filename">Filename:</label>
<input id="filename" name="filename" value="{{.FileName}}" type="text" placeholder="image-1.jpg" autofocus required>
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
Sadly, resizing only works when uploading <tt>.jpeg</tt>, <tt>.webp</tt>, <tt>.heic</tt> and <tt>.png</tt> files.
Feel free to specify a max width of 1200 pixels, for example.
@@ -98,12 +109,11 @@ window.addEventListener('load', uploadFiles.init);
<p>Finally, pick the files or photos to upload.
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
If you're uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p>To delete a file, upload an empty file.
If youre uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">
<a href="/view/index"><button type="button">Cancel</button></a>
<a href="/view/{{.Dir}}{{.Path}}"><button type="button">Cancel</button></a>
<p>You can also paste images or drag and drop files.
</form>
</body>

View File

@@ -3,20 +3,44 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>{{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
h1 { text-wrap: balance }
label { display: inline-block; width: 10ch; margin: 4px 0 }
input[type=text] { width: 30ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100% }
@media (prefers-color-scheme: dark) {
html { color: #ffe; background-color: #110 }
footer { border-top: 1px solid #666 }
.right img { border: 1px solid #111 }
.left img { border: 1px solid #111 }
pre { background-color: #333 }
button { background-color: #eee; color: inherit }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
img { opacity: .75; transition: opacity .5s ease-in-out }
img:hover { opacity: 1 }
input, input[type="text"], textarea, button { color: #eeeee8; background-color: #555 }
}
</style>
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{.Title}}" href="/view/{{.Path}}.rss" />
</head>
<body>
<header id="view">
<header>
<a href="#main">Skip</a>
<a href="index">Home</a>
<a href="changes">Changes</a>
<a href="/edit/{{.Path}}" accesskey="e">Edit</a>
<a href="/add/{{.Path}}" accesskey="a">Add</a>
<a href="/diff/{{.Path}}" accesskey="d">Diff</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg&pagename={{.Base}}" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>

View File

@@ -3,11 +3,11 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Add to {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed }
form, textarea { width: 100% }
</style>
</head>
<body>

View File

@@ -3,11 +3,11 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #eed; }
body { hyphens: auto; }
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #eed }
body { hyphens: auto }
del { background-color: #fab }
ins { background-color: #af8 }
pre { white-space: normal; background-color: white; border: 1px solid #eee; padding: 1ch }

View File

@@ -3,20 +3,46 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Editing {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto; color: #111; background-color: #eed }
body { hyphens: auto; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
form, textarea { box-sizing: border-box; width: 100% }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
</style>
<script>
window.addEventListener("load", () => {
let t = document.getElementsByTagName('textarea').item(0);
t.addEventListener("keydown", (event) => {
if (event.ctrlKey && !event.altKey && !event.metaKey) {
let ch;
if (event.key == "i") {
ch = ["*", "*"];
} else if (event.key == "b") {
ch = ["**", "**"];
} else if (event.key == "k") {
ch = ["[", "]()"];
}
if (ch) {
event.preventDefault();
let s = t.value.substring(t.selectionStart, t.selectionEnd);
t.setRangeText(ch[0] + s + ch[1]);
}
}
});
});
</script>
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Path}}" method="POST">
<form id="editor" action="/save/{{.Path}}" method="POST">
<p>Use <tt>Control+I</tt> for italics, <tt>Control+B</tt> for bold, <tt>Control+k</tt> for link.</p>
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="" autofocus>{{printf "%s" .Body}}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
Text" lang="{{.Language}}" autofocus>{{ or .Body (printf "# %s " .Today) | printf "%s" }}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Save">
<button formaction="/preview/{{.Path}}" type="submit">Preview</button>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>

View File

@@ -3,23 +3,23 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Search for {{.Query}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 20ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
img { max-width: 20%; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 20ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
img { max-width: 20% }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8; }
.score { font-size: smaller; opacity: 0.8 }
</style>
</head>
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="#main">Skip</a>
<a href="/view/index">Home</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>

View File

@@ -3,17 +3,17 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #eed; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #eed }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 12ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
</style>
</head>
<body>

View File

@@ -3,12 +3,12 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Upload File</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed; }
body { hyphens: auto; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed }
body { hyphens: auto }
form, textarea { width: 100% }
label { display: inline-block; width: 20ch }
input [type=text] { width: 30ch }
.last { max-width: 20% }
@@ -16,7 +16,7 @@ input [type=text] { width: 30ch }
<script type="text/javascript">
var uploadFiles = {
init: function() {
let e = document.getElementById('form');
let e = document.getElementById('upload');
if (e) {
e.addEventListener('paste', uploadFiles.pasteHandler);
e.addEventListener('dragover', e => e.preventDefault());
@@ -46,7 +46,7 @@ var uploadFiles = {
uploadFiles.post(files)
},
post: function(files) {
let action = document.getElementById('form').getAttribute('action');
let action = document.getElementById('upload').getAttribute('action');
var fd = new FormData();
fd.append("name", document.getElementById('name').value);
fd.append("maxwidth", document.getElementById('maxwidth').value);
@@ -72,27 +72,24 @@ window.addEventListener('load', uploadFiles.init);
</head>
<body lang="en">
<h1>Upload Files</h1>
{{if ne .Last ""}}
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a>
{{if .Image}}
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"><br>
Links:<tt>{{range .Actual}}<br>![]({{.}}){{end}}</tt>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{end}}
<form id="add" action="/append/{{.Dir}}{{.Base}}" method="POST">
<input type="hidden" name="body" value="{{range .Actual}}![]({{.}})
{{if .Uploads}}
<p>Previous uploads:
<p>{{range .Uploads}}
{{if .Image}}<img class="upload" src="/view/{{$.Dir}}{{.Path}}">{{else}}<a class="upload" href="/view/{{$.Dir}}{{.Path}}">{{end}}{{end}}
<form id="add" action="/append/{{.Dir}}{{.Path}}" method="POST">
<input type="hidden" name="body" value="{{range .Uploads}}{{if .Image}}!{{end}}[{{.Name}}]({{.Path}})
{{end}}">
<p>Append this to <a href="/view/{{.Dir}}{{.Base}}">{{.Title}}</a>?
<input type="submit" value="Add">
<input type="hidden" name="pagename" value="{{.Name}}">
<p>Append it to <a href="/view/{{.Dir}}{{.Path}}">{{.Title}}</a>?
<input type="submit" value="Add">
</form>
{{end}}
<form id="form" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<form id="upload" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>When uploading a picture from a phone, its filename is going to be something like IMG_1234.JPG.
Please provide your own filename. End the base name with "-1" to auto-increment.
Use <tt>.jpg</tt>, <tt>.png</tt> or <tt>.webp</tt> as the extension if you want to resize the picture.
<p><label for="name">Filename:</label>
<input id="name" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
<p><label for="filename">Filename:</label>
<input id="filename" name="filename" value="{{.FileName}}" type="text" placeholder="image-1.jpg" autofocus required>
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
Sadly, resizing only works when uploading <tt>.jpeg</tt>, <tt>.webp</tt>, <tt>.heic</tt> and <tt>.png</tt> files.
Feel free to specify a max width of 1200 pixels, for example.
@@ -105,12 +102,11 @@ window.addEventListener('load', uploadFiles.init);
<p>Finally, pick the files or photos to upload.
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
If you're uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p>You can rename or delete files <a href="/list/{{.Dir}}">from the file list</a>.
<p><label for="file">Pick files to upload:</label>
If youre uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">
<a href="/view/index"><button type="button">Cancel</button></a>
<a href="/view/{{.Dir}}{{.Path}}"><button type="button">Cancel</button></a>
<p>You can also paste images or drag and drop files.
</form>
</body>

View File

@@ -3,29 +3,30 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<link rel="alternate" type="application/rss+xml" title="Campaign Wiki: {{.Title}}" href="/view/{{.Path}}.rss" />
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #eed; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
label { display: inline-block; width: 10ch; }
input#search, input#id { width: 30ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #eed }
body { hyphens: auto }
header a { margin-right: 1ch }
h1 { text-wrap: balance }
label { display: inline-block; width: 10ch }
input#search, input#id { width: 30ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
</style>
</head>
<body>
<header id="view">
<header>
<a href="#main">Skip</a>
<a href="index">Home</a>
<a href="changes">Changes</a>
<a href="/edit/{{.Path}}" accesskey="e">Edit</a>
<a href="/add/{{.Path}}" accesskey="a">Add</a>
<a href="/diff/{{.Path}}" accesskey="d">Diff</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg&pagename={{.Base}}" accesskey="u">Upload</a>
<a href="/archive/{{.Dir}}data.zip" accesskey="u">Zip</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
@@ -38,7 +39,7 @@ img { max-width: 100%; }
<button>Edit</button>
</form>
</header>
<main id="main">
<main>
<h1>{{.Title}}</h1>
{{.Html}}
</main>

View File

@@ -6,16 +6,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>{{.Title}}</title>
<style>
html { font-family: sans-serif; font-size: large; max-width: 50ch; padding: 1ch; margin: auto; color: #111; background-color: #f9f9f9; }
body { hyphens: auto; font-size: large; }
header a { margin-right: 1ch; }
label { width: 7ch; display: inline-block; }
#search { width: 30ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { font-family: sans-serif; font-size: large; max-width: 50ch; padding: 1ch; margin: auto; color: #111; background-color: #f9f9f9 }
body { hyphens: auto; font-size: large }
header a { margin-right: 1ch }
label { width: 7ch; display: inline-block }
#search { width: 30ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
textarea { width: 97%; margin-top: 1ch 0 0 0; padding: 0 0.5ch; font: inherit;
border-radius: 6px; border: 1px outset #eee; }
p { margin: 0.5ch 0 0 0; }
#send { float: right; font-size: large; }
border-radius: 6px; border: 1px outset #eee }
p { margin: 0.5ch 0 0 0 }
#send { float: right; font-size: large }
</style>
</head>
<body>

View File

@@ -3,17 +3,17 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Search for {{.Query}}</title>
<style>
html { font-family: sans-serif; font-size: large; max-width: 40ch; padding: 1ch; margin: auto; color: #111; background-color: #f9f9f9; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
label { width: 7ch; display: inline-block; }
#search { width: 30ch; }
button { font-size: large; background-color: #eee; color: inherit; border-radius: 6px; border-width: 1px; }
html { font-family: sans-serif; font-size: large; max-width: 40ch; padding: 1ch; margin: auto; color: #111; background-color: #f9f9f9 }
body { hyphens: auto }
header a { margin-right: 1ch }
label { width: 7ch; display: inline-block }
#search { width: 30ch }
button { font-size: large; background-color: #eee; color: inherit; border-radius: 6px; border-width: 1px }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8; }
.score { font-size: smaller; opacity: 0.8 }
</style>
</head>
<body>

View File

@@ -3,16 +3,72 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Upload</title>
<style>
html { max-width: 50ch; padding: 2ch; margin: auto; color: #000; background-color: #f9f9f9; }
body { hyphens: auto; }
form, textarea { width: 100%; }
html { max-width: 50ch; padding: 2ch; margin: auto; color: #000; background-color: #f9f9f9 }
body { hyphens: auto }
form, textarea { width: 100% }
label { display: inline-block; width: 7ch }
.last { max-width: 100%; }
#name { width: 25ch; }
.upload { max-width: 100% }
#name { width: 25ch }
</style>
<script type="text/javascript">
var uploadFiles = {
init: function() {
let e = document.getElementById('upload');
if (e) {
e.addEventListener('paste', uploadFiles.pasteHandler);
e.addEventListener('dragover', e => e.preventDefault());
e.addEventListener('drop', uploadFiles.dropHandler);
}
},
pasteHandler: function(e) {
uploadFiles.handle(e.clipboardData);
},
dropHandler: function(e) {
e.preventDefault();
uploadFiles.handle(e.dataTransfer);
},
handle: function(dataTransfer) {
let files = [];
if (dataTransfer.items) {
[...dataTransfer.items].forEach((item, i) => {
if (item.kind === "file")
files.push(item.getAsFile());
});
} else {
[...dataTransfer.files].forEach((file, i) => {
files.push(file);
});
}
if (files.length)
uploadFiles.post(files)
},
post: function(files) {
let action = document.getElementById('upload').getAttribute('action');
var fd = new FormData();
fd.append("filename", document.getElementById('filename').value);
fd.append("maxwidth", document.getElementById('maxwidth').value);
fd.append("quality", document.getElementById('quality').value);
for (var i = 0; i < files.length; i++) {
fd.append("file", files[i]);
}
try {
fetch(action, { method: "POST", body: fd })
.then(response => {
if (response.ok) {
window.location = response.url;
} else {
alert(response.text);
}})
} catch (e) {
alert(e);
}
},
};
window.addEventListener('load', uploadFiles.init);
</script>
</head>
<body lang="en">
<header>
@@ -21,30 +77,30 @@ label { display: inline-block; width: 7ch }
</header>
<main>
<h1>Upload</h1>
{{if ne .Last ""}}
{{if .Image}}
<p><img class="last" src="/view/{{.Dir}}{{.Last}}">
{{end}}
<p>Use the following to post the image:
<pre>![]({{.Last}})</a></pre>
<form id="add" action="/append/{{.Dir}}{{.Base}}" method="POST">
<input type="hidden" name="body" value="{{range .Actual}}![]({{.}})
{{if .Uploads}}
<p>Previous uploads:
<p>{{range .Uploads}}
{{if .Image}}<img class="upload" src="/view/{{$.Dir}}{{.Path}}">{{else}}<a class="upload" href="/view/{{$.Dir}}{{.Path}}">{{end}}{{end}}
<form id="add" action="/append/{{.Dir}}{{.Path}}" method="POST">
<input type="hidden" name="body" value="{{range .Uploads}}{{if .Image}}!{{end}}[{{.Name}}]({{.Path}})
{{end}}">
<p>Append this to <a href="/view/{{.Dir}}{{.Base}}">{{.Title}}</a>?
<input type="submit" value="Add">
<input type="hidden" name="pagename" value="{{.Name}}">
<p>Append it to <a href="/view/{{.Dir}}{{.Path}}">{{.Title}}</a>?
<input type="submit" value="Add">
</form>
{{end}}
<form id="upload" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>What name to use for the uploads.
Make sure to increase the number at the end if you already uploaded images!
If you dont, your upload overwrites the existing images.
<p><label for="text">Name:</label>
<input id="name" name="name" value="{{.Name}}" type="text" autofocus required>
<input name="maxwidth" value="1200" type="hidden">
<input name="quality" value="75" type="hidden">
<p><label for="filename">Name:</label>
<input id="filename" name="filename" value="{{.FileName}}" type="text" placeholder="image-1.jpg" autofocus required>
<input id="maxwidth" name="maxwidth" value="1200" type="hidden">
<input id="quality" name="quality" value="75" type="hidden">
<p><label for="file">Photos:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Upload">
<a href="/view/{{.Dir}}{{.Path}}"><button type="button">Cancel</button></a>
</form>
<main>
</body>

View File

@@ -6,46 +6,46 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>{{.Title}}</title>
<style>
html { font-family: sans-serif; font-size: large; max-width: 40ch; padding: 1ch; margin: auto; color: #111; background-color: #f9f9f9; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
label { width: 7ch; display: inline-block; }
#search { width: 30ch; }
button { font-size: large; background-color: #eee; color: inherit; border-radius: 6px; border-width: 1px; }
main > *, footer { clear: both; }
html { font-family: sans-serif; font-size: large; max-width: 40ch; padding: 1ch; margin: auto; color: #111; background-color: #f9f9f9 }
body { hyphens: auto }
header a { margin-right: 1ch }
label { width: 7ch; display: inline-block }
#search { width: 30ch }
button { font-size: large; background-color: #eee; color: inherit; border-radius: 6px; border-width: 1px }
main > *, footer { clear: both }
main > p, main > ul, main > ol, main > dl {
float: right;
color: #000; background: #8fd;
padding: 3px 1ch; margin: 1pt auto 1pt 5ch;
border-radius: 6px; border: 1px outset #eee; }
border-radius: 6px; border: 1px outset #eee }
main blockquote, ul p, ol p, dl p {
padding: 0; margin: 0; }
padding: 0; margin: 0 }
main blockquote p {
float: left;
color: #000; background: #ccc;
padding: 3px 1ch; margin: 1pt 5ch 1pt 0;
border-radius: 6px; border: 1px outset #eee; }
border-radius: 6px; border: 1px outset #eee }
p + blockquote > p, blockquote + p {
margin-top: 5pt; }
margin-top: 5pt }
/* for the marker */
main ul {
padding-left: 2em; }
padding-left: 2em }
main ol {
display: table; }
display: table }
ol li {
counter-increment: list-item;
display: table-row; }
display: table-row }
ol li::before {
content: counter(list-item) ".\a0";
display: table-cell;
text-align: right; }
text-align: right }
/* footer */
footer p { margin: 0.5ch 0 0 0; }
footer p { margin: 0.5ch 0 0 0 }
textarea {
width: 97%; margin: 1ch 0 0 0; padding: 0 0.5ch; font: inherit;
border-radius: 6px; border: 1px outset #eee; }
#send { float: right; font-size: large; }
img { max-width: 100%; margin-top: 5px; }
border-radius: 6px; border: 1px outset #eee }
#send { float: right; font-size: large }
img { max-width: 100%; margin-top: 5px }
</style>
</head>
<body>
@@ -53,7 +53,7 @@ img { max-width: 100%; margin-top: 5px; }
<a href="index">Home</a>
<a href="{{.Today}}" accesskey="t">Today</a>
<a href="/edit/{{.Path}}" accesskey="e">Edit</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg&pagename={{.Base}}" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Suchen:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>

View File

@@ -0,0 +1,6 @@
# Community Wiki theme
A green theme that sticks close to the default.
It has the "create new page" form field.
(Back up to the [list of themes](../index).)

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Add to {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto }
body { hyphens: auto; color: #111; background-color: #def4b5; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
form, textarea { box-sizing: border-box; width: 100%; font-size: inherit }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
</style>
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form id="editor" action="/append/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="{{.Language}}" autofocus required></textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Add">
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #def4b5 }
body { hyphens: auto }
del { background-color: #fab }
ins { background-color: #af8 }
pre { white-space: normal; background-color: white; border: 1px solid #eee; padding: 1ch }
</style>
</head>
<body>
<header>
<a href="/view/{{.Path}}">Back</a>
</header>
<main id="main">
<h1>{{.Title}}</h1>
<p>This is the diff between <a href="/view/{{.Path}}.md~">the backup</a> and <a href="/view/{{.Path}}.md">the current copy</a>.</p>
<pre>
{{.Diff}}
</pre>
</main>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<base href="/view/{{.Dir}}">
<title>Editing {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto }
body { hyphens: auto; color: #111; background-color: #def4b5; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
form, textarea { box-sizing: border-box; width: 100%; font-size: inherit }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
</style>
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form id="editor" action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="{{.Language}}" autofocus>{{printf "%s" .Body}}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Save">
<button formaction="/preview/{{.Path}}" type="submit">Preview</button>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<docs>http://blogs.law.harvard.edu/tech/rss</docs>
<title>{{.Title}}</title>
<link>https://campaignwiki.org/view/{{.Name}}</link>
<webMaster>alex@alexschroeder.ch (Alex Schroeder)</webMaster>
<atom:link href="https://campaignwiki.org/view/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
<description>This is the feed for the campaign wiki {{.Title}}.</description>
<image>
<url>https://campaignwiki.org/blue-mountain-logo.jpg</url>
<title>{{.Title}}</title>
<link>https://campaignwiki.org/view/{{.Name}}</link>
</image>
{{range .Items}}
<item>
<title>{{.Title}}</title>
<link>https://campaignwiki.org/view/{{.Name}}</link>
<guid>https://campaignwiki.org/view/{{.Name}}</guid>
<description>{{.Html}}</description>
<pubDate>{{.Date}}</pubDate>
{{range .Hashtags}}
<category>{{.}}</category>
{{end}}
</item>
{{end}}
</channel>
</rss>

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Preview: {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #def4b5 }
body { hyphens: auto }
header a { margin-right: 1ch }
h1 { text-wrap: balance; background-color: #517005; color: #fff; padding: 0.2ch; margin-top: 0.1 }
header { line-height: 1.6 }
footer { background-color: #cd9; border-bottom:solid; margin: 3em 0 0 0; padding: 1ch; border-top:2px solid }
input[type=text] { width: 25ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100% }
</style>
</head>
<body>
<header>
<a href="#edit">Skip</a>
</header>
<main>
<h1>Previewing {{.Title}}</h1>
{{.Html}}
</main>
<hr>
<section id="edit">
<h2>Editing {{.Title}}</h2>
<form action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" lang="{{.Language}}" autofocus>{{printf "# %s\n\n%s" .Title .Body}}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Save">
<button formaction="/preview/{{.Path}}" type="submit">Preview</button>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</section>
</body>
</html>

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Search for {{.Query}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #def4b5 }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 20ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8 }
.image { display: inline-block; margin-right: 1em; max-width: calc(20% - 1em); font-size: small }
.image img { max-width: 100% }
</style>
</head>
<body>
<header>
<a href="#main">Skip</a>
<a href="/view/{{.Dir}}index">Home</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" value="{{.Query}}" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<button>Go</button>
</form>
</header>
<main id="main">
<h1>Search for {{.Query}}</h1>
{{if .Results}}
<p>
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">First</a>{{end}}
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
Page {{.Page}}
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{range .Items}}
<article lang="{{.Language}}">
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
{{range .Images}}
<p class="image"><a href="/view/{{.Path}}"><img loading="lazy" src="/view/{{.Path}}"></a><br/>{{.Html}}
{{end}}
</article>
{{end}}
<p>
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">First</a>{{end}}
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
Page {{.Page}}
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{else}}
<p>No results.</p>
{{end}}
</main>
</body>
</html>

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #def4b5 }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 12ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100% }
</style>
</head>
<body>
<main id="main">
<h1>{{.Title}}</h1>
{{.Html}}
</main>
<footer>
<address>
Comments? Send mail to Alex Schroeder <<a href="mailto:alex@alexschroeder.ch">alex@alexschroeder.ch</a>>
</address>
</footer>
</body>
</html>

View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Upload File</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #def4b5 }
body { hyphens: auto }
form, textarea { width: 100% }
label { display: inline-block; width: 20ch }
input [type=text] { width: 30ch }
.last { max-width: 20% }
</style>
<script type="text/javascript">
var uploadFiles = {
init: function() {
let e = document.getElementById('upload');
if (e) {
e.addEventListener('paste', uploadFiles.pasteHandler);
e.addEventListener('dragover', e => e.preventDefault());
e.addEventListener('drop', uploadFiles.dropHandler);
}
},
pasteHandler: function(e) {
uploadFiles.handle(e.clipboardData);
},
dropHandler: function(e) {
e.preventDefault();
uploadFiles.handle(e.dataTransfer);
},
handle: function(dataTransfer) {
let files = [];
if (dataTransfer.items) {
[...dataTransfer.items].forEach((item, i) => {
if (item.kind === "file")
files.push(item.getAsFile());
});
} else {
[...dataTransfer.files].forEach((file, i) => {
files.push(file);
});
}
if (files.length)
uploadFiles.post(files)
},
post: function(files) {
let action = document.getElementById('upload').getAttribute('action');
var fd = new FormData();
fd.append("filename", document.getElementById('filename').value);
fd.append("maxwidth", document.getElementById('maxwidth').value);
fd.append("quality", document.getElementById('quality').value);
for (var i = 0; i < files.length; i++) {
fd.append("file", files[i]);
}
try {
fetch(action, { method: "POST", body: fd })
.then(response => {
if (response.ok) {
window.location = response.url;
} else {
alert(response.text);
}})
} catch (e) {
alert(e);
}
},
};
window.addEventListener('load', uploadFiles.init);
</script>
</head>
<body lang="en">
<h1>Upload Files</h1>
{{if .Uploads}}
<p>Previous uploads:
<p>{{range .Uploads}}
{{if .Image}}<img class="upload" src="/view/{{$.Dir}}{{.Path}}">{{else}}<a class="upload" href="/view/{{$.Dir}}{{.Path}}">{{end}}{{end}}
<form id="add" action="/append/{{.Dir}}{{.Path}}" method="POST">
<input type="hidden" name="body" value="{{range .Uploads}}{{if .Image}}!{{end}}[{{.Name}}]({{.Path}})
{{end}}">
<input type="hidden" name="pagename" value="{{.Name}}">
<p>Append it to <a href="/view/{{.Dir}}{{.Path}}">{{.Title}}</a>?
<input type="submit" value="Add">
</form>
{{end}}
<form id="upload" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>When uploading a picture from a phone, its filename is going to be something like IMG_1234.JPG.
Please provide your own filename. End the base name with "-1" to auto-increment.
Use <tt>.jpg</tt>, <tt>.png</tt> or <tt>.webp</tt> as the extension if you want to resize the picture.
<p><label for="filename">Filename:</label>
<input id="filename" name="filename" value="{{.FileName}}" type="text" placeholder="image-1.jpg" autofocus required>
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
Sadly, resizing only works when uploading <tt>.jpeg</tt>, <tt>.webp</tt>, <tt>.heic</tt> and <tt>.png</tt> files.
Feel free to specify a max width of 1200 pixels, for example.
<p><label for="maxwidth">Max width:</label>
<input id="maxwidth" name="maxwidth" value="{{.MaxWidth}}" type="number" min="10" placeholder="1200">
<p>If the filename you provided above ends in <tt>.jpg</tt> or <tt>.webp</tt>, you can specify a quality.
Typically, a quality of 60 is not too bad and a quality of 90 is more than enough.
<p><label for="quality">Quality:</label>
<input id="quality" name="quality" value="{{.Quality}}" type="number" min="1" max="99" placeholder="75">
<p>Finally, pick the files or photos to upload.
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
If youre uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">
<a href="/view/{{.Dir}}{{.Path}}"><button type="button">Cancel</button></a>
<p>You can also paste images or drag and drop files.
</form>
</body>
</html>

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>{{.Title}}</title>
<link rel="alternate" type="application/rss+xml" title="Campaign Wiki: {{.Title}}" href="/view/{{.Name}}.rss" />
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #def4b5 }
body { hyphens: auto }
header a { margin-right: 1ch }
h1 { text-wrap: balance; background-color: #517005; color: #fff; padding: 0.2ch; margin-top: 0.1 }
header { line-height: 1.6 }
footer { background-color: #cd9; border-bottom:solid; margin: 3em 0 0 0; padding: 1ch; border-top:2px solid }
label { display: inline-block; width: 10ch }
input[type=text] { width: 25ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100% }
</style>
</head>
<body>
<header id="view">
<a href="#main">Skip</a>
<a href="index">Home</a>
<a href="changes">Changes</a>
<a href="/edit/{{.Name}}" accesskey="e">Edit</a>
<a href="/add/{{.Name}}" accesskey="a">Add</a>
<a href="/diff/{{.Name}}" accesskey="d">Diff</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
<a href="/archive/{{.Dir}}data.zip" accesskey="u">Zip</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<button>Go</button>
</form>
<form action="/edit/{{.Dir}}" method="GET">
<label for="id">New page:</label>
<input id="id" type="text" spellcheck="false" name="id" accesskey="g" value="{{.Today}}" required>
<button>Edit</button>
</form>
</header>
<main id="main">
<h1>{{.Title}}</h1>
{{.Html}}
</main>
<footer>
<address>
Comments? Send mail to Alex Schroeder &lt;<a href="mailto:alex@alexschroeder.ch">alex@alexschroeder.ch</a>&gt;
</address>
</footer>
</body>
</html>

View File

@@ -3,11 +3,11 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Add to {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eee; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eee }
form, textarea { width: 100% }
</style>
</head>
<body>

View File

@@ -3,11 +3,11 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #eee; }
body { hyphens: auto; }
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #eee }
body { hyphens: auto }
del { background-color: #fab }
ins { background-color: #af8 }
pre { white-space: normal; background-color: white; border: 1px solid #eee; padding: 1ch }

View File

@@ -3,16 +3,20 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Bearbeiten von {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eee; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto }
body { hyphens: auto; color: #111; background-color: #eee; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee }
form, textarea { box-sizing: border-box; width: 100%; font-size: inherit }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
</style>
</head>
<body>
<h1>Bearbeiten von {{.Title}}</h1>
<form action="/save/{{.Path}}" method="POST">
<form id="editor" action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="" autofocus>{{printf "%s" .Body}}</textarea>

View File

@@ -3,18 +3,18 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Suche nach {{.Query}}</title>
<style>
html { max-width: 65ch; padding: 2ch; margin: auto; color: #111; background-color: #eee; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 20ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
img { max-width: 20%; }
html { max-width: 65ch; padding: 2ch; margin: auto; color: #111; background-color: #eee }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 20ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
img { max-width: 20% }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8; }
.score { font-size: smaller; opacity: 0.8 }
</style>
</head>
<body>

View File

@@ -3,17 +3,17 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #eee; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #eee }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 12ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
</style>
</head>
<body>

View File

@@ -3,12 +3,12 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Upload File</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eee; }
body { hyphens: auto; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eee }
body { hyphens: auto }
form, textarea { width: 100% }
label { display: inline-block; width: 20ch }
input [type=text] { width: 30ch }
.last { max-width: 20% }
@@ -16,7 +16,7 @@ input [type=text] { width: 30ch }
<script type="text/javascript">
var uploadFiles = {
init: function() {
let e = document.getElementById('form');
let e = document.getElementById('upload');
if (e) {
e.addEventListener('paste', uploadFiles.pasteHandler);
e.addEventListener('dragover', e => e.preventDefault());
@@ -46,9 +46,9 @@ var uploadFiles = {
uploadFiles.post(files)
},
post: function(files) {
let action = document.getElementById('form').getAttribute('action');
let action = document.getElementById('upload').getAttribute('action');
var fd = new FormData();
fd.append("name", document.getElementById('name').value);
fd.append("filename", document.getElementById('filename').value);
fd.append("maxwidth", document.getElementById('maxwidth').value);
fd.append("quality", document.getElementById('quality').value);
for (var i = 0; i < files.length; i++) {
@@ -72,45 +72,41 @@ window.addEventListener('load', uploadFiles.init);
</head>
<body lang="en">
<h1>Upload Files</h1>
{{if ne .Last ""}}
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a>
{{if .Image}}
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"><br>
Links:<tt>{{range .Actual}}<br>![]({{.}}){{end}}</tt>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{end}}
<form id="add" action="/append/{{.Dir}}{{.Base}}" method="POST">
<input type="hidden" name="body" value="{{range .Actual}}![]({{.}})
{{if .Uploads}}
<p>Previous uploads:
<p>{{range .Uploads}}
{{if .Image}}<img class="upload" src="/view/{{$.Dir}}{{.Path}}">{{else}}<a class="upload" href="/view/{{$.Dir}}{{.Path}}">{{end}}{{end}}
<form id="add" action="/append/{{.Dir}}{{.Path}}" method="POST">
<input type="hidden" name="body" value="{{range .Uploads}}{{if .Image}}!{{end}}[{{.Name}}]({{.Path}})
{{end}}">
<p>Append this to <a href="/view/{{.Dir}}{{.Base}}">{{.Title}}</a>?
<input type="submit" value="Add">
<input type="hidden" name="pagename" value="{{.Name}}">
<p>Append it to <a href="/view/{{.Dir}}{{.Path}}">{{.Title}}</a>?
<input type="submit" value="Add">
</form>
{{end}}
<form id="form" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<form id="upload" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>When uploading a picture from a phone, its filename is going to be something like IMG_1234.JPG.
Please provide your own filename. End the base name with "-1" to auto-increment.
Use <tt>.jpg</tt>, <tt>.png</tt> or <tt>.webp</tt> as the extension if you want to resize the picture.
<p><label for="name">Filename:</label>
<input id="name" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
<p><label for="filename">Filename:</label>
<input id="filename" name="filename" value="{{.FileName}}" type="text" placeholder="image-1.jpg" autofocus required>
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
Sadly, resizing only works when uploading <tt>.jpeg</tt>, <tt>.webp</tt>, <tt>.heic</tt> and <tt>.png</tt> files.
Feel free to specify a max width of 1200 pixels, for example.
<p><label for="maxwidth">Max width:</label>
<input id="maxwidth" name="maxwidth" value="{{.MaxWidth}}" type="number" min="10" placeholder="1200">
<p>If the filename you provided above ends in <tt>.jpg</tt>, you can specify a quality.
<p>If the filename you provided above ends in <tt>.jpg</tt> or <tt>.webp</tt>, you can specify a quality.
Typically, a quality of 60 is not too bad and a quality of 90 is more than enough.
<p><label for="quality">Quality:</label>
<input id="quality" name="quality" value="{{.Quality}}" type="number" min="1" max="99" placeholder="75">
<p>Finally, pick the files or photos to upload.
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
If you're uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p>To delete a file, upload an empty file.
<p><label for="file">Pick files to upload:</label>
If youre uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">
<a href="/view/index"><button type="button">Cancel</button></a>
<a href="/view/{{.Dir}}{{.Path}}"><button type="button">Cancel</button></a>
<p>You can also paste images or drag and drop files.
</form>
</body>

View File

@@ -3,24 +3,24 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #eee; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
label { width: 7ch; display: inline-block; }
input { width: 30ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #eee }
body { hyphens: auto }
header a { margin-right: 1ch }
label { width: 7ch; display: inline-block }
input { width: 30ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
</style>
</head>
<body>
<header>
<a href="/view/index">Willkommen</a>
<a href="/edit/{{.Path}}" accesskey="e">Bearbeiten</a>
<a href="/upload/{{.Dir}}" accesskey="u">Upload</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg&pagename={{.Base}}" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Suchen:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>

View File

@@ -6,6 +6,7 @@ your own sites.
- [themes/alexschroeder.ch](alexschroeder.ch/README)
- [themes/campaignwiki.org](campaignwiki.org/README)
- [themes/communitywiki.org](communitywiki.org/README)
- [themes/flying-carpet.ch](flying-carpet.ch/README)
- [themes/transjovian.org](transjovian.org/README)

View File

@@ -18,4 +18,6 @@ links to other pages: On a line by itself (no inline links!) write
"=>", a space, the URL, and optionally another space and the text to
use.
Unfortunately, this magic happens in JavaScript. 😭
=> ../index Themes

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Editing {{.Title}}</title>
<style>
form, textarea { width: 100% }

View File

@@ -3,17 +3,17 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Search for {{.Query}}</title>
<style>
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 20ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 20ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8; }
.image { display: inline-block; margin-right: 1em; max-width: calc(20% - 1em); font-size: small; }
.image img { max-width: 100%; }
.score { font-size: smaller; opacity: 0.8 }
.image { display: inline-block; margin-right: 1em; max-width: calc(20% - 1em); font-size: small }
.image img { max-width: 100% }
</style>
</head>
<body>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
label { display: inline-block; width: 10ch }

View File

@@ -3,13 +3,13 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Add to {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222 }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee; }
form, textarea { width: 100%; }
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee }
form, textarea { width: 100% }
</style>
</head>
<body>

View File

@@ -3,12 +3,12 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #ddd; background-color: #222; }
html { max-width: 70ch; padding: 1ch; margin: auto; color: #ddd; background-color: #222 }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
body { hyphens: auto; }
body { hyphens: auto }
del { background-color: #fab }
ins { background-color: #af8 }
pre { white-space: normal; color: #222; background-color: #ddd; border: 1px solid #eee; padding: 1ch }

View File

@@ -3,18 +3,21 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Editing {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto }
body { hyphens: auto; color: #ddd; background-color: #222; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee; }
form, textarea { width: 100%; }
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee }
form, textarea { box-sizing: border-box; width: 100%; font-size: inherit }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
</style>
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Path}}" method="POST">
<form id="editor" action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="" autofocus>{{printf "%s" .Body}}</textarea>

View File

@@ -3,24 +3,24 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Search for {{.Query}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222 }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 20ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
img { max-width: 20%; }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 20ch }
button { background-color: #eee; color: #222; border-radius: 4px; border-width: 1px }
img { max-width: 20% }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8; }
.score { font-size: smaller; opacity: 0.8 }
</style>
</head>
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="#main">Skip</a>
<a href="/view/index">Home</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>

View File

@@ -3,18 +3,18 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #ddd; background-color: #222; }
html { max-width: 65ch; padding: 1ch; margin: auto; color: #ddd; background-color: #222 }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 12ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
</style>
</head>
<body>

View File

@@ -3,14 +3,14 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Upload File</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222 }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
body { hyphens: auto; }
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee; }
form, textarea { width: 100%; }
body { hyphens: auto }
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee }
form, textarea { width: 100% }
label { display: inline-block; width: 20ch }
input [type=text] { width: 30ch }
.last { max-width: 20% }
@@ -18,7 +18,7 @@ input [type=text] { width: 30ch }
<script type="text/javascript">
var uploadFiles = {
init: function() {
let e = document.getElementById('form');
let e = document.getElementById('upload');
if (e) {
e.addEventListener('paste', uploadFiles.pasteHandler);
e.addEventListener('dragover', e => e.preventDefault());
@@ -48,7 +48,7 @@ var uploadFiles = {
uploadFiles.post(files)
},
post: function(files) {
let action = document.getElementById('form').getAttribute('action');
let action = document.getElementById('upload').getAttribute('action');
var fd = new FormData();
fd.append("name", document.getElementById('name').value);
fd.append("maxwidth", document.getElementById('maxwidth').value);
@@ -74,27 +74,24 @@ window.addEventListener('load', uploadFiles.init);
</head>
<body lang="en">
<h1>Upload Files</h1>
{{if ne .Last ""}}
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a>
{{if .Image}}
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"><br>
Links:<tt>{{range .Actual}}<br>![]({{.}}){{end}}</tt>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{end}}
<form id="add" action="/append/{{.Dir}}{{.Base}}" method="POST">
<input type="hidden" name="body" value="{{range .Actual}}![]({{.}})
{{if .Uploads}}
<p>Previous uploads:
<p>{{range .Uploads}}
{{if .Image}}<img class="upload" src="/view/{{$.Dir}}{{.Path}}">{{else}}<a class="upload" href="/view/{{$.Dir}}{{.Path}}">{{end}}{{end}}
<form id="add" action="/append/{{.Dir}}{{.Path}}" method="POST">
<input type="hidden" name="body" value="{{range .Uploads}}{{if .Image}}!{{end}}[{{.Name}}]({{.Path}})
{{end}}">
<p>Append this to <a href="/view/{{.Dir}}{{.Base}}">{{.Title}}</a>?
<input type="submit" value="Add">
<input type="hidden" name="pagename" value="{{.Name}}">
<p>Append it to <a href="/view/{{.Dir}}{{.Path}}">{{.Title}}</a>?
<input type="submit" value="Add">
</form>
{{end}}
<form id="form" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<form id="upload" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>When uploading a picture from a phone, its filename is going to be something like IMG_1234.JPG.
Please provide your own filename. End the base name with "-1" to auto-increment.
Use <tt>.jpg</tt>, <tt>.png</tt> or <tt>.webp</tt> as the extension if you want to resize the picture.
<p><label for="name">Filename:</label>
<input id="name" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
<p><label for="filename">Filename:</label>
<input id="filename" name="filename" value="{{.FileName}}" type="text" placeholder="image-1.jpg" autofocus required>
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
Sadly, resizing only works when uploading <tt>.jpeg</tt>, <tt>.webp</tt>, <tt>.heic</tt> and <tt>.png</tt> files.
Feel free to specify a max width of 1200 pixels, for example.
@@ -107,12 +104,11 @@ window.addEventListener('load', uploadFiles.init);
<p>Finally, pick the files or photos to upload.
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
If you're uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p>To delete a file, upload an empty file.
<p><label for="file">Pick files to upload:</label>
If youre uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">
<a href="/view/index"><button type="button">Cancel</button></a>
<a href="/view/{{.Dir}}{{.Path}}"><button type="button">Cancel</button></a>
<p>You can also paste images or drag and drop files.
</form>
</body>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #ddd; background-color: #222 }
@@ -12,9 +12,9 @@ input, button { color: #222; background-color: #ddd; border: 1px solid #eee }
body { hyphens: auto }
header a { margin-right: 1ch }
h1 { text-wrap: balance }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
form { display: inline-block }
input#search { width: 12ch }
button { background-color: #eee; color: #222; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100% }
</style>
@@ -23,18 +23,19 @@ img { max-width: 100% }
<header>
<a href="#main">Skip</a>
<a href="/view/index">Home</a>
<a href="changes">Changes</a>
<a href="/edit/{{.Path}}" accesskey="e">Edit</a>
<a href="/add/{{.Path}}" accesskey="a">Add</a>
<a href="/diff/{{.Path}}" accesskey="d">Diff</a>
<a href="/view/{{.Path}}.md" accesskey="r">Raw</a>
<a href="/upload/{{.Dir}}" accesskey="u">Upload</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg&pagename={{.Base}}" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<input type="submit" value="Go"/>
<button>Go</button>
</form>
</header>
<main id="main">
<main>
<h1>{{.Title}}</h1>
{{.Html}}
</main>

View File

@@ -3,12 +3,12 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Upload File</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
form, textarea { width: 100% }
label { display: inline-block; width: 20ch }
input [type=text] { width: 30ch }
.last { max-width: 20% }
@@ -48,7 +48,7 @@ var uploadFiles = {
post: function(files) {
let action = document.getElementById('upload').getAttribute('action');
var fd = new FormData();
fd.append("name", document.getElementById('name').value);
fd.append("filename", document.getElementById('filename').value);
fd.append("maxwidth", document.getElementById('maxwidth').value);
fd.append("quality", document.getElementById('quality').value);
for (var i = 0; i < files.length; i++) {
@@ -72,33 +72,30 @@ window.addEventListener('load', uploadFiles.init);
</head>
<body lang="en">
<h1>Upload Files</h1>
{{if ne .Last ""}}
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a>
{{if .Image}}
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"><br>
Links:<tt>{{range .Actual}}<br>![]({{.}}){{end}}</tt>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{end}}
<form id="add" action="/append/{{.Dir}}{{.Base}}" method="POST">
<input type="hidden" name="body" value="{{range .Actual}}![]({{.}})
{{if .Uploads}}
<p>Previous uploads:
<p>{{range .Uploads}}
{{if .Image}}<img class="upload" src="/view/{{$.Dir}}{{.Path}}">{{else}}<a class="upload" href="/view/{{$.Dir}}{{.Path}}">{{end}}{{end}}
<form id="add" action="/append/{{.Dir}}{{.Path}}" method="POST">
<input type="hidden" name="body" value="{{range .Uploads}}{{if .Image}}!{{end}}[{{.Name}}]({{.Path}})
{{end}}">
<p>Append this to <a href="/view/{{.Dir}}{{.Base}}">{{.Title}}</a>?
<input type="submit" value="Add">
<input type="hidden" name="pagename" value="{{.Name}}">
<p>Append it to <a href="/view/{{.Dir}}{{.Path}}">{{.Title}}</a>?
<input type="submit" value="Add">
</form>
{{end}}
<form id="upload" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>When uploading a picture from a phone, its filename is going to be something like IMG_1234.JPG.
Please provide your own filename. End the base name with "-1" to auto-increment.
Use <tt>.jpg</tt>, <tt>.png</tt> or <tt>.webp</tt> as the extension if you want to resize the picture.
<p><label for="name">Filename:</label>
<input id="name" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
<p><label for="filename">Filename:</label>
<input id="filename" name="filename" value="{{.FileName}}" type="text" placeholder="image-1.jpg" autofocus required>
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
Sadly, resizing only works when uploading <tt>.jpeg</tt>, <tt>.webp</tt>, <tt>.heic</tt> and <tt>.png</tt> files.
Feel free to specify a max width of 1200 pixels, for example.
<p><label for="maxwidth">Max width:</label>
<input id="maxwidth" name="maxwidth" value="{{.MaxWidth}}" type="number" min="10" placeholder="1200">
<p>If the filename you provided above ends in <tt>.jpg</tt>, you can specify a quality.
<p>If the filename you provided above ends in <tt>.jpg</tt> or <tt>.webp</tt>, you can specify a quality.
Typically, a quality of 60 is not too bad and a quality of 90 is more than enough.
<p><label for="quality">Quality:</label>
<input id="quality" name="quality" value="{{.Quality}}" type="number" min="1" max="99" placeholder="75">
@@ -106,11 +103,10 @@ window.addEventListener('load', uploadFiles.init);
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
If youre uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p>You can delete and rename files <a href="/list/{{.Dir}}">from the file list</a>.
<p><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">
<a href="/view/index"><button type="button">Cancel</button></a>
<a href="/view/{{.Dir}}{{.Path}}"><button type="button">Cancel</button></a>
<p>You can also paste images or drag and drop files.
</form>
</body>

View File

@@ -26,14 +26,18 @@ import (
"time"
)
type upload struct {
type Upload struct {
Dir string
FileName string
Name string
Last string
Image bool
MaxWidth string
Quality string
Actual []string
Uploads []FileUpload
}
type FileUpload struct {
Name string
Image bool
}
var lastRe = regexp.MustCompile(`^(.*?)([0-9]+)([^0-9]*)$`)
@@ -43,7 +47,8 @@ var baseRe = regexp.MustCompile(`^(.*?)-[0-9]+$`)
// parameters are used to copy name, maxwidth and quality from the previous upload. If the previous name contains a
// number, this is incremented by one.
func uploadHandler(w http.ResponseWriter, r *http.Request, dir string) {
data := &upload{Dir: dir}
data := &Upload{Dir: pathEncode(dir)}
var err error
maxwidth := r.FormValue("maxwidth")
if maxwidth != "" {
data.MaxWidth = maxwidth
@@ -52,26 +57,37 @@ func uploadHandler(w http.ResponseWriter, r *http.Request, dir string) {
if quality != "" {
data.Quality = quality
}
name := r.FormValue("filename")
if isHiddenName(name) {
filename := r.FormValue("filename")
if isHiddenName(filename) {
http.Error(w, "the file would be hidden", http.StatusForbidden)
return
}
var err error
if name != "" {
data.Name, err = next(filepath.FromSlash(dir), name, 0)
} else if last := r.FormValue("last"); last != "" {
data.Last = last
mimeType := mime.TypeByExtension(path.Ext(last))
data.Image = strings.HasPrefix(mimeType, "image/")
data.Name, err = next(filepath.FromSlash(dir), last, 1)
data.Actual = r.Form["actual"]
if filename == "" {
filename = "image-1.jpg"
}
filename, err = next(filepath.FromSlash(dir), filename, 0)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
http.Error(w, "cannot determine filename", http.StatusInternalServerError)
return
}
data.FileName = filename
name := r.FormValue("pagename")
if isHiddenName(name) {
http.Error(w, "the page would be hidden", http.StatusForbidden)
return
}
if name != "" {
data.Name = name
} else {
data.Name = basename(filename)
}
data.Uploads = make([]FileUpload, len(r.Form["uploads"]))
for i, s := range r.Form["uploads"] {
data.Uploads[i].Name = s
mimeType := mime.TypeByExtension(path.Ext(s))
data.Uploads[i].Image = strings.HasPrefix(mimeType, "image/")
}
renderTemplate(w, dir, "upload", data)
}
@@ -123,7 +139,7 @@ func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
return
}
data := url.Values{}
fn := r.FormValue("name")
fn := r.FormValue("filename")
// This is like the id query parameter: it may not contain any slashes, so it's a path and a filepath.
if strings.Contains(fn, "/") {
http.Error(w, "the file may not contain slashes", http.StatusBadRequest)
@@ -133,6 +149,21 @@ func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
http.Error(w, "the file would be hidden", http.StatusForbidden)
return
}
data.Set("filename", fn)
pn := r.FormValue("pagename")
if pn != "" {
data.Set("pagename", pn)
}
// This is like the id query parameter: it may not contain any slashes, so it's a path and a filepath.
if strings.Contains(fn, "/") {
http.Error(w, "the file may not contain slashes", http.StatusBadRequest)
return
}
if isHiddenName(fn) {
http.Error(w, "the file would be hidden", http.StatusForbidden)
return
}
data.Set("filename", fn)
// Quality is a number. If no quality is set and a quality is required, 75 is used.
q := 75
quality := r.FormValue("quality")
@@ -247,7 +278,7 @@ func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
log.Println("Copied", fp)
}
}
data.Add("actual", fn)
data.Add("uploads", fn)
username, _, ok := r.BasicAuth()
if ok {
log.Println("Saved", filepath.ToSlash(fp), "by", username)
@@ -256,29 +287,38 @@ func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
}
updateTemplate(fp)
}
data.Set("last", fn) // has no slashes
http.Redirect(w, r, "/upload/" + nameEscape(dir) + "?" + data.Encode(), http.StatusFound)
}
// Base returns a page name matching the first uploaded file: no extension and no appended number. If the name
// refers to a directory, returns "index". This is used to create the form target in "upload.html", for example.
func (u *upload) Base() string {
n := u.Name[:strings.LastIndex(u.Name, ".")]
m := baseRe.FindStringSubmatch(n)
// basename returns a name matching the uploaded file but with no extension and no appended number. Given an uploaded
// file "example-1.jpg" this returns "example".
func basename(s string) string {
e := strings.LastIndex(s, ".")
if e > 0 {
s = s[:e]
}
m := baseRe.FindStringSubmatch(s)
if m != nil {
return m[1]
}
if n == "." {
return "index"
}
return n
return s
}
// Title returns the title of the matching page, if it exists.
func (u *upload) Title() string {
// Path returns the Name with some special characters percent-escaped.
func (u *Upload) Path() string {
return pathEncode(u.Name)
}
// Path returns the Name with some special characters percent-escaped.
func (f *FileUpload) Path() string {
return pathEncode(f.Name)
}
// Title returns the title of the matching page. If the page does not exist, the page name is returned.
func (u *Upload) Title() string {
index.RLock()
defer index.RUnlock()
name := path.Join(u.Dir, u.Base())
name := path.Join(u.Dir, u.Name)
title, ok := index.titles[name]
if ok {
return title
@@ -287,6 +327,6 @@ func (u *upload) Title() string {
}
// Today returns the date, as a string, for use in templates.
func (u *upload) Today() string {
func (u *Upload) Today() string {
return time.Now().Format(time.DateOnly)
}

View File

@@ -11,6 +11,7 @@ import (
"image/png"
"mime"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
@@ -21,10 +22,10 @@ func TestUpload(t *testing.T) {
cleanup(t, "testdata/files")
// for uploads, the directory is not created automatically
os.MkdirAll("testdata/files", 0755)
assert.HTTPStatusCode(t, makeHandler(uploadHandler, false), "GET", "/upload/testdata/files/", nil, 200)
assert.HTTPStatusCode(t, makeHandler(uploadHandler, false, http.MethodGet), "GET", "/upload/testdata/files/", nil, 200)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, err := writer.CreateFormField("name")
field, err := writer.CreateFormField("filename")
assert.NoError(t, err)
_, err = field.Write([]byte("ok.txt"))
assert.NoError(t, err)
@@ -34,10 +35,10 @@ func TestUpload(t *testing.T) {
assert.NoError(t, err)
err = writer.Close()
assert.NoError(t, err)
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/files/",
writer.FormDataContentType(), form, "/upload/testdata/files/?actual=ok.txt&last=ok.txt")
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/files/",
writer.FormDataContentType(), form, "/upload/testdata/files/?filename=ok.txt&uploads=ok.txt")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/files/ok.txt", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/files/ok.txt", nil),
"Hello!")
}
@@ -47,14 +48,14 @@ func TestUploadPng(t *testing.T) {
os.MkdirAll("testdata/png", 0755)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field, _ := writer.CreateFormField("filename")
field.Write([]byte("ok.png"))
file, _ := writer.CreateFormFile("file", "ok.png")
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
png.Encode(file, img)
writer.Close()
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/png/",
writer.FormDataContentType(), form, "/upload/testdata/png/?actual=ok.png&last=ok.png")
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/png/",
writer.FormDataContentType(), form, "/upload/testdata/png/?filename=ok.png&uploads=ok.png")
}
func TestUploadJpg(t *testing.T) {
@@ -63,14 +64,14 @@ func TestUploadJpg(t *testing.T) {
os.MkdirAll("testdata/jpg", 0755)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field, _ := writer.CreateFormField("filename")
field.Write([]byte("ok.jpg"))
file, _ := writer.CreateFormFile("file", "ok.jpg")
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
jpeg.Encode(file, img, &jpeg.Options{Quality: 90})
writer.Close()
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/jpg/",
writer.FormDataContentType(), form, "/upload/testdata/jpg/?actual=ok.jpg&last=ok.jpg")
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/jpg/",
writer.FormDataContentType(), form, "/upload/testdata/jpg/?filename=ok.jpg&uploads=ok.jpg")
}
func TestUploadHeic(t *testing.T) {
@@ -79,7 +80,7 @@ func TestUploadHeic(t *testing.T) {
os.MkdirAll("testdata/heic", 0755)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field, _ := writer.CreateFormField("filename")
field.Write([]byte("ok.jpg")) // target
file, _ := writer.CreateFormFile("file", "ok.heic") // source
// convert -size 1x1 canvas: heic:- | base64
@@ -97,8 +98,8 @@ YXQAAAApKAGvEyE1mvXho5qH3STtzcWnOxedwNIXAKNDaJNqz3uONoCHeUhi/HA=`
assert.NoError(t, err)
file.Write(img)
writer.Close()
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/heic/",
writer.FormDataContentType(), form, "/upload/testdata/heic/?actual=ok.jpg&last=ok.jpg")
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/heic/",
writer.FormDataContentType(), form, "/upload/testdata/heic/?filename=ok.jpg&uploads=ok.jpg")
fp := "testdata/heic/ok.jpg"
fi, err := os.Open(fp)
assert.NoError(t, err)
@@ -114,14 +115,14 @@ func TestUploadWebp(t *testing.T) {
os.MkdirAll("testdata/webp", 0755)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field, _ := writer.CreateFormField("filename")
field.Write([]byte("ok.jpg")) // target
file, _ := writer.CreateFormFile("file", "ok.webp") // source
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
webp.Encode(file, img)
writer.Close()
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/webp/",
writer.FormDataContentType(), form, "/upload/testdata/webp/?actual=ok.jpg&last=ok.jpg")
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/webp/",
writer.FormDataContentType(), form, "/upload/testdata/webp/?filename=ok.jpg&uploads=ok.jpg")
fp := "testdata/webp/ok.jpg"
fi, err := os.Open(fp)
assert.NoError(t, err)
@@ -137,14 +138,14 @@ func TestConvertToWebp(t *testing.T) {
os.MkdirAll("testdata/towebp", 0755)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field, _ := writer.CreateFormField("filename")
field.Write([]byte("ok.webp"))
file, _ := writer.CreateFormFile("file", "ok.png")
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
png.Encode(file, img)
writer.Close()
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/towebp/",
writer.FormDataContentType(), form, "/upload/testdata/towebp/?actual=ok.webp&last=ok.webp")
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/towebp/",
writer.FormDataContentType(), form, "/upload/testdata/towebp/?filename=ok.webp&uploads=ok.webp")
fp := "testdata/towebp/ok.webp"
fi, err := os.Open(fp)
assert.NoError(t, err)
@@ -167,13 +168,13 @@ What happened just now?`), 0644))
// delete it by upload a zero byte file
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field, _ := writer.CreateFormField("filename")
field.Write([]byte("nothing.txt"))
file, _ := writer.CreateFormFile("file", "test.txt")
file.Write([]byte(""))
writer.Close()
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/delete/",
writer.FormDataContentType(), form, "/upload/testdata/delete/?actual=nothing.txt&last=nothing.txt")
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/delete/",
writer.FormDataContentType(), form, "/upload/testdata/delete/?filename=nothing.txt&uploads=nothing.txt")
// check that it worked
assert.NoFileExists(t, "testdata/delete/nothing.txt")
}
@@ -188,17 +189,17 @@ But here: jasmin dreams`)}
p.save()
// check location for upload
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/multi/culture", nil)
assert.Contains(t, body, `href="/upload/testdata/multi/?filename=culture-1.jpg"`)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/multi/culture", nil)
assert.Contains(t, body, `href="/upload/testdata/multi/?filename=culture-1.jpg&pagename=culture"`)
// check location for drop
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", "/upload/testdata/multi/", nil)
body = assert.HTTPBody(makeHandler(uploadHandler, false, http.MethodGet), "GET", "/upload/testdata/multi/", nil)
assert.Contains(t, body, `action="/drop/testdata/multi/"`)
// actually do the upload
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field, _ := writer.CreateFormField("filename")
field.Write([]byte("2023-10-02-hike-1.jpg"))
field, _ = writer.CreateFormField("maxwidth")
field.Write([]byte("15"))
@@ -208,17 +209,17 @@ But here: jasmin dreams`)}
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
jpeg.Encode(file, img, &jpeg.Options{Quality: 90})
writer.Close()
location := HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/testdata/multi/",
location := HTTPUploadLocation(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/multi/",
writer.FormDataContentType(), form)
url, _ := url.Parse(location)
assert.Equal(t, "/upload/testdata/multi/", url.Path, "Redirect to upload location")
values := url.Query()
assert.Equal(t, "2023-10-02-hike-1.jpg", values.Get("last"))
assert.Equal(t, "2023-10-02-hike-1.jpg", values.Get("uploads"))
assert.Equal(t, "15", values.Get("maxwidth"))
assert.Equal(t, "50", values.Get("quality"))
// check the result page
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", url.Path, values)
body = assert.HTTPBody(makeHandler(uploadHandler, false, http.MethodGet), "GET", url.Path, values)
assert.Contains(t, body, `value="2023-10-02-hike-2.jpg"`)
assert.Contains(t, body, `value="15"`)
assert.Contains(t, body, `value="50"`)
@@ -235,31 +236,31 @@ There is no answer`)}
p.save()
// check location for upload
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/dir/test", nil)
assert.Contains(t, body, `href="/upload/testdata/dir/?filename=test-1.jpg"`)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/test", nil)
assert.Contains(t, body, `href="/upload/testdata/dir/?filename=test-1.jpg&pagename=test"`)
// check location for drop
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", "/upload/testdata/dir/", nil)
body = assert.HTTPBody(makeHandler(uploadHandler, false, http.MethodGet), "GET", "/upload/testdata/dir/", nil)
assert.Contains(t, body, `action="/drop/testdata/dir/"`)
// actually do the upload
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field, _ := writer.CreateFormField("filename")
field.Write([]byte("test.jpg"))
file, _ := writer.CreateFormFile("file", "ok.jpg")
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
jpeg.Encode(file, img, &jpeg.Options{Quality: 90})
writer.Close()
location := HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/testdata/dir/",
location := HTTPUploadLocation(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/dir/",
writer.FormDataContentType(), form)
url, _ := url.Parse(location)
assert.Equal(t, "/upload/testdata/dir/", url.Path, "Redirect to upload location")
values := url.Query()
assert.Equal(t, "test.jpg", values.Get("last"))
assert.Equal(t, "test.jpg", values.Get("uploads"))
// check the result page
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", url.Path, values)
body = assert.HTTPBody(makeHandler(uploadHandler, false, http.MethodGet), "GET", url.Path, values)
assert.Contains(t, body, `src="/view/testdata/dir/test.jpg"`)
}
@@ -268,7 +269,7 @@ func TestUploadTwoInOne(t *testing.T) {
os.MkdirAll("testdata/two", 0755)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field, _ := writer.CreateFormField("filename")
field.Write([]byte("2024-02-19-hike-1.jpg"))
file1, _ := writer.CreateFormFile("file", "one.jpg")
img1 := image.NewRGBA(image.Rect(0, 0, 10, 10))
@@ -277,12 +278,13 @@ func TestUploadTwoInOne(t *testing.T) {
img2 := image.NewRGBA(image.Rect(0, 0, 20, 20))
jpeg.Encode(file2, img2, &jpeg.Options{Quality: 90})
writer.Close()
location := HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/testdata/two/",
location := HTTPUploadLocation(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/two/",
writer.FormDataContentType(), form)
url, _ := url.Parse(location)
assert.Equal(t, "/upload/testdata/two/", url.Path, "Redirect to upload location")
values := url.Query()
assert.Equal(t, "2024-02-19-hike-2.jpg", values.Get("last"))
assert.Equal(t, "2024-02-19-hike-1.jpg", values["uploads"][0])
assert.Equal(t, "2024-02-19-hike-2.jpg", values["uploads"][1])
// check the files
assert.FileExists(t, "testdata/two/2024-02-19-hike-1.jpg")
assert.FileExists(t, "testdata/two/2024-02-19-hike-2.jpg")
@@ -293,7 +295,7 @@ func TestUploadTwoInOneAgain(t *testing.T) {
os.MkdirAll("testdata/zwei", 0755)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field, _ := writer.CreateFormField("filename")
field.Write([]byte("image.jpg"))
file1, _ := writer.CreateFormFile("file", "one.jpg")
img1 := image.NewRGBA(image.Rect(0, 0, 10, 10))
@@ -302,12 +304,13 @@ func TestUploadTwoInOneAgain(t *testing.T) {
img2 := image.NewRGBA(image.Rect(0, 0, 20, 20))
jpeg.Encode(file2, img2, &jpeg.Options{Quality: 90})
writer.Close()
location := HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/testdata/zwei/",
location := HTTPUploadLocation(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/zwei/",
writer.FormDataContentType(), form)
url, _ := url.Parse(location)
assert.Equal(t, "/upload/testdata/zwei/", url.Path, "Redirect to upload location")
values := url.Query()
assert.Equal(t, "image-1.jpg", values.Get("last"))
assert.Equal(t, "image.jpg", values["uploads"][0])
assert.Equal(t, "image-1.jpg", values["uploads"][1])
// check the files
assert.FileExists(t, "testdata/zwei/image.jpg")
assert.FileExists(t, "testdata/zwei/image-1.jpg")
@@ -338,15 +341,15 @@ Leute, die ich nie gesehen
Unfassbar, all das`)}
p.save()
// check location for upload on a page name containing an umlaut
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/umlaut/%C3%A4rger", nil)
assert.Contains(t, body, `href="/upload/testdata/umlaut/?filename=%c3%a4rger-1.jpg"`) // changed to lowercase
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/umlaut/%C3%A4rger", nil)
assert.Contains(t, body, `href="/upload/testdata/umlaut/?filename=%c3%a4rger-1.jpg&pagename=%c3%a4rger"`) // lower case
// check location for drop in a directory containing an umlaut
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", "/upload/%C3%A4rger/dir/", nil)
body = assert.HTTPBody(makeHandler(uploadHandler, false, http.MethodGet), "GET", "/upload/%C3%A4rger/dir/", nil)
assert.Contains(t, body, `action="/drop/%c3%a4rger/dir/"`) // changed to lowercase
// actual upload
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, err := writer.CreateFormField("name")
field, err := writer.CreateFormField("filename")
assert.NoError(t, err)
_, err = field.Write([]byte("ärger.txt"))
assert.NoError(t, err)
@@ -356,12 +359,44 @@ Unfassbar, all das`)}
assert.NoError(t, err)
err = writer.Close()
assert.NoError(t, err)
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/umlaut/",
writer.FormDataContentType(), form, "/upload/testdata/umlaut/?actual=%C3%A4rger.txt&last=%C3%A4rger.txt")
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/umlaut/",
writer.FormDataContentType(), form, "/upload/testdata/umlaut/?filename=%C3%A4rger.txt&uploads=%C3%A4rger.txt")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/umlaut/%C3%A4rger.txt", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/umlaut/%C3%A4rger.txt", nil),
"Hello!")
assert.Contains(t,
assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/umlaut/", nil),
"ärger.txt")
}
func TestUploadHash(t *testing.T) {
cleanup(t, "testdata/#hash")
// create a page
p := &Page{Name: "testdata/#hash/#number", Body: []byte(`# Number
Countless heads to see
Bald and hairy, wearing hats
I wait my number`)}
p.save()
// check location for upload on a page name containing an hash
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/%23hash/%23number", nil)
assert.Contains(t, body, `href="/upload/testdata/%23hash/?filename=%23number-1.jpg&pagename=%23number"`)
// check location for drop in a directory containing an hash
body = assert.HTTPBody(makeHandler(uploadHandler, false, http.MethodGet), "GET", "/upload/%23number/dir/", nil)
assert.Contains(t, body, `action="/drop/%23number/dir/"`)
// actual upload
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, err := writer.CreateFormField("filename")
assert.NoError(t, err)
_, err = field.Write([]byte("#number.txt"))
assert.NoError(t, err)
file, err := writer.CreateFormFile("file", "#number.txt")
assert.NoError(t, err)
_, err = file.Write([]byte("Hello!"))
assert.NoError(t, err)
err = writer.Close()
assert.NoError(t, err)
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/%23hash/",
writer.FormDataContentType(), form, "/upload/testdata/%23hash/?filename=%23number.txt&uploads=%23number.txt")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/%23hash/%23number.txt", nil),
"Hello!")
assert.FileExists(t, "testdata/#hash/#number.txt")
}

View File

@@ -3,28 +3,30 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>{{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
h1 { text-wrap: balance }
form { display: inline-block }
input#search { width: 12ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
</style>
</head>
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="#main">Skip</a>
<a href="/view/index">Home</a>
<a href="changes">Changes</a>
<a href="/edit/{{.Path}}" accesskey="e">Edit</a>
<a href="/add/{{.Path}}" accesskey="a">Add</a>
<a href="/diff/{{.Path}}" accesskey="d">Diff</a>
<a href="/archive/{{.Dir}}data.zip" accesskey="z">Zip</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg&pagename={{.Base}}" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>

View File

@@ -15,32 +15,32 @@ func TestRootHandler(t *testing.T) {
// relies on index.md in the current directory!
func TestViewHandler(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/index", nil),
"Welcome to Oddμ")
}
func TestViewHandlerDir(t *testing.T) {
cleanup(t, "testdata/dir")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/", nil, "/view/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata", nil, "/view/testdata/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/", nil, "/view/testdata/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/", nil, "/view/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata", nil, "/view/testdata/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/", nil, "/view/testdata/index")
assert.NoError(t, os.Mkdir("testdata/dir", 0755))
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
assert.NoError(t, os.Mkdir("testdata/dir/dir", 0755))
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir/dir", nil, "/view/testdata/dir/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir/dir/", nil, "/view/testdata/dir/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/dir", nil, "/view/testdata/dir/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/dir/", nil, "/view/testdata/dir/dir/index")
assert.NoError(t, os.WriteFile("testdata/dir/dir.md", []byte(`# Blackbird
The oven hums and
the music plays, coffee smells
blackbirds sing outside
`), 0644))
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/dir/dir", nil), "<h1>Blackbird</h1>")
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/dir/dir.md", nil), "# Blackbird")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir/dir/", nil, "/view/testdata/dir/dir/index")
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/dir", nil), "<h1>Blackbird</h1>")
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/dir.md", nil), "# Blackbird")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/dir/", nil, "/view/testdata/dir/dir/index")
}
// relies on index.md in the current directory!
@@ -48,7 +48,7 @@ func TestViewHandlerWithId(t *testing.T) {
data := make(url.Values)
data.Set("id", "index")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/", data),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/", data),
"Welcome to Oddμ")
}
@@ -59,14 +59,14 @@ func TestPageTitleWithAmp(t *testing.T) {
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
"Rock &amp; Roll")
p = &Page{Name: "testdata/amp/Rock & Roll", Body: []byte("# Sex & Drugs & Rock'n'Roll\nOh no!")}
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
"Sex &amp; Drugs")
}
@@ -76,7 +76,7 @@ func TestPageTitleWithQuestionMark(t *testing.T) {
p := &Page{Name: "testdata/q/How about no?", Body: []byte("No means no")}
p.save()
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/q/How%20about%20no%3F", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/q/How%20about%20no%3F", nil)
assert.Contains(t, body, "No means no")
assert.Contains(t, body, "<a href=\"/edit/testdata/q/How%20about%20no%3F\" accesskey=\"e\">Edit</a>")
}
@@ -91,23 +91,23 @@ In the autumn chill
`), 0644))
fi, err := os.Stat("testdata/file-mod/now.txt")
assert.NoError(t, err)
h := makeHandler(viewHandler, false)
h := makeHandler(viewHandler, false, http.MethodGet)
assert.Equal(t, []string{fi.ModTime().UTC().Format(http.TimeFormat)},
HTTPHeaders(h, "GET", "/view/testdata/file-mod/now.txt", nil, "Last-Modified"))
HTTPStatusCodeIfModifiedSince(t, h, "/view/testdata/file-mod/now.txt", fi.ModTime())
}
func TestForbidden(t *testing.T) {
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/", nil, http.StatusFound)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/.", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/.htaccess", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/.git/description", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/../oddmu", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/", nil, http.StatusFound)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/.", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/.htaccess", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/.git/description", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/../oddmu", nil, http.StatusForbidden)
data := make(url.Values)
data.Set("id", "..")
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/", data, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/", data, http.StatusForbidden)
data.Set("id", "foo/bar")
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/", data, http.StatusBadRequest)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/", data, http.StatusBadRequest)
}
func TestPageLastModified(t *testing.T) {
@@ -120,7 +120,7 @@ I like spring better
p.save()
fi, err := os.Stat("testdata/page-mod/now.md")
assert.NoError(t, err)
h := makeHandler(viewHandler, false)
h := makeHandler(viewHandler, false, http.MethodGet)
assert.Equal(t, []string{fi.ModTime().UTC().Format(http.TimeFormat)},
HTTPHeaders(h, "GET", "/view/testdata/page-mod/now", nil, "Last-Modified"))
HTTPStatusCodeIfModifiedSince(t, h, "/view/testdata/page-mod/now", fi.ModTime())
@@ -136,7 +136,7 @@ Just me and the birds.
p.save()
fi, err := os.Stat("testdata/head/peace.md")
assert.NoError(t, err)
h := makeHandler(viewHandler, false)
h := makeHandler(viewHandler, false, http.MethodGet, http.MethodHead)
assert.Equal(t, []string(nil),
HTTPHeaders(h, "HEAD", "/view/testdata/head/war", nil, "Last-Modified"))
assert.Equal(t, []string(nil),
@@ -149,13 +149,13 @@ Just me and the birds.
func TestViewUmlaut(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/%C3%A4rger", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/%C3%A4rger", nil),
`<a href="/edit/%C3%A4rger">`)
}
func TestMimeType(t *testing.T) {
assert.Equal(t, []string{"text/markdown; charset=utf-8"},
HTTPHeaders(makeHandler(viewHandler, false), "GET", "/view/index.md", nil, "Content-Type"))
assert.Equal(t, []string{"text/css; charset=utf-8"},
HTTPHeaders(makeHandler(viewHandler, false), "GET", "/view/themes/alexschroeder.ch/oddmu.css", nil, "Content-Type"))
HTTPHeaders(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/index.md", nil, "Content-Type"))
assert.Equal(t, []string{"text/html; charset=utf-8"},
HTTPHeaders(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/view.html", nil, "Content-Type"))
}

Some files were not shown because too many files have changed in this diff Show More