5 Commits
v1.1 ... grep

Author SHA1 Message Date
Alex Schroeder
2188f99dea go fmt 2023-09-26 00:07:34 +02:00
Alex Schroeder
c063174063 Allow b elements in snippets 2023-09-25 17:20:35 +02:00
Alex Schroeder
e1258da63b Index hashtags and grep the rest 2023-09-25 16:31:33 +02:00
Alex Schroeder
8eb700fb0b Use full text search 2023-09-25 14:07:00 +02:00
Alex Schroeder
7514c2173b Add exact serch from the command line 2023-09-25 09:21:13 +02:00
80 changed files with 1022 additions and 3591 deletions

View File

@@ -31,7 +31,7 @@ test:
upload:
go build
rsync --itemize-changes --archive oddmu sibirocobombus.root:/home/oddmu/
ssh sibirocobombus.root "systemctl restart oddmu; systemctl restart alex; systemctl restart claudia"
ssh sibirocobombus.root "systemctl restart oddmu; systemctl restart alex"
@echo Changes to the template files need careful consideration
docs:

View File

@@ -10,75 +10,21 @@ as a *secondary* medium: collaboration and conversation happens
elsewhere, in chat, on social media. The wiki serves as the text
repository that results from these discussions.
If you're the only user and it just runs on your laptop, then you can
think of it as a [memex](https://en.wikipedia.org/wiki/Memex), a
memory extender.
The wiki lists no recent changes. The expectation is that the people
that care were involved in the discussions beforehand.
This wiki uses a [Markdown
library](https://github.com/gomarkdown/markdown) to generate the web
pages from Markdown. There are two extensions Oddmu adds to the
library: local links `[[like this]]`, hashtags `#Like_This` and
fediverse account links like @alex@alexschroeder.ch.
library: local links `[[like this]]` and hashtags `#Like_This`.
This wiki uses the [lingua](https://github.com/pemistahl/lingua-go)
library to detect languages in order to get hyphenation right.
This wiki uses the [lingua](github.com/pemistahl/lingua-go) library to
detect languages in order to get hyphenation right.
This wiki uses the standard
[html/template](https://pkg.go.dev/html/template) library to generate
HTML.
## Documentation
This project uses man(1) pages. They are generated from text files
using [scdoc](https://git.sr.ht/~sircmpwn/scdoc). These are the files
available:
[oddmu(1)](/oddmu.git/blob/main/man/oddmu.1.txt): This man page has a
short introduction to Oddmu, its configuration via templates and
environment variables, plus points to the other man pages.
[oddmu(5)](/oddmu.git/blob/main/man/oddmu.5.txt): 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.
[oddmu-search(1)](/oddmu.git/blob/main/man/oddmu-search.1.txt): This
man page documents the "search" subcommand which you can use to build
indexes lists of page links. These are important for feeds.
[oddmu-replace(1)](/oddmu.git/blob/main/man/oddmu-replace.1.txt): This
man page documents the "replace" subcommand to make mass changes to
the files much like find(1), grep(1) and sed(1) or perl (1).
[oddmu-missing(1)](/oddmu.git/blob/main/man/oddmu-missing.1.txt): This
man page documents the "missing" subcommand to list local links that
don't point to any existing pages or files.
[oddmu-html(1)](/oddmu.git/blob/main/man/oddmu-html.1.txt): This man
page documents the "html" subcommand to generate HTML from Markdown
pages from the command line.
[oddmu-static(1)](/oddmu.git/blob/main/man/oddmu-static.1.txt): This
man page documents the "static" subcommand to generate an entire
static website from the command line, avoiding the need to run Oddmu
as a server. Also great for archiving.
[oddmu-templates(5)](/oddmu.git/blob/main/man/oddmu-templates.5.txt):
This man page documents how the templates can be changed (how they
*must* be changed) and lists the attributes available for the various
templates.
[oddmu-apache(5)](/oddmu.git/blob/main/man/oddmu-apache.5.txt): This
man page documents how to set up the web server for various common
tasks such as using logins to limit what visitors can edit.
[oddmu.service(5)](/oddmu.git/blob/main/man/oddmu.service.5.txt): This
man page documents how to setup a systemd unit and have it manage
Oddmu. “Great configurability brings brings great burdens.”
## Building
```sh
@@ -102,35 +48,6 @@ your browser to http://localhost:8080/ to use it.
If you spot any, [contact](https://alexschroeder.ch/wiki/Contact) me.
## Source
If you're interested in making changes to the code, here's a
high-level introduction to the various source files.
- *_test.go are the test files; a few library functions are defined in
wiki_test.go.
- *_cmd.go are the files implementing the various subcommands with
matching names
- accounts.go implements the webfinger code to fetch fediverse account
link destinations with the URI provided by webfinger
- add_append.go implements the /add and /append handlers
- diff.go implements the /diff handler
- edit_save.go implements the /edit and /save handlers
- feed.go implements the feed for a page based on the links it lists
- highlight.go implements the bold tags for matches when showing
search results
- index.go implements the index of all the hashtags
- languages.go implements the language detection
- page.go implements the page loading and saving
- parser.go implements the Markdown parsing
- score.go implements the page scoring when showing search results
- search.go implements the /search handler
- snippets.go implements the page summaries for search results
- tokenizer.go implements the various tokenizers used
- upload_drop.go implements the /upload and /drop handlers
- view.go implements the /view handler
- wiki.go implements the main function
## References
[Writing Web Applications](https://golang.org/doc/articles/wiki/)

View File

@@ -57,7 +57,6 @@ func account(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
data[i] >= '0' && data[i] <= '9' ||
data[i] == '@' ||
data[i] == '.' ||
data[i] == '_' ||
data[i] == '-') {
if data[i] == '@' {
if d != 0 {
@@ -89,9 +88,8 @@ func account(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
go lookUpAccountUri(string(account), string(domain))
}
link := &ast.Link{
AdditionalAttributes: []string{`class="account"`},
Destination: []byte(uri),
Title: data[0:i],
Destination: []byte(uri),
Title: data[0:i],
}
ast.AppendChild(link, &ast.Text{Leaf: ast.Leaf{Literal: data[0 : d-1]}})
return i, link

View File

@@ -10,7 +10,7 @@ import (
// initAccounts()
// p := &Page{Body: []byte(`@alex, @alex@alexschroeder.ch said`)}
// p.renderHtml()
// r := `<p>@alex, <a href="https://alexschroeder.ch/users/alex">@alex</a> said</p>
// r := `<p>@alex, <a href="https://alexschroeder.ch/users/alex" rel="nofollow">@alex</a> said</p>
// `
// assert.Equal(t, r, string(p.Html))
// }

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width">
<title>Add to {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background: #ffe; }
form, textarea { width: 100%; }
</style>
</head>
@@ -14,7 +14,6 @@ form, textarea { width: 100%; }
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="" 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/{{.Name}}"><button type="button">Cancel</button></a></p>
</form>

View File

@@ -1,8 +1,6 @@
package main
import (
"bytes"
"log"
"net/http"
)
@@ -25,32 +23,15 @@ func appendHandler(w http.ResponseWriter, r *http.Request, name string) {
body := r.FormValue("body")
p, err := loadPage(name)
if err != nil {
p = &Page{Name: name, Body: []byte(body)}
p = &Page{Title: name, Name: name, Body: []byte(body)}
} else {
p.append([]byte(body))
p.handleTitle(false)
p.Body = append(p.Body, []byte(body)...)
}
p.handleTitle(false)
err = p.save()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.FormValue("notify") == "on" {
err = p.notify()
if err != nil {
log.Println("notify:", err)
}
}
http.Redirect(w, r, "/view/"+name, http.StatusFound)
}
func (p *Page) append(body []byte) {
// ensure an empty line at the end
if bytes.HasSuffix(p.Body, []byte("\n\n")) {
} else if bytes.HasSuffix(p.Body, []byte("\n")) {
p.Body = append(p.Body, '\n')
} else {
p.Body = append(p.Body, '\n', '\n')
}
p.Body = append(p.Body, body...)
}

View File

@@ -6,26 +6,14 @@ import (
"os"
"regexp"
"testing"
"time"
)
func TestEmptyLineAdd(t *testing.T) {
p := &Page{Name: "testdata/add/fire", Body: []byte(`# Coal
Black rocks light as foam
Shaking, puring, shoveling`)}
p.append([]byte("Into the oven"))
assert.Equal(t, string(p.Body), `# Coal
Black rocks light as foam
Shaking, puring, shoveling
Into the oven`)
}
// wipes testdata
func TestAddAppend(t *testing.T) {
cleanup(t, "testdata/add")
_ = os.RemoveAll("testdata")
index.load()
p := &Page{Name: "testdata/add/fire", Body: []byte(`# Fire
p := &Page{Name: "testdata/fire", Body: []byte(`# Fire
Orange sky above
Reflects a distant fire
It's not `)}
@@ -35,43 +23,13 @@ It's not `)}
data.Set("body", "barbecue")
assert.Regexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(viewHandler, true),
"GET", "/view/testdata/add/fire", nil))
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/fire", nil))
assert.NotRegexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(addHandler, true),
"GET", "/add/testdata/add/fire", nil))
HTTPRedirectTo(t, makeHandler(appendHandler, true),
"POST", "/append/testdata/add/fire", data, "/view/testdata/add/fire")
assert.Regexp(t, regexp.MustCompile(`not</p>\s*<p>barbecue`),
assert.HTTPBody(makeHandler(viewHandler, true),
"GET", "/view/testdata/add/fire", nil))
}
func TestAddAppendChanges(t *testing.T) {
cleanup(t, "testdata/notification2", "changes.md", "changes.md~")
restore(t, "index.md")
os.Remove("changes.md")
today := time.Now().Format(time.DateOnly)
p := &Page{Name: "testdata/notification2/" + today + "-water", Body: []byte(`# Water
Sunlight dancing fast
Blue and green and pebbles gray
`)}
p.save()
data := url.Values{}
data.Set("body", "Stand in cold water")
data.Add("notify", "on")
HTTPRedirectTo(t, makeHandler(appendHandler, true),
"POST", "/append/testdata/notification2/"+today+"-water",
data, "/view/testdata/notification2/"+today+"-water")
// The changes.md file was created
s, err := os.ReadFile("changes.md")
assert.NoError(t, err)
d := time.Now().Format(time.DateOnly)
assert.Equal(t, "# Changes\n\n## "+d+"\n* [Water](testdata/notification2/"+today+"-water)\n", string(s))
// Link added to index.md file
s, err = os.ReadFile("index.md")
assert.NoError(t, err)
assert.Contains(t, string(s), "\n* [Water](testdata/notification2/"+today+"-water)\n")
assert.HTTPBody(makeHandler(addHandler, true), "GET", "/add/testdata/fire", nil))
HTTPRedirectTo(t, makeHandler(appendHandler, true), "POST", "/append/testdata/fire", data, "/view/testdata/fire")
assert.Regexp(t, regexp.MustCompile("Its not barbecue"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/fire", nil))
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}

View File

@@ -1,176 +0,0 @@
package main
import (
"path"
"regexp"
"strings"
"time"
)
// notify adds a link to the "changes" page, as well as to all the existing hashtag pages. If the "changes" page does
// not exist, it is created. If the hashtag page does not exist, it is not. Hashtag pages are considered optional.
func (p *Page) notify() error {
p.handleTitle(false)
if p.Title == "" {
p.Title = p.Name
}
esc := nameEscape(p.Name)
link := "* [" + p.Title + "](" + esc + ")\n"
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(` + esc + `\)\n`)
dir := path.Dir(p.Name)
// Recent changes for all pages
err := addLinkWithDate("changes", link, re)
if err != nil {
return err
}
// For blog pages only…
if p.isBlog() {
// Add to the index only if the blog post is for the current year
if strings.HasPrefix(path.Base(p.Name), time.Now().Format("2006")) {
err := addLink("index", link, re)
if err != nil {
return err
}
}
// Update hashtag pages
p.renderHtml() // to set hashtags
for _, hashtag := range p.Hashtags {
err := addLink(path.Join(dir, hashtag), link, re)
if err != nil {
return err
}
}
}
return nil
}
// addLinkWithDate adds the link to a page, with date header for today. If a match already exists, it is removed. If
// this leaves a date header without any links, it is removed as well. If a list is found, the link is added at the top
// of the list. Lists must use the asterisk, not the minus character.
func addLinkWithDate(name, link string, re *regexp.Regexp) error {
date := time.Now().Format(time.DateOnly)
org := ""
c, err := loadPage(name)
if err != nil {
// create a new page
c = &Page{Name: "changes", Body: []byte("# Changes\n\n## " + date + "\n" + link)}
} else {
org = string(c.Body)
// remove the old match, if one exists
loc := re.FindIndex(c.Body)
if loc != nil {
r := c.Body[:loc[0]]
if loc[1] < len(c.Body) {
r = append(r, c.Body[loc[1]:]...)
}
c.Body = r
if loc[0] >= 14 && len(c.Body) >= loc[0]+15 {
// remove the preceding date if there are now two dates following each other
re := regexp.MustCompile(`(?m)^## (\d\d\d\d-\d\d-\d\d)\n\n## (\d\d\d\d-\d\d-\d\d)\n`)
if re.Match(c.Body[loc[0]-14 : loc[0]+15]) {
c.Body = append(c.Body[0:loc[0]-14], c.Body[loc[0]+1:]...)
}
} else if len(c.Body) == loc[0] {
// remove a trailing date
re := regexp.MustCompile(`## (\d\d\d\d-\d\d-\d\d)\n`)
if re.Match(c.Body[loc[0]-14 : loc[0]]) {
c.Body = c.Body[0 : loc[0]-14]
}
}
}
// locate the beginning of the list to insert the line
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n`)
loc = re.FindIndex(c.Body)
if loc == nil {
// if no list was found, use the end of the page
loc = []int{len(c.Body)}
}
// start with new page content
r := []byte("")
// check if there is a date right before the insertion point
addDate := true
if loc[0] >= 14 {
re := regexp.MustCompile(`(?m)^## (\d\d\d\d-\d\d-\d\d)\n`)
m := re.Find(c.Body[loc[0]-14 : loc[0]])
if m == nil {
// not a date: insert date, don't move insertion point
} else if string(c.Body[loc[0]-11:loc[0]-1]) == date {
// if the date is our date, don't add it, don't move insertion point
addDate = false
} else {
// if the date is not out date, move the insertion point
loc[0] -= 14
}
}
// append up to the insertion point
r = append(r, c.Body[:loc[0]]...)
// append date, if necessary
if addDate {
// ensure paragraph break
if len(r) > 0 && r[len(r)-1] != '\n' {
r = append(r, '\n')
}
if len(r) > 1 && r[len(r)-2] != '\n' {
r = append(r, '\n')
}
r = append(r, []byte("## ")...)
r = append(r, []byte(date)...)
r = append(r, '\n')
}
// append link
r = append(r, []byte(link)...)
// if we just added a date, add an empty line after the single-element list
if len(c.Body) > loc[0] && c.Body[loc[0]] != '*' {
r = append(r, '\n')
}
// append the rest
r = append(r, c.Body[loc[0]:]...)
c.Body = r
}
// only save if something changed
if string(c.Body) != org {
return c.save()
}
return nil
}
// addLink adds a link to a named page, if the page exists and doesn't contain the link. If the link exists but with a
// different title, the title is fixed.
func addLink(name, link string, re *regexp.Regexp) error {
c, err := loadPage(name)
if err != nil {
// Skip non-existing files: no error
return nil
}
org := string(c.Body)
// 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(c.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(c.Body)
if loc == nil {
// if no list was found, use the end of the page
m := len(c.Body)
loc = []int{m, m}
} else {
// if a list item was found, use just the beginning as insertion point
loc[1] = loc[0]
}
}
// start with new page content
r := []byte("")
// append up to the insertion point
r = append(r, c.Body[:loc[0]]...)
// append link
r = append(r, []byte(link)...)
// append the rest
r = append(r, c.Body[loc[1]:]...)
c.Body = r
// only save if something changed
if string(c.Body) != org {
return c.save()
}
return nil
}

View File

@@ -1,199 +0,0 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"testing"
"time"
)
// Note TestEditSaveChanges and TestAddAppendChanges.
func TestChanges(t *testing.T) {
cleanup(t, "changes.md", "changes.md~")
restore(t, "index.md")
os.Remove("changes.md")
today := time.Now().Format(time.DateOnly)
p := &Page{Name: "testdata/" + today + "-machine",
Body: []byte(`# Washing machine
Churning growling thing
Water spraying in a box
Out of sight and dark`)}
p.notify()
// Link added to changes.md file
s, err := os.ReadFile("changes.md")
assert.NoError(t, err)
assert.Contains(t, string(s), "[Washing machine](testdata/"+today+"-machine)")
// Link added to index.md file
s, err = os.ReadFile("index.md")
assert.NoError(t, err)
assert.Contains(t, string(s), "\n* [Washing machine](testdata/"+today+"-machine)\n")
}
func TestChangesWithHashtag(t *testing.T) {
cleanup(t, "changes.md", "changes.md~")
restore(t, "index.md")
os.Remove("changes.md")
intro := "# Haiku\n"
line := "* [Hotel room](testdata/changes/2023-10-27-hotel)\n"
h := &Page{Name: "testdata/changes/Haiku", Body: []byte(intro)}
h.save()
p := &Page{Name: "testdata/changes/2023-10-27-hotel",
Body: []byte(`# Hotel room
White linen and white light
Wooden floor and painted walls
Home away from home
#Haiku #Poetry`)}
p.notify()
s, err := os.ReadFile("changes.md")
assert.NoError(t, err)
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))
assert.NoFileExists(t, "testdata/changes/Poetry.md")
}
func TestChangesWithList(t *testing.T) {
cleanup(t, "changes.md", "changes.md~")
intro := "# Changes\n\nThis is a paragraph.\n\n"
line := "* [a change](change)\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.WriteFile("changes.md", []byte(intro+d+line), 0644)
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
// new line was added at the beginning of the list
assert.Equal(t, intro+d+new_line+line, string(s))
}
func TestChangesWithOldList(t *testing.T) {
cleanup(t, "changes.md", "changes.md~")
intro := "# Changes\n\nThis is a paragraph.\n\n"
line := "* [a change](change)\n"
y := "## " + time.Now().Add(-24*time.Hour).Format(time.DateOnly) + "\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.WriteFile("changes.md", []byte(intro+y+line), 0644)
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
// new line was added at the beginning of the list
assert.Equal(t, intro+d+new_line+"\n"+y+line, string(s))
}
func TestChangesWithOldDisappearingListAtTheEnd(t *testing.T) {
cleanup(t, "changes.md", "changes.md~")
intro := "# Changes\n\nThis is a paragraph.\n\n"
line := "* [a change](testdata/changes/alex)\n"
y := "## " + time.Now().Add(-24*time.Hour).Format(time.DateOnly) + "\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.WriteFile("changes.md", []byte(intro+y+line), 0644)
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
// new line was added at the beginning of the list, with the new date, and the old date disappeared
assert.Equal(t, intro+d+new_line, string(s))
}
func TestChangesWithOldDisappearingListInTheMiddle(t *testing.T) {
cleanup(t, "changes.md", "changes.md~")
intro := "# Changes\n\nThis is a paragraph.\n\n"
line := "* [a change](testdata/changes/alex)\n"
other := "* [other change](testdata/changes/whatever)\n"
yy := "## " + time.Now().Add(-48*time.Hour).Format(time.DateOnly) + "\n"
y := "## " + time.Now().Add(-24*time.Hour).Format(time.DateOnly) + "\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.WriteFile("changes.md", []byte(intro+y+line+"\n"+yy+other), 0644)
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
// new line was added at the beginning of the list, with the new date, and the old date disappeared
assert.Equal(t, intro+d+new_line+"\n"+yy+other, string(s))
}
func TestChangesWithListAtTheTop(t *testing.T) {
cleanup(t, "testdata/changes", "changes.md", "changes.md~")
line := "* [a change](change)\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.WriteFile("changes.md", []byte(line), 0644)
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
// new line was added at the top, no error due to missing introduction
assert.Equal(t, d+new_line+line, string(s))
}
func TestChangesWithNoList(t *testing.T) {
cleanup(t, "testdata/changes", "changes.md", "changes.md~")
intro := "# Changes\n\nThis is a paragraph."
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.WriteFile("changes.md", []byte(intro), 0644)
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
// into is still there and a new list was started
assert.Equal(t, intro+"\n\n"+d+new_line, string(s))
}
func TestChangesWithUpdate(t *testing.T) {
cleanup(t, "testdata/changes", "changes.md", "changes.md~")
intro := "# Changes\n\nThis is a paragraph.\n\n"
other := "* [other change](testdata/changes/whatever)\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
line := "* [a change](testdata/changes/alex)\n"
os.WriteFile("changes.md", []byte(intro+d+other+line), 0644)
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
// the change was already listed, but now it moved up and has a new title
assert.Equal(t, intro+d+new_line+other, string(s))
}
func TestChangesWithNoChangeToTheOrder(t *testing.T) {
cleanup(t, "testdata/changes", "changes.md", "changes.md~")
intro := "# Changes\n\nThis is a paragraph.\n\n"
other := "* [other change](testdata/changes/whatever)\n"
line := "* [a change](testdata/changes/alex)\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.WriteFile("changes.md", []byte(intro+d+line+other), 0644)
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
// the change was already listed at the top, so just use the new title
assert.Equal(t, intro+d+new_line+other, string(s))
}
func TestChangesWithNoChanges(t *testing.T) {
cleanup(t, "testdata/changes", "changes.md", "changes.md~")
intro := "# Changes\n\nThis is a paragraph.\n\n"
other := "* [other change](testdata/changes/whatever)\n"
line := "* [a change](testdata/changes/alex)\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.Remove("changes.md~")
os.WriteFile("changes.md", []byte(intro+d+line+other), 0644)
p := &Page{Name: "testdata/changes/alex", Body: []byte("# a change\nHallo!")}
p.notify()
s, err := os.ReadFile("changes.md")
assert.NoError(t, err)
// the change was already listed at the top, so no change was necessary
assert.Equal(t, intro+d+line+other, string(s))
// since the file hasn't changed, no backup was necessary
assert.NoFileExists(t, "changes.md~")
}

View File

@@ -10,6 +10,6 @@ func TestLoadAndSearch(t *testing.T) {
index.reset()
go index.load()
q := "Oddµ"
pages, _ := search(q, "", 1, false)
pages, _, _ := search(q, 1)
assert.Zero(t, len(pages))
}

66
diff.go
View File

@@ -1,66 +0,0 @@
package main
import (
"bytes"
"github.com/sergi/go-diff/diffmatchpatch"
"html"
"html/template"
"net/http"
"net/url"
"os"
"strings"
)
func diffHandler(w http.ResponseWriter, r *http.Request, name string) {
p, err := loadPage(name)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
p.handleTitle(true)
p.renderHtml()
renderTemplate(w, "diff", p)
}
// Diff computes the diff for a page. At this point, renderHtml has already been called so the Name is escaped.
func (p *Page) Diff() template.HTML {
name, err := url.PathUnescape(p.Name)
if err != nil {
return template.HTML("Cannot unescape " + p.Name)
}
a := name + ".md~"
t1, err := os.ReadFile(a)
if err != nil {
return template.HTML("Cannot read " + a + ", so the page is new.")
}
b := name + ".md"
t2, err := os.ReadFile(b)
if err != nil {
return template.HTML("Cannot read " + b + ", so the page was deleted.")
}
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(string(t1), string(t2), false)
return template.HTML(diff2html(dmp.DiffCleanupSemantic(diffs)))
}
func diff2html(diffs []diffmatchpatch.Diff) string {
var buff bytes.Buffer
for _, item := range diffs {
text := strings.ReplaceAll(html.EscapeString(item.Text), "\n", "<br>")
switch item.Type {
case diffmatchpatch.DiffInsert:
_, _ = buff.WriteString("<ins>")
_, _ = buff.WriteString(text)
_, _ = buff.WriteString("</ins>")
case diffmatchpatch.DiffDelete:
_, _ = buff.WriteString("<del>")
_, _ = buff.WriteString(text)
_, _ = buff.WriteString("</del>")
case diffmatchpatch.DiffEqual:
_, _ = buff.WriteString("<span>")
_, _ = buff.WriteString(text)
_, _ = buff.WriteString("</span>")
}
}
return buff.String()
}

View File

@@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; 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 }
</style>
</head>
<body>
<header>
<a href="/view/{{.Name}}">Back</a>
</header>
<main id="main">
<h1>{{.Title}}</h1>
<p>This is the diff between <a href="/view/{{.Name}}.md~">the backup</a> and <a href="/view/{{.Name}}.md">the current copy</a>.</p>
<pre>
{{.Diff}}
</pre>
</main>
</body>
</html>

View File

