forked from mirror/oddmu
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eb013a4da | ||
|
|
e8f6ae0450 | ||
|
|
9bf3beb440 | ||
|
|
cd6809d791 | ||
|
|
7c5a3860e7 | ||
|
|
a7c343decb | ||
|
|
18bb5da8c0 | ||
|
|
2a0ea791ec | ||
|
|
726586b39d | ||
|
|
8f30704be9 | ||
|
|
616ae0a1ba | ||
|
|
af86b865bf | ||
|
|
7110e0af68 | ||
|
|
8841372814 | ||
|
|
fefa283775 | ||
|
|
5a09d65dab | ||
|
|
2cf0855994 | ||
|
|
f98312e12f | ||
|
|
d213ee2815 | ||
|
|
0cd09666c6 | ||
|
|
bd9364dc09 | ||
|
|
93fd49bc4c | ||
|
|
300e411960 | ||
|
|
10cea2bf2c | ||
|
|
830af140eb | ||
|
|
c758dd7df7 | ||
|
|
969df2aef9 | ||
|
|
39f414694c | ||
|
|
fa67508692 | ||
|
|
d5696135c1 | ||
|
|
284fc3094d | ||
|
|
57161bbc98 | ||
|
|
d855d9d91a | ||
|
|
ca85250514 | ||
|
|
649fde81fe | ||
|
|
8a47e9c5fe | ||
|
|
fd9a515e0f | ||
|
|
da04c6dc27 | ||
|
|
bd2da1414c | ||
|
|
6d1a5462b4 | ||
|
|
3dcaf8aca1 | ||
|
|
80ce16f873 | ||
|
|
41347ad5dc | ||
|
|
6a911b2860 | ||
|
|
1d6db77660 | ||
|
|
8a8afcb56f | ||
|
|
6803b8e90d | ||
|
|
ff357a4048 |
2
Makefile
2
Makefile
@@ -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
|
||||
|
||||
51
README.md
51
README.md
@@ -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
|
||||
|
||||
8
RELEASE
8
RELEASE
@@ -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
|
||||
|
||||
11
add.html
11
add.html
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
83
changes.go
83
changes.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>`)
|
||||
|
||||
11
edit.html
11
edit.html
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
2
go.mod
@@ -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
6
go.sum
@@ -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=
|
||||
|
||||
149
hashtags_cmd.go
149
hashtags_cmd.go
@@ -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
108
list.go
@@ -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)
|
||||
}
|
||||
59
list.html
59
list.html
@@ -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>
|
||||
30
list_test.go
30
list_test.go
@@ -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">`)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>.\&
|
||||
|
||||
@@ -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>.
|
||||
|
||||
10
man/oddmu.1
10
man/oddmu.1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
30
page.go
@@ -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 == "." {
|
||||
|
||||
@@ -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)
|
||||
|
||||
16
preview.html
16
preview.html
@@ -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>
|
||||
|
||||
@@ -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>!")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
22
search.html
22
search.html
@@ -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>
|
||||
|
||||
@@ -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?")
|
||||
|
||||
16
static.html
16
static.html
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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><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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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><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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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></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 don’t, 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
6
themes/communitywiki.org/README.md
Normal file
6
themes/communitywiki.org/README.md
Normal 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).)
|
||||
25
themes/communitywiki.org/add.html
Normal file
25
themes/communitywiki.org/add.html
Normal 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>
|
||||
28
themes/communitywiki.org/diff.html
Normal file
28
themes/communitywiki.org/diff.html
Normal 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>
|
||||
29
themes/communitywiki.org/edit.html
Normal file
29
themes/communitywiki.org/edit.html
Normal 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>
|
||||
27
themes/communitywiki.org/feed.html
Normal file
27
themes/communitywiki.org/feed.html
Normal 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>
|
||||
41
themes/communitywiki.org/preview.html
Normal file
41
themes/communitywiki.org/preview.html
Normal 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>
|
||||
59
themes/communitywiki.org/search.html
Normal file
59
themes/communitywiki.org/search.html
Normal 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>
|
||||
30
themes/communitywiki.org/static.html
Normal file
30
themes/communitywiki.org/static.html
Normal 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>
|
||||
113
themes/communitywiki.org/upload.html
Normal file
113
themes/communitywiki.org/upload.html
Normal 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 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><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>
|
||||
54
themes/communitywiki.org/view.html
Normal file
54
themes/communitywiki.org/view.html
Normal 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 <<a href="mailto:alex@alexschroeder.ch">alex@alexschroeder.ch</a>>
|
||||
</address>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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><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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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% }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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><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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
40
upload.html
40
upload.html
@@ -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 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 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>
|
||||
|
||||
108
upload_drop.go
108
upload_drop.go
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
22
view.html
22
view.html
@@ -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>
|
||||
|
||||
62
view_test.go
62
view_test.go
@@ -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 & 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 & 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
Reference in New Issue
Block a user