@@ -1,96 +0,0 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"testing"
"time"
)
func TestDiff(t *testing.T) {
cleanup(t, "testdata/diff")
index.load()
s := `# Bread
The oven breathes
Fills us with the thought of bread
Oh so fresh, so warm.`
r := `# Bread
The oven whispers
Fills us with the thought of bread
Oh so fresh, so warm.`
p := &Page{Name: "testdata/diff/bread", Body: []byte(s)}
p.save()
p.Body = []byte(r)
p.save()
body := assert.HTTPBody(makeHandler(diffHandler, true),
"GET", "/diff/testdata/diff/bread", nil)
assert.Contains(t, body, `<del>breathe</del>`)
assert.Contains(t, body, `<ins>whisper</ins>`)
}
func TestDiffPercentEncoded(t *testing.T) {
cleanup(t, "testdata/diff")
index.load()
s := `# Coup de Gras
Playing D&D
We talk about a killing
Mispronouncing words`
r := `# Coup de Grace
Playing D&D
We talk about a killing
Mispronouncing words`
p := &Page{Name: "testdata/diff/coup de grace", Body: []byte(s)}
p.save()
p.Body = []byte(r)
p.save()
body := assert.HTTPBody(makeHandler(diffHandler, true),
"GET", "/diff/testdata/diff/coup%20de%20grace", nil)
assert.Contains(t, body, `<del>s</del>`)
assert.Contains(t, body, `<ins>ce</ins>`)
}
func TestDiffBackup(t *testing.T) {
cleanup(t, "testdata/backup")
s := `# Cold Rooms
I shiver at home
the monitor glares and moans
fear or cold, who knows?`
r := `# Cold Rooms
I shiver at home
the monitor glares and moans
I hate the machine!`
u := `# Cold Rooms
I shiver at home
the monitor glares and moans
my grey heart grows cold`
p := &Page{Name: "testdata/backup/cold", Body: []byte(s)}
p.save()
p = &Page{Name: "testdata/backup/cold", Body: []byte(r)}
p.save()
body := string(p.Diff())
// diff from s to r:
assert.Contains(t, body, `<del>fear or cold, who knows?</del>`)
assert.Contains(t, body, `<ins>I hate the machine!</ins>`)
p = &Page{Name: "testdata/backup/cold", Body: []byte(u)}
p.save()
body = string(p.Diff())
// diff from s to u since r was not 60 min or older
assert.Contains(t, body, `<del>fear or cold, who knows?</del>`)
assert.Contains(t, body, `<ins>my grey heart grows cold</ins>`)
// set timestamp 2h in the past
ts := time.Now().Add(-2 * time.Hour)
assert.NoError(t, os.Chtimes("testdata/backup/cold.md~", ts, ts))
p = &Page{Name: "testdata/backup/cold", Body: []byte(r)}
p.save()
body = string(p.Diff())
// diff from u to r:
assert.Contains(t, body, `<del>my grey heart grows cold</del>`)
assert.Contains(t, body, `<ins>I hate the machine!</ins>`)
}

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width">
<title>Editing {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background: #ffe; }
form, textarea { width: 100%; }
</style>
</head>
@@ -16,7 +16,6 @@ form, textarea { width: 100%; }
<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="/view/changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Save">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
</form>

View File

@@ -1,7 +1,6 @@
package main
import (
"log"
"net/http"
)
@@ -29,11 +28,5 @@ func saveHandler(w http.ResponseWriter, r *http.Request, name string) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.FormValue("notify") == "on" {
err = p.notify()
if err != nil {
log.Println("notify:", err)
}
}
http.Redirect(w, r, "/view/"+name, http.StatusFound)
}

View File

@@ -4,59 +4,24 @@ import (
"github.com/stretchr/testify/assert"
"net/url"
"os"
"regexp"
"testing"
"time"
)
// wipes testdata
func TestEditSave(t *testing.T) {
cleanup(t, "testdata/save")
_ = os.RemoveAll("testdata")
data := url.Values{}
data.Set("body", "Hallo!")
// View of the non-existing page redirects to the edit page
HTTPRedirectTo(t, makeHandler(viewHandler, true),
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
// Edit page can be fetched
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
"GET", "/edit/testdata/save/alex", nil, 200)
// Posting to the save URL saves a page
HTTPRedirectTo(t, makeHandler(saveHandler, true),
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
// Page now contains the text
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, true),
"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),
"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, true),
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
}
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/alex", nil, "/edit/testdata/alex")
assert.HTTPStatusCode(t, makeHandler(editHandler, true), "GET", "/edit/testdata/alex", nil, 200)
HTTPRedirectTo(t, makeHandler(saveHandler, true), "POST", "/save/testdata/alex", data, "/view/testdata/alex")
assert.Regexp(t, regexp.MustCompile("Hallo!"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/alex", nil))
func TestEditSaveChanges(t *testing.T) {
cleanup(t, "testdata/notification", "changes.md")
restore(t, "index.md")
os.Remove("changes.md")
data := url.Values{}
data.Set("body", "Hallo!")
data.Add("notify", "on")
// Posting to the save URL saves a page
HTTPRedirectTo(t, makeHandler(saveHandler, true),
"POST", "/save/testdata/notification/2023-10-28-alex",
data, "/view/testdata/notification/2023-10-28-alex")
// The changes.md file was created
s, err := os.ReadFile("changes.md")
assert.NoError(t, err)
d := time.Now().Format(time.DateOnly)
assert.Equal(t, "# Changes\n\n## "+d+
"\n* [testdata/notification/2023-10-28-alex](testdata/notification/2023-10-28-alex)\n",
string(s))
// Link added to index.md file
s, err = os.ReadFile("index.md")
assert.NoError(t, err)
assert.Contains(t, string(s),
"\n* [testdata/notification/2023-10-28-alex](testdata/notification/2023-10-28-alex)\n")
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/parser"
"html/template"
"os"
"path"
@@ -25,7 +26,7 @@ func feed(p *Page, ti time.Time) *Feed {
feed.Name = p.Name
feed.Title = p.Title
feed.Date = ti.Format(time.RFC1123Z)
parser, _ := wikiParser()
parser := parser.New()
doc := markdown.Parse(p.Body, parser)
items := make([]Item, 0)
inListItem := false

View File

@@ -2,6 +2,7 @@ package main
import (
"github.com/stretchr/testify/assert"
"os"
"testing"
)
@@ -11,11 +12,12 @@ func TestFeed(t *testing.T) {
"Welcome to Oddµ")
}
// wipes testdata
func TestFeedItems(t *testing.T) {
cleanup(t, "testdata/feed")
_ = os.RemoveAll("testdata")
index.load()
p1 := &Page{Name: "testdata/feed/cactus", Body: []byte(`# Cactus
p1 := &Page{Name: "testdata/cactus", Body: []byte(`# Cactus
Green head and white hair
A bench in the evening sun
Unmoved by the news
@@ -23,7 +25,7 @@ Unmoved by the news
#Succulent`)}
p1.save()
p2 := &Page{Name: "testdata/feed/dragon", Body: []byte(`# Dragon
p2 := &Page{Name: "testdata/dragon", Body: []byte(`# Dragon
My palm tree grows straight
Up and up to touch the sky
Ignoring the roof
@@ -31,14 +33,14 @@ Ignoring the roof
#Palmtree`)}
p2.save()
p3 := &Page{Name: "testdata/feed/plants", Body: []byte(`# Plants
p3 := &Page{Name: "testdata/plants", Body: []byte(`# Plants
Writing poems about plants.
* [My Cactus](cactus)
* [My Dragon Tree](dragon)`)}
p3.save()
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/feed/plants.rss", nil)
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/plants.rss", nil)
assert.Contains(t, body, "<title>Plants</title>")
assert.Contains(t, body, "<title>Cactus</title>")
assert.Contains(t, body, "<title>Dragon</title>")
@@ -46,4 +48,8 @@ Writing poems about plants.
assert.Contains(t, body, "&lt;h1&gt;Dragon&lt;/h1&gt;")
assert.Contains(t, body, "<category>Succulent</category>")
assert.Contains(t, body, "<category>Palmtree</category>")
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}

20
go.mod
View File

@@ -4,33 +4,25 @@ go 1.21.0
require (
github.com/anthonynsimon/bild v0.13.0
github.com/charmbracelet/lipgloss v0.8.0
github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386
github.com/gomarkdown/markdown v0.0.0-20230912175223-14b07df9d538
github.com/google/subcommands v1.2.0
github.com/hexops/gotextdiff v1.0.3
github.com/microcosm-cc/bluemonday v1.0.25
github.com/pemistahl/lingua-go v1.4.0
github.com/sergi/go-diff v1.3.1
github.com/stretchr/testify v1.8.4
golang.org/x/exp v0.0.0-20221106115401-f9659909a136
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/kr/pretty v0.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/shopspring/decimal v1.3.1 // indirect
golang.org/x/image v0.10.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/exp v0.0.0-20221106115401-f9659909a136 // indirect
golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9 // indirect
golang.org/x/net v0.12.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

68
go.sum
View File

@@ -2,23 +2,18 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/anthonynsimon/bild v0.13.0 h1:mN3tMaNds1wBWi1BrJq0ipDBhpkooYfu7ZFSMhXt1C8=
github.com/anthonynsimon/bild v0.13.0/go.mod h1:tpzzp0aYkAsMi1zmfhimaDyX1xjn2OUc1AJZK/TF0AE=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/lipgloss v0.8.0 h1:IS00fk4XAHcf8uZKc3eHeMUTCxUH6NkaTrdyCQk84RU=
github.com/charmbracelet/lipgloss v0.8.0/go.mod h1:p4eYUZZJ/0oXTuCQKFF8mqyKCz0ja6y+7DniDDw5KKU=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386 h1:EcQR3gusLHN46TAD+G+EbaaqJArt5vHhNpXAa12PQf4=
github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20230912175223-14b07df9d538 h1:ePDpFu7l0QUV46/9A7icfL2wvIOzTJLCWh4RO2NECzE=
github.com/gomarkdown/markdown v0.0.0-20230912175223-14b07df9d538/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -35,33 +30,17 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pemistahl/lingua-go v1.4.0 h1:ifYhthrlW7iO4icdubwlduYnmwU37V1sbNrwhKBR4rM=
github.com/pemistahl/lingua-go v1.4.0/go.mod h1:ECuM1Hp/3hvyh7k8aWSqNCPlTxLemFZsRjocUf3KgME=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
@@ -70,56 +49,20 @@ github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tL
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
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/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20221106115401-f9659909a136 h1:Fq7F/w7MAa1KJ5bt2aJ62ihqp9HDcRuyILskkpIAurw=
golang.org/x/exp v0.0.0-20221106115401-f9659909a136/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9 h1:uc17S921SPw5F2gJo7slQ3aqvr2RwpL7eb3+DZncu3s=
golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.10.0 h1:gXjUUtwtx5yOE0VKWq1CH4IJAClq4UGgUA3i+rpON9M=
golang.org/x/image v0.10.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
@@ -128,6 +71,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -14,7 +14,7 @@ type htmlCmd struct {
}
func (*htmlCmd) Name() string { return "html" }
func (*htmlCmd) Synopsis() string { return "render a page as HTML" }
func (*htmlCmd) Synopsis() string { return "Render a page as HTML." }
func (*htmlCmd) Usage() string {
return `html [-view] <page name>:
Render a page as HTML.
@@ -33,7 +33,7 @@ func htmlCli(w io.Writer, useTemplate bool, args []string) subcommands.ExitStatu
for _, arg := range args {
p, err := loadPage(arg)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", arg, err)
fmt.Fprintf(w, "Cannot load %s: %s\n", arg, err)
return subcommands.ExitFailure
}
initAccounts()
@@ -43,7 +43,7 @@ func htmlCli(w io.Writer, useTemplate bool, args []string) subcommands.ExitStatu
t := "view.html"
err := templates.ExecuteTemplate(w, t, p)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", t, arg, err)
fmt.Fprintf(w, "Cannot execute %s template for %s: %s\n", t, arg, err)
return subcommands.ExitFailure
}
} else {

View File

@@ -15,9 +15,7 @@ func TestHtmlCmd(t *testing.T) {
<p>Hello! 🙃</p>
<p>Check out the <a href="README">README</a>.</p>
<p>Or <a href="test">create a new page</a>.</p>
<p>Check out the <a href="README" rel="nofollow">README</a>.</p>
`
assert.Equal(t, r, b.String())

View File

@@ -5,7 +5,6 @@
package main
import (
"golang.org/x/exp/constraints"
"io/fs"
"log"
"path/filepath"
@@ -165,10 +164,10 @@ func (p *Page) updateIndex() {
// Do not reuse the old id. We need a new one for
// indexing to work.
id = index.addDocument(p.Body)
// The page name stays the same but the title may have
// changed.
index.documents[id] = p.Name
p.handleTitle(false)
// The page name stays the same but the title may have
// changed.
index.titles[p.Name] = p.Title
}
}
@@ -227,17 +226,37 @@ func (idx *Index) search(q string) []string {
names = append(names, name)
}
}
return names
return grep(tokens(q), names)
}
func grep(tokens, names []string) []string {
results := make([]string, 0)
NameLoop:
for _, name := range names {
p, err := loadPage(name)
if err != nil {
log.Printf("Cannot load %s: %s", name, err)
continue
}
body := strings.ToLower(string(p.Body))
for _, token := range tokens {
if !strings.Contains(body, token) {
continue NameLoop
}
}
results = append(results, name)
}
return results
}
// intersection returns the set intersection between a and b.
// a and b have to be sorted in ascending order and contain no duplicates.
func intersection[T constraints.Ordered](a []T, b []T) []T {
func intersection(a []docid, b []docid) []docid {
maxLen := len(a)
if len(b) > maxLen {
maxLen = len(b)
}
r := make([]T, 0, maxLen)
r := make([]docid, 0, maxLen)
var i, j int
for i < len(a) && j < len(b) {
if a[i] < b[j] {

View File

@@ -2,6 +2,4 @@
Hello! 🙃
Check out the [[README]].
Or [create a new page](test).
Check out the [README](README).

View File

@@ -2,6 +2,7 @@ package main
import (
"github.com/stretchr/testify/assert"
"os"
"strings"
"testing"
)
@@ -10,7 +11,7 @@ import (
func TestIndex(t *testing.T) {
index.load()
q := "Oddµ"
pages, _ := search(q, "", 1, false)
pages, _, _ := search(q, 1)
assert.NotZero(t, len(pages))
for _, p := range pages {
assert.NotContains(t, p.Title, "<b>")
@@ -22,19 +23,20 @@ func TestIndex(t *testing.T) {
func TestSearchHashtag(t *testing.T) {
index.load()
q := "#like_this"
pages, _ := search(q, "", 1, false)
pages, _, _ := search(q, 1)
assert.NotZero(t, len(pages))
}
// wipes testdata
func TestIndexUpdates(t *testing.T) {
cleanup(t, "testdata/update")
name := "testdata/update/test"
_ = os.RemoveAll("testdata")
name := "testdata/test"
index.load()
p := &Page{Name: name, Body: []byte("#Old Name\nThis is a test.")}
p := &Page{Name: name, Body: []byte("This is a test.")}
p.save()
// Find the phrase
pages, _ := search("This is a test", "", 1, false)
pages, _, _ := search("This is a test", 1)
found := false
for _, p := range pages {
if p.Name == name {
@@ -45,7 +47,7 @@ func TestIndexUpdates(t *testing.T) {
assert.True(t, found)
// Find the phrase, case insensitive
pages, _ = search("this is a test", "", 1, false)
pages, _, _ = search("this is a test", 1)
found = false
for _, p := range pages {
if p.Name == name {
@@ -56,7 +58,7 @@ func TestIndexUpdates(t *testing.T) {
assert.True(t, found)
// Find some words
pages, _ = search("this test", "", 1, false)
pages, _, _ = search("this test", 1)
found = false
for _, p := range pages {
if p.Name == name {
@@ -67,9 +69,9 @@ func TestIndexUpdates(t *testing.T) {
assert.True(t, found)
// Update the page and no longer find it with the old phrase
p = &Page{Name: name, Body: []byte("# New page\nGuvf vf n grfg.")}
p = &Page{Name: name, Body: []byte("Guvf vf n grfg.")}
p.save()
pages, _ = search("This is a test", "", 1, false)
pages, _, _ = search("This is a test", 1)
found = false
for _, p := range pages {
if p.Name == name {
@@ -80,7 +82,7 @@ func TestIndexUpdates(t *testing.T) {
assert.False(t, found)
// Find page using a new word
pages, _ = search("Guvf", "", 1, false)
pages, _, _ = search("Guvf", 1)
found = false
for _, p := range pages {
if p.Name == name {
@@ -90,8 +92,7 @@ func TestIndexUpdates(t *testing.T) {
}
assert.True(t, found)
// Make sure the title was updated
index.RLock()
defer index.RUnlock()
assert.Equal(t, index.titles[name], "New page")
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}

View File

@@ -1,6 +1,5 @@
docs: oddmu-apache.5 oddmu-html.1 oddmu-missing.1 oddmu-notify.1 \
oddmu-replace.1 oddmu-search.1 oddmu-search.7 oddmu-static.1 \
oddmu-templates.5 oddmu.1 oddmu.5 oddmu.service.5
docs: oddmu.1 oddmu.5 oddmu-templates.5 oddmu-apache.5 oddmu.service.5 oddmu-replace.1 \
oddmu-search.1 oddmu-search.7 oddmu-html.1
oddmu%: oddmu%.txt
scdoc < $< > $@

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-APACHE" "5" "2023-11-05"
.TH "ODDMU-APACHE" "5" "2023-09-18"
.PP
.SH NAME
.PP
@@ -13,22 +13,24 @@ oddmu-apache - how to setup Apache as a reverse proxy for Oddmu
.PP
.SS DESCRIPTION
.PP
The oddmu program serves the current working directory as a wiki on port 8080.\&
This is an unpriviledged port so an ordinary user account can do this.\&
The oddmu program serves the current working directory as a wiki on
port 8080.\& This is an unpriviledged port so an ordinary use account
can do this.\&
.PP
The best way to protect the wiki against vandalism and spam is to use a regular
web server as reverse proxy.\& This page explains how to setup Apache on Debian to
do this.\&
The best way to protect the wiki against vandalism and spam is to use
a regular web server as reverse proxy.\& This page explains how to setup
Apache on Debian to do this.\&
.PP
.SS CONFIGURATION
.PP
HTTPS is not part of Oddmu.\& You probably want to configure this in your
webserver.\& I guess you could use stunnel, too.\& If you'\&re using Apache, you can
use "mod_md" to manage your domain.\&
HTTPS is not part of the wiki.\& You probably want to configure this in
your webserver.\& I guess you could use stunnel, too.\& If you'\&re using
Apache, you can use "mod_md" to manage your domain.\&
.PP
In the example below, the site is configured in a file called
"/etc/apache2/sites-available/500-transjovian.\&conf" and a link poins there from
"/etc/apache2/sites-enabled".\& Create this link using \fIa2ensite\fR(1).\&
"/etc/apache2/sites-available/500-transjovian.\&conf" and a link poins
there from "/etc/apache2/sites-enabled".\& Create this link using
\fIa2ensite\fR(1).\&
.PP
.nf
.RS 4
@@ -44,29 +46,22 @@ MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder\&.ch
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(\&.*))?$" "http://localhost:8080/$1"
ProxyPassMatch ^/(search|(view|edit|save|add|append|upload|drop)/(\&.*))?$ http://localhost:8080/$1
</VirtualHost>
.fi
.RE
.PP
First, it manages the domain, getting the necessary certificates.\& It redirects
regular HTTP traffic from port 80 to port 443.\& It turns on the SSL engine for
port 443.\& It proxies the requests for Oddmu to port 8080.\& Importantly, it
doesn'\&t send \fIall\fR the requests to Oddmu.\& This allows us to still host static
files using the web server.\&
First, it manages the domain, getting the necessary certificates.\& It
redirects regular HTTP traffic from port 80 to port 443.\& It turns on
the SSL engine for port 443.\& It proxies the requests for the wiki to
port 8080.\&
.PP
This is what happens:
Thus, this is what happens:
.PP
.PD 0
.IP \(bu 4
The user tells the browser to visit "transjovian.\&org"
.IP \(bu 4
The browser sends a request for "http://transjovian.\&org" (on port 80)
.IP \(bu 4
Apache redirects this to "https://transjovian.\&org/" by default (now on port 443)
.IP \(bu 4
This is proxied to "http://transjovian.\&org:8080/" (now on port 8080)
.PD
\fB The user tells the browser to visit `transjovian.\&org`
\fR The browser sends a request for `http://transjovian.\&org` (on port 80)
\fB Apache redirects this to `https://transjovian.\&org/` by default (now on port 443)
\fR This is proxied to `http://transjovian.\&org:8080/` (no encryption, on port 8080)
.PP
Restart the server, gracefully:
.PP
@@ -76,23 +71,18 @@ apachectl graceful
.fi
.RE
.PP
To serve both HTTP and HTTPS, don'\&t redirect from the first virtual host to the
second instead just proxy to the wiki like you did for the second virtual
host: use a copy of the "ProxyPassMatch" directive instead of "RewriteEngine on"
and "RewriteRule".\&
To serve both HTTP and HTTPS, don'\&t redirect from the first virtual
host to the second instead just proxy to the wiki like you did for
the second virtual host: use a copy of the "ProxyPassMatch" directive
instead of "RewriteEngine on" and "RewriteRule".\&
.PP
.SS Access
.PP
Access control is not part of Oddmu.\& By default, the wiki is editable by all.\&
This is most likely not what you want unless you'\&re running it stand-alone,
unconnected to the Internet a person memex on your laptop, for example.\&
Access control is not part of the wiki.\& By default, the wiki is
editable by all.\& This is most likely not what you want unless you'\&re
running it stand-alone, unconnected to the Internet.\&
.PP
The following instructions create user accounts with passwords just for Oddmu.\&
These users are not real users on the web server and don'\&t have access to a
shell, mail, or any other service.\&
.PP
Create a new password file called ".\&htpasswd" and add the user "alex".\& The "-c"
flag creates the file.\&
Create a new password file called ".\&htpasswd" and add the user "alex":
.PP
.nf
.RS 4
@@ -101,8 +91,9 @@ htpasswd -c \&.htpasswd alex
.fi
.RE
.PP
To add more users, don'\&t use the "-c" option or you will overwrite the existing
file.\& To add another user, use no option at all.\&
To add more users, don'\&t use the "-c" option or you will overwrite it!\&
.PP
To add another user:
.PP
.nf
.RS 4
@@ -110,7 +101,7 @@ htpasswd \&.htpasswd berta
.fi
.RE
.PP
To remove a user, use the "-D" option.\&
To remove a user:
.PP
.nf
.RS 4
@@ -118,9 +109,9 @@ htpasswd -D \&.htpasswd berta
.fi
.RE
.PP
Modify your site configuration and protect the "/edit/", "/save/", "/add/",
"/append/", "/upload/" and "/drop/" URLs with a password by adding the following
to your "<VirtualHost *:443>" section:
Modify your site configuration and protect the "/edit/", "/save/",
"/add/", "/append/", "/upload/" and "/drop/" URLs with a password by
adding the following to your "<VirtualHost *:443>" section:
.PP
.nf
.RS 4
@@ -135,9 +126,10 @@ to your "<VirtualHost *:443>" section:
.PP
.SS Serve static files
.PP
If you want to serve static files as well, add a document root to your webserver
configuration.\& In this case, the document root is the directory where all the
data files are.\& Apache does not serve files such as ".\&htpasswd".\&
If you want to serve static files as well, add a document root to your
webserver configuration.\& In this case, the document root is the
directory where all the data files are.\& Apache will not serve files
such as ".\&htpasswd".\&
.PP
.nf
.RS 4
@@ -148,10 +140,10 @@ DocumentRoot /home/oddmu
.fi
.RE
.PP
Make sure that none of the subdirectories look like the wiki paths "/view/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/" or
"/search/".\& For example, create a file called "robots.\&txt" containing the
following, telling all robots that they'\&re not welcome.\&
Make sure that none of the subdirectories look like the wiki paths
"/view/", "/edit/", "/save/", "/add/", "/append/", "/upload/",
"/drop/" or "/search".\& For example, create a file called "robots.\&txt"
containing the following, telling all robots that they'\&re not welcome.\&
.PP
.nf
.RS 4
@@ -160,18 +152,16 @@ Disallow: /
.fi
.RE
.PP
You site now serves "/robots.\&txt" without interfering with the wiki, and without
needing a wiki page.\&
.PP
Another option would be to create a CSS file and use it with a <link> element in
all the templates instead of relying on the <style> element.\&
You site now serves "/robots.\&txt" without interfering with the wiki,
and without needing a wiki page.\&
.PP
.SS Different logins for different access rights
.PP
What if you have a site with various subdirectories and each subdirectory is for
a different group of friends?\& You can set this up using your webserver.\& One way
to do this is to require specific usernames (which must have a password in the
password file mentioned above.\&
What if you have a site with various subdirectories and each
subdirectory is for a different group of friends?\& You can set this up
using your webserver.\& One way to do this is to require specific
usernames (which must have a password in the password file mentioned
above.\&
.PP
This requires a valid login by the user "alex" or "berta":
.PP
@@ -185,8 +175,9 @@ This requires a valid login by the user "alex" or "berta":
.PP
.SS Private wikis
.PP
Based on the above, you can prevent people from \fIreading\fR the wiki.\& The
"LocationMatch" must cover all the URLs in order to protect everything.\&
Based on the above, you can prevent people from \fIreading\fR the wiki.\&
The "LocationMatch" must cover the "/view/" URLs in order to protect
\fBeverything\fR.\&
.PP
.nf
.RS 4
@@ -201,11 +192,12 @@ Based on the above, you can prevent people from \fIreading\fR the wiki.\& The
.PP
.SS Virtual hosting
.PP
Virtual hosting in this context means that the program serves two different
sites for two different domains from the same machine.\& Oddmu doesn'\&t support
that, but your webserver does.\& Therefore, start an Oddmu instance for every
domain name, each listening on a different port.\& Then set up your web server
such that ever domain acts as a reverse proxy to a different Oddmu instance.\&
Virtual hosting in this context means that the program serves two
different sites for two different domains from the same machine.\& Oddmu
doesn'\&t support that, but your webserver does.\& Therefore, start an
Oddmu instance for every domain name, each listening on a different
port.\& Then set up your web server such that ever domain acts as a
reverse proxy to a different Oddmu instance.\&
.PP
.SH SEE ALSO
.PP
@@ -223,12 +215,6 @@ https://httpd.\&apache.\&org/docs/current/mod/mod_proxy.\&html
"Robot exclusion standard" on Wikipedia.\&
https://en.\&wikipedia.\&org/wiki/Robot_exclusion_standard
.PP
"<style>: The Style Information element"
https://developer.\&mozilla.\&org/en-US/docs/Web/HTML/Element/style
.PP
"<link>: The External Resource Link element"
https://developer.\&mozilla.\&org/en-US/docs/Web/HTML/Element/link
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

View File

@@ -6,22 +6,24 @@ oddmu-apache - how to setup Apache as a reverse proxy for Oddmu
## DESCRIPTION
The oddmu program serves the current working directory as a wiki on port 8080.
This is an unpriviledged port so an ordinary user account can do this.
The oddmu program serves the current working directory as a wiki on
port 8080. This is an unpriviledged port so an ordinary use account
can do this.
The best way to protect the wiki against vandalism and spam is to use a regular
web server as reverse proxy. This page explains how to setup Apache on Debian to
do this.
The best way to protect the wiki against vandalism and spam is to use
a regular web server as reverse proxy. This page explains how to setup
Apache on Debian to do this.
## CONFIGURATION
HTTPS is not part of Oddmu. You probably want to configure this in your
webserver. I guess you could use stunnel, too. If you're using Apache, you can
use "mod_md" to manage your domain.
HTTPS is not part of the wiki. You probably want to configure this in
your webserver. I guess you could use stunnel, too. If you're using
Apache, you can use "mod_md" to manage your domain.
In the example below, the site is configured in a file called
"/etc/apache2/sites-available/500-transjovian.conf" and a link poins there from
"/etc/apache2/sites-enabled". Create this link using _a2ensite_(1).
"/etc/apache2/sites-available/500-transjovian.conf" and a link poins
there from "/etc/apache2/sites-enabled". Create this link using
_a2ensite_(1).
```
MDomain transjovian.org
@@ -36,22 +38,21 @@ MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder.ch
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(.*))?$" "http://localhost:8080/$1"
ProxyPassMatch ^/(search|(view|edit|save|add|append|upload|drop)/(.*))?$ http://localhost:8080/$1
</VirtualHost>
```
First, it manages the domain, getting the necessary certificates. It redirects
regular HTTP traffic from port 80 to port 443. It turns on the SSL engine for
port 443. It proxies the requests for Oddmu to port 8080. Importantly, it
doesn't send _all_ the requests to Oddmu. This allows us to still host static
files using the web server.
First, it manages the domain, getting the necessary certificates. It
redirects regular HTTP traffic from port 80 to port 443. It turns on
the SSL engine for port 443. It proxies the requests for the wiki to
port 8080.
This is what happens:
Thus, this is what happens:
- The user tells the browser to visit "transjovian.org"
- The browser sends a request for "http://transjovian.org" (on port 80)
- Apache redirects this to "https://transjovian.org/" by default (now on port 443)
- This is proxied to "http://transjovian.org:8080/" (now on port 8080)
* The user tells the browser to visit `transjovian.org`
* The browser sends a request for `http://transjovian.org` (on port 80)
* Apache redirects this to `https://transjovian.org/` by default (now on port 443)
* This is proxied to `http://transjovian.org:8080/` (no encryption, on port 8080)
Restart the server, gracefully:
@@ -59,45 +60,41 @@ Restart the server, gracefully:
apachectl graceful
```
To serve both HTTP and HTTPS, don't redirect from the first virtual host to the
second instead just proxy to the wiki like you did for the second virtual
host: use a copy of the "ProxyPassMatch" directive instead of "RewriteEngine on"
and "RewriteRule".
To serve both HTTP and HTTPS, don't redirect from the first virtual
host to the second instead just proxy to the wiki like you did for
the second virtual host: use a copy of the "ProxyPassMatch" directive
instead of "RewriteEngine on" and "RewriteRule".
## Access
Access control is not part of Oddmu. By default, the wiki is editable by all.
This is most likely not what you want unless you're running it stand-alone,
unconnected to the Internet a person memex on your laptop, for example.
Access control is not part of the wiki. By default, the wiki is
editable by all. This is most likely not what you want unless you're
running it stand-alone, unconnected to the Internet.
The following instructions create user accounts with passwords just for Oddmu.
These users are not real users on the web server and don't have access to a
shell, mail, or any other service.
Create a new password file called ".htpasswd" and add the user "alex". The "-c"
flag creates the file.
Create a new password file called ".htpasswd" and add the user "alex":
```
cd /home/oddmu
htpasswd -c .htpasswd alex
```
To add more users, don't use the "-c" option or you will overwrite the existing
file. To add another user, use no option at all.
To add more users, don't use the "-c" option or you will overwrite it!
To add another user:
```
htpasswd .htpasswd berta
```
To remove a user, use the "-D" option.
To remove a user:
```
htpasswd -D .htpasswd berta
```
Modify your site configuration and protect the "/edit/", "/save/", "/add/",
"/append/", "/upload/" and "/drop/" URLs with a password by adding the following
to your "<VirtualHost \*:443>" section:
Modify your site configuration and protect the "/edit/", "/save/",
"/add/", "/append/", "/upload/" and "/drop/" URLs with a password by
adding the following to your "<VirtualHost \*:443>" section:
```
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
@@ -110,9 +107,10 @@ to your "<VirtualHost \*:443>" section:
## Serve static files
If you want to serve static files as well, add a document root to your webserver
configuration. In this case, the document root is the directory where all the
data files are. Apache does not serve files such as ".htpasswd".
If you want to serve static files as well, add a document root to your
webserver configuration. In this case, the document root is the
directory where all the data files are. Apache will not serve files
such as ".htpasswd".
```
DocumentRoot /home/oddmu
@@ -121,28 +119,26 @@ DocumentRoot /home/oddmu
</Directory>
```
Make sure that none of the subdirectories look like the wiki paths "/view/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/" or
"/search/". For example, create a file called "robots.txt" containing the
following, telling all robots that they're not welcome.
Make sure that none of the subdirectories look like the wiki paths
"/view/", "/edit/", "/save/", "/add/", "/append/", "/upload/",
"/drop/" or "/search". For example, create a file called "robots.txt"
containing the following, telling all robots that they're not welcome.
```
User-agent: *
Disallow: /
```
You site now serves "/robots.txt" without interfering with the wiki, and without
needing a wiki page.
Another option would be to create a CSS file and use it with a <link> element in
all the templates instead of relying on the <style> element.
You site now serves "/robots.txt" without interfering with the wiki,
and without needing a wiki page.
## Different logins for different access rights
What if you have a site with various subdirectories and each subdirectory is for
a different group of friends? You can set this up using your webserver. One way
to do this is to require specific usernames (which must have a password in the
password file mentioned above.
What if you have a site with various subdirectories and each
subdirectory is for a different group of friends? You can set this up
using your webserver. One way to do this is to require specific
usernames (which must have a password in the password file mentioned
above.
This requires a valid login by the user "alex" or "berta":
@@ -154,8 +150,9 @@ This requires a valid login by the user "alex" or "berta":
## Private wikis
Based on the above, you can prevent people from _reading_ the wiki. The
"LocationMatch" must cover all the URLs in order to protect everything.
Based on the above, you can prevent people from _reading_ the wiki.
The "LocationMatch" must cover the "/view/" URLs in order to protect
*everything*.
```
<Location />
@@ -168,11 +165,12 @@ Based on the above, you can prevent people from _reading_ the wiki. The
## Virtual hosting
Virtual hosting in this context means that the program serves two different
sites for two different domains from the same machine. Oddmu doesn't support
that, but your webserver does. Therefore, start an Oddmu instance for every
domain name, each listening on a different port. Then set up your web server
such that ever domain acts as a reverse proxy to a different Oddmu instance.
Virtual hosting in this context means that the program serves two
different sites for two different domains from the same machine. Oddmu
doesn't support that, but your webserver does. Therefore, start an
Oddmu instance for every domain name, each listening on a different
port. Then set up your web server such that ever domain acts as a
reverse proxy to a different Oddmu instance.
# SEE ALSO
@@ -190,12 +188,6 @@ https://httpd.apache.org/docs/current/mod/mod_proxy.html
"Robot exclusion standard" on Wikipedia.
https://en.wikipedia.org/wiki/Robot_exclusion_standard
"<style>: The Style Information element"
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style
"<link>: The External Resource Link element"
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-HTML" "1" "2023-10-09"
.TH "ODDMU-HTML" "1" "2023-09-22"
.PP
.SH NAME
.PP
@@ -17,16 +17,16 @@ oddmu-html - render Oddmu page HTML from the command-line
.PP
.SH DESCRIPTION
.PP
The "html" subcommand opens the Markdown file for the given page name (appending
the ".\&md" extension) and prints the HTML to STDOUT without invoking the
"view.\&html" template.\&
The "html" subcommand opens the Markdown file for the given page name
(appending the ".\&md" extension) and prints the HTML to STDOUT without
invoking the "view.\&html" template.\&
.PP
.SH OPTIONS
.PP
\fB-view\fR
.RS 4
Use the "view.\&html" template to render the page.\& Without this, the HTML
lacks html and body tags.\&
Use the "view.\&html" template to render the page.\& Without this,
the HTML will lack html and body tags.\&
.PP
.RE
.SH EXAMPLE
@@ -41,8 +41,8 @@ oddmu html README
.PP
.SH ENVIRONMENT
.PP
The ODDMU_WEBFINGER environment variable has no effect in this situation.\&
Fediverse accounts are not linked to their profile pages.\&
The ODDMU_WEBFINGER environment variable has no effect in this
situation.\& Fediverse accounts are not linked to their profile pages.\&
.PP
.SH SEE ALSO
.PP

View File

@@ -10,15 +10,15 @@ oddmu-html - render Oddmu page HTML from the command-line
# DESCRIPTION
The "html" subcommand opens the Markdown file for the given page name (appending
the ".md" extension) and prints the HTML to STDOUT without invoking the
"view.html" template.
The "html" subcommand opens the Markdown file for the given page name
(appending the ".md" extension) and prints the HTML to STDOUT without
invoking the "view.html" template.
# OPTIONS
*-view*
Use the "view.html" template to render the page. Without this, the HTML
lacks html and body tags.
Use the "view.html" template to render the page. Without this,
the HTML will lack html and body tags.
# EXAMPLE
@@ -30,8 +30,8 @@ oddmu html README
# ENVIRONMENT
The ODDMU_WEBFINGER environment variable has no effect in this situation.
Fediverse accounts are not linked to their profile pages.
The ODDMU_WEBFINGER environment variable has no effect in this
situation. Fediverse accounts are not linked to their profile pages.
# SEE ALSO

View File

@@ -1,57 +0,0 @@
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-MISSING" "1" "2023-11-05"
.PP
.SH NAME
.PP
oddmu-missing - list missing pages from the command-line
.PP
.SH SYNOPSIS
.PP
\fBoddmu missing\fR
.PP
.SH DESCRIPTION
.PP
The "missing" subcommand lists pages and their local links that are missing.\&
.PP
Any links that seem like they might point outside the wiki are ignored: links
that start with a slash "/" and links that start with a known URL schema
(currently: "http:", "https:", "ftp:", "mailto:", "gopher:", "gemini:",
"finger:").\&
.PP
Notably, links that start with ".\&.\&/" are reported as missing.\&
.PP
.SH EXAMPLE
.PP
Looking for broken links:
.PP
.nf
.RS 4
oddmu missing
.fi
.RE
.PP
Result:
.PP
.nf
.RS 4
Page Missing
README github\&.com/pemistahl/lingua-go
.fi
.RE
.PP
This shows how the README file had a link where the URL was missing the scheme
"https://".\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-replace\fR(1), \fIoddmu-missing\fR(7)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

View File

@@ -1,46 +0,0 @@
ODDMU-MISSING(1)
# NAME
oddmu-missing - list missing pages from the command-line
# SYNOPSIS
*oddmu missing*
# DESCRIPTION
The "missing" subcommand lists pages and their local links that are missing.
Any links that seem like they might point outside the wiki are ignored: links
that start with a slash "/" and links that start with a known URL schema
(currently: "http:", "https:", "ftp:", "mailto:", "gopher:", "gemini:",
"finger:").
Notably, links that start with "../" are reported as missing.
# EXAMPLE
Looking for broken links:
```
oddmu missing
```
Result:
```
Page Missing
README github.com/pemistahl/lingua-go
```
This shows how the README file had a link where the URL was missing the scheme
"https://".
# SEE ALSO
_oddmu_(1), _oddmu-replace_(1), _oddmu-missing_(7)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -1,68 +0,0 @@
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-NOTIFY" "1" "2023-11-06"
.PP
.SH NAME
.PP
oddmu-notify - add links to changes.\&md, index.\&md, and hashtag pages
.PP
.SH SYNOPSIS
.PP
\fBoddmu notify\fR \fIpage names.\&.\&.\&\fR
.PP
.SH DESCRIPTION
.PP
The "notify" subcommand takes all the page names provided (without the ".\&md"
extension) and adds links to it from other pages.\&
.PP
A new link is added to the \fBchanges\fR page if it doesn'\&t exist.\& The current date
of the machine Oddmu is running on is used as the heading.\& If the requested link
already exists on the changes page, it is moved up to the current date.\& If that
leaves an old date without any links, that date heading is removed.\&
.PP
A page whose name starts with an ISO date (YYYY-MM-DD, e.\&g.\& "2023-10-28") is
called a \fBblog\fR page.\&
.PP
A link is created from the \fBindex\fR page to blog pages if and only if the blog
pages are from the current year.\& The idea is that the front page contains a lot
of links to blog posts but eventually the blog post links are moved onto archive
pages (one per year, for example), or simply deleted.\& As when editing older
pages, links to those pages should not get added to the index as if those older
pages were new again.\& A link on the changes page is enough.\&
.PP
For every \fBhashtag\fR used on the pages named, another link might be created.\& If a
page named like the hashtag exists, a backlink is added to it.\& A hashtag
consists of a number sign ('\&#'\&) followed by Unicode letters, numbers or the
underscore ('\&_'\&).\& Thus, a hashtag ends with punctuation or whitespace.\&
.PP
If a link already exists but it'\&s title is no longer correct, it is updated.\&
.PP
New links added for blog pages are added at the top of the first unnumbered list
using the asterisk ('\&*'\&).\& If no such list exists, a new one is started at the
bottom of the page.\& This allows you to have a different unnumbered list further
up on the page, as long as it uses the minus for items ('\&-'\&).\&
.PP
.SH EXAMPLE
.PP
After writing the file "2023-11-05-climate.\&md" containing the hashtag
"#Climate", add links to it from "index.\&md", "changes.\&md", and "Climate.\&md" (if
it exists):
.PP
.nf
.RS 4
oddmu notify 2023-11-05-climate
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

View File

@@ -1,59 +0,0 @@
ODDMU-NOTIFY(1)
# NAME
oddmu-notify - add links to changes.md, index.md, and hashtag pages
# SYNOPSIS
*oddmu notify* _page names..._
# DESCRIPTION
The "notify" subcommand takes all the page names provided (without the ".md"
extension) and adds links to it from other pages.
A new link is added to the *changes* page if it doesn't exist. The current date
of the machine Oddmu is running on is used as the heading. If the requested link
already exists on the changes page, it is moved up to the current date. If that
leaves an old date without any links, that date heading is removed.
A page whose name starts with an ISO date (YYYY-MM-DD, e.g. "2023-10-28") is
called a *blog* page.
A link is created from the *index* page to blog pages if and only if the blog
pages are from the current year. The idea is that the front page contains a lot
of links to blog posts but eventually the blog post links are moved onto archive
pages (one per year, for example), or simply deleted. As when editing older
pages, links to those pages should not get added to the index as if those older
pages were new again. A link on the changes page is enough.
For every *hashtag* used on the pages named, another link might be created. If a
page named like the hashtag exists, a backlink is added to it. A hashtag
consists of a number sign ('#') followed by Unicode letters, numbers or the
underscore ('\_'). Thus, a hashtag ends with punctuation or whitespace.
If a link already exists but it's title is no longer correct, it is updated.
New links added for blog pages are added at the top of the first unnumbered list
using the asterisk ('\*'). If no such list exists, a new one is started at the
bottom of the page. This allows you to have a different unnumbered list further
up on the page, as long as it uses the minus for items ('-').
# EXAMPLE
After writing the file "2023-11-05-climate.md" containing the hashtag
"#Climate", add links to it from "index.md", "changes.md", and "Climate.md" (if
it exists):
```
oddmu notify 2023-11-05-climate
```
# SEE ALSO
_oddmu_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-REPLACE" "1" "2023-11-24"
.TH "ODDMU-REPLACE" "1" "2023-09-22"
.PP
.SH NAME
.PP
@@ -13,36 +13,30 @@ oddmu-replace - replace text in Oddmu pages from the command-line
.PP
.SH SYNOPSIS
.PP
\fBoddmu replace\fR [-confirm] [-regexp] \fIterm\fR \fIreplacement\fR
\fBoddmu replace\fR [-confirm] \fIregexp\fR \fIreplacement\fR
.PP
.SH DESCRIPTION
.PP
The "replace" subcommand does a search and replace on all the Markdown files in
the current directory and its subdirectories.\&
The "replace" subcommand replaces the Markdown files in the current
directory (!\&), returning the replace result as a Markdown-formatted
list.\&
.PP
.SH OPTIONS
.PP
\fB-confirm\fR
.RS 4
By default, the replacement doesn'\&t save the changes made.\& Instead, a
unified diff is produced and printed.\& Given this option, the changed
Markdown files are saved to disk.\&
.PP
.RE
\fB-regexp\fR
.RS 4
By default, the term to be replaced is just a string.\& With this flag,
the term is a regular expression and the replacement can contain
backreferences ($1, $2, $3, etc.\&) to capture groups.\&
By default, the replacement doesn'\&t save the changes made.\&
Instead, a unified diff is produced and printed.\& Given this
option, the changed Markdown files are saved to disk.\&
.PP
.RE
.SH EXAMPLE
.PP
Replace "Oddmu" in the Markdown files of the current directory:
Replace for "oddmu" in the Markdown files of the current directory:
.PP
.nf
.RS 4
oddmu replace Oddmu Oddµ
oddmu replace oddmu
.fi
.RE
.PP
@@ -50,51 +44,18 @@ Result:
.PP
.nf
.RS 4
--- README\&.md~
+++ README\&.md
(diff omitted)
1 file would be changed\&.
This is a dry run\&. Use -confirm to make it happen\&.
Replace oddmu: 1 result
* [Oddµ: A minimal wiki](README) (5)
.fi
.RE
.PP
.SH NOTES
.PP
This is the equivalent of using \fIsed\fR(1) with the --quiet, --regexp-extended,
--in-place=~ and --expression command with the s command
"s/regexp/replacement/g" except that it prints a unified diff per default
instead of making any changes and the regexp rules differ slightly.\&
.PP
The search is case-sensitive.\& To make it case-insensitive, search for a regular
expression that sets the case-insensitive flag, e.\&g.\& "(?\&i)oddmu".\&
.PP
.SH SECURITY
.PP
Consider creating a backup before doing replacements!\&
.PP
The following Bash script creates a copy of the current directory using hard
links.\& If you'\&re in a directory called "wiki", it creates a sibling directory
called "wiki-2023-11-24" (using the current date) full of links.\& This takes
little space and time.\& It works as a backup as long as you don'\&t use an
application that edits files in place.\& Most programs overwrite old files by
creating new files with the same name, so you should be safe.\&
.PP
.nf
.RS 4
#!/usr/bin/bash
d=$(basename $(pwd))
t=$(date --iso-8601)
echo Creating a snapshot of $d in \&.\&./$d-$t
rsync --link-dest "\&.\&./$d" --archive \&. "\&.\&./$d-$t/"
.fi
.RE
.PP
The above wouldn'\&t work for database files, for example.\& There, the database
changes the file in place thus the file is changed in the backup directory as
well.\& For Oddmu and the usual text editors, it works.\& If you use Emacs, don'\&t
set \fIbackup-by-copying\fR, \fIbackup-by-copying-when-linked\fR and related variables.\&
This is the equivalent of using \fIsed\fR(1) with the --quiet,
--regexp-extended, --in-place=~ and --expression command with the s
command "s/regexp/replacement/g" except that it prints a unified diff
per default instead of making any changes and the regexp rules differ
slightly.\&
.PP
.SH SEE ALSO
.PP

View File

@@ -6,78 +6,43 @@ oddmu-replace - replace text in Oddmu pages from the command-line
# SYNOPSIS
*oddmu replace* [-confirm] [-regexp] _term_ _replacement_
*oddmu replace* [-confirm] _regexp_ _replacement_
# DESCRIPTION
The "replace" subcommand does a search and replace on all the Markdown files in
the current directory and its subdirectories.
The "replace" subcommand replaces the Markdown files in the current
directory (!), returning the replace result as a Markdown-formatted
list.
# OPTIONS
*-confirm*
By default, the replacement doesn't save the changes made. Instead, a
unified diff is produced and printed. Given this option, the changed
Markdown files are saved to disk.
*-regexp*
By default, the term to be replaced is just a string. With this flag,
the term is a regular expression and the replacement can contain
backreferences ($1, $2, $3, etc.) to capture groups.
By default, the replacement doesn't save the changes made.
Instead, a unified diff is produced and printed. Given this
option, the changed Markdown files are saved to disk.
# EXAMPLE
Replace "Oddmu" in the Markdown files of the current directory:
Replace for "oddmu" in the Markdown files of the current directory:
```
oddmu replace Oddmu Oddµ
oddmu replace oddmu
```
Result:
```
--- README.md~
+++ README.md
(diff omitted)
1 file would be changed.
This is a dry run. Use -confirm to make it happen.
Replace oddmu: 1 result
* [Oddµ: A minimal wiki](README) (5)
```
# NOTES
This is the equivalent of using _sed_(1) with the --quiet, --regexp-extended,
\--in-place=~ and --expression command with the s command
"s/regexp/replacement/g" except that it prints a unified diff per default
instead of making any changes and the regexp rules differ slightly.
The search is case-sensitive. To make it case-insensitive, search for a regular
expression that sets the case-insensitive flag, e.g. "(?i)oddmu".
# SECURITY
Consider creating a backup before doing replacements!
The following Bash script creates a copy of the current directory using hard
links. If you're in a directory called "wiki", it creates a sibling directory
called "wiki-2023-11-24" (using the current date) full of links. This takes
little space and time. It works as a backup as long as you don't use an
application that edits files in place. Most programs overwrite old files by
creating new files with the same name, so you should be safe.
```
#!/usr/bin/bash
d=$(basename $(pwd))
t=$(date --iso-8601)
echo Creating a snapshot of $d in ../$d-$t
rsync --link-dest "../$d" --archive . "../$d-$t/"
```
The above wouldn't work for database files, for example. There, the database
changes the file in place thus the file is changed in the backup directory as
well. For Oddmu and the usual text editors, it works. If you use Emacs, don't
set _backup-by-copying_, _backup-by-copying-when-linked_ and related variables.
This is the equivalent of using _sed_(1) with the --quiet,
\--regexp-extended, --in-place=~ and --expression command with the s
command "s/regexp/replacement/g" except that it prints a unified diff
per default instead of making any changes and the regexp rules differ
slightly.
# SEE ALSO

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-SEARCH" "1" "2023-10-10"
.TH "ODDMU-SEARCH" "1" "2023-09-19"
.PP
.SH NAME
.PP
@@ -13,29 +13,24 @@ oddmu-search - search the Oddmu pages from the command-line
.PP
.SH SYNOPSIS
.PP
\fBoddmu search\fR [-extract] [-page \fIn\fR] \fIterms.\&.\&.\&\fR
\fBoddmu search\fR [-page \fIn\fR] \fIterms.\&.\&.\&\fR
.PP
.SH DESCRIPTION
.PP
The "search" subcommand searches the Markdown files in the current
directory.\&
directory (!\&), returning the search result as a Markdown-formatted
list.\&
.PP
Be default, this returns a Markdown-formatted list suitable for pasting into
Oddmu pages.\&
.PP
See \fIoddmu-search\fR(7) for more information of how pages are searched, sorted and
scored.\&
The use of a trigram index makes it possible to find substrings and
for the word order not to matter, but it also makes the search results
a bit harder to understand.\& See \fIoddmu-search\fR(7) for more.\&
.PP
.SH OPTIONS
.PP
\fB-extract\fR
.RS 4
Print search extracts for interactive use from the command-line.\&
.RE
\fB-page\fR \fIn\fR
.RS 4
Search results are paginated and by default only the first page is
shown.\& This option allows you to view other pages.\&
Search results are paginated and by default only the first
page is shown.\& This option allows you to view other pages.\&
.PP
.RE
.SH EXAMPLE

View File

@@ -6,26 +6,23 @@ oddmu-search - search the Oddmu pages from the command-line
# SYNOPSIS
*oddmu search* [-extract] [-page _n_] _terms..._
*oddmu search* [-page _n_] _terms..._
# DESCRIPTION
The "search" subcommand searches the Markdown files in the current
directory.
directory (!), returning the search result as a Markdown-formatted
list.
Be default, this returns a Markdown-formatted list suitable for pasting into
Oddmu pages.
See _oddmu-search_(7) for more information of how pages are searched, sorted and
scored.
The use of a trigram index makes it possible to find substrings and
for the word order not to matter, but it also makes the search results
a bit harder to understand. See _oddmu-search_(7) for more.
# OPTIONS
*-extract*
Print search extracts for interactive use from the command-line.
*-page* _n_
Search results are paginated and by default only the first page is
shown. This option allows you to view other pages.
Search results are paginated and by default only the first
page is shown. This option allows you to view other pages.
# EXAMPLE

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-SEARCH" "7" "2023-10-28"
.TH "ODDMU-SEARCH" "7" "2023-09-18"
.PP
.SH NAME
.PP
@@ -17,60 +17,23 @@ oddmu-search - understanding the Oddmu search engine
.PP
.SH DESCRIPTION
.PP
The wiki keeps an index of all the hash tags and page titles in memory.\& Using
hashtags and predicates in your queries speeds them up because fewer files are
opened.\&
The index indexes trigrams.\& Each group of three characters is a
trigram.\& A document with content "This is a test" is turned to lower
case and indexed under the trigrams "thi", "his", "is ", "s i", " is",
"is ", "s a", " a ", "a t", " te", "tes", "est".\&
.PP
A hashtag starts with a number sign ('\&#'\&) and contains numbers, letters, and the
underscore ('\&_'\&).\&
Each query is split into words and then processed the same way.\& A
query with the words "this test" is turned to lower case and produces
the trigrams "thi", "his", "tes", "est".\& This means that the word
order is not considered when searching for documents.\&
.PP
Example: #old_school random encounter
This also means that there is no stemming.\& Searching for "testing"
won'\&t find "This is a test" because there are no matches for the
trigrams "sti", "tin", "ing".\&
.PP
The title predicate filters for pages where the term is contained in the page
title.\&
.PP
Example: title:geo title:cache zürich
.PP
The blog predicate filters for pages where the page name begins with an ISO date
like "2023-09-26" if true, or doesn'\&t begin with an ISO date if false.\&
.PP
Example: blog:false fountain
.PP
The sorting of all the pages does not depend on the number of matches or any
kind of score because computing the score is expensive as this requires the page
to be loaded from disk.\& Therefore, results are sorted by title:
.PP
.PD 0
.IP \(bu 4
If a page title matches the query string exactly, it gets sorted first.\&
.IP \(bu 4
If the page title contains the query string, it gets sorted next.\&
.IP \(bu 4
If the page name starts with a number, it is sorted descending.\&
.IP \(bu 4
All other pages follow, sorted ascending.\&
.PD
.PP
The effect is that first, the pages with matches in the page title are shown,
and then all the others.\& Within these two groups, the most recent blog posts are
shown first.\& This assumes that blog pages start with an ISO date like
"2023-09-16".\&
.PP
When searching for a hashtag, a page name (not the title!\&) matching the hashtag
exactly (without the leading '\&#'\&) is listed first, even if it doesn'\&t contain
the hashtag.\& It is assumed that this page offers some kind of introduction to
people searching for the hashtag.\&
.PP
Example: When people click on the hashtag "#Oddµ" and a page named "Oddµ" exists
(in other words, the file "Oddµ.\&md" exists), it is prepended to the results even
if it doesn'\&t have the hashtag "#Oddµ" and even if it has a title of "Oddµ, a
minimal wiki" (which wouldn'\&t be an exact match).\&
.PP
The score and highlighting of snippets is used to help visitors decide which
links to click.\&
.PP
Each document found is scored.\& Each of the following increases the score by one
point:
These trigrams are looked up in the index, resulting in the list of
documents.\& Each document found is then scored.\& Each of the following
increases the score by one point:
.PP
.PD 0
.IP \(bu 4
@@ -85,9 +48,37 @@ a word matches at the end of a word
a word matches as a whole word
.PD
.PP
A document with content "This is a test" when searched with the phrase "this
test" therefore gets a score of 8: the entire phrase does not match but each
word gets four points.\&
A document with content "This is a test" when searched with the phrase
"this test" therefore gets a score of 8: the entire phrase does not
match but each word gets four points.\&
.PP
Trigrams are sometimes strange: In a text containing the words "main"
and "rail", a search for "mail" returns a match because the trigrams
"mai" and "ail" are found.\& In this situation, the result has a score
of 0.\&
.PP
The sorting of all the pages, however, does not depend on scoring!\&
Computing the score is expensive because the page must be loaded from
disk.\& Therefore, results are sorted by title:
.PP
.PD 0
.IP \(bu 4
If the page title contains the query string, it gets sorted first.\&
.IP \(bu 4
If the page name (the filename!\&) begins with a number, it is sorted
descending.\&
.IP \(bu 4
All other pages follow, sorted ascending.\&
.PD
.PP
The effect is that first, the pages with matches in the page title are
shown, and then all the others.\& Within these two groups, the most
recent blog posts are shown first.\& This assumes that blog pages start
with an ISO date like "2023-09-16".\&
.PP
The score and highlighting of snippets is used to help visitors decide
which links to click.\& A score of 0 indicates that all the trigrams
were found but \fIno exact matches\fR for any of the terms.\&
.PP
.SH SEE ALSO
.PP

View File

@@ -10,54 +10,23 @@ oddmu-search - understanding the Oddmu search engine
# DESCRIPTION
The wiki keeps an index of all the hash tags and page titles in memory. Using
hashtags and predicates in your queries speeds them up because fewer files are
opened.
The index indexes trigrams. Each group of three characters is a
trigram. A document with content "This is a test" is turned to lower
case and indexed under the trigrams "thi", "his", "is ", "s i", " is",
"is ", "s a", " a ", "a t", " te", "tes", "est".
A hashtag starts with a number sign ('#') and contains numbers, letters, and the
underscore ('\_').
Each query is split into words and then processed the same way. A
query with the words "this test" is turned to lower case and produces
the trigrams "thi", "his", "tes", "est". This means that the word
order is not considered when searching for documents.
Example: #old_school random encounter
This also means that there is no stemming. Searching for "testing"
won't find "This is a test" because there are no matches for the
trigrams "sti", "tin", "ing".
The title predicate filters for pages where the term is contained in the page
title.
Example: title:geo title:cache zürich
The blog predicate filters for pages where the page name begins with an ISO date
like "2023-09-26" if true, or doesn't begin with an ISO date if false.
Example: blog:false fountain
The sorting of all the pages does not depend on the number of matches or any
kind of score because computing the score is expensive as this requires the page
to be loaded from disk. Therefore, results are sorted by title:
- If a page title matches the query string exactly, it gets sorted first.
- If the page title contains the query string, it gets sorted next.
- If the page name starts with a number, it is sorted descending.
- All other pages follow, sorted ascending.
The effect is that first, the pages with matches in the page title are shown,
and then all the others. Within these two groups, the most recent blog posts are
shown first. This assumes that blog pages start with an ISO date like
"2023-09-16".
When searching for a hashtag, a page name (not the title!) matching the hashtag
exactly (without the leading '#') is listed first, even if it doesn't contain
the hashtag. It is assumed that this page offers some kind of introduction to
people searching for the hashtag.
Example: When people click on the hashtag "#Oddµ" and a page named "Oddµ" exists
(in other words, the file "Oddµ.md" exists), it is prepended to the results even
if it doesn't have the hashtag "#Oddµ" and even if it has a title of "Oddµ, a
minimal wiki" (which wouldn't be an exact match).
The score and highlighting of snippets is used to help visitors decide which
links to click.
Each document found is scored. Each of the following increases the score by one
point:
These trigrams are looked up in the index, resulting in the list of
documents. Each document found is then scored. Each of the following
increases the score by one point:
- the entire phrase matches
- a word matches
@@ -65,9 +34,32 @@ point:
- a word matches at the end of a word
- a word matches as a whole word
A document with content "This is a test" when searched with the phrase "this
test" therefore gets a score of 8: the entire phrase does not match but each
word gets four points.
A document with content "This is a test" when searched with the phrase
"this test" therefore gets a score of 8: the entire phrase does not
match but each word gets four points.
Trigrams are sometimes strange: In a text containing the words "main"
and "rail", a search for "mail" returns a match because the trigrams
"mai" and "ail" are found. In this situation, the result has a score
of 0.
The sorting of all the pages, however, does not depend on scoring!
Computing the score is expensive because the page must be loaded from
disk. Therefore, results are sorted by title:
- If the page title contains the query string, it gets sorted first.
- If the page name (the filename!) begins with a number, it is sorted
descending.
- All other pages follow, sorted ascending.
The effect is that first, the pages with matches in the page title are
shown, and then all the others. Within these two groups, the most
recent blog posts are shown first. This assumes that blog pages start
with an ISO date like "2023-09-16".
The score and highlighting of snippets is used to help visitors decide
which links to click. A score of 0 indicates that all the trigrams
were found but _no exact matches_ for any of the terms.
# SEE ALSO

View File

@@ -1,62 +0,0 @@
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-STATIC" "1" "2023-11-05"
.PP
.SH NAME
.PP
oddmu-static - create a static copy of the site
.PP
.SH SYNOPSIS
.PP
\fBoddmu static\fR \fIdir-name\fR
.PP
.SH DESCRIPTION
.PP
The "static" subcommand generates a static copy of the pages in the current
directory and saves them in the given target directory.\& The target directory
must not exist to unser no existing files are clobbered.\&
.PP
All pages (files with the ".\&md" extension) are turned into HTML files (with the
".\&html" extension) using the "static.\&html" template.\& Links pointing to existing
pages get ".\&html" appended.\&
.PP
Hidden files and directories (starting with a ".\&") and backup files (ending with
a "~") are skipped.\&
.PP
All other files are \fIlinked\fR into the same directory.\&
.PP
.SH EXAMPLE
.PP
Generate a static copy of the site:
.PP
.nf
.RS 4
oddmu static \&.\&./archive
.fi
.RE
.PP
.SH LIMITATIONS
.PP
Links from files to pages do not get ".\&html" appended.\& This affects existing
HTML or XML files including SVG files.\&
.PP
Links to absolute URLs (starting with "/") are not changed at all.\& It is up to
you to migrate static folders and applications.\&
.PP
.SH ENVIRONMENT
.PP
The ODDMU_WEBFINGER environment variable has no effect in this situation.\&
Fediverse accounts are not linked to their profile pages.\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-templates\fR(5)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

View File

@@ -1,53 +0,0 @@
ODDMU-STATIC(1)
# NAME
oddmu-static - create a static copy of the site
# SYNOPSIS
*oddmu static* _dir-name_
# DESCRIPTION
The "static" subcommand generates a static copy of the pages in the current
directory and saves them in the given target directory. The target directory
must not exist to unser no existing files are clobbered.
All pages (files with the ".md" extension) are turned into HTML files (with the
".html" extension) using the "static.html" template. Links pointing to existing
pages get ".html" appended.
Hidden files and directories (starting with a ".") and backup files (ending with
a "~") are skipped.
All other files are _linked_ into the same directory.
# EXAMPLE
Generate a static copy of the site:
```
oddmu static ../archive
```
# LIMITATIONS
Links from files to pages do not get ".html" appended. This affects existing
HTML or XML files including SVG files.
Links to absolute URLs (starting with "/") are not changed at all. It is up to
you to migrate static folders and applications.
# ENVIRONMENT
The ODDMU_WEBFINGER environment variable has no effect in this situation.
Fediverse accounts are not linked to their profile pages.
# SEE ALSO
_oddmu_(1), _oddmu-templates_(5)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-TEMPLATES" "5" "2023-10-29" "File Formats Manual"
.TH "ODDMU-TEMPLATES" "5" "2023-09-22" "File Formats Manual"
.PP
.SH NAME
.PP
@@ -15,49 +15,38 @@ oddmu-templates - how to write the templates
.PP
The templates can refer to the following properties of a page:
.PP
\fI{{.\&Title}}\fR is the page title.\& If the page doesn'\&t provide its own title, the
page name is used.\&
\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, escaped for use in URLs.\& More
specifically, it is URI escaped except for the slashes.\& 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.\&
.PP
\fI{{.\&Today}}\fR is the current date, in ISO format.\& This is useful for "new page"
like links or forms (see \fBEXAMPLE\fR below).\&
.PP
For the \fIview.\&html\fR and \fIstatic.\&html\fR template:
For the \fIview.\&html\fR template:
.PP
\fI{{.\&Html}}\fR is the rendered Markdown, as HTML.\&
.PP
\fI{{.\&Hashtags}}\fR is an array of strings.\&
.PP
For the \fIdiff.\&html\fR template:
.PP
\fI{{.\&Diff}}\fR is the diff for this page.\& This is only computed on demand so it can
be used in other templates, too.\& It probably doesn'\&t make much sense to do so,
however.\&
.PP
For the \fIedit.\&html\fR template:
.PP
\fI{{printf "%s" .\&Body}}\fR is the Markdown, as a string (the data itself is a byte
array and that'\&s why we need to call \fIprintf\fR).\&
\fI{{printf "%s" .\&Body}}\fR is the Markdown, as a string (the data itself
is a byte array and that'\&s why we need to call \fIprintf\fR).\&
.PP
For the \fIsearch.\&html\fR template only:
.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
first page number is 1.\& The last page is expensive to dermine and so that isn'\&t
done.\&
\fI{{.\&Previous}}\fR, \fI{{.\&Page}}\fR, \fI{{.\&Next}}\fR and \fI{{.\&Last}}\fR are the
previous, current, next and last page number in the results since
doing arithmetics in templates is hard.\& The first page number is 1.\&
.PP
\fI{{.\&More}}\fR indicates if there are any more search results.\&
.PP
\fI{{.\&Results}}\fR indicates if there were any search results at all.\&
.PP
\fI{{.\&Items}}\fR is an array of pages, each containing a search result.\& A search
result is a page (with the properties seen above).\& Thus, to refer to them, you
need to use a \fI{{range .\&Items}}\fR\fI{{end}}\fR construct.\&
\fI{{.\&Items}}\fR is an array of pages, each containing a search result.\& A
search result is a page (with the properties seen above).\& Thus, to
refer to them, you need to use a \fI{{range .\&Items}}\fR\fI{{end}}\fR
construct.\&
.PP
For items in the search result:
.PP
@@ -71,8 +60,8 @@ For the \fIfeed.\&html\fR template:
.PP
\fI{{.\&Title}}\fR is the title of the underlying main page.\&
.PP
\fI{{.\&Date}}\fR is the date of the last update to the underlying main page, in RFC
822 format.\&
\fI{{.\&Date}}\fR is the date of the last update to the underlying main page,
in RFC 822 format.\&
.PP
\fI{{.\&Items}}\fR is an array of feed items.\&
.PP
@@ -82,17 +71,19 @@ For items in the feed:
.PP
\fI{{.\&Title}}\fR is the title of the page.\&
.PP
\fI{{.\&Html}}\fR is the rendered Markdown, as escaped (!\&) HTML.\&
\fI{{.\&Html}}\fR is the rendered Markdown, as HTML.\&
.PP
\fI{{.\&Hashtags}}\fR is an array of strings.\&
.PP
\fI{{.\&Date}}\fR, the date of the last update to this page, in RFC 822 format.\&
\fI{{.\&Date}}\fR, the date of the last update to this page, in RFC 822
format.\&
.PP
The \fIupload.\&html\fR template cannot refer to anything.\&
.PP
When calling the \fIsave\fR and \fIappend\fR action, the page name is taken from the URL
path and the page content is taken from the \fIbody\fR form parameter.\& To
illustrate, here'\&s how to edit the "welcome" page using \fIcurl\fR:
When calling the \fIsave\fR and \fIappend\fR action, the page name is taken
from the URL path and the page content is taken from the \fIbody\fR form
parameter.\& To illustrate, here'\&s how to edit the "welcome" page using
\fIcurl\fR:
.PP
.nf
.RS 4
@@ -101,76 +92,42 @@ curl --form body="Did you bring a towel?"
.fi
.RE
.PP
When calling the \fIsearch\fR action, the query is taken from the URL parameter \fIq\fR.\&
When calling the \fIsearch\fR action, the query is taken from the URL
parameter \fIq\fR.\&
.PP
.nf
.RS 4
curl http://localhost:8080/search/?q=towel
curl http://localhost:8080/search?q=towel
.fi
.RE
.PP
.SS Non-English hyphenation
.PP
Automatic hyphenation by the browser requires two things: The style sheet must
indicate "hyphen: auto" for an HTML element such as "body", and that element
must have a "lang" set (usually a two letter language code such as "de" for
German).\&
Automatic hyphenation by the browser requires two things: The style
sheet must indicate "hyphen: auto" for an HTML element such as "body",
and that element must have a "lang" set (usually a two letter language
code such as "de" for German).\&
.PP
Oddmu attempts to detect the correct language for each page.\& It assumes that
languages are not mixed on the same page.\& If you know that you'\&re only going to
use a small number of languages or just a single language!\& you can set the
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
codes, e.\&g.\& "en" or "en,de,fr,pt".\&
Oddmu attempts to detect the correct language for each page.\& It
assumes that languages are not mixed on the same page.\& If you know
that you'\&re only going to use a small number of languages or just a
single language!\& you can set the 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 the template 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.\&
"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 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.\&
.PP
.SH EXAMPLE
.PP
The following link in a template takes people to today'\&s page.\& If no such page
exists, they are redirected to the edit form where it can be created.\&
.PP
.nf
.RS 4
<a href="/view/{{\&.Today}}" accesskey="t">Today</a>
.fi
.RE
.PP
The following form allows people to edit the suggested page name.\&
.PP
.nf
.RS 4
<form role="new" 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>
.fi
.RE
.PP
.SH NOTES
.PP
The template are always used as-is, irrespective of the current directory.\&
Therefore, a link to a specific page must be \fIabsolute\fR or it'\&ll point to a
different page depending on the current directory.\&
.PP
Consider the link to "/view/index".\& No matter what page a visitor is looking,
this takes visitors to the top "index" page.\& If the link points to "index"
instead, it takes a visitor to the "index" page of the current directory.\&
.PP
Example: If a visitor is looking at "/view/projects/wiki" and follows a link to
"index", they end up on "/view/projects/index", not on "/view/index".\&
.PP
.SH SEE ALSO
SEE ALSO
.PP
\fIoddmu\fR(1)
.PP
@@ -180,15 +137,16 @@ https://developer.\&mozilla.\&org/en-US/docs/Learn/HTML
"Learn to style HTML using CSS"
https://developer.\&mozilla.\&org/en-US/docs/Learn/CSS
.PP
The "text/template" library explains how to write templates from a programmer
perspective.\& https://pkg.\&go.\&dev/text/template
The "text/template" library explains how to write templates from a
programmer perspective.\& https://pkg.\&go.\&dev/text/template
.PP
The "html/template" library explains how the templates are made more secure in a
HTML context.\& https://pkg.\&go.\&dev/html/template
The "html/template" library explains how the templates are made more
secure in a HTML context.\& https://pkg.\&go.\&dev/html/template
.PP
"Lingua" is the library used to detect languages.\&
https://github.\&com/pemistahl/lingua-go
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
Maintained by Alex Schroeder <alex@gnu.\&org>.\& Up-to-date sources can be
found at https://alexschroeder.\&ch/cgit/oddmu/.\&

View File

@@ -8,49 +8,38 @@ oddmu-templates - how to write the templates
The templates can refer to the following properties of a page:
_{{.Title}}_ is the page title. If the page doesn't provide its own title, the
page name is used.
_{{.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, escaped for use in URLs. More
specifically, it is URI escaped except for the slashes. The page name
doesn't include the _.md_ extension.
_{{.Dir}}_ is the page directory, percent-escaped except for the slashes.
_{{.Today}}_ is the current date, in ISO format. This is useful for "new page"
like links or forms (see *EXAMPLE* below).
For the _view.html_ and _static.html_ template:
For the _view.html_ template:
_{{.Html}}_ is the rendered Markdown, as HTML.
_{{.Hashtags}}_ is an array of strings.
For the _diff.html_ template:
_{{.Diff}}_ is the diff for this page. This is only computed on demand so it can
be used in other templates, too. It probably doesn't make much sense to do so,
however.
For the _edit.html_ template:
_{{printf "%s" .Body}}_ is the Markdown, as a string (the data itself is a byte
array and that's why we need to call _printf_).
_{{printf "%s" .Body}}_ is the Markdown, as a string (the data itself
is a byte array and that's why we need to call _printf_).
For the _search.html_ template only:
_{{.Previous}}_, _{{.Page}}_ and _{{.Next}}_ are the previous, current and next
page number in the results since doing arithmetics in templates is hard. The
first page number is 1. The last page is expensive to dermine and so that isn't
done.
_{{.Previous}}_, _{{.Page}}_, _{{.Next}}_ and _{{.Last}}_ are the
previous, current, next and last page number in the results since
doing arithmetics in templates is hard. The first page number is 1.
_{{.More}}_ indicates if there are any more search results.
_{{.Results}}_ indicates if there were any search results at all.
_{{.Items}}_ is an array of pages, each containing a search result. A search
result is a page (with the properties seen above). Thus, to refer to them, you
need to use a _{{range .Items}}_ … _{{end}}_ construct.
_{{.Items}}_ is an array of pages, each containing a search result. A
search result is a page (with the properties seen above). Thus, to
refer to them, you need to use a _{{range .Items}}_ … _{{end}}_
construct.
For items in the search result:
@@ -64,8 +53,8 @@ _{{.Name}}_ is the page name, escaped for use in URLs.
_{{.Title}}_ is the title of the underlying main page.
_{{.Date}}_ is the date of the last update to the underlying main page, in RFC
822 format.
_{{.Date}}_ is the date of the last update to the underlying main page,
in RFC 822 format.
_{{.Items}}_ is an array of feed items.
@@ -79,83 +68,55 @@ _{{.Html}}_ is the rendered Markdown, as escaped (!) HTML.
_{{.Hashtags}}_ is an array of strings.
_{{.Date}}_, the date of the last update to this page, in RFC 822 format.
_{{.Date}}_, the date of the last update to this page, in RFC 822
format.
The _upload.html_ template cannot refer to anything.
When calling the _save_ and _append_ action, the page name is taken from the URL
path and the page content is taken from the _body_ form parameter. To
illustrate, here's how to edit the "welcome" page using _curl_:
When calling the _save_ and _append_ action, the page name is taken
from the URL path and the page content is taken from the _body_ form
parameter. To illustrate, here's how to edit the "welcome" page using
_curl_:
```
curl --form body="Did you bring a towel?" \
http://localhost:8080/save/welcome
```
When calling the _search_ action, the query is taken from the URL parameter _q_.
When calling the _search_ action, the query is taken from the URL
parameter _q_.
```
curl http://localhost:8080/search/?q=towel
curl http://localhost:8080/search?q=towel
```
## Non-English hyphenation
Automatic hyphenation by the browser requires two things: The style sheet must
indicate "hyphen: auto" for an HTML element such as "body", and that element
must have a "lang" set (usually a two letter language code such as "de" for
German).
Automatic hyphenation by the browser requires two things: The style
sheet must indicate "hyphen: auto" for an HTML element such as "body",
and that element must have a "lang" set (usually a two letter language
code such as "de" for German).
Oddmu attempts to detect the correct language for each page. It assumes that
languages are not mixed on the same page. If you know that you're only going to
use a small number of languages or just a single language! you can set the
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
codes, e.g. "en" or "en,de,fr,pt".
Oddmu attempts to detect the correct language for each page. It
assumes that languages are not mixed on the same page. If you know
that you're only going to use a small number of languages or just a
single language! you can set the 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 the template 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.
"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 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.
# EXAMPLE
The following link in a template takes people to today's page. If no such page
exists, they are redirected to the edit form where it can be created.
```
<a href="/view/{{.Today}}" accesskey="t">Today</a>
```
The following form allows people to edit the suggested page name.
```
<form role="new" 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>
```
# NOTES
The template are always used as-is, irrespective of the current directory.
Therefore, a link to a specific page must be _absolute_ or it'll point to a
different page depending on the current directory.
Consider the link to "/view/index". No matter what page a visitor is looking,
this takes visitors to the top "index" page. If the link points to "index"
instead, it takes a visitor to the "index" page of the current directory.
Example: If a visitor is looking at "/view/projects/wiki" and follows a link to
"index", they end up on "/view/projects/index", not on "/view/index".
# SEE ALSO
SEE ALSO
_oddmu_(1)
@@ -165,15 +126,16 @@ https://developer.mozilla.org/en-US/docs/Learn/HTML
"Learn to style HTML using CSS"
https://developer.mozilla.org/en-US/docs/Learn/CSS
The "text/template" library explains how to write templates from a programmer
perspective. https://pkg.go.dev/text/template
The "text/template" library explains how to write templates from a
programmer perspective. https://pkg.go.dev/text/template
The "html/template" library explains how the templates are made more secure in a
HTML context. https://pkg.go.dev/html/template
The "html/template" library explains how the templates are made more
secure in a HTML context. https://pkg.go.dev/html/template
"Lingua" is the library used to detect languages.
https://github.com/pemistahl/lingua-go
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.
Maintained by Alex Schroeder <alex@gnu.org>. Up-to-date sources can be
found at https://alexschroeder.ch/cgit/oddmu/.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "1" "2023-11-24"
.TH "ODDMU" "1" "2023-09-22"
.PP
.SH NAME
.PP
@@ -31,19 +31,15 @@ is "index.\&md".\& If no such file exists, oddmu offers you to create the page.\
If your files don'\&t provide their own title ("# title"), the file name (without
".\&md") is used for the page title.\&
.PP
Every file can be viewed as feed by using the extension ".\&rss".\& The
feed items are based on links in bullet lists using the asterix
("*").\&
.PP
Subdirectories are created as necessary.\&
.PP
See \fIoddmu\fR(5) for details about the page formatting.\&
.PP
.SH CONFIGURATION
.PP
The template files are the HTML files in the working directory:
"add.\&html", "diff.\&html", "edit.\&html", "search.\&html", "upload.\&html" and
"view.\&html".\& Feel free to change the templates and restart the server.\&
The template files are the HTML files in the working directory: "add.\&html",
"edit.\&html", "search.\&html", "upload.\&html" and "view.\&html".\& Feel free to change
the templates and restart the server.\&
.PP
The first change you should make is to replace the name and email
address in the footer of "view.\&html".\& Look for "Your Name" and
@@ -52,7 +48,7 @@ address in the footer of "view.\&html".\& Look for "Your Name" and
The second change you should make is to replace the name, email
address and domain name in "feed.\&html".\& Look for "Your Name" and
"example.\&org".\& This second template is used to generate the RSS feeds
(despite its ".\&html" extension).\&
(despite it'\&s ".\&html" extension).\&
.PP
See \fIoddmu-templates\fR(5) for more.\&
.PP
@@ -75,20 +71,13 @@ server as a reverse proxy.\&
.PP
See \fIoddmu-apache\fR(5) for an example.\&
.PP
Oddmu assumes that all the users that can edit pages or upload files are trusted
users and therefore their content is trusted.\& Therefore, Oddmu doesn'\&t perform
HTML sanitization!\&
.PP
.SH OPTIONS
.PP
The oddmu program can be run on the command-line using various subcommands.\&
.PP
.PD 0
.IP \(bu 4
to generate the HTML for a single page, see \fIoddmu-html\fR(1)
.IP \(bu 4
to generate the HTML for the entire site, using Oddmu as a static site
generator, see \fIoddmu-static\fR(1)
to generate the HTML for a page, see \fIoddmu-html\fR(1)
.IP \(bu 4
to search a regular expression and replace it across all files, see
\fIoddmu-replace\fR(1)
@@ -96,18 +85,13 @@ to search a regular expression and replace it across all files, see
to emulate a search of the files, see \fIoddmu-search\fR(1); to understand how the
search engine indexes pages and how it sorts and scores results, see
\fIoddmu-search\fR(7)
.IP \(bu 4
to find missing pages (local links that go nowhere), see \fIoddmu-missing\fR(1)
.IP \(bu 4
to add links to changes, index and hashtag pages to pages you created locally,
see \fIoddmu-notify\fR(1)
.PD
.PP
.SH EXAMPLE
.PP
When saving a page, the page name is take from the URL and the page content is
taken from the "body" form parameter.\& To illustrate, here'\&s how to edit a page
using \fIcurl\fR(1):
When saving a page, the page name is take from the URL and the page
content is taken from the "body" form parameter.\& To illustrate, here'\&s
how to edit a page using \fIcurl\fR(1):
.PP
.nf
.RS 4
@@ -123,6 +107,13 @@ This is a minimal wiki.\& There is no version history.\& It'\&s well suited as a
on social media.\& The wiki serves as the text repository that results from these
discussions.\&
.PP
The wiki lists no recent changes.\& The expectation is that the people that care
were involved in the discussions beforehand.\&
.PP
The wiki also produces no feed.\& The assumption is that announcements are made on
social media: blogs, news aggregators, discussion forums, the fediverse, but
humans.\&
.PP
The idea is that the webserver handles as many tasks as possible.\& It logs
requests, does rate limiting, handles encryption, gets the certificates, and so
on.\& The web server acts as a reverse proxy and the wiki ends up being a content
@@ -132,97 +123,24 @@ on your point of view.\& See \fIoddmu-apache\fR(5).\&
.SH NOTES
.PP
Page names are filenames with ".\&md" appended.\& If your filesystem cannot handle
it, it can'\&t be a page name.\& Filenames can contain slashes and oddmu creates
subdirectories as necessary.\&
it, it can'\&t be a page name.\&
.PP
Files may not end with a tilde ('\&~'\&) these are backup files.\& When saving pages
and file uploads, the old file renamed to the backup file unless the backup file
is less than an hour old, thus collapsing all edits made in an hour into a
single diff when comparing backup and current version.\&
The pages are indexed as the server starts and the index is kept in memory.\& If
you have a ton of pages, this takes a lot of memory.\&
.PP
The \fBindex\fR page is the default page.\& People visiting the "root" of the site are
redirected to "/view/index".\&
.PP
The \fBchanges\fR page is where links to new and changed files are added.\& As an
author, you can prevent this from happening by deselecting the checkbox "Add
link to the list of changes.\&" The changes page can be edited like every other
page, so it'\&s easy to undo mistakes.\&
.PP
Links on the changes page are grouped by date.\& When new links are added, the
current date of the machine Oddmu is running on is used.\& If a link already
exists on the changes page, it is moved up to the current date.\& If that leaves
an old date without any links, that date heading is removed.\&
.PP
If you want to link to the changes page, you need to do this yourself.\& Add a
link from the index, for example.\& The "view.\&html" template currently doesn'\&t do
it.\& See \fIoddmu-templates\fR(5) if you want to add the link to the template.\&
.PP
A page whose name starts with an ISO date (YYYY-MM-DD, e.\&g.\& "2023-10-28") is
called a \fBblog\fR page.\& When creating or editing blog pages, links to it are added
from other pages.\&
.PP
If the blog page name starts with the current year, a link is created from the
index page back to the blog page being created or edited.\& Again, you can prevent
this from happening by deselecting the checkbox "Add link to the list of
changes.\&" The index page can be edited like every other page, so it'\&s easy to
undo mistakes.\&
.PP
For every \fBhashtag\fR used, another link might be created.\& If a page named like
the hashtag exists, a backlink is added to it, linking to the new or edited blog
page.\&
.PP
If a link to the new or edited blog page already exists but it'\&s title is no
longer correct, it is updated.\&
.PP
New links added for blog pages are added at the top of the first unnumbered list
using the asterisk ('\&*'\&).\& If no such list exists, a new one is started at the
bottom of the page.\& This allows you to have a different unnumbered list further
up on the page, as long as it uses the minus for items ('\&-'\&).\&
.PP
Changes made locally do not create any links on the changes page, the index page
or on any hashtag pages.\& See \fIoddmu-notify\fR(1) for a way to add the necessary
links to the changes page and possibly to the index and hashtag pages.\&
.PP
A hashtag consists of a number sign ('\&#'\&) followed by Unicode letters, numbers
or the underscore ('\&_'\&).\& Thus, a hashtag ends with punctuation or whitespace.\&
.PP
The page names, titles and hashtags are loaded into memory when the server
starts.\& If you have a lot of pages, this takes a lot of memory.\& If you change
the files while the wiki runs, changes to names (creating, renaming or deleting
files), titles or hashtags confuse Oddmu.\& Restart the program in order to
resolve this.\&
Files may not end with a tilde ('\&~'\&) these are backup files.\&
.PP
You cannot edit uploaded files.\& If you upload a file called "hello.\&txt" and
attempt to edit it by using "/edit/hello.\&txt" you create a page with the name
"hello.\&txt.\&md" instead.\&
attempt to edit it by using "/edit/hello.\&txt" you will create a page with the
name "hello.\&txt.\&md" instead.\&
.PP
You cannot delete uploaded files via the web but you can delete regular wiki
pages by saving an empty page.\&
pages by saving an empty file.\&
.PP
.SH SEE ALSO
.PP
.PD 0
.IP \(bu 4
\fIoddmu\fR(5), about the markup syntax and how feeds are generated based on link lists
.IP \(bu 4
\fIoddmu.\&service\fR(5), on how to run the service under systemd
.IP \(bu 4
\fIoddmu-apache\fR(5), on how to set up a web server such as Apache
.IP \(bu 4
\fIoddmu-html\fR(1), on how to render a page from the command-line
.IP \(bu 4
\fIoddmu-missing\fR(1), on how to find broken local links from the command-line
.IP \(bu 4
\fIoddmu-replace\fR(1), on how to search and replace text from the command-line
.IP \(bu 4
\fIoddmu-search\fR(1), on how to run a search from the command-line
.IP \(bu 4
\fIoddmu-search\fR(7), on how search works
.IP \(bu 4
\fIoddmu-static\fR(1), on generating a static site from the command-line
.IP \(bu 4
\fIoddmu-templates\fR(5), on how to write the HTML templates
.PD
\fIoddmu\fR(5), \fIoddmu.\&service\fR(5), oddmu-apache_(5), \fIoddmu-html\fR(1),
\fIoddmu-replace\fR(1), \fIoddmu-search\fR(1), \fIoddmu-search\fR(7), \fIoddmu-feed\fR(1)
.PP
.SH AUTHORS
.PP

View File

@@ -25,8 +25,7 @@ If your files don't provide their own title ("# title"), the file name (without
".md") is used for the page title.
Every file can be viewed as feed by using the extension ".rss". The
feed items are based on links in bullet lists using the asterix
("\*").
feed items are based on links in bullet lists using the asterix ("*").
Subdirectories are created as necessary.
@@ -34,9 +33,9 @@ See _oddmu_(5) for details about the page formatting.
# CONFIGURATION
The template files are the HTML files in the working directory:
"add.html", "diff.html", "edit.html", "search.html", "upload.html" and
"view.html". Feel free to change the templates and restart the server.
The template files are the HTML files in the working directory: "add.html",
"edit.html", "search.html", "upload.html" and "view.html". Feel free to change
the templates and restart the server.
The first change you should make is to replace the name and email
address in the footer of "view.html". Look for "Your Name" and
@@ -45,7 +44,7 @@ address in the footer of "view.html". Look for "Your Name" and
The second change you should make is to replace the name, email
address and domain name in "feed.html". Look for "Your Name" and
"example.org". This second template is used to generate the RSS feeds
(despite its ".html" extension).
(despite it's ".html" extension).
See _oddmu-templates_(5) for more.
@@ -68,31 +67,22 @@ server as a reverse proxy.
See _oddmu-apache_(5) for an example.
Oddmu assumes that all the users that can edit pages or upload files are trusted
users and therefore their content is trusted. Therefore, Oddmu doesn't perform
HTML sanitization!
# OPTIONS
The oddmu program can be run on the command-line using various subcommands.
- to generate the HTML for a single page, see _oddmu-html_(1)
- to generate the HTML for the entire site, using Oddmu as a static site
generator, see _oddmu-static_(1)
- to generate the HTML for a page, see _oddmu-html_(1)
- to search a regular expression and replace it across all files, see
_oddmu-replace_(1)
- to emulate a search of the files, see _oddmu-search_(1); to understand how the
search engine indexes pages and how it sorts and scores results, see
_oddmu-search_(7)
- to find missing pages (local links that go nowhere), see _oddmu-missing_(1)
- to add links to changes, index and hashtag pages to pages you created locally,
see _oddmu-notify_(1)
# EXAMPLE
When saving a page, the page name is take from the URL and the page content is
taken from the "body" form parameter. To illustrate, here's how to edit a page
using _curl_(1):
When saving a page, the page name is take from the URL and the page
content is taken from the "body" form parameter. To illustrate, here's
how to edit a page using _curl_(1):
```
curl --form body="Did you bring a towel?" \
@@ -106,6 +96,9 @@ _secondary_ medium: collaboration and conversation happens elsewhere, in chat,
on social media. The wiki serves as the text repository that results from these
discussions.
The wiki lists no recent changes. The expectation is that the people that care
were involved in the discussions beforehand.
The idea is that the webserver handles as many tasks as possible. It logs
requests, does rate limiting, handles encryption, gets the certificates, and so
on. The web server acts as a reverse proxy and the wiki ends up being a content
@@ -115,85 +108,24 @@ on your point of view. See _oddmu-apache_(5).
# NOTES
Page names are filenames with ".md" appended. If your filesystem cannot handle
it, it can't be a page name. Filenames can contain slashes and oddmu creates
subdirectories as necessary.
it, it can't be a page name.
Files may not end with a tilde ('~') these are backup files. When saving pages
and file uploads, the old file renamed to the backup file unless the backup file
is less than an hour old, thus collapsing all edits made in an hour into a
single diff when comparing backup and current version.
The pages are indexed as the server starts and the index is kept in memory. If
you have a ton of pages, this takes a lot of memory.
The *index* page is the default page. People visiting the "root" of the site are
redirected to "/view/index".
The *changes* page is where links to new and changed files are added. As an
author, you can prevent this from happening by deselecting the checkbox "Add
link to the list of changes." The changes page can be edited like every other
page, so it's easy to undo mistakes.
Links on the changes page are grouped by date. When new links are added, the
current date of the machine Oddmu is running on is used. If a link already
exists on the changes page, it is moved up to the current date. If that leaves
an old date without any links, that date heading is removed.
If you want to link to the changes page, you need to do this yourself. Add a
link from the index, for example. The "view.html" template currently doesn't do
it. See _oddmu-templates_(5) if you want to add the link to the template.
A page whose name starts with an ISO date (YYYY-MM-DD, e.g. "2023-10-28") is
called a *blog* page. When creating or editing blog pages, links to it are added
from other pages.
If the blog page name starts with the current year, a link is created from the
index page back to the blog page being created or edited. Again, you can prevent
this from happening by deselecting the checkbox "Add link to the list of
changes." The index page can be edited like every other page, so it's easy to
undo mistakes.
For every *hashtag* used, another link might be created. If a page named like
the hashtag exists, a backlink is added to it, linking to the new or edited blog
page.
If a link to the new or edited blog page already exists but it's title is no
longer correct, it is updated.
New links added for blog pages are added at the top of the first unnumbered list
using the asterisk ('\*'). If no such list exists, a new one is started at the
bottom of the page. This allows you to have a different unnumbered list further
up on the page, as long as it uses the minus for items ('-').
Changes made locally do not create any links on the changes page, the index page
or on any hashtag pages. See _oddmu-notify_(1) for a way to add the necessary
links to the changes page and possibly to the index and hashtag pages.
A hashtag consists of a number sign ('#') followed by Unicode letters, numbers
or the underscore ('\_'). Thus, a hashtag ends with punctuation or whitespace.
The page names, titles and hashtags are loaded into memory when the server
starts. If you have a lot of pages, this takes a lot of memory. If you change
the files while the wiki runs, changes to names (creating, renaming or deleting
files), titles or hashtags confuse Oddmu. Restart the program in order to
resolve this.
Files may not end with a tilde ('~') these are backup files.
You cannot edit uploaded files. If you upload a file called "hello.txt" and
attempt to edit it by using "/edit/hello.txt" you create a page with the name
"hello.txt.md" instead.
attempt to edit it by using "/edit/hello.txt" you will create a page with the
name "hello.txt.md" instead.
You cannot delete uploaded files via the web but you can delete regular wiki
pages by saving an empty page.
pages by saving an empty file.
# SEE ALSO
- _oddmu_(5), about the markup syntax and how feeds are generated based on link lists
- _oddmu.service_(5), on how to run the service under systemd
- _oddmu-apache_(5), on how to set up a web server such as Apache
- _oddmu-html_(1), on how to render a page from the command-line
- _oddmu-missing_(1), on how to find broken local links from the command-line
- _oddmu-replace_(1), on how to search and replace text from the command-line
- _oddmu-search_(1), on how to run a search from the command-line
- _oddmu-search_(7), on how search works
- _oddmu-static_(1), on generating a static site from the command-line
- _oddmu-templates_(5), on how to write the HTML templates
_oddmu_(5), _oddmu.service_(5), oddmu-apache_(5), _oddmu-html_(1),
_oddmu-replace_(1), _oddmu-search_(1), _oddmu-search_(7)
# AUTHORS

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "5" "2023-11-12" "File Formats Manual"
.TH "ODDMU" "5" "2023-09-21" "File Formats Manual"
.PP
.SH NAME
.PP
@@ -13,41 +13,26 @@ oddmu - text formatting of wiki pages
.PP
.SH SYNTAX
.PP
The wiki pages are UTF-8 encoded Markdown files (with the ".\&md" extension).\&
Oddmu links are regular Markdown links to page names (without the ".\&md"
extension):
The wiki pages are UTF-8 encoded Markdown files.\&
.PP
.nf
.RS 4
[link text](page-name)
.fi
.RE
.PP
The page name has to be percent-encoded.\& See the section "Percent Encoding".\&
.PP
If you link to the actual Markdown file (with the ".\&md" extension), then Oddmu
serves the Markdown file!\&
.PP
There are three Oddµ-specific extensions: local links, hashtags and fediverse
account links.\& The Markdown library used features some additional extensions,
most importantly tables and definition lists.\&
There are three Oddµ-specific extensions: local links, hashtags and
fediverse account links.\& The Markdown library used features some
additional extensions, most importantly tables and definition lists.\&
.PP
.SS Local links
.PP
Local links use double square brackets [[like this]].\& Oddmu does not treat
underscores like spaces, so [[like this]] and [[like_this]] link to different
destinations and are served by different files: "like this.\&md" and
"like_this.\&md".\&
Local links use double square brackets [[like this]].\&
.PP
.SS Hashtags
.PP
Hashtags are single word links to searches for themselves.\& Use the underscore to
use hashtags consisting of multiple words.\& Hashtags are distinguished from page
titles because there is no space after the hash.\&
Hashtags are single word links to searches for themselves.\& Use the
underscore to use hashtags consisting of multiple words.\& Hashtags are
distinguished from page titles because there is no space after the
hash.\&
.PP
.nf
.RS 4
# Example
# Title
Text
@@ -55,11 +40,6 @@ Text
.fi
.RE
.PP
When a page containing hashtags is saved, a link to that page is added to every
page with the same name as the hashtag, if it exists.\& In the example above, if
the file "Tag.\&md" or the file "Another_Tag.\&md" exists, a link to the Example
page is added.\&
.PP
.SS Tables
.PP
A table with footers and a columnspan:
@@ -89,116 +69,58 @@ Internet
.PP
.SS Fediverse account links
.PP
Fediverse accounts look a bit like an at sign followed by an email address, e.\&g.\&
@alex@alexschroeder.\&ch.\& When rendering a page, these turn into a username linked
to a profile page.\& In this case, "@alex" would be linked to
"https://alexschroeder.\&ch/users/alex".\&
Fediverse accounts look a bit like an at sign followed by an email
address, e.\&g.\& @alex@alexschroeder.\&ch.\& When rendering a page, these
turn into a username linked to a profile page.\& In this case, "@alex"
would be linked to "https://alexschroeder.\&ch/users/alex".\&
.PP
In many cases, this works as is.\& In reality, however, the link to the profile
page needs to be retrieved via webfinger.\& Oddµ does that in the background, and
as soon as the information is available, the actual profile link is used when
pages are rendered.\& In the example above, the result would be
"https://social.\&alexschroeder.\&ch/@alex".\&
In many cases, this will work as is.\& In reality, however, the link to
the profile page needs to be retrieved via webfinger.\& Oddµ does that
in the background, and as soon as the information is available, the
actual profile link is used when pages are rendered.\& In the example
above, the result would be "https://social.\&alexschroeder.\&ch/@alex".\&
.PP
As this sort of packground network activity is surprising, it is not enabled by
default.\& Set the environment variable ODDMU_WEBFINGER to "1" in order to enable
this.\&
As this sort of packground network activity is surprising, it is not
enabled by default.\& Set the environment variable ODDMU_WEBFINGER to
"1" in order to enable this.\&
.PP
.SS Other extensions
.PP
The Markdown processor comes with a few extensions:
.PP
.PD 0
.IP \(bu 4
emphasis markers inside words are ignored
.IP \(bu 4
fenced code blocks are supported
.IP \(bu 4
autolinking of "naked" URLs are supported
.IP \(bu 4
strikethrough using two tildes is supported (~~like this~~)
.IP \(bu 4
it is strict about prefix heading rules
.IP \(bu 4
you can specify an id for headings ({#id})
.IP \(bu 4
trailing backslashes turn into line breaks
.PD
.PP
.SH FEEDS
.PP
Every file can be viewed as feed by using the extension ".\&rss".\& The feed items
are based on links in bullet lists using the asterix ("*").\& The items must
point to local pages.\& This is why the link may not contain two forward slashes
("//").\&
.PP
Assume this is the index page.\& The feed would be "/view/index.\&rss".\& It would
contain the pages "Arianism", "Donatism" and "Monophysitism" but it would not
contain the pages "Feed" and "About" since the list items don'\&t start with an
asterix.\&
.PP
.nf
.RS 4
# Main Page
Hello and welcome! Here are some important links:
- [Feed](index\&.rss)
- [About](about)
Recent posts:
* [Arianism](arianism)
* [Donatism](donatism)
* [Monophysitism](monophysitism)
.fi
.RE
.PP
The feed contains at most 10 items, starting at the top.\&
\fB emphasis markers inside words are ignored
\fR fenced code blocks are supported
\fB autolinking of "naked" URLs are supported
\fR strikethrough using two tildes is supported (~~like this~~)
\fB it is strict about prefix heading rules
\fR you can specify an id for headings ({#id})
\fB trailing backslashes turn into line breaks
\fR MathJax is supported (but needs a separte setup)
.PP
.SH PERCENT ENCODING
.PP
If you use Markdown links to local pages, you must percent-encode the link
target.\& Any character that is not an "unreserved character" according to RFC
3986 might need to be encoded.\& The unreserved characters are a-z, A-Z, 0-9, as
well as the four characters '\&-'\&, '\&_'\&, '\&.\&'\& and '\&~'\&.\&
If you use Markdown links to local pages, you must percent-encode the
link target.\& Any character that is not an "unreserved character"
according to RFC 3986 might need to be encoded.\& The unreserved
characters are a-z, A-Z, 0-9, as well as the four characters '\&-'\&,
\&'\&_'\&, '\&.\&'\& and '\&~'\&.\&
.PP
Percent-encoding means that each character is converted into one or more bytes,
and each byte is represented as a percent character followed by a hexadecimal
representation.\&
Percent-encoding means that each character is converted into one or
more bytes, and each byte is represented as a percent character
followed by a hexadecimal representation.\&
.PP
Realistically, what probably works best is to use a browser.\& If you type
"http://example.\&org/Alex Schröder" into the address bar, you'\&ll get sent to the
example domain.\& If you now copy the address and paste it back into a text
editor, you'\&ll get "http://example.\&org/Alex%20Schr%C3%B6der" and that'\&s how
you'\&ll learn that the Space is encoded by %20 and that the character '\&ö'\& is
encoded by %C3%B6.\& To link to the page "Alex Schröder" you would write something
like this: "[Alex](Alex%20Schr%C3%B6der)".\&
.PP
Another thing that'\&s common is that your page name contains a colon.\&
This is legal.\& The URL parser might still reject it.\& If you run the
"missing" subcommand, you'\&ll get to see error: "first path segment in
URL cannot contain colon".\& The solution is to prepend ".\&/"!\&
.PP
Example:
.PP
.nf
.RS 4
[2021-10-15 Re: Mark It Down](2021-10-15_Re:_Mark_It_Down)
.fi
.RE
.PP
Fixed:
.PP
.nf
.RS 4
[2021-10-15 Re: Mark It Down](\&./2021-10-15_Re:_Mark_It_Down)
.fi
.RE
Realistically, what probably works best is to use a browser.\& If you
type "http://example.\&org/Alex Schröder" into the address bar, you'\&ll
get sent to the example domain.\& If you now copy the address and paste
it back into a text editor, you'\&ll get
"http://example.\&org/Alex%20Schr%C3%B6der" and that'\&s how you'\&ll learn
that the Space is encoded by %20 and that the character '\&ö'\& is encoded
by %C3%B6.\& To link to the page "Alex Schröder" you would write
something like this: "[Alex](Alex%20Schr%C3%B6der)".\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-missing\fR(1)
\fIoddmu\fR(1)
.PP
This wiki uses the Go Markdown library.\&
https://github.\&com/gomarkdown/markdown

View File

@@ -6,49 +6,31 @@ oddmu - text formatting of wiki pages
# SYNTAX
The wiki pages are UTF-8 encoded Markdown files (with the ".md" extension).
Oddmu links are regular Markdown links to page names (without the ".md"
extension):
The wiki pages are UTF-8 encoded Markdown files.
```
[link text](page-name)
```
The page name has to be percent-encoded. See the section "Percent Encoding".
If you link to the actual Markdown file (with the ".md" extension), then Oddmu
serves the Markdown file!
There are three Oddµ-specific extensions: local links, hashtags and fediverse
account links. The Markdown library used features some additional extensions,
most importantly tables and definition lists.
There are three Oddµ-specific extensions: local links, hashtags and
fediverse account links. The Markdown library used features some
additional extensions, most importantly tables and definition lists.
## Local links
Local links use double square brackets [[like this]]. Oddmu does not treat
underscores like spaces, so [[like this]] and [[like_this]] link to different
destinations and are served by different files: "like this.md" and
"like_this.md".
Local links use double square brackets [[like this]].
## Hashtags
Hashtags are single word links to searches for themselves. Use the underscore to
use hashtags consisting of multiple words. Hashtags are distinguished from page
titles because there is no space after the hash.
Hashtags are single word links to searches for themselves. Use the
underscore to use hashtags consisting of multiple words. Hashtags are
distinguished from page titles because there is no space after the
hash.
```
# Example
# Title
Text
#Tag #Another_Tag
```
When a page containing hashtags is saved, a link to that page is added to every
page with the same name as the hashtag, if it exists. In the example above, if
the file "Tag.md" or the file "Another_Tag.md" exists, a link to the Example
page is added.
## Tables
A table with footers and a columnspan:
@@ -74,44 +56,45 @@ Internet
## Fediverse account links
Fediverse accounts look a bit like an at sign followed by an email address, e.g.
@alex@alexschroeder.ch. When rendering a page, these turn into a username linked
to a profile page. In this case, "@alex" would be linked to
"https://alexschroeder.ch/users/alex".
Fediverse accounts look a bit like an at sign followed by an email
address, e.g. @alex@alexschroeder.ch. When rendering a page, these
turn into a username linked to a profile page. In this case, "@alex"
would be linked to "https://alexschroeder.ch/users/alex".
In many cases, this works as is. In reality, however, the link to the profile
page needs to be retrieved via webfinger. Oddµ does that in the background, and
as soon as the information is available, the actual profile link is used when
pages are rendered. In the example above, the result would be
"https://social.alexschroeder.ch/@alex".
In many cases, this will work as is. In reality, however, the link to
the profile page needs to be retrieved via webfinger. Oddµ does that
in the background, and as soon as the information is available, the
actual profile link is used when pages are rendered. In the example
above, the result would be "https://social.alexschroeder.ch/@alex".
As this sort of packground network activity is surprising, it is not enabled by
default. Set the environment variable ODDMU_WEBFINGER to "1" in order to enable
this.
As this sort of packground network activity is surprising, it is not
enabled by default. Set the environment variable ODDMU_WEBFINGER to
"1" in order to enable this.
## Other extensions
The Markdown processor comes with a few extensions:
- emphasis markers inside words are ignored
- fenced code blocks are supported
- autolinking of "naked" URLs are supported
- strikethrough using two tildes is supported (~~like this~~)
- it is strict about prefix heading rules
- you can specify an id for headings ({#id})
- trailing backslashes turn into line breaks
* emphasis markers inside words are ignored
* fenced code blocks are supported
* autolinking of "naked" URLs are supported
* strikethrough using two tildes is supported (~~like this~~)
* it is strict about prefix heading rules
* you can specify an id for headings ({#id})
* trailing backslashes turn into line breaks
* MathJax is supported (but needs a separte setup)
# FEEDS
Every file can be viewed as feed by using the extension ".rss". The feed items
are based on links in bullet lists using the asterix ("\*"). The items must
point to local pages. This is why the link may not contain two forward slashes
("//").
Every file can be viewed as feed by using the extension ".rss". The
feed items are based on links in bullet lists using the asterix ("*").
The items must point to local pages. This is why the link may not
contain two forward slashes ("//").
Assume this is the index page. The feed would be "/view/index.rss". It would
contain the pages "Arianism", "Donatism" and "Monophysitism" but it would not
contain the pages "Feed" and "About" since the list items don't start with an
asterix.
Assume this is the index page. The feed would be "/view/index.rss". It
would contain the pages "Arianism", "Donatism" and "Monophysitism" but
it would not contain the pages "Feed" and "About" since the list items
don't start with an asterix.
```
# Main Page
@@ -132,43 +115,28 @@ The feed contains at most 10 items, starting at the top.
# PERCENT ENCODING
If you use Markdown links to local pages, you must percent-encode the link
target. Any character that is not an "unreserved character" according to RFC
3986 might need to be encoded. The unreserved characters are a-z, A-Z, 0-9, as
well as the four characters '-', '\_', '.' and '~'.
If you use Markdown links to local pages, you must percent-encode the
link target. Any character that is not an "unreserved character"
according to RFC 3986 might need to be encoded. The unreserved
characters are a-z, A-Z, 0-9, as well as the four characters '-',
'\_', '.' and '~'.
Percent-encoding means that each character is converted into one or more bytes,
and each byte is represented as a percent character followed by a hexadecimal
representation.
Percent-encoding means that each character is converted into one or
more bytes, and each byte is represented as a percent character
followed by a hexadecimal representation.
Realistically, what probably works best is to use a browser. If you type
"http://example.org/Alex Schröder" into the address bar, you'll get sent to the
example domain. If you now copy the address and paste it back into a text
editor, you'll get "http://example.org/Alex%20Schr%C3%B6der" and that's how
you'll learn that the Space is encoded by %20 and that the character 'ö' is
encoded by %C3%B6. To link to the page "Alex Schröder" you would write something
like this: "[Alex](Alex%20Schr%C3%B6der)".
Another thing that's common is that your page name contains a colon.
This is legal. The URL parser might still reject it. If you run the
"missing" subcommand, you'll get to see error: "first path segment in
URL cannot contain colon". The solution is to prepend "./"!
Example:
```
[2021-10-15 Re: Mark It Down](2021-10-15_Re:_Mark_It_Down)
```
Fixed:
```
[2021-10-15 Re: Mark It Down](./2021-10-15_Re:_Mark_It_Down)
```
Realistically, what probably works best is to use a browser. If you
type "http://example.org/Alex Schröder" into the address bar, you'll
get sent to the example domain. If you now copy the address and paste
it back into a text editor, you'll get
"http://example.org/Alex%20Schr%C3%B6der" and that's how you'll learn
that the Space is encoded by %20 and that the character 'ö' is encoded
by %C3%B6. To link to the page "Alex Schröder" you would write
something like this: "[Alex](Alex%20Schr%C3%B6der)".
# SEE ALSO
_oddmu_(1), _oddmu-missing_(1)
_oddmu_(1)
This wiki uses the Go Markdown library.
https://github.com/gomarkdown/markdown

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU.SERVICE" "5" "2023-10-28"
.TH "ODDMU.SERVICE" "5" "2023-09-21"
.PP
.SH NAME
.PP
@@ -13,11 +13,12 @@ oddmu.\&service - how to setup Oddmu using systemd
.PP
.SS DESCRIPTION
.PP
Here'\&s how to setup a wiki using systemd such that it starts automatically when
the system boots and gets restarted automatically when it crashes.\&
Here'\&s how to setup a wiki using systemd such that it starts
automatically when the system boots and gets restarted automatically
when it crashes.\&
.PP
First, create a new user called "oddmu" with it'\&s own home directory but without
a login.\&
First, create a new user called "oddmu" with it'\&s own home directory
but without a login.\&
.PP
.nf
.RS 4
@@ -25,15 +26,15 @@ adduser --system --home /home/oddmu oddmu
.fi
.RE
.PP
The directory "/home/oddmu" contains the templates and all the data files.\& Copy
all the templates files ending in ".\&html" from the source distribution to
"/home/oddmu".\&
The directory "/home/oddmu" contains the templates and all the data
files.\& Copy all the tempaltes files ending in ".\&html" from the source
distribution to "/home/oddmu".\&
.PP
If you want to keep everything in one place, copy the binary "oddmu" and the
service file "oddmu.\&service" to "/home/oddmu", too.\&
If you want to keep everything in one place, copy the binary "oddmu"
and the service file "oddmu.\&service" to "/home/oddmu", too.\&
.PP
Edit the `oddmu.\&service` file.\& These are the lines you most likely have to take
care of:
Edit the `oddmu.\&service` file.\& These are the lines you most likely
have to take care of:
.PP
.nf
.RS 4
@@ -53,7 +54,8 @@ systemctl enable --now oddmu
.fi
.RE
.PP
You should be able to visit the wiki at http://localhost:8080/.\&
You should be able to visit the wiki at
http://localhost:8080/.\&
.PP
Check the log:
.PP
@@ -71,15 +73,6 @@ journalctl --follow --unit oddmu
.fi
.RE
.PP
For it to restart when the server reboots:
.PP
.nf
.RS 4
sudo ln -sf /home/oddmu/oddmu\&.service
/etc/systemd/system/multi-user\&.target\&.wants/
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIsystemd.\&exec\fR(5), \fIcapabilities\fR(7)

View File

@@ -6,25 +6,26 @@ oddmu.service - how to setup Oddmu using systemd
## DESCRIPTION
Here's how to setup a wiki using systemd such that it starts automatically when
the system boots and gets restarted automatically when it crashes.
Here's how to setup a wiki using systemd such that it starts
automatically when the system boots and gets restarted automatically
when it crashes.
First, create a new user called "oddmu" with it's own home directory but without
a login.
First, create a new user called "oddmu" with it's own home directory
but without a login.
```
adduser --system --home /home/oddmu oddmu
```
The directory "/home/oddmu" contains the templates and all the data files. Copy
all the templates files ending in ".html" from the source distribution to
"/home/oddmu".
The directory "/home/oddmu" contains the templates and all the data
files. Copy all the tempaltes files ending in ".html" from the source
distribution to "/home/oddmu".
If you want to keep everything in one place, copy the binary "oddmu" and the
service file "oddmu.service" to "/home/oddmu", too.
If you want to keep everything in one place, copy the binary "oddmu"
and the service file "oddmu.service" to "/home/oddmu", too.
Edit the `oddmu.service` file. These are the lines you most likely have to take
care of:
Edit the `oddmu.service` file. These are the lines you most likely
have to take care of:
```
ExecStart=/home/oddmu/oddmu
@@ -40,7 +41,8 @@ ln -s /home/oddmu/oddmu.service /etc/systemd/system/
systemctl enable --now oddmu
```
You should be able to visit the wiki at http://localhost:8080/.
You should be able to visit the wiki at
http://localhost:8080/.
Check the log:
@@ -54,13 +56,6 @@ Follow the log:
journalctl --follow --unit oddmu
```
For it to restart when the server reboots:
```
sudo ln -sf /home/oddmu/oddmu.service \
/etc/systemd/system/multi-user.target.wants/
```
# SEE ALSO
_oddmu_(1), _systemd.exec_(5), _capabilities_(7)

View File

@@ -1,119 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/google/subcommands"
"io"
"io/fs"
"net/url"
"os"
"path/filepath"
"strings"
)
type missingCmd struct {
}
func (*missingCmd) Name() string { return "missing" }
func (*missingCmd) Synopsis() string { return "list missing pages" }
func (*missingCmd) Usage() string {
return `missing:
Listing pages with links to missing pages.
`
}
func (cmd *missingCmd) SetFlags(f *flag.FlagSet) {
}
func (cmd *missingCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return missingCli(os.Stdout, f.Args())
}
func missingCli(w io.Writer, args []string) subcommands.ExitStatus {
names := make(map[string]bool)
err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
filename := path
if info.IsDir() || strings.HasPrefix(filename, ".") {
return nil
}
if strings.HasSuffix(filename, ".md") {
name := strings.TrimSuffix(filename, ".md")
names[name] = true
} else {
names[filename] = false
}
return nil
})
if err != nil {
fmt.Fprintln(w, err)
return subcommands.ExitFailure
}
found := false
for name, isPage := range names {
if !isPage {
continue
}
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(os.Stderr, "Loading %s: %s\n", name, err)
return subcommands.ExitFailure
}
for _, link := range p.links() {
u, err := url.Parse(link)
if err != nil {
fmt.Fprintln(os.Stderr, name, err)
return subcommands.ExitFailure
}
if u.Scheme == "" && u.Path != "" && !strings.HasPrefix(u.Path, "/") {
// feeds can work if the matching page works
u.Path = strings.TrimSuffix(u.Path, ".rss")
// links to the source file can work
u.Path = strings.TrimSuffix(u.Path, ".md")
// pages containing a colon need the ./ prefix
u.Path = strings.TrimPrefix(u.Path, "./")
// check whether the destinatino is a known page
destination, err := url.PathUnescape(u.Path)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot decode %s: %s\n", link, err)
return subcommands.ExitFailure
}
_, ok := names[destination]
if !ok {
if !found {
fmt.Fprintln(w, "Page\tMissing")
found = true
}
fmt.Fprintf(w, "%s\t%s\n", name, link)
}
}
}
}
if !found {
fmt.Fprintln(w, "No missing pages found.")
}
return subcommands.ExitSuccess
}
// links parses the page content and returns an array of link destinations.
func (p *Page) links() []string {
var links []string
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:
links = append(links, string(v.Destination))
}
}
return ast.GoToNext
})
return links
}

View File

@@ -1,18 +0,0 @@
package main
import (
"bytes"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestMissingCmd(t *testing.T) {
b := new(bytes.Buffer)
s := missingCli(b, nil)
assert.Equal(t, subcommands.ExitSuccess, s)
r := `Page Missing
index test
`
assert.Equal(t, r, b.String())
}

View File

@@ -1,47 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"io"
"os"
)
type notifyCmd struct {
}
func (*notifyCmd) Name() string { return "notify" }
func (*notifyCmd) Synopsis() string { return "add links to changes.md, index.md, and hashtag pages" }
func (*notifyCmd) Usage() string {
return `notify <page name> ...:
For each page, add entries to changes.md, index.md, and hashtag pages.
This is useful when writing pages offline and replicates the behaviour
triggered by the "Add link to the list of changes" checkbox, online.
`
}
func (cmd *notifyCmd) SetFlags(f *flag.FlagSet) {
}
func (cmd *notifyCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return notifyCli(os.Stdout, f.Args())
}
func notifyCli(w io.Writer, args []string) subcommands.ExitStatus {
index.load()
for _, name := range args {
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(os.Stderr, "Loading %s: %s\n", name, err)
return subcommands.ExitFailure
}
err = p.notify()
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %s\n", name, err)
return subcommands.ExitFailure
}
}
return subcommands.ExitSuccess
}

57
page.go
View File

@@ -7,11 +7,8 @@ import (
"log"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
)
// Page is a struct containing information about a single page. Title
@@ -30,21 +27,19 @@ type Page struct {
Hashtags []string
}
var blogRe = regexp.MustCompile(`^\d\d\d\d-\d\d-\d\d`)
// santizeStrict uses bluemonday to sanitize the HTML away. No elements are allowed except for the b tag because this is
// used for snippets.
// santize uses bluemonday to sanitize the HTML. An exceptions is made
// for the b tag because this is used for snippets.
func sanitizeStrict(s string) template.HTML {
policy := bluemonday.StrictPolicy()
policy.AllowElements("b")
return template.HTML(policy.Sanitize(s))
}
// unsafeBytes does not use bluemonday to sanitize the HTML used for pages. This is where you make changes if you want
// to be more lenient. If you look at the git repository, there are older versions containing the function sanitizeBytes
// which would do elaborate checking.
func unsafeBytes(bytes []byte) template.HTML {
return template.HTML(bytes)
// santizeBytes uses bluemonday to sanitize the HTML.
func sanitizeBytes(bytes []byte) template.HTML {
policy := bluemonday.UGCPolicy()
policy.AllowAttrs("class").OnElements("a") // for hashtags
return template.HTML(policy.SanitizeBytes(bytes))
}
// nameEscape returns the page name safe for use in URLs. That is,
@@ -68,7 +63,8 @@ func (p *Page) save() error {
s := bytes.ReplaceAll(p.Body, []byte{'\r'}, []byte{})
if len(s) == 0 {
p.removeFromIndex()
return os.Rename(filename, filename+"~")
_ = os.Rename(filename, filename+"~")
return os.Remove(filename)
}
p.Body = s
p.updateIndex()
@@ -80,22 +76,10 @@ func (p *Page) save() error {
return err
}
}
backup(filename)
_ = os.Rename(filename, filename+"~")
return os.WriteFile(filename, s, 0644)
}
// backup a file by renaming (!) it unless the existing backup is less than an hour old. A backup gets a tilde appended
// to it ("~"). This is true even if the file refers to a binary file like "image.png" and most applications don't know
// what to do with a file called "image.png~".
func backup(filename string) error {
backup := filename + "~"
fi, err := os.Stat(backup)
if err != nil || time.Since(fi.ModTime()).Minutes() >= 60 {
return os.Rename(filename, backup)
}
return nil
}
// loadPage loads a Page given a name. The filename loaded is that
// Page.Name with the ".md" extension. The Page.Title is set to the
// Page.Name (and possibly changed, later). The Page.Body is set to
@@ -110,9 +94,9 @@ func loadPage(name string) (*Page, error) {
return &Page{Title: name, Name: name, Body: body, Language: ""}, nil
}
// handleTitle extracts the title from a Page and sets Page.Title, if any. If replace is true, the page title is also
// removed from Page.Body. Make sure not to save this! This is only for rendering. In a template, the title is a
// separate attribute and is not repeated in the HTML.
// handleTitle extracts the title from a Page and sets Page.Title, if
// any. If replace is true, the page title is also removed from
// Page.Body. Make sure not to save this! This is only for rendering.
func (p *Page) handleTitle(replace bool) {
s := string(p.Body)
m := titleRegexp.FindStringSubmatch(s)
@@ -138,23 +122,10 @@ func (p *Page) summarize(q string) {
p.Language = language(t)
}
// isBlog returns true if the page name starts with an ISO date
func (p *Page) isBlog() bool {
name := path.Base(p.Name)
return blogRe.MatchString(name)
}
// Dir returns the directory the page is in. 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 := filepath.Dir(p.Name)
if d == "." {
return ""
}
return d + "/"
}
// Today returns the date, as a string, for use in templates.
func (p *Page) Today() string {
return time.Now().Format(time.DateOnly)
return d
}

View File

@@ -2,6 +2,7 @@ package main
import (
"github.com/stretchr/testify/assert"
"os"
"regexp"
"testing"
)
@@ -18,25 +19,30 @@ But yearn for blue sky`)}
assert.Regexp(t, regexp.MustCompile("^My back"), string(p.Body))
}
// wipes testdata
func TestPageDir(t *testing.T) {
cleanup(t, "testdata/dir")
_ = os.RemoveAll("testdata")
index.load()
p := &Page{Name: "testdata/dir/moon", Body: []byte(`# Moon
p := &Page{Name: "testdata/moon", Body: []byte(`# Moon
From bed to bathroom
A slow shuffle in the dark
Moonlight floods the aisle`)}
p.save()
o, err := loadPage("testdata/dir/moon")
o, err := loadPage("testdata/moon")
assert.NoError(t, err, "load page")
assert.Equal(t, p.Body, o.Body)
assert.FileExists(t, "testdata/dir/moon.md")
assert.FileExists(t, "testdata/moon.md")
// Saving an empty page deletes it.
p = &Page{Name: "testdata/dir/moon", Body: []byte("")}
p = &Page{Name: "testdata/moon", Body: []byte("")}
p.save()
assert.NoFileExists(t, "testdata/dir/moon.md")
assert.NoFileExists(t, "testdata/moon.md")
// But the backup still exists.
assert.FileExists(t, "testdata/dir/moon.md~")
assert.FileExists(t, "testdata/moon.md~")
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}

View File

@@ -4,7 +4,6 @@ import (
"bytes"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"net/url"
)
@@ -50,7 +49,8 @@ func hashtag() (func(p *parser.Parser, data []byte, offset int) (int, ast.Node),
hashtags = append(hashtags, string(data[1:i]))
link := &ast.Link{
AdditionalAttributes: []string{`class="tag"`},
Destination: append([]byte("/search/?q=%23"), data[1:i]...),
Destination: append([]byte("/search?q=%23"), data[1:i]...),
Title: data[0:i],
}
text := bytes.ReplaceAll(data[0:i], []byte("_"), []byte(" "))
ast.AppendChild(link, &ast.Text{Leaf: ast.Leaf{Literal: text}})
@@ -58,11 +58,10 @@ func hashtag() (func(p *parser.Parser, data []byte, offset int) (int, ast.Node),
}, &hashtags
}
// wikiParser returns a parser with the Oddmu specific changes. Specifically: [[wiki links]], #hash_tags,
// @webfinger@accounts. It also uses the CommonExtensions and Block Attributes, and no MathJax ($).
func wikiParser() (*parser.Parser, *[]string) {
extensions := (parser.CommonExtensions | parser.Attributes) & ^parser.MathJax
parser := parser.NewWithExtensions(extensions)
// renderHtml renders the Page.Body to HTML and sets Page.Html,
// Page.Language, Page.Hashtags, and escapes Page.Name.
func (p *Page) renderHtml() {
parser := parser.New()
prev := parser.RegisterInline('[', nil)
parser.RegisterInline('[', wikiLink(parser, prev))
fn, hashtags := hashtag()
@@ -70,26 +69,9 @@ func wikiParser() (*parser.Parser, *[]string) {
if useWebfinger {
parser.RegisterInline('@', account)
}
return parser, hashtags
}
// wikiRenderer is a Renderer for Markdown that adds lazy loading of images. This in turn requires an exception for the
// sanitization policy!
func wikiRenderer() *html.Renderer {
htmlFlags := html.CommonFlags | html.LazyLoadImages
opts := html.RendererOptions{Flags: htmlFlags}
renderer := html.NewRenderer(opts)
return renderer
}
// renderHtml renders the Page.Body to HTML and sets Page.Html, Page.Language, Page.Hashtags, and escapes Page.Name.
// Note: If the rendered HTML doesn't contain the attributes or elements you expect it to contain, check sanitizeBytes!
func (p *Page) renderHtml() {
parser, hashtags := wikiParser()
renderer := wikiRenderer()
maybeUnsafeHTML := markdown.ToHTML(p.Body, parser, renderer)
maybeUnsafeHTML := markdown.ToHTML(p.Body, parser, nil)
p.Name = nameEscape(p.Name)
p.Html = unsafeBytes(maybeUnsafeHTML)
p.Html = sanitizeBytes(maybeUnsafeHTML)
p.Language = language(p.plainText())
p.Hashtags = *hashtags
}

View File

@@ -43,7 +43,7 @@ I am cold, alone
Too faint to focus, so far
I am cold, alone</p>
<p><a class="tag" href="/search/?q=%23Haiku">#Haiku</a> <a class="tag" href="/search/?q=%23Cold_Poets">#Cold Poets</a></p>
<p><a class="tag" href="/search?q=%23Haiku" rel="nofollow">#Haiku</a> <a class="tag" href="/search?q=%23Cold_Poets" rel="nofollow">#Cold Poets</a></p>
`
assert.Equal(t, r, string(p.Html))
}
@@ -57,29 +57,8 @@ Our [[time together]]`)}
r := `<h1>Photos and Books</h1>
<p>Blue and green and black
Sky and grass and <a href="cliffs">ragged cliffs</a>
Our <a href="time%20together">time together</a></p>
Sky and grass and <a href="cliffs" rel="nofollow">ragged cliffs</a>
Our <a href="time%20together" rel="nofollow">time together</a></p>
`
assert.Equal(t, r, string(p.Html))
}
func TestPageHtmlDollar(t *testing.T) {
p := &Page{Body: []byte(`# No $dollar$ can buy this
Dragonfly hovers
darts chases turns lands and rests
A mighty jewel`)}
p.renderHtml()
r := `<h1>No $dollar$ can buy this</h1>
<p>Dragonfly hovers
darts chases turns lands and rests
A mighty jewel</p>
`
assert.Equal(t, r, string(p.Html))
}
func TestLazyLoadImages(t *testing.T) {
p := &Page{Body: []byte(`![](test.jpg)`)}
p.renderHtml()
assert.Contains(t, string(p.Html), "lazy")
}

View File

@@ -19,40 +19,32 @@ import (
type replaceCmd struct {
confirm bool
regexp bool
}
func (cmd *replaceCmd) SetFlags(f *flag.FlagSet) {
f.BoolVar(&cmd.confirm, "confirm", false, "do the replacement instead of just doing a dry run")
f.BoolVar(&cmd.regexp, "regexp", false, "the search string is a regular expression")
}
func (*replaceCmd) Name() string { return "replace" }
func (*replaceCmd) Synopsis() string { return "search and replace in all the pages" }
func (*replaceCmd) Synopsis() string { return "Search and replace a regular expression." }
func (*replaceCmd) Usage() string {
return `replace [-confirm] [-regexp] <term> <replacement>:
Search a string or a regular expression and replace it. By default,
this is a dry run and nothing is saved. If this is a regular
expression, the replacement can use $1, $2, etc. to refer to capture
groups in the regular expression.
return `replace [-confirm] <regexp> <replacement>:
Search a regular expression and replace it. By default, this is a
dry run and nothing is saved. The replacement can use $1, $2, etc.
to refer to capture groups in the regular expression.
`
}
func (cmd *replaceCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return replaceCli(os.Stdout, cmd.confirm, cmd.regexp, f.Args())
return replaceCli(os.Stdout, cmd.confirm, f.Args())
}
func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) subcommands.ExitStatus {
func replaceCli(w io.Writer, confirm bool, args []string) subcommands.ExitStatus {
if len(args) != 2 {
fmt.Fprintln(os.Stderr, "Replace takes exactly two arguments.")
fmt.Fprintln(w, "Replace takes exactly two arguments.")
return subcommands.ExitFailure
}
var re *regexp.Regexp
if isRegexp {
re = regexp.MustCompile(args[0])
} else {
re = regexp.MustCompile(regexp.QuoteMeta(args[0]))
}
re := regexp.MustCompile(args[0])
repl := []byte(args[1])
changes := 0
err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
@@ -69,7 +61,7 @@ func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) sub
result := re.ReplaceAll(body, repl)
if !slices.Equal(result, body) {
changes++
if isConfirmed {
if confirm {
fmt.Fprintln(w, path)
_ = os.Rename(path, path+"~")
err = os.WriteFile(path, result, 0644)
@@ -85,23 +77,15 @@ func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) sub
return nil
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
fmt.Fprintln(w, err)
return subcommands.ExitFailure
}
if changes == 1 {
if isConfirmed {
fmt.Fprintln(w, "1 file was changed.")
} else {
fmt.Fprintln(w, "1 file would be changed.")
}
fmt.Fprintln(w, "1 change was made.")
} else {
if isConfirmed {
fmt.Fprintf(w, "%d files were changed.\n", changes)
} else {
fmt.Fprintf(w, "%d files would be changed.\n", changes)
}
fmt.Fprintf(w, "%d changes were made.\n", changes)
}
if !isConfirmed && changes > 0 {
if !confirm && changes > 0 {
fmt.Fprintln(w, "This is a dry run. Use -confirm to make it happen.")
}
return subcommands.ExitSuccess

View File

@@ -4,20 +4,22 @@ import (
"bytes"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"os"
"testing"
)
// wipes testdata
func TestReplaceCmd(t *testing.T) {
cleanup(t, "testdata/replace")
_ = os.RemoveAll("testdata")
index.load()
p := &Page{Name: "testdata/replace/pluto", Body: []byte(`# Pluto
p := &Page{Name: "testdata/pluto", Body: []byte(`# Pluto
Out there is a rock
And more rocks uncountable
You are no planet`)}
p.save()
r := `--- testdata/replace/pluto.md~
+++ testdata/replace/pluto.md
r := `--- testdata/pluto.md~
+++ testdata/pluto.md
@@ -1,4 +1,4 @@
# Pluto
Out there is a rock
@@ -27,12 +29,16 @@ You are no planet`)}
+You are planetoid
\ No newline at end of file
1 file would be changed.
1 change was made.
This is a dry run. Use -confirm to make it happen.
`
b := new(bytes.Buffer)
s := replaceCli(b, false, true, []string{`\bno planet`, `planetoid`})
s := replaceCli(b, false, []string{`\bno planet`, `planetoid`})
assert.Equal(t, subcommands.ExitSuccess, s)
assert.Equal(t, r, b.String())
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}

View File

@@ -2,6 +2,7 @@ package main
import (
"regexp"
"strings"
)
// score splits the query string q into terms and scores the text
@@ -9,7 +10,7 @@ import (
// characters quoted.
func score(q string, s string) int {
score := 0
re, err := regexp.Compile("(?i)" + regexp.QuoteMeta(q))
re, err := regexp.Compile("(?i)" + q)
if err == nil {
m := re.FindAllString(s, -1)
if m != nil {
@@ -17,8 +18,8 @@ func score(q string, s string) int {
score += len(m)
}
}
for _, token := range highlightTokens(q) {
re, err := regexp.Compile(`(?is)(\pL?)(` + regexp.QuoteMeta(token) + `)(\pL?)`)
for _, v := range strings.Fields(q) {
re, err := regexp.Compile(`(?is)(\pL?)(` + v + `)(\pL?)`)
if err != nil {
continue
}

207
search.go
View File

@@ -3,8 +3,6 @@ package main
import (
"log"
"net/http"
"path"
"regexp"
"slices"
"strconv"
"strings"
@@ -18,11 +16,11 @@ import (
// a search result, Body and Html are simple extracts.
type Search struct {
Query string
Dir string
Items []*Page
Previous int
Page int
Next int
Last int
More bool
Results bool
}
@@ -31,41 +29,28 @@ type Search struct {
// whether the query string matches the page title; 2. descending if
// the page titles start with a digit; 3. otherwise ascending.
// Access to the index requires a read lock!
func sortNames(tokens []string) func(a, b string) int {
func sortNames(q string) func(a, b string) int {
return func(a, b string) int {
// If only one page contains the query string, it
// takes precedence.
ia := false
ib := false
for _, token := range tokens {
if !ia && strings.Contains(index.titles[a], token) {
ia = true
}
if !ib && strings.Contains(index.titles[b], token) {
ib = true
}
}
ia := strings.Contains(index.titles[a], q)
ib := strings.Contains(index.titles[b], q)
if ia && !ib {
return -1
} else if !ia && ib {
return 1
}
// Page names starting with a number come first. If
// both page names start with a number (like an ISO
// date), sort by page name, descending.
// If both page names start with a number (like an ISO date),
// sort descending.
ra, _ := utf8.DecodeRuneInString(a)
na := unicode.IsNumber(ra)
rb, _ := utf8.DecodeRuneInString(b)
nb := unicode.IsNumber(rb)
if na && !nb {
return -1
} else if !na && nb {
return 1
} else if na && nb {
if unicode.IsNumber(ra) && unicode.IsNumber(rb) {
if a < b {
return 1
} else if a > b {
return -1
} else {
return 0
}
}
// Otherwise sort by title, ascending.
@@ -73,170 +58,66 @@ func sortNames(tokens []string) func(a, b string) int {
return -1
} else if index.titles[a] > index.titles[b] {
return 1
} else {
return 0
}
// Either the titles are equal or the index isn't
// initialized.
if a < b {
return -1
} else if a > b {
return 1
}
return 0
}
}
// load the pages named.
func load(names []string) []*Page {
items := make([]*Page, len(names))
for i, name := range names {
p, err := loadPage(name)
if err != nil {
log.Printf("Error loading %s: %s", name, err)
} else {
items[i] = p
}
}
return items
}
// itemsPerPage says how many items to print on a page of search
// results.
const itemsPerPage = 20
// search returns a sorted []Page where each page contains an extract of the actual Page.Body in its Page.Html. Page
// size is 20. Specify either the page number to return, or that all the results should be returned. Only ask for all
// results if runtime is not an issue, like on the command line. The boolean return value indicates whether there are
// more results.
func search(q string, dir string, page int, all bool) ([]*Page, bool) {
// search returns a sorted []Page where each page contains an extract
// of the actual Page.Body in its Page.Html. Page size is 20. The
// boolean return value indicates whether there are more results.
func search(q string, page int) ([]*Page, bool, int) {
if len(q) == 0 {
return make([]*Page, 0), false
return make([]*Page, 0), false, 0
}
names := index.search(q) // hashtags or all names
names = filterPrefix(names, dir)
predicates, terms := predicatesAndTokens(q)
names = filterNames(names, predicates)
slices.SortFunc(names, sortNames(terms))
names, keepFirst := prependQueryPage(names, dir, q)
names := index.search(q)
slices.SortFunc(names, sortNames(q))
from := itemsPerPage * (page - 1)
to := from + itemsPerPage - 1
items, more := grep(terms, names, from, to, all, keepFirst)
if from > len(names) {
return make([]*Page, 0), false, 0
}
to := from + itemsPerPage
if to > len(names) {
to = len(names)
}
items := load(names[from:to])
for _, p := range items {
p.score(q)
p.summarize(q)
}
return items, more
}
// filterPrefix filters the names by prefix. A prefix of "." means
// that all the names are returned, since this is what path.Dir
// returns for "no directory".
func filterPrefix(names []string, prefix string) []string {
if prefix == "." {
return names
}
r := make([]string, 0)
for _, name := range names {
if strings.HasPrefix(name, prefix) {
r = append(r, name)
}
}
return r
}
// filterNames filters the names by all the predicats such as
// "title:foo" or "blog:true".
func filterNames(names, predicates []string) []string {
if len(predicates) == 0 {
return names
}
// the intersection requires sorted lists
slices.Sort(names)
index.RLock()
defer index.RUnlock()
for _, predicate := range predicates {
r := make([]string, 0)
if strings.HasPrefix(predicate, "title:") {
token := predicate[6:]
for _, name := range names {
if strings.Contains(strings.ToLower(index.titles[name]), token) {
r = append(r, name)
}
}
} else if predicate == "blog:true" || predicate == "blog:false" {
blog := predicate == "blog:true"
re := regexp.MustCompile(`(^|/)\d\d\d\d-\d\d-\d\d`)
for _, name := range names {
match := re.MatchString(name)
if blog && match || !blog && !match {
r = append(r, name)
}
}
} else {
log.Printf("Unsupported predicate: %s", predicate)
}
names = intersection(names, r)
}
return names
}
// grep searches the files for matches to all the tokens. It returns just a single page of results based [from:to-1] and
// returns if there are more results. The all parameter ignores pagination (the from and to parameters). The keepFirst
// parameter keeps the first page in the list, even if there is no match. This is used for hashtag pages.
func grep(tokens, names []string, from, to int, all, keepFirst bool) ([]*Page, bool) {
pages := make([]*Page, 0)
i := 0
NameLoop:
for n, name := range names {
p, err := loadPage(name)
if err != nil {
log.Printf("grep: cannot load %s: %s", name, err)
continue NameLoop
}
if n != 0 || !keepFirst {
body := strings.ToLower(string(p.Body))
for _, token := range tokens {
if !strings.Contains(body, token) {
continue NameLoop
}
}
}
i++
if all || i > from {
pages = append(pages, p)
}
if !all && i > to {
return pages, true
}
}
return pages, false
}
// prependQueryPage prepends the query itself, if a matching page name exists. This helps if people remember the name
// exactly, or if searching for a hashtag. This function assumes that q is not the empty string. Return wether a page
// was prepended or not.
func prependQueryPage(names []string, dir, q string) ([]string, bool) {
index.RLock()
defer index.RUnlock()
if q[0] == '#' && !strings.Contains(q[1:], "#") {
q = q[1:]
}
q = path.Join(dir, q)
// if q exists in names, move it to the front
i := slices.Index(names, q)
if i == 0 {
return names, false
} else if i != -1 {
r := []string{q}
r = append(r, names[0:i]...)
r = append(r, names[i+1:]...)
return r, false
}
// otherwise, if q is a known page name, prepend it
_, ok := index.titles[q]
if ok {
return append([]string{q}, names...), true
}
return names, false
return items, to < len(names), len(names)/itemsPerPage + 1
}
// searchHandler presents a search result. It uses the query string in
// the form parameter "q" and the template "search.html". For each
// page found, the HTML is just an extract of the actual body.
// Search is limited to a directory and its subdirectories.
func searchHandler(w http.ResponseWriter, r *http.Request, dir string) {
func searchHandler(w http.ResponseWriter, r *http.Request) {
q := r.FormValue("q")
page, err := strconv.Atoi(r.FormValue("page"))
if err != nil {
page = 1
}
items, more := search(q, dir, page, false)
s := &Search{Query: q, Dir: dir, Items: items, Previous: page - 1, Page: page, Next: page + 1,
items, more, last := search(q, page)
s := &Search{Query: q, Items: items, Previous: page - 1, Page: page, Next: page + 1, Last: last,
Results: len(items) > 0, More: more}
renderTemplate(w, "search", s)
}

View File

@@ -6,12 +6,12 @@
<meta name="viewport" content="width=device-width">
<title>Search for {{.Query}}</title>
<style>
html { max-width: 65ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
html { max-width: 65ch; padding: 2ch; margin: auto; color: #111; background: #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; }
button { background: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
img { max-width: 20%; }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8; }
@@ -21,9 +21,9 @@ img { max-width: 20%; }
<header>
<a href="#main">Skip navigation</a>
<a href="/view/index">Home</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<form role="search" action="/search" 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>
<input id="search" type="text" value="{{.Query}}" spellcheck="false" name="q" accesskey="f" required>
<button>Go</button>
</form>
</header>
@@ -31,10 +31,11 @@ img { max-width: 20%; }
<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}}
{{if gt .Page 2}}<a href="/search?q={{.Query}}&page=1">First</a>{{end}}
{{if gt .Page 1}}<a href="/search?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
Page {{.Page}}
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{if .More}}<a href="/search?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{if lt .Next .Last}}<a href="/search?q={{.Query}}&page={{.Last}}">Last</a>{{end}}
{{range .Items}}
<article lang="{{.Language}}">
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
@@ -43,10 +44,11 @@ img { max-width: 20%; }
</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}}
{{if gt .Page 2}}<a href="/search?q={{.Query}}&page=1">First</a>{{end}}
{{if gt .Page 1}}<a href="/search?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
Page {{.Page}}
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{if .More}}<a href="/search?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{if lt .Next .Last}}<a href="/search?q={{.Query}}&page={{.Last}}">Last</a>{{end}}
{{else}}
<p>No results.</p>
{{end}}

View File

@@ -1,27 +1,27 @@
package main
import (
"bytes"
"context"
"flag"
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/google/subcommands"
"io"
"io/fs"
"os"
"regexp"
"path/filepath"
"slices"
"strings"
)
type searchCmd struct {
page int
all bool
extract bool
page int
exact bool
}
func (cmd *searchCmd) SetFlags(f *flag.FlagSet) {
f.IntVar(&cmd.page, "page", 1, "the page in the search result set, default 1")
f.BoolVar(&cmd.all, "all", false, "show all the pages and ignore -page")
f.BoolVar(&cmd.extract, "extract", false, "print page extract instead of link list")
f.IntVar(&cmd.page, "page", 1, "the page in the search result set")
f.BoolVar(&cmd.exact, "exact", false, "look for exact matches (do not use the trigram index)")
}
func (*searchCmd) Name() string { return "search" }
@@ -30,58 +30,93 @@ func (*searchCmd) Usage() string {
return `search [-page <n>] <terms>:
Search for pages matching terms and print the result set as a
Markdown list. Before searching, all the pages are indexed. Thus,
startup is slow. The benefit is that the page order is exactly as
when the wiki runs.
startup is slow. The benefit is that the page order and scores are
exactly as when the wiki runs.
`
}
func (cmd *searchCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return searchCli(os.Stdout, cmd.page, cmd.all, cmd.extract, false, f.Args())
return searchCli(os.Stdout, cmd.page, cmd.exact, f.Args())
}
// searchCli runs the search command on the command line. It is used
// here with an io.Writer for easy testing.
func searchCli(w io.Writer, n int, all, extract bool, quiet bool, args []string) subcommands.ExitStatus {
index.load()
q := strings.Join(args, " ")
items, more := search(q, ".", n, true)
if !quiet {
fmt.Fprint(os.Stderr, "Search for ", q)
if !all {
fmt.Fprint(os.Stderr, ", page ", n)
}
fmt.Fprint(os.Stderr, ": ", len(items))
if len(items) == 1 {
fmt.Fprint(os.Stderr, " result\n")
} else {
fmt.Fprint(os.Stderr, " results\n")
}
}
if extract {
searchExtract(w, items)
func searchCli(w io.Writer, n int, exact bool, args []string) subcommands.ExitStatus {
var fn func(q string, n int) ([]*Page, bool, int)
if exact {
fn = searchExact
} else {
for _, p := range items {
fmt.Fprintf(w, "* [%s](%s)\n", p.Title, p.Name)
}
index.load()
fn = search
}
if more {
fmt.Fprintf(os.Stderr, "There are more results\n")
for _, q := range args {
items, more, _ := fn(q, n)
if len(items) == 1 {
fmt.Fprintf(w, "Search for %s, page %d: 1 result\n", q, n)
} else {
fmt.Fprintf(w, "Search for %s, page %d: %d results\n", q, n, len(items))
}
for _, p := range items {
fmt.Fprintf(w, "* [%s](%s) (%d)\n", p.Title, p.Name, p.Score)
}
if more {
fmt.Fprintf(w, "There are more results\n")
}
}
return subcommands.ExitSuccess
}
// searchExtract prints the search extracts to stdout with highlighting for a terminal.
func searchExtract(w io.Writer, items []*Page) {
heading := lipgloss.NewStyle().Bold(true).Underline(true)
quote := lipgloss.NewStyle().PaddingLeft(4).Width(78)
match := lipgloss.NewStyle().Bold(true)
re := regexp.MustCompile(`<b>(.*?)</b>`)
for _, p := range items {
s := re.ReplaceAllString(string(p.Html), match.Render(`$1`))
fmt.Fprintln(w, heading.Render(p.Title))
if p.Name != p.Title {
fmt.Fprintln(w, p.Name)
}
fmt.Fprintln(w, quote.Render(s))
// searchExact opens all the files and searches them, one by one.
func searchExact(q string, page int) ([]*Page, bool, int) {
if len(q) == 0 {
return make([]*Page, 0), false, 0
}
terms := bytes.Fields([]byte(q))
pages := make(map[string]*Page)
names := make([]string, 0)
index.titles = make(map[string]string)
err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
filename := path
if info.IsDir() || strings.HasPrefix(filename, ".") || !strings.HasSuffix(filename, ".md") {
return nil
}
name := strings.TrimSuffix(filename, ".md")
p, err := loadPage(name)
if err != nil {
return err
}
for _, term := range terms {
if !bytes.Contains(p.Body, term) {
return nil
}
}
p.handleTitle(false)
pages[p.Name] = p
index.titles[p.Name] = p.Title
names = append(names, p.Name)
return nil
})
if err != nil {
return make([]*Page, 0), false, 0
}
slices.SortFunc(names, sortNames(q))
from := itemsPerPage * (page - 1)
if from > len(names) {
return make([]*Page, 0), false, 0
}
to := from + itemsPerPage
if to > len(names) {
to = len(names)
}
items := make([]*Page, 0)
for i := from; i < to; i++ {
p := pages[names[i]]
p.score(q)
p.summarize(q)
items = append(items, p)
}
return items, to < len(names), len(names)/itemsPerPage + 1
}

View File

@@ -9,10 +9,11 @@ import (
func TestSearchCmd(t *testing.T) {
b := new(bytes.Buffer)
s := searchCli(b, 1, false, false, true, []string{"oddµ"})
s := searchCli(b, 1, false, []string{"oddµ"})
assert.Equal(t, subcommands.ExitSuccess, s)
r := `* [Oddµ: A minimal wiki](README)
* [Welcome to Oddµ](index)
r := `Search for oddµ, page 1: 2 results
* [Oddµ: A minimal wiki](README) (5)
* [Welcome to Oddµ](index) (5)
`
assert.Equal(t, r, b.String())
}

View File

@@ -1,156 +1,23 @@
package main
import (
"fmt"
"github.com/stretchr/testify/assert"
"net/url"
"slices"
"os"
"testing"
)
func TestSortNames(t *testing.T) {
index.Lock()
for _, s := range []string{"Alex", "Berta", "Chris", "2015-06-14", "2023-09-26"} {
index.titles[s] = s
}
index.Unlock()
terms := []string{"Z"}
fn := sortNames(terms)
assert.Equal(t, 1, fn("Berta", "Alex"), "B is after A")
assert.Equal(t, -1, fn("Alex", "Berta"), "A is before B")
assert.Equal(t, 0, fn("Berta", "Berta"), "B and B are equal")
assert.Equal(t, -1, fn("2023-09-26", "Alex"), "numbers before letters")
assert.Equal(t, 1, fn("Alex", "2023-09-26"), "numbers after letters")
assert.Equal(t, -1, fn("2023-09-26", "2015-06-14"), "higher numbers before lower numbers")
assert.Equal(t, 1, fn("2015-06-14", "2023-09-26"), "lower numbers after higher numbers")
names := []string{"Berta", "Chris", "Alex"}
slices.SortFunc(names, sortNames(terms))
assert.True(t, slices.IsSorted(names), fmt.Sprintf("Sorted: %v", names))
}
func TestPrependMatches(t *testing.T) {
index.Lock()
for _, s := range []string{"Alex", "Berta", "Chris"} {
index.titles[s] = s
}
index.Unlock()
r := []string{"Berta", "Chris"} // does not prepend
u := []string{"Alex", "Berta", "Chris"} // does prepend
v, _ := prependQueryPage(r, "", "Alex")
assert.Equal(t, u, v, "prepend q")
v, _ = prependQueryPage(r, "", "lex")
assert.Equal(t, r, v, "exact matches only")
v, _ = prependQueryPage(r, "", "#Alex")
assert.Equal(t, u, v, "prepend hashtag")
v, _ = prependQueryPage(r, "", "#Alex #Berta")
assert.Equal(t, r, v, "do not prepend two hashtags")
v, _ = prependQueryPage(r, "", "#alex")
assert.Equal(t, r, v, "do not ignore case")
v, _ = prependQueryPage(u, "", "Alex")
assert.Equal(t, u, v, "do not prepend q twice")
v, _ = prependQueryPage([]string{"Berta", "Alex", "Chris"}, "", "Alex")
assert.Equal(t, u, v, "sort q to the front")
v, _ = prependQueryPage([]string{"Berta", "Chris", "Alex"}, "", "Alex")
assert.Equal(t, u, v, "sort q to the front")
}
func TestSearch(t *testing.T) {
data := url.Values{}
data.Set("q", "oddµ")
body := assert.HTTPBody(makeHandler(searchHandler, false), "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)
assert.NotContains(t, body, "Welcome")
}
func TestSearchDir(t *testing.T) {
cleanup(t, "testdata/dir")
p := &Page{Name: "testdata/dir/dice", Body: []byte(`# Dice
A tiny drum roll
Dice rolling bouncing stopping
Where is lady luck?`)}
p.save()
data := url.Values{}
data.Set("q", "luck")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
assert.Contains(t, body, "luck")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata", data)
assert.Contains(t, body, "luck")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/dir", data)
assert.Contains(t, body, "luck")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/other", data)
assert.Contains(t, body, "No results")
}
func TestTitleSearch(t *testing.T) {
items, more := search("title:readme", "", 1, false)
assert.Equal(t, 0, len(items), "no page found")
assert.False(t, more)
items, more = search("title:wel", "", 1, false) // README also contains "wel"
assert.Equal(t, 1, len(items), "one page found")
assert.Equal(t, "index", items[0].Name, "Welcome to Oddµ")
assert.Greater(t, items[0].Score, 0, "matches result in a score")
assert.False(t, more)
items, more = search("wel", "", 1, false)
assert.Greater(t, len(items), 1, "two pages found")
assert.False(t, more)
}
func TestBlogSearch(t *testing.T) {
cleanup(t, "testdata/grep")
p := &Page{Name: "testdata/grep/2023-09-25", Body: []byte(`# Back then
I check the git log
Was it 2015
We met in the park?`)}
p.save()
items, _ := search("blog:false", "", 1, false)
for _, item := range items {
assert.NotEqual(t, "Back then", item.Title, item.Name)
}
items, _ = search("blog:true", "", 1, false)
assert.Equal(t, 1, len(items), "one blog page found")
assert.Equal(t, "Back then", items[0].Title, items[0].Name)
}
func TestHashtagSearch(t *testing.T) {
cleanup(t, "testdata/hashtag")
p := &Page{Name: "testdata/hashtag/Haiku", Body: []byte("# Haikus\n")}
p.save()
p = &Page{Name: "testdata/hashtag/2023-10-28", Body: []byte(`# Tea
My tongue is on fire
It looked so calm and peaceful
A quick sip too quick
#Haiku`)}
p.save()
items, _ := search("#Haiku", "testdata/hashtag", 1, false)
assert.Equal(t, 2, len(items), "two pages found")
assert.Equal(t, "Haikus", items[0].Title, items[0].Name)
assert.Equal(t, "Tea", items[1].Title, items[1].Name)
assert.Contains(t,
assert.HTTPBody(searchHandler, "GET", "/search", data), "Welcome")
}
// wipes testdata
func TestSearchQuestionmark(t *testing.T) {
cleanup(t, "testdata/question")
p := &Page{Name: "testdata/question/Odd?", Body: []byte(`# Even?
_ = os.RemoveAll("testdata")
p := &Page{Name: "testdata/Odd?", Body: []byte(`# Even?
We look at the plants.
They need water. We need us.
@@ -158,36 +25,8 @@ The silence streches.`)}
p.save()
data := url.Values{}
data.Set("q", "look")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
body := assert.HTTPBody(searchHandler, "GET", "/search", data)
assert.Contains(t, body, "We <b>look</b>")
assert.NotContains(t, body, "Odd?")
assert.Contains(t, body, "Even?")
}
func TestSearchPagination(t *testing.T) {
cleanup(t, "testdata/pagination")
index.load()
alphabet := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for _, r := range alphabet {
s := fmt.Sprintf("secret%c secretX", r)
p := &Page{Name: "testdata/pagination/" + string(r), Body: []byte(s)}
p.save()
}
items, more := search("secretA", "", 1, false)
assert.Equal(t, 1, len(items), "one page found, %v", items)
assert.Equal(t, "testdata/pagination/A", items[0].Name)
assert.False(t, more)
items, more = search("secretX", "", 1, false)
assert.Equal(t, itemsPerPage, len(items))
assert.Equal(t, "testdata/pagination/A", items[0].Name)
assert.Equal(t, "testdata/pagination/T", items[itemsPerPage-1].Name)
assert.True(t, more)
items, more = search("secretX", "", 2, false)
assert.Equal(t, 6, len(items))
assert.Equal(t, "testdata/pagination/U", items[0].Name)
assert.Equal(t, "testdata/pagination/Z", items[5].Name)
assert.False(t, more)
}

View File

@@ -8,7 +8,7 @@ import (
// re returns a regular expression matching any word in q.
func re(q string) (*regexp.Regexp, error) {
fields := highlightTokens(q)
fields := strings.Fields(q)
quoted := make([]string, len(fields))
for i, w := range fields {
quoted[i] = regexp.QuoteMeta(w)

View File

@@ -10,7 +10,7 @@ func TestSnippets(t *testing.T) {
h := `We are immersed in a sea of dead people. <b>All</b> the dead that have gone before us, silent now, just … to ask ourselves or them! what it was <b>all</b> about. Instead we drown ourselves in no<b>is</b>e. … surrounded by false friends claiming that <b>all</b> <b>is</b> <b>well</b>. And look at us! Yes, we are <b>well</b>. …`
q := "title:all is well"
q := "all is well"
r := snippets(q, s)
assert.Equal(t, h, r)
}

View File

@@ -1,30 +0,0 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<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; }
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 Your Name <<a href="mailto:you@example.org">you@example.org</a>>
</address>
</footer>
</body>
</html>

View File

@@ -1,147 +0,0 @@
package main
import (
"bytes"
"context"
"flag"
"fmt"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/html"
"github.com/google/subcommands"
"io/fs"
"net/url"
"os"
"path/filepath"
"strings"
)
type staticCmd struct {
}
func (*staticCmd) Name() string { return "static" }
func (*staticCmd) Synopsis() string { return "generate static HTML files for all pages" }
func (*staticCmd) Usage() string {
return `static <dir name>:
Create static copies in the given directory.
`
}
func (cmd *staticCmd) SetFlags(f *flag.FlagSet) {
}
func (cmd *staticCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
args := f.Args()
if len(args) != 1 {
fmt.Println("Exactly one target directory is required")
return subcommands.ExitFailure
}
return staticCli(filepath.Clean(args[0]))
}
func staticCli(dir string) subcommands.ExitStatus {
err := os.Mkdir(dir, 0755)
if err != nil {
fmt.Println(err)
return subcommands.ExitFailure
}
initAccounts()
err = filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
return staticFile(path, dir, info, err)
})
if err != nil {
fmt.Println(err)
return subcommands.ExitFailure
}
return subcommands.ExitSuccess
}
// staticFile is used to walk the file trees and do the right thing for the destination directory: create
// subdirectories, link files, render HTML files.
func staticFile(path, dir string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
filename := path
// skip "hidden" files and backup files, avoid recursion
if strings.HasPrefix(filename, ".") ||
strings.HasSuffix(filename, "~") ||
strings.HasPrefix(filename, dir) {
return nil
}
// recreate subdirectories
if info.IsDir() {
return os.Mkdir(filepath.Join(dir, filename), 0755)
}
// render pages
if strings.HasSuffix(filename, ".md") {
return staticPage(filename, dir)
}
// remaining files are linked
return os.Link(filename, filepath.Join(dir, filename))
}
// staticPage takes the filename of a page (ending in ".md") and generates a static HTML page.
func staticPage(filename, dir string) error {
name := strings.TrimSuffix(filename, ".md")
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", name, err)
return err
}
p.handleTitle(true)
// instead of p.renderHtml() we do it all ourselves, appending ".html" to all the local links
parser, hashtags := wikiParser()
doc := markdown.Parse(p.Body, parser)
ast.WalkFunc(doc, staticLinks)
opts := html.RendererOptions{
Flags: html.CommonFlags,
}
renderer := html.NewRenderer(opts)
maybeUnsafeHTML := markdown.Render(doc, renderer)
p.Name = nameEscape(p.Name)
p.Html = unsafeBytes(maybeUnsafeHTML)
p.Language = language(p.plainText())
p.Hashtags = *hashtags
return p.write(filepath.Join(dir, name+".html"))
}
// staticLinks checks a node and if it is a link to a local page, it appends ".html" to the link destination.
func staticLinks(node ast.Node, entering bool) ast.WalkStatus {
if entering {
switch v := node.(type) {
case *ast.Link:
// not an absolute URL, not a full URL, not a mailto: URI
if !bytes.HasPrefix(v.Destination, []byte("/")) &&
!bytes.Contains(v.Destination, []byte("://")) &&
!bytes.HasPrefix(v.Destination, []byte("mailto:")) {
// pointing to a page file (instead of an image file, for example).
fn, err := url.PathUnescape(string(v.Destination))
if err != nil {
return ast.GoToNext
}
_, err = os.Stat(fn + ".md")
if err != nil {
return ast.GoToNext
}
v.Destination = append(v.Destination, []byte(".html")...)
}
}
}
return ast.GoToNext
}
func (p *Page) write(destination string) error {
t := "static.html"
f, err := os.Create(destination)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot create %s.html: %s\n", destination, err)
return err
}
err = templates.ExecuteTemplate(f, t, p)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", t, destination, err)
return err
}
return nil
}

View File

@@ -1,19 +0,0 @@
package main
import (
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestStatusCmd(t *testing.T) {
cleanup(t, "testdata/static")
s := staticCli("testdata/static")
assert.Equal(t, subcommands.ExitSuccess, s)
// pages
assert.FileExists(t, "testdata/static/index.html")
assert.FileExists(t, "testdata/static/README.html")
// regular files
assert.FileExists(t, "testdata/static/static_cmd.go")
assert.FileExists(t, "testdata/static/static_cmd_test.go")
}

View File

@@ -7,6 +7,13 @@ import (
"unicode/utf8"
)
// tokenize returns a slice of alphanumeric tokens for the given text.
func tokenize(text string) []string {
return strings.FieldsFunc(text, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
})
}
// lowercaseFilter returns a slice of lower case tokens.
func lowercaseFilter(tokens []string) []string {
r := make([]string, len(tokens))
@@ -16,60 +23,14 @@ func lowercaseFilter(tokens []string) []string {
return r
}
// tokenizeWithPredicates returns a slice of tokens for the given
// text, including punctuation. Use this to begin tokenizing the query
// string.
func tokenizeOnWhitespace(q string) []string {
return strings.Fields(q)
}
// predicateFilter returns two slices of tokens: the first with
// predicates, the other without predicates. Use this for query
// string tokens.
func predicateFilter(tokens []string) ([]string, []string) {
with := make([]string, 0)
without := make([]string, 0)
for _, token := range tokens {
if strings.Contains(token, ":") {
with = append(with, token)
} else {
without = append(without, token)
}
}
return with, without
}
// predicatesAndTokens returns two slices of tokens: the first with
// predicates, the other without predicates, all of them lower case.
// Use this for query strings.
func predicatesAndTokens(q string) ([]string, []string) {
tokens := tokenizeOnWhitespace(q)
// tokens returns a slice of alphanumeric tokens.
func tokens(text string) []string {
tokens := tokenize(text)
tokens = lowercaseFilter(tokens)
return predicateFilter(tokens)
return tokens
}
// noPredicateFilter returns a slice of tokens: the predicates without
// the predicate, and all the others. That is: "foo:bar baz" is turned
// into ["bar", "baz"] and the predicate "foo:" is dropped.
func noPredicateFilter(tokens []string) []string {
r := make([]string, 0)
for _, token := range tokens {
parts := strings.Split(token, ":")
r = append(r, parts[len(parts)-1])
}
return r
}
// highlightTokens returns the tokens to highlight, including title
// predicates.
func highlightTokens(q string) []string {
tokens := tokenizeOnWhitespace(q)
tokens = lowercaseFilter(tokens)
return noPredicateFilter(tokens)
}
// hashtags returns a slice of hashtags. Use this to extract hashtags
// from a page body.
// hashtags returns a slice of hashtags.
func hashtags(s []byte) []string {
hashtags := make([]string, 0)
for {

View File

@@ -5,12 +5,9 @@ import (
"testing"
)
func TestHashtags(t *testing.T) {
func TestTokenizer(t *testing.T) {
assert.EqualValues(t, []string{}, tokens(""), "empty string")
assert.EqualValues(t, []string{"franc"}, tokens("Franc"), "lower case")
assert.EqualValues(t, []string{"i", "don", "t", "know", "what", "to", "do"}, tokens("I don't know what to do."))
assert.EqualValues(t, []string{"#truth"}, hashtags([]byte("This is boring. #Truth")), "hashtags")
}
func TestTokensAndPredicates(t *testing.T) {
predicates, terms := predicatesAndTokens("foo title:bar")
assert.EqualValues(t, []string{"foo"}, terms)
assert.EqualValues(t, []string{"title:bar"}, predicates)
}

View File

@@ -6,35 +6,28 @@
<meta name="viewport" content="width=device-width">
<title>Upload File</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background: #ffe; }
body { hyphens: auto; }
form, textarea { width: 100%; }
label { display: inline-block; width: 20ch }
.last { max-width: 20% }
</style>
</head>
<body lang="en">
<h1>Upload File</h1>
{{if ne .Last ""}}
<p>Previous upload: <a href="/view/{{.Last}}">{{.Last}}</a></p>
{{if .Image}}
<p><img class="last" src="/view/{{.Last}}"></p>
{{end}}
{{end}}
<form action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<form action="/drop/{{.}}" method="POST" enctype="multipart/form-data">
<p>When uploading pictures from a phone, its filename is going to be something cryptic like IMG_1234.JPG.
Please provide your own filename.
<p><label for="text">Filename to use:</label>
<input id="text" name="name" value="{{.Name}}" type="text" placeholder="image.jpg" autofocus required>
<input id="text" name="name" type="text" placeholder="image.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 for JPG and PNG files. Luckily, most pictures from a phone camera are JPG images.
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">
<input id="maxwidth" name="maxwidth" type="number" min="10" placeholder="1200">
<p>If the uploaded file is a JPEG-encoded picture, like most pictures from a phone, 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">
<input id="quality" name="quality" type="number" min="1" max="99" placeholder="75">
<p>Finally, pick the file or photo to upload.
Picture metadata is only removed if the picture gets resized.
Providing a new max width is recommended for all pictures.

View File

@@ -6,58 +6,17 @@ import (
"image/jpeg"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
)
type Upload struct {
Dir string
Name string
Last string
Image bool
MaxWidth string
Quality string
}
var lastRe = regexp.MustCompile(`^(.*)([0-9]+)(.*)$`)
// uploadHandler uses the "upload.html" template to enable uploads.
// The file is saved using the saveUploadHandler. URL parameter are
// used to copy name, maxwidth and quality from the previous upload.
// If the previous name contains a number, this is incremented by
// one.
// The file is saved using the saveUploadHandler.
func uploadHandler(w http.ResponseWriter, r *http.Request, dir string) {
data := &Upload{Dir: dir}
maxwidth := r.FormValue("maxwidth")
if maxwidth != "" {
data.MaxWidth = maxwidth
}
quality := r.FormValue("quality")
if quality != "" {
data.Quality = quality
}
last := r.FormValue("last")
if last != "" {
ext := strings.ToLower(filepath.Ext(last))
switch ext {
case ".png", ".jpg", ".jpeg":
data.Image = true
}
data.Last = path.Join(dir, last)
m := lastRe.FindStringSubmatch(last)
if m != nil {
n, err := strconv.Atoi(m[2])
if err == nil {
data.Name = m[1] + strconv.Itoa(n+1) + m[3]
}
}
}
renderTemplate(w, "upload", data)
renderTemplate(w, "upload", dir)
}
// dropHandler takes the "name" form field and the "file" form
@@ -75,9 +34,7 @@ func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
http.Error(w, "file exists", http.StatusInternalServerError)
return
}
data := url.Values{}
name := r.FormValue("name")
data.Set("last", name)
filename := filepath.Base(name)
if filename == "." || filepath.Dir(name) != "." {
http.Error(w, "no filename", http.StatusInternalServerError)
@@ -89,7 +46,11 @@ func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
return
}
defer file.Close()
backup(filename)
// backup an existing file with the same name
_, err = os.Stat(filename)
if err != nil {
os.Rename(filename, filename+"~")
}
// create the new file
path := d + "/" + filename
dst, err := os.Create(path)
@@ -110,7 +71,6 @@ func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data.Add("maxwidth", maxwidth)
ext := strings.ToLower(filepath.Ext(path))
var encoder imgio.Encoder
switch ext {
@@ -125,7 +85,6 @@ func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data.Add("quality", quality)
}
encoder = imgio.JPEGEncoder(q)
default:
@@ -149,5 +108,5 @@ func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
}
}
http.Redirect(w, r, "/upload/"+d+"/?"+data.Encode(), http.StatusFound)
http.Redirect(w, r, "/view/"+path, http.StatusFound)
}

View File

@@ -7,16 +7,17 @@ import (
"image/jpeg"
"image/png"
"mime/multipart"
"net/url"
"os"
"regexp"
"testing"
)
// wipes testdata
func TestUpload(t *testing.T) {
cleanup(t, "testdata/files")
_ = os.RemoveAll("testdata")
// 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)
os.MkdirAll("testdata", 0755)
assert.HTTPStatusCode(t, makeHandler(uploadHandler, false), "GET", "/upload/testdata/", nil, 200)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, err := writer.CreateFormField("name")
@@ -28,17 +29,20 @@ func TestUpload(t *testing.T) {
file.Write([]byte("Hello!"))
err = writer.Close()
assert.NoError(t, err)
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/files/",
writer.FormDataContentType(), form, "/upload/testdata/files/?last=ok.txt")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/files/ok.txt", nil),
"Hello!")
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/",
writer.FormDataContentType(), form, "/view/testdata/ok.txt")
assert.Regexp(t, regexp.MustCompile("Hello!"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/ok.txt", nil))
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}
// wipes testdata
func TestUploadPng(t *testing.T) {
cleanup(t, "testdata/png")
_ = os.RemoveAll("testdata")
// for uploads, the directory is not created automatically
os.MkdirAll("testdata/png", 0755)
os.MkdirAll("testdata", 0755)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
@@ -47,14 +51,18 @@ func TestUploadPng(t *testing.T) {
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/?last=ok.png")
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/",
writer.FormDataContentType(), form, "/view/testdata/ok.png")
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}
// wipes testdata
func TestUploadJpg(t *testing.T) {
cleanup(t, "testdata/jpg")
_ = os.RemoveAll("testdata")
// for uploads, the directory is not created automatically
os.MkdirAll("testdata/jpg", 0755)
os.MkdirAll("testdata", 0755)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
@@ -63,94 +71,9 @@ func TestUploadJpg(t *testing.T) {
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/?last=ok.jpg")
}
func TestUploadMultiple(t *testing.T) {
cleanup(t, "testdata/multi")
p := &Page{Name: "testdata/multi/culture", Body: []byte(`# Culture
The road has walls
Iron gates and tree tops
But here: jasmin dreams`)}
p.save()
// check location for upload
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/multi/culture", nil)
assert.Contains(t, body, `href="/upload/testdata/multi/"`)
// check location for drop
body = assert.HTTPBody(makeHandler(uploadHandler, false), "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.Write([]byte("2023-10-02-hike-1.jpg"))
field, _ = writer.CreateFormField("maxwidth")
field.Write([]byte("15"))
field, _ = writer.CreateFormField("quality")
field.Write([]byte("50"))
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/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, "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)
assert.Contains(t, body, `value="2023-10-02-hike-2.jpg"`)
assert.Contains(t, body, `value="15"`)
assert.Contains(t, body, `value="50"`)
assert.Contains(t, body, `src="/view/testdata/multi/2023-10-02-hike-1.jpg"`)
}
func TestUploadDir(t *testing.T) {
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/",
writer.FormDataContentType(), form, "/view/testdata/ok.jpg")
t.Cleanup(func() {
assert.NoError(t, os.Remove("test.md"))
assert.NoError(t, os.Remove("test.jpg"))
_ = os.RemoveAll("testdata")
})
p := &Page{Name: "test", Body: []byte(`# Test
Eyes are an abyss
We stare into each other
There is no answer`)}
p.save()
// check location for upload
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/test", nil)
assert.Contains(t, body, `href="/upload/"`)
// check location for drop
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", "/upload/", nil)
assert.Contains(t, body, `action="/drop/"`)
// actually do the upload
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
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/",
writer.FormDataContentType(), form)
url, _ := url.Parse(location)
assert.Equal(t, "/upload/", url.Path, "Redirect to upload location")
values := url.Query()
assert.Equal(t, "test.jpg", values.Get("last"))
// check the result page
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", url.Path, values)
assert.Contains(t, body, `src="/view/test.jpg"`)
}

27
view.go
View File

@@ -3,7 +3,6 @@ package main
import (
"net/http"
"os"
"path"
"strings"
"time"
)
@@ -13,22 +12,19 @@ func rootHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/view/index", http.StatusFound)
}
// viewHandler serves pages. If the requested URL maps to an existing file, it is served. If the requested URL maps to a
// directory, the browser is redirected to the index page. If the requested URL ends in ".rss" and the corresponding
// file ending with ".md" exists, a feed is generated and the "feed.html" template is used (it is used to generate a RSS
// 2.0 feed, no matter what the template's extension is). If the requested URL maps to a page name, the corresponding
// file (ending in ".md") is loaded and served using the "view.html" template. If none of the above, the browser is
// redirected to an edit page.
//
// Caching: a 304 NOT MODIFIED is returned if the request has an If-Modified-Since header that matches the file's
// modification time, truncated to one second. Truncation is required because the file's modtime has sub-second
// precision and the HTTP timestamp for the Last-Modified header has not.
// viewHandler serves existing files (including markdown files with
// the .md extension). If the requested file does not exist, a page
// with the same name is loaded. This means adding the .md extension
// and using the "view.html" template to render the HTML. Both
// attempts fail, the browser is redirected to an edit page. As far as
// caching goes: we respond with a 304 NOT MODIFIED if the request has
// an If-Modified-Since header that matches the file's modification
// time, truncated to one second, because the file's modtime has
// sub-second precision and the HTTP timestamp for the Last-Modified
// header has not.
func viewHandler(w http.ResponseWriter, r *http.Request, name string) {
file := true
rss := false
if name == "" {
name = "."
}
fn := name
fi, err := os.Stat(fn)
if err != nil {
@@ -40,9 +36,6 @@ func viewHandler(w http.ResponseWriter, r *http.Request, name string) {
}
fn += ".md"
fi, err = os.Stat(fn)
} else if fi.IsDir() {
http.Redirect(w, r, path.Join("/view", name, "index"), http.StatusFound)
return
}
if err == nil {
h, ok := r.Header["If-Modified-Since"]

View File

@@ -6,12 +6,12 @@
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background: #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; }
button { background: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
</style>
@@ -22,11 +22,10 @@ img { max-width: 100%; }
<a href="/view/index">Home</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}}" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<form role="search" action="/search" 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 id="search" type="text" spellcheck="false" name="q" accesskey="f" required>
<button>Go</button>
</form>
</header>

View File

@@ -3,7 +3,6 @@ package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"net/url"
"os"
"regexp"
"testing"
@@ -19,97 +18,104 @@ func TestViewHandler(t *testing.T) {
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/index", nil))
}
func TestViewHandlerDir(t *testing.T) {
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/", nil, "/view/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/man", nil, "/view/man/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/man/", nil, "/view/man/index")
}
// relies on index.md in the current directory!
func TestViewHandlerWithId(t *testing.T) {
data := make(url.Values)
data.Set("id", "index")
assert.Regexp(t, regexp.MustCompile("Welcome to Oddµ"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/", data))
}
// wipes testdata
func TestPageTitleWithAmp(t *testing.T) {
cleanup(t, "testdata/amp")
_ = os.RemoveAll("testdata")
p := &Page{Name: "testdata/amp/Rock & Roll", Body: []byte("Dancing")}
p := &Page{Name: "testdata/Rock & Roll", Body: []byte("Dancing")}
p.save()
assert.Regexp(t, regexp.MustCompile("Rock &amp; Roll"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil))
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/Rock%20%26%20Roll", nil))
p = &Page{Name: "testdata/amp/Rock & Roll", Body: []byte("# Sex & Drugs & Rock'n'Roll\nOh no!")}
p = &Page{Name: "testdata/Rock & Roll", Body: []byte("# Sex & Drugs & Rock'n'Roll\nOh no!")}
p.save()
assert.Regexp(t, regexp.MustCompile("Sex &amp; Drugs"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil))
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/Rock%20%26%20Roll", nil))
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}
// wipes testdata
func TestPageTitleWithQuestionMark(t *testing.T) {
cleanup(t, "testdata/q")
_ = os.RemoveAll("testdata")
p := &Page{Name: "testdata/q/How about no?", Body: []byte("No means no")}
p := &Page{Name: "testdata/How about no?", Body: []byte("No means no")}
p.save()
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/q/How%20about%20no%3F", nil)
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/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>")
assert.Contains(t, body, "<a href=\"/edit/testdata/How%20about%20no%3F\" accesskey=\"e\">Edit</a>")
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}
// wipes testdata
func TestFileLastModified(t *testing.T) {
cleanup(t, "testdata/file-mod")
assert.NoError(t, os.Mkdir("testdata/file-mod", 0755))
assert.NoError(t, os.WriteFile("testdata/file-mod/now.txt", []byte(`
_ = os.RemoveAll("testdata")
assert.NoError(t, os.Mkdir("testdata", 0755))
assert.NoError(t, os.WriteFile("testdata/now.txt", []byte(`
A spider sitting
Unmoving and still
In the autumn chill
`), 0644))
fi, err := os.Stat("testdata/file-mod/now.txt")
fi, err := os.Stat("testdata/now.txt")
assert.NoError(t, err)
h := makeHandler(viewHandler, true)
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())
HTTPHeaders(h, "GET", "/view/testdata/now.txt", nil, "Last-Modified"))
HTTPStatusCodeIfModifiedSince(t, h, "/view/testdata/now.txt", fi.ModTime())
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}
// wipes testdata
func TestPageLastModified(t *testing.T) {
cleanup(t, "testdata/page-mod")
p := &Page{Name: "testdata/page-mod/now", Body: []byte(`
_ = os.RemoveAll("testdata")
p := &Page{Name: "testdata/now", Body: []byte(`
The sky glows softly
Sadly, the birds are quiet
I like spring better
`)}
p.save()
fi, err := os.Stat("testdata/page-mod/now.md")
fi, err := os.Stat("testdata/now.md")
assert.NoError(t, err)
h := makeHandler(viewHandler, true)
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())
HTTPHeaders(h, "GET", "/view/testdata/now", nil, "Last-Modified"))
HTTPStatusCodeIfModifiedSince(t, h, "/view/testdata/now", fi.ModTime())
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}
// wipes testdata
func TestPageHead(t *testing.T) {
cleanup(t, "testdata/head")
p := &Page{Name: "testdata/head/peace", Body: []byte(`
_ = os.RemoveAll("testdata")
p := &Page{Name: "testdata/peace", Body: []byte(`
No urgent typing
No todos, no list, no queue.
Just me and the birds.
`)}
p.save()
fi, err := os.Stat("testdata/head/peace.md")
fi, err := os.Stat("testdata/peace.md")
assert.NoError(t, err)
h := makeHandler(viewHandler, true)
assert.Equal(t, []string(nil),
HTTPHeaders(h, "HEAD", "/view/testdata/head/war", nil, "Last-Modified"))
HTTPHeaders(h, "HEAD", "/view/testdata/war", nil, "Last-Modified"))
assert.Equal(t, []string(nil),
HTTPHeaders(h, "GET", "/view/testdata/head/war", nil, "Last-Modified"))
HTTPHeaders(h, "GET", "/view/testdata/war", nil, "Last-Modified"))
assert.Equal(t, []string{fi.ModTime().UTC().Format(http.TimeFormat)},
HTTPHeaders(h, "HEAD", "/view/testdata/head/peace", nil, "Last-Modified"))
HTTPHeaders(h, "HEAD", "/view/testdata/peace", nil, "Last-Modified"))
assert.Equal(t, "",
assert.HTTPBody(h, "HEAD", "/view/testdata/head/peace", nil))
assert.HTTPBody(h, "HEAD", "/view/testdata/peace", nil))
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}

39
wiki.go
View File

@@ -13,8 +13,8 @@ import (
// Templates are parsed at startup.
var templates = template.Must(
template.ParseFiles("edit.html", "add.html", "view.html", "diff.html",
"search.html", "static.html", "upload.html", "feed.html"))
template.ParseFiles("edit.html", "add.html", "view.html",
"search.html", "upload.html", "feed.html"))
// validPath is a regular expression where the second group matches a
// page, so when the editHandler is called, a URL path of "/edit/foo"
@@ -38,28 +38,20 @@ func renderTemplate(w http.ResponseWriter, tmpl string, data any) {
}
}
// makeHandler returns a handler that uses the URL path without the first path element as its argument, e.g. if the URL
// path is /edit/foo/bar, the editHandler is called with "foo/bar" as its argument. This uses the second group from the
// validPath regular expression. The boolean argument indicates whether the following path is required. When false, a
// URL like /upload/ is OK. The argument can also be provided using a form parameter, i.e. call /edit/?id=foo/bar.
// makeHandler returns a handler that uses the URL path without the
// first path element as its argument, e.g. if the URL path is
// /edit/foo/bar, the editHandler is called with "foo/bar" as its
// argument. This uses the second group from the validPath regular
// expression. The boolean argument indicates whether the following
// path is required. When false, a URL /upload/ is OK.
func makeHandler(fn func(http.ResponseWriter, *http.Request, string), required bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
m := validPath.FindStringSubmatch(r.URL.Path)
if m != nil && (!required || len(m[2]) > 0) {
fn(w, r, m[2])
return
} else {
http.NotFound(w, r)
}
err := r.ParseForm()
if err != nil {
http.Error(w, "Cannot parse form", 400)
return
}
id := r.Form.Get("id")
if m != nil {
fn(w, r, id)
return
}
http.NotFound(w, r)
}
}
@@ -98,23 +90,19 @@ func scheduleLoadLanguages() {
func serve() {
http.HandleFunc("/", rootHandler)
http.HandleFunc("/view/", makeHandler(viewHandler, true))
http.HandleFunc("/diff/", makeHandler(diffHandler, true))
http.HandleFunc("/edit/", makeHandler(editHandler, true))
http.HandleFunc("/save/", makeHandler(saveHandler, true))
http.HandleFunc("/add/", makeHandler(addHandler, true))
http.HandleFunc("/append/", makeHandler(appendHandler, true))
http.HandleFunc("/upload/", makeHandler(uploadHandler, false))
http.HandleFunc("/drop/", makeHandler(dropHandler, false))
http.HandleFunc("/search/", makeHandler(searchHandler, false))
http.HandleFunc("/search", searchHandler)
go scheduleLoadIndex()
go scheduleLoadLanguages()
initAccounts()
port := getPort()
log.Printf("Serving a wiki on port %s", port)
err := http.ListenAndServe(":"+port, nil)
if err != nil {
log.Println(err)
}
http.ListenAndServe(":"+port, nil)
}
// commands does the command line parsing in case Oddmu is called with
@@ -126,11 +114,8 @@ func commands() {
subcommands.Register(subcommands.FlagsCommand(), "")
subcommands.Register(subcommands.CommandsCommand(), "")
subcommands.Register(&htmlCmd{}, "")
subcommands.Register(&staticCmd{}, "")
subcommands.Register(&searchCmd{}, "")
subcommands.Register(&replaceCmd{}, "")
subcommands.Register(&missingCmd{}, "")
subcommands.Register(&notifyCmd{}, "")
flag.Parse()
ctx := context.Background()

View File

@@ -6,8 +6,6 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"slices"
"strings"
"testing"
"time"
@@ -51,8 +49,10 @@ func HTTPRedirectTo(t *testing.T, handler http.HandlerFunc, method, url string,
return isRedirectCode
}
// HTTPUploadLocation returns the location header after an upload.
func HTTPUploadLocation(t *testing.T, handler http.HandlerFunc, url, contentType string, body *bytes.Buffer) string {
// HTTPUploadAndRedirectTo checks that the request results in a redirect and it
// checks the destination of the redirect. It returns whether the
// request did in fact result in a redirect.
func HTTPUploadAndRedirectTo(t *testing.T, handler http.HandlerFunc, url, contentType string, body *bytes.Buffer, destination string) bool {
w := httptest.NewRecorder()
req, err := http.NewRequest("POST", url, body)
req.Header.Set("Content-Type", contentType)
@@ -62,17 +62,9 @@ func HTTPUploadLocation(t *testing.T, handler http.HandlerFunc, url, contentType
isRedirectCode := code >= http.StatusMultipleChoices && code <= http.StatusTemporaryRedirect
assert.True(t, isRedirectCode, "Expected HTTP redirect status code for %q but received %d", url, code)
headers := w.Result().Header["Location"]
assert.True(t, len(headers) == 1, "Expected a single redirect header but got %d locations", len(headers))
return headers[0]
}
// HTTPUploadAndRedirectTo checks that the request results in a redirect and it
// checks the destination of the redirect. It returns whether the
// request did in fact result in a redirect.
func HTTPUploadAndRedirectTo(t *testing.T, handler http.HandlerFunc, url, contentType string, body *bytes.Buffer, destination string) {
location := HTTPUploadLocation(t, handler, url, contentType, body)
assert.Equal(t, destination, location,
"Expected HTTP redirect location %s for %q but received %s", destination, url, location)
assert.True(t, len(headers) == 1 && headers[0] == destination,
"Expected HTTP redirect location %s for %q but received %v", destination, url, headers)
return isRedirectCode
}
// HTTPStatusCodeIfModifiedSince checks that the request results in a
@@ -85,70 +77,3 @@ func HTTPStatusCodeIfModifiedSince(t *testing.T, handler http.HandlerFunc, url s
handler(w, req)
assert.Equal(t, http.StatusNotModified, w.Code)
}
// restore remembers the file content before the test starts and restores the file at the end. Important for files such
// as "index.md".
func restore(t *testing.T, files ...string) {
data := make(map[string][]byte)
stat := make(map[string]os.FileInfo)
for _, file := range files {
s, err := os.Stat(file)
if err != nil {
t.Log("Could not stat ", file, ": ", err)
continue
}
c, err := os.ReadFile(file)
if err != nil {
t.Log("Could not read ", file, ": ", err)
continue
}
stat[file] = s
data[file] = c
}
t.Cleanup(func() {
for file, c := range data {
m := stat[file].Mode()
err := os.WriteFile(file, c, m)
if err != nil {
t.Log("Could not restore ", file, ": ", err)
}
t := stat[file].ModTime()
os.Chtimes(file, t, t)
}
})
}
// cleanup deletes any directories mentioned and removes all pages in those directories from the index. Incidentally, if
// a filename such as "changes.md" or "changes.md~" is provided instead of a directory, then that page file is removed
// and any mention of it is removed from the index.
func cleanup(t *testing.T, dirs ...string) {
t.Cleanup(func() {
for _, dir := range dirs {
_ = os.RemoveAll(dir)
}
index.Lock()
defer index.Unlock()
for name := range index.titles {
for _, dir := range dirs {
if strings.HasPrefix(name, dir) {
delete(index.titles, name)
}
}
}
ids := []docid{}
for id, name := range index.documents {
for _, dir := range dirs {
if strings.HasPrefix(name, dir) {
delete(index.documents, id)
ids = append(ids, id)
}
}
}
for hashtag, docs := range index.token {
index.token[hashtag] = slices.DeleteFunc(ids, func(id docid) bool {
return slices.Contains(docs, id)
})
}
})
}