67 Commits
v1.13 ... v1.16

Author SHA1 Message Date
Alex Schroeder
77a38ddf66 Ready for release 1.16 2025-04-06 13:32:19 +02:00
Alex Schroeder
d3ffe82a90 The "toc" subcommand now takes filenames
In order to make use of the shell's file-completion, the command now
takes filenames. These end in '.md'. Previously, that resulted in
errors such as "Cannot load index.md: open index.md.md: no such file
or directory".
2025-04-05 23:05:06 +02:00
Alex Schroeder
4a12721462 The "notify" subcommand now takes filenames
In order to make use of the shell's file-completion, the command now
takes filenames. These end in '.md'. Previously, that resulted in
errors such as "Cannot load index.md: open index.md.md: no such file
or directory".
2025-04-05 23:02:03 +02:00
Alex Schroeder
07b1277764 The "links" subcommand now takes filenames
In order to make use of the shell's file-completion, the command now
takes filenames. These end in '.md'. Previously, that resulted in
errors such as "Cannot load index.md: open index.md.md: no such file
or directory".
2025-04-05 23:02:03 +02:00
Alex Schroeder
f99a54e2ef The "html" subcommand now takes filenames
In order to make use of the shell's file-completion, the command now
takes filenames. These end in '.md'. Previously, that resulted in
errors such as "Cannot load index.md: open index.md.md: no such file
or directory".
2025-04-05 23:02:03 +02:00
Alex Schroeder
56a4461bd6 Fix mux setup 2025-03-24 18:30:00 +01:00
Alex Schroeder
2e38daf667 Use a new http.ServeMux
Don't rely on http.DefaultServeMux because all the dependencies also
have access to it and might register unexpected routes.
2025-03-24 17:47:36 +01:00
Alex Schroeder
a5421372d8 Set explicit timeouts
Read timeout: 2 min (the client has two minutes to upload all its
files).

Write timeout: 5 min (the server has five minutes to process all the
files).

Idle timout: 2 min (the client has two minutes to make a new request
without needing to do another TLS handshake).
2025-03-24 17:41:22 +01:00
Alex Schroeder
7017363f6a Fix upload error at the top directory
Skipping filepath.Clean resulted in the empty string for the top
directory, so that all file uploads failed. This commit fixes that.
2025-03-16 23:16:34 +01:00
Alex Schroeder
9a7a1ee2a9 Standardize spelling of nginx
Use all lower case.
2025-03-16 22:46:57 +01:00
Alex Schroeder
76d7598854 Fix bug in upload templates
For uploads, do not use {{.Path}} instead of {{.Name}}.
2025-03-16 22:46:16 +01:00
Alex Schroeder
d7b48b975b Small documentation change
Add a note to change ExecStart when using a memex.
2025-03-14 17:59:54 +01:00
Alex Schroeder
4314a35d1d Bring some features from my templates here
Ctrl+B, Ctrl+I, Ctrl+K support.
Preview button.

And a documentation update.
2025-03-14 17:28:50 +01:00
Alex Schroeder
63e1c987f2 Add the logo 2025-03-14 01:01:28 +01:00
Alex Schroeder
7d748f82da Add the default templates to the tarballs 2025-03-14 01:01:10 +01:00
Alex Schroeder
8385bc424a Improving path vs filepath code
Rename the "path" argument to "name" if it refers to a page name (i.e.
a path) or "fp" if it refers to a filepath.

Don't escape Page.Name when ready to render a HTML template because in
some case this means unescaping in functions.

New method isHiddenName to replace ad-hoc checks for "." prefixes and
the like.
2025-03-14 00:56:40 +01:00
Alex Schroeder
820763bf23 Change {{.Name}} to {{.Path}} in HTML templates
This is a major change for anybody who has templates. In order to
handle the encoding of directory and filenames correctly for uploads,
page.Base() was added. It required at url.PathUnescape because it uses
page.Name and that used to be escaped before rendering. Instead of
doing that, there's now the actual .Name (unescaped) and
.Path (escaped). This change is only necessary if the name contains
characters that the html/template doesn't escape for
encodePathSegment: semicolon, comma and question-mark. We can ignore
the forward-slash because we don't allow that in page names.
2025-03-12 09:29:38 +01:00
Alex Schroeder
7d40fa4adb Use nameEscape for all redirects
Redirects don't use templates and therefore don't escape the path
names.
2025-03-12 08:35:24 +01:00
Alex Schroeder
6e24603c27 Add some more Umlaut tests 2025-03-11 09:27:56 +01:00
Alex Schroeder
5096627b87 Unescape the base name of the page
This still feels wrong. Is the p.Name not always unescaped?
2025-03-11 09:26:57 +01:00
Alex Schroeder
0c691123ff Test the redirect with Umlauts
Surprisingly, this uses the unescaped path and that doesn't look
right. Then again, it's an IRI instead of an URI and perhaps that's
OK, too.
2025-03-11 09:25:51 +01:00
Alex Schroeder
5770966cdd Prevent trailing ? in error message
If values is nil, don't print the "?" at the end of the URL.
2025-03-11 09:25:08 +01:00
Alex Schroeder
a001a77692 Use urlpath.Join 2025-03-11 09:24:41 +01:00
Alex Schroeder
0e29ed77ea Test upload with Umlaut in filename 2025-03-10 20:06:14 +01:00
Alex Schroeder
173bb62a79 Change MICRO SIGN to GREEK SMALL LETTER MU 😭 (tests) 2025-03-10 20:02:13 +01:00
Alex Schroeder
58249aac85 Change MICRO SIGN to GREEK SMALL LETTER MU 😭 2025-03-05 20:05:38 +01:00
Alex Schroeder
1fdd502e95 Fix upload templates for themes
Note that webp images can be written, too.
2025-02-23 00:15:05 +01:00
Alex Schroeder
196ff605c3 Rewrite file uploading
Converting now happens not only when images are resized by setting the
max width. Setting the quality or changing the file extension also
results in conversion.

Previously, it was possible to upload a JPEG file and save it using a
PNG filename -- but the file itself was still JPEG encoded!
2025-02-23 00:08:43 +01:00
Alex Schroeder
6c1e595f13 Add minimal after uploading HEIC and WebP image 2025-02-22 21:49:53 +01:00
Alex Schroeder
34fdb5d9a9 Add WebP support for uploading and resizing 2025-02-22 21:46:47 +01:00
Alex Schroeder
b28614fa52 Fix README
The HEIC library is a WASM blob that no longer prevents
cross-compilation. Blows my mind but here we are.
2025-02-11 19:05:44 +01:00
Alex Schroeder
e7511ed059 Update templates of transjovian.org theme
Upload handling multiple files to add to the original page instead of
just the last one.
2025-02-09 14:31:47 +01:00
Alex Schroeder
a5d03dd136 Update templates of alexschroeder.ch theme
Added preview button.

Search with image previews.

Upload handling multiple files to add to the original page instead of
just the last one.
2025-02-09 14:16:37 +01:00
Alex Schroeder
87d5efcb7a Add section for unreleased 1.16 2025-02-09 13:24:31 +01:00
Alex Schroeder
c9bb062a04 Plain theme
Add support for rocket links, Gemini style.

Add an index.md file so that the Home link works.
2025-02-09 13:20:42 +01:00
Alex Schroeder
e2eec5e052 Chat theme
Better support for lists.

Add an index.md file so that the Home link works.
2025-02-09 13:20:42 +01:00
Alex Schroeder
26033de177 Update release-information 2025-02-09 11:46:35 +01:00
Alex Schroeder
e1ba007f97 Don't link hashtags in link text
This change depends on a change to the markdown library. Specifically,
the parser's InsideLink must be public.

This means that the #like_this hashtag from the README.md in the
source directory is no longer available, so that test had to be
rewritten.

Another change to reduce the number of false hashtags was to use the
hashtag parser for all situations: It's also used to identify hashtags
in the search query string. The parser doesn't automatically turn the
matches to lower-case, however, so that has to be done when indexing
documents and when searching for hashtags.

The hashtags command for the commandline no longer prints a hash for
all the tags.
2025-02-07 20:05:36 +01:00
Alex Schroeder
e90ff9e7dd Fix timestamp handling for backup files
The timestamp has to be updated!
2024-11-16 11:45:24 +01:00
Alex Schroeder
70356e850a Fiddle with case for the headings 2024-11-16 11:37:09 +01:00
Alex Schroeder
81a59fd6ac Add preview test 2024-11-15 19:29:05 +01:00
Alex Schroeder
52d6f26eed Add unreleased 0.15 to the docs 2024-11-15 19:28:46 +01:00
Alex Schroeder
171910ff4f Fix release notes for 0.14 2024-11-15 19:26:45 +01:00
Alex Schroeder
5fb0f57b5c Add base element to edit template 2024-11-15 19:22:56 +01:00
Alex Schroeder
d712b132cc Add base element to preview template
Without this, links in the preview cannot be followed as they all
point to /preview/ instead of /view/.
2024-11-09 23:13:50 +01:00
Alex Schroeder
199c236c08 Use parser.EscapeChars
With github.com/gomarkdown/markdown commit 7e0a027d98c5 escapeChars is
now exported as EscapeChars and therefore the whole wrapper function
is no longer necessary.
2024-10-05 14:13:12 +02:00
Alex Schroeder
0e7f7a2c05 Add quotes 2024-09-30 08:44:02 +02:00
Alex Schroeder
4af15b48db Small documentation improvements 2024-09-30 08:42:14 +02:00
Alex Schroeder
9b6c54ccb4 Allow escaping of @ 2024-09-30 08:35:02 +02:00
Alex Schroeder
83f447b643 Move mod_dav documentation to new man page 2024-09-26 01:10:43 +02:00
Alex Schroeder
d5e37fa90a Document WebDAV setup 2024-09-26 01:08:26 +02:00
Alex Schroeder
609da1fbc2 Improve the man tests
Add a check for template documentation. Add checks to prevent tests
from passing when nothing was found.
2024-08-30 17:52:59 +02:00
Alex Schroeder
ba32e0dcce Document the list template 2024-08-30 17:34:11 +02:00
Alex Schroeder
e975c527d1 Add test for all actions in the man page
Add the /list/, /delete/ and /rename/ actions to the man page.
2024-08-30 14:03:32 +02:00
Alex Schroeder
656b9490a1 Fix delete test 2024-08-30 14:03:12 +02:00
Alex Schroeder
9bd7ca59fa Add rename action 2024-08-29 14:46:10 +02:00
Alex Schroeder
56f95553d6 Update man pages 2024-08-29 13:10:06 +02:00
Alex Schroeder
76e63278d6 Add subcommands to synopsis of oddmu(1) man page
Add more subcommands to the Options section, too.
2024-08-29 13:09:25 +02:00
Alex Schroeder
1e957b5411 Use EXAMPLES instead of EXAMPLE for man pages 2024-08-29 13:08:24 +02:00
Alex Schroeder
e666fb44cb Add list and delete actions 2024-08-29 10:44:42 +02:00
Alex Schroeder
754bf11516 Fix test due to themes/plain/README
That file has no Markdown title as recognized by Oddmu and therefore
its title is "README" (equal to the page name).
2024-08-27 19:41:35 +02:00
Alex Schroeder
7eeb81fa94 README reword stow example 2024-08-27 18:30:50 +02:00
Alex Schroeder
9c70935362 More work on the plain text theme 2024-08-24 23:39:21 +02:00
Alex Schroeder
9d65c01bb0 Improve description of transjovian.org theme 2024-08-24 23:20:27 +02:00
Alex Schroeder
0179d393dd Fix the scdoc-to-markdown script
When in a literal block, be more restrictive about what gets changed
before printing.
2024-08-24 23:07:48 +02:00
Alex Schroeder
f8b97f794b Add plain text theme 2024-08-24 22:49:10 +02:00
Alex Schroeder
b801f83fe0 Fix dist target 2024-08-24 22:49:02 +02:00
144 changed files with 2056 additions and 687 deletions

6
.gitignore vendored
View File

@@ -1,3 +1,7 @@
/oddmu
test.md
/testdata/
/oddmu-darwin-*
/oddmu-linux-*
/oddmu-windows-*
/oddmu.exe
/oddmu

View File

@@ -51,7 +51,7 @@ clean:
rm --force oddmu oddmu.exe oddmu-{linux,darwin,windows}-{amd64,arm64}{,.tar.gz}
cd man && make clean
dist: oddmu-linux-amd64.tar.gz oddmu-linux-arm64 oddmu-darwin-amd64.tar.gz oddmu-windows-amd64.tar.gz
dist: oddmu-linux-amd64.tar.gz oddmu-linux-arm64.tar.gz oddmu-darwin-amd64.tar.gz oddmu-windows-amd64.tar.gz
oddmu-linux-amd64: *.go
GOOS=linux GOARCH=amd64 go build -o $@
@@ -72,7 +72,7 @@ oddmu-windows-amd64.tar.gz: oddmu.exe
%.tar.gz: %
tar --create --file $@ --transform='s/^$</oddmu/' --transform='s/^/oddmu\//' --exclude='*~' \
$< Makefile *.socket *.service *.md man/Makefile man/*.1 man/*.5 man/*.7 themes/
$< *.html Makefile *.socket *.service *.md man/Makefile man/*.1 man/*.5 man/*.7 themes/
priv:
sudo setcap 'cap_net_bind_service=+ep' oddmu

View File

@@ -1,4 +1,4 @@
# Oddµ: A minimal wiki
# Oddμ: A minimal wiki
This program helps you run a minimal wiki, blog, digital garden, memex
or Zettelkasten. There is no version history.
@@ -19,7 +19,7 @@ write-access to the site.
It's well suited as a simple static site generator. There are no
plugins.
When Oddµ runs as a web server, it serves all the Markdown files
When Oddμ runs as a web server, it serves all the Markdown files
(ending in `.md`) as web pages. These pages can be edited via the web.
Oddmu adds the following extensions to Markdown: local links `[[like
@@ -32,7 +32,7 @@ necessary.
Other files can be uploaded and images (ending in `.jpg`, `.jpeg`,
`.png`, `.heic` or `.webp`) can be resized when they are uploaded
(resulting in `.jpg` or `.png` files).
(resulting in `.jpg`, `.png` or `.webp` files).
## Documentation
@@ -138,6 +138,10 @@ edit.
This man page documents how to setup a systemd unit and have it manage
Oddmu. “Great configurability brings great burdens.”
[oddmu-webdav(5)](https://alexschroeder.ch/view/oddmu/oddmu-webdav.5):
This man page documents how to set up the Apache web server so that
the wiki can be accessed via Web-DAV.
Leaving:
[oddmu-export(1)](https://alexschroeder.ch/view/oddmu/oddmu-export.1):
@@ -163,8 +167,6 @@ make docs
The `Makefile` in the `man` directory has targets to create Markdown
and HTML files.
The HEIC library uses C code and prevents cross-compilation.
As the repository changed URLs a few times (from GitHub, to
self-hosted using `cgit` to self-hosted using `legit`), there is no
way to install it using `go install`. You need to `git clone` the
@@ -205,9 +207,9 @@ into `$HOME/.local/share/man/`.
make install
```
To install it elsewhere, here's an example using [GNU
Stow](https://www.gnu.org/software/stow/) to install it into
`/usr/local/stow` in a way that allows you to uninstall it later:
Here's an example using [GNU Stow](https://www.gnu.org/software/stow/)
to install it into `/usr/local/stow` in a way that allows you to
uninstall it later:
```sh
sudo mkdir /usr/local/stow/oddmu
@@ -238,6 +240,7 @@ high-level introduction to the various source files.
search results
- `index.go` implements the index of all the hashtags
- `languages.go` implements the language detection
- `list.go` implements the file list page
- `page.go` implements the page loading and saving
- `parser.go` implements the Markdown parsing
- `preview.go` implements the `/preview` handler
@@ -275,18 +278,10 @@ extensions can be added.
### Filenames and URL path
One of the sad parts of the code is the distinction between path and
filepath. On a Linux system, this doesn't matter. I suspect that it
also doesn't matter on MacOS and Windows because the file systems
handle forward slashes just fine. The code still tries to do the right
thing. A path that is derived from a URL is a path with slashes.
Before accessing a file, it has to be turned into a filepath using
`filepath.FromSlashes` and in the rare case where the inverse happens,
use `filepath.ToSlashes`. Any path received via the URL path uses
slashes and needs to be converted to a filepath before passing it to
any `os` function. Any path received within a `path/filepath.WalkFunc`
is a filepath and needs to be converted to use slashes when used in
HTML output.
There are some simplifications made. The code doesn't consider the
various encodings (UTF-8 NFC on the web vs UTF-8 NFD for HFS+, for
example; it also doesn't check for characters in page names that are
illegal filenames on the filesystem used).
If you need to access the page name in code that is used from a
template, you have to decode the path. See the code in `diff.go` for
@@ -307,6 +302,20 @@ to Oddmu, or you can require authentication for certain actions.
Furthermore, you can do the same for directories, allowing you to use
subdirectories as separate sites, each with their own editors.
### Templates
The `themes` folder has some ideas of how to tweak the HTML templates.
### Permissions
An unexplored idea would be to parse a config file that has usernames
and passwords, groups usernames into roles, and assigns access to the
various actions based on these roles. This would obviate the need for
a web server acting as a reverse proxy.
Then again, not having to care about roles and permissions has been a
relief.
## Dependencies
This section lists the non-standard libraries Oddmu uses and their
@@ -328,9 +337,12 @@ in turn can be used by browsers to get hyphenation right. Apache-2.0.
is used to sniff the MIME type of files with unknown filename
extensions. MIT.
[github.com/gen2brain/heic](https://github.com/gen2brain/heic) is
used to decode HEIC files (the new default file format for photos on
iPhones). LGPL-3.0-only.
[github.com/gen2brain/heic](https://github.com/gen2brain/heic) is used
to decode HEIC files (the new default file format for photos on
iPhones). MIT.
[github.com/gen2brain/webp](https://github.com/gen2brain/webp) is used
to encode and decode WebP files. MIT.
[github.com/disintegration/imaging](https://github.com/disintegration/imaging)
is used to resize images. MIT.

View File

@@ -4,6 +4,8 @@ When preparing a new release
1. Run tests
2. Update man/oddmu-releases.7.txt
- add missing items
- change "(unreleased)"
3. make docs

View File

@@ -12,11 +12,11 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<form action="/append/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="{{.Language}}" autofocus required></textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Add">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -48,7 +48,7 @@ func appendHandler(w http.ResponseWriter, r *http.Request, name string) {
return
}
}
http.Redirect(w, r, "/view/"+name, http.StatusFound)
http.Redirect(w, r, "/view/" + nameEscape(name), http.StatusFound)
}
func (p *Page) append(body []byte) {

View File

@@ -16,7 +16,7 @@ import (
// are skipped. If the environment variable ODDMU_FILTER is a regular expression that matches the starting directory,
// this is a "separate site"; if the regular expression does not match, this is the "main site" and page names must also
// not match the regular expression.
func archiveHandler(w http.ResponseWriter, r *http.Request, path string) {
func archiveHandler(w http.ResponseWriter, r *http.Request, name string) {
filter := os.Getenv("ODDMU_FILTER")
re, err := regexp.Compile(filter)
if err != nil {
@@ -24,30 +24,30 @@ func archiveHandler(w http.ResponseWriter, r *http.Request, path string) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
matches := re.MatchString(path)
dir := filepath.Dir(filepath.FromSlash(path))
matches := re.MatchString(name)
dir := filepath.Dir(filepath.FromSlash(name))
z := zip.NewWriter(w)
err = filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
err = filepath.Walk(dir, func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
return filepath.SkipDir
}
} else if !strings.HasPrefix(filepath.Base(path), ".") &&
(matches || !re.MatchString(path)) {
zf, err := z.Create(path)
} else if !strings.HasPrefix(filepath.Base(fp), ".") &&
(matches || !re.MatchString(filepath.ToSlash(fp))) {
zf, err := z.Create(fp)
if err != nil {
log.Println(err)
return err
}
file, err := os.Open(path)
f, err := os.Open(fp)
if err != nil {
log.Println(err)
return err
}
_, err = io.Copy(zf, file)
_, err = io.Copy(zf, f)
if err != nil {
log.Println(err)
return err

View File

@@ -20,10 +20,10 @@ func (p *Page) notify() error {
if p.Title == "" {
p.Title = p.Name
}
esc := nameEscape(path.Base(p.Name))
esc := nameEscape(p.Base())
link := "* [" + p.Title + "](" + esc + ")\n"
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(` + esc + `\)\n`)
dir := path.Dir(p.Name)
dir := p.Dir()
err := addLinkWithDate(path.Join(dir, "changes"), link, re)
if err != nil {
log.Printf("Updating changes in %s failed: %s", dir, err)
@@ -31,7 +31,7 @@ func (p *Page) notify() error {
}
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")) {
if strings.HasPrefix(p.Base(), time.Now().Format("2006")) {
err := addLink(path.Join(dir, "index"), true, link, re)
if err != nil {
log.Printf("Updating index in %s failed: %s", dir, err)

View File

@@ -6,7 +6,6 @@ import (
"html"
"html/template"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -25,11 +24,7 @@ func diffHandler(w http.ResponseWriter, r *http.Request, name string) {
// 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 {
path, err := url.PathUnescape(p.Name)
if err != nil {
return template.HTML("Cannot unescape " + p.Name)
}
fp := filepath.FromSlash(path)
fp := filepath.FromSlash(p.Name)
a := fp + ".md~"
t1, err := os.ReadFile(a)
if err != nil {

View File

@@ -15,11 +15,11 @@ pre { white-space: normal; background-color: white; border: 1px solid #eee; padd
</head>
<body>
<header>
<a href="/view/{{.Name}}">Back</a>
<a href="/view/{{.Path}}">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>
<p>This is the diff between <a href="/view/{{.Path}}.md~">the backup</a> and <a href="/view/{{.Path}}.md">the current copy</a>.</p>
<pre>
{{.Diff}}
</pre>

View File

@@ -70,6 +70,7 @@ I hate the machine!`
I shiver at home
the monitor glares and moans
my grey heart grows cold`
// create s and overwrite it with r
p := &Page{Name: "testdata/backup/cold", Body: []byte(s)}
p.save()
p = &Page{Name: "testdata/backup/cold", Body: []byte(r)}
@@ -78,19 +79,29 @@ my grey heart grows cold`
// 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>`)
// save u
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
// diff from s to u since r was not 60 min or older and so the backup is kept
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))
assert.NoError(t, os.Chtimes("testdata/backup/cold.md", ts, ts))
// save r
p = &Page{Name: "testdata/backup/cold", Body: []byte(r)}
p.save()
body = string(p.Diff())
// diff from u to r:
// diff from u to r since enough time has passed and the old backup is discarded
assert.Contains(t, body, `<del>my grey heart grows cold</del>`)
assert.Contains(t, body, `<ins>I hate the machine!</ins>`)
// save s
p = &Page{Name: "testdata/backup/cold", Body: []byte(s)}
p.save()
body = string(p.Diff())
// diff from u to s since this is still "the same" editing window
assert.Contains(t, body, `<del>my grey heart grows cold</del>`)
assert.Contains(t, body, `<ins>fear or cold, who knows?</ins>`)
}

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<base href="/view/{{.Dir}}">
<title>Editing {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
@@ -12,14 +13,14 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Name}}" method="POST">
<form action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="{{.Language}}" autofocus>{{printf "%s" .Body}}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Save">
<button formaction="/preview/{{.Name}}" type="submit">Preview</button>
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<button formaction="/preview/{{.Path}}" type="submit">Preview</button>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -41,5 +41,5 @@ func saveHandler(w http.ResponseWriter, r *http.Request, name string) {
return
}
}
http.Redirect(w, r, "/view/"+name, http.StatusFound)
http.Redirect(w, r, "/view/" + nameEscape(name), http.StatusFound)
}

View File

@@ -13,8 +13,8 @@ func TestExportCmd(t *testing.T) {
b := new(bytes.Buffer)
s := exportCli(b, "feed.html", minimalIndex(t))
assert.Equal(t, subcommands.ExitSuccess, s)
assert.Contains(t, b.String(), "<title>Oddµ: A minimal wiki</title>")
assert.Contains(t, b.String(), "<title>Welcome to Oddµ</title>")
assert.Contains(t, b.String(), "<title>Oddμ: A minimal wiki</title>")
assert.Contains(t, b.String(), "<title>Welcome to Oddμ</title>")
}
func TestExportCmdLanguage(t *testing.T) {
@@ -50,6 +50,6 @@ func TestExportCmdJsonFeed(t *testing.T) {
b := new(bytes.Buffer)
s := exportCli(b, "testdata/json/template.json", minimalIndex(t))
assert.Equal(t, subcommands.ExitSuccess, s)
assert.Contains(t, b.String(), `"title": "Oddµ: A minimal wiki"`)
assert.Regexp(t, regexp.MustCompile("&lt;h1.*&gt;Welcome to Oddµ&lt;/h1&gt;"), b.String()) // skip id
assert.Contains(t, b.String(), `"title": "Oddμ: A minimal wiki"`)
assert.Regexp(t, regexp.MustCompile("&lt;h1.*&gt;Welcome to Oddμ&lt;/h1&gt;"), b.String()) // skip id
}

View File

@@ -63,7 +63,7 @@ func feed(p *Page, ti time.Time) *Feed {
if !ok || bytes.Contains(link.Destination, []byte("//")) {
return ast.GoToNext
}
name := path.Join(path.Dir(p.Name), string(link.Destination))
name := path.Join(p.Dir(), string(link.Destination))
fi, err := os.Stat(filepath.FromSlash(name) + ".md")
if err != nil {
return ast.GoToNext

View File

@@ -5,7 +5,7 @@
<link>https://example.org/</link>
<managingEditor>you@example.org (Your Name)</managingEditor>
<webMaster>you@example.org (Your Name)</webMaster>
<atom:link href="https://example.org/view/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
<atom:link href="https://example.org/view/{{.Path}}.rss" rel="self" type="application/rss+xml"/>
<description>This is the digital garden of Your Name.</description>
<image>
<url>https://example.org/view/logo.jpg</url>
@@ -15,8 +15,8 @@
{{range .Items}}
<item>
<title>{{.Title}}</title>
<link>https://example.org/view/{{.Name}}</link>
<guid>https://example.org/view/{{.Name}}</guid>
<link>https://example.org/view/{{.Path}}</link>
<guid>https://example.org/view/{{.Path}}</guid>
<description>{{.Html}}</description>
<pubDate>{{.Date}}</pubDate>
{{range .Hashtags}}

View File

@@ -9,7 +9,7 @@ import (
func TestFeed(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index.rss", nil),
"Welcome to Oddµ")
"Welcome to Oddμ")
}
func TestNoFeed(t *testing.T) {

9
go.mod
View File

@@ -9,7 +9,8 @@ require (
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f
github.com/fsnotify/fsnotify v1.7.0
github.com/gabriel-vasile/mimetype v1.4.3
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6
github.com/gen2brain/heic v0.3.1
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e
github.com/google/subcommands v1.2.0
github.com/hexops/gotextdiff v1.0.3
github.com/microcosm-cc/bluemonday v1.0.26
@@ -23,15 +24,15 @@ require (
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/ebitengine/purego v0.7.1 // indirect
github.com/gen2brain/heic v0.3.1 // indirect
github.com/ebitengine/purego v0.8.1 // indirect
github.com/gen2brain/webp v0.5.2 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.6 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/shopspring/decimal v1.3.1 // indirect
github.com/tetratelabs/wazero v1.7.3 // indirect
github.com/tetratelabs/wazero v1.8.1 // indirect
golang.org/x/image v0.15.0 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.21.0 // indirect

16
go.sum
View File

@@ -7,18 +7,22 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA=
github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gen2brain/heic v0.0.0-20230113233934-ca402e77a786 h1:zvgtcRb2B5gynWjm+Fc9oJZPHXwmcgyH0xCcNm6Rmo4=
github.com/gen2brain/heic v0.0.0-20230113233934-ca402e77a786/go.mod h1:aKVJoQ0cc9K5Xb058XSnnAxXLliR97qbSqWBlm5ca1E=
github.com/gen2brain/heic v0.3.1 h1:ClY5YTdXdIanw7pe9ZVUM9XcsqH6CCCa5CZBlm58qOs=
github.com/gen2brain/heic v0.3.1/go.mod h1:m2sVIf02O7wfO8mJm+PvE91lnq4QYJy2hseUon7So10=
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6 h1:ZPy+2XJ8u0bB3sNFi+I72gMEMS7MTg7aZCCXPOjV8iw=
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gen2brain/webp v0.5.2 h1:aYdjbU/2L98m+bqUdkYMOIY93YC+EN3HuZLMaqgMD9U=
github.com/gen2brain/webp v0.5.2/go.mod h1:Nb3xO5sy6MeUAHhru9H3GT7nlOQO5dKRNNlE92CZrJw=
github.com/gomarkdown/markdown v0.0.0-20240930133403-7e0a027d98c5 h1:qIhG9h8tUzKsVHn0iHtWUohq7Ve7btgA8rGp7TvrIHw=
github.com/gomarkdown/markdown v0.0.0-20240930133403-7e0a027d98c5/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e h1:ESHlT0RVZphh4JGBz49I5R6nTdC8Qyc08vU25GQHzzQ=
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
@@ -59,6 +63,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tetratelabs/wazero v1.7.3 h1:PBH5KVahrt3S2AHgEjKu4u+LlDbbk+nsGE3KLucy6Rw=
github.com/tetratelabs/wazero v1.7.3/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y=
github.com/tetratelabs/wazero v1.8.1 h1:NrcgVbWfkWvVc4UtT4LRLDf91PsOzDzefMdwhLfA550=
github.com/tetratelabs/wazero v1.8.1/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -66,8 +72,6 @@ golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@@ -8,9 +8,18 @@ import (
)
func TestHashtagsCmd(t *testing.T) {
cleanup(t, "testdata/hashtag")
p := &Page{Name: "testdata/hashtag/hash", Body: []byte(`# Hash
I hope for a time
not like today, relentless,
just crocus blooming
#Crocus`)}
p.save()
b := new(bytes.Buffer)
s := hashtagsCli(b)
assert.Equal(t, subcommands.ExitSuccess, s)
x := b.String()
assert.Contains(t, x, "#like_this\t")
assert.Contains(t, x, "crocus\t")
}

View File

@@ -7,30 +7,32 @@ import (
"github.com/google/subcommands"
"io"
"os"
"strings"
)
type htmlCmd struct {
useTemplate bool
template string
}
func (*htmlCmd) Name() string { return "html" }
func (*htmlCmd) Synopsis() string { return "render a page as HTML" }
func (*htmlCmd) Usage() string {
return `html [-view] <page name> ...:
return `html [-template <template name>] <page name> ...:
Render one or more pages as HTML.
Use a single - to read Markdown from stdin.
`
}
func (cmd *htmlCmd) SetFlags(f *flag.FlagSet) {
f.BoolVar(&cmd.useTemplate, "view", false, "use the 'view.html' template.")
f.StringVar(&cmd.template, "template", "",
"use the given HTML file as a template (probably view.html or static.html).")
}
func (cmd *htmlCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return htmlCli(os.Stdout, cmd.useTemplate, f.Args())
return htmlCli(os.Stdout, cmd.template, f.Args())
}
func htmlCli(w io.Writer, useTemplate bool, args []string) subcommands.ExitStatus {
func htmlCli(w io.Writer, template string, args []string) subcommands.ExitStatus {
if len(args) == 1 && args[0] == "-" {
body, err := io.ReadAll(os.Stdin)
if err != nil {
@@ -38,15 +40,20 @@ func htmlCli(w io.Writer, useTemplate bool, args []string) subcommands.ExitStatu
return subcommands.ExitFailure
}
p := &Page{Name: "stdin", Body: body}
return p.printHtml(w, useTemplate)
return p.printHtml(w, template)
}
for _, arg := range args {
p, err := loadPage(arg)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", arg, err)
for _, name := range args {
if !strings.HasSuffix(name, ".md") {
fmt.Fprintf(os.Stderr, "%s does not end in '.md'\n", name)
return subcommands.ExitFailure
}
status := p.printHtml(w, useTemplate)
name = name[0:len(name)-3]
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", name, err)
return subcommands.ExitFailure
}
status := p.printHtml(w, template)
if status != subcommands.ExitSuccess {
return status
}
@@ -54,9 +61,9 @@ func htmlCli(w io.Writer, useTemplate bool, args []string) subcommands.ExitStatu
return subcommands.ExitSuccess
}
func (p *Page) printHtml(w io.Writer, useTemplate bool) subcommands.ExitStatus {
if useTemplate {
t := "view.html"
func (p *Page) printHtml(w io.Writer, template string) subcommands.ExitStatus {
if len(template) > 0 {
t := template
loadTemplates()
p.handleTitle(true)
p.renderHtml()

View File

@@ -9,9 +9,9 @@ import (
func TestHtmlCmd(t *testing.T) {
b := new(bytes.Buffer)
s := htmlCli(b, false, []string{"index"})
s := htmlCli(b, "", []string{"index.md"})
assert.Equal(t, subcommands.ExitSuccess, s)
r := `<h1 id="welcome-to-oddµ">Welcome to Oddµ</h1>
r := `<h1 id="welcome-to-oddμ">Welcome to Oddμ</h1>
<p>Hello! 🙃</p>

View File

@@ -62,10 +62,12 @@ func (idx *indexStore) reset() {
}
// addDocument adds the text as a new document. This assumes that the index is locked!
// The hashtags (only!) are used as tokens. They are stored in lower case.
func (idx *indexStore) addDocument(text []byte) docid {
id := idx.next_id
idx.next_id++
for _, token := range hashtags(text) {
token = strings.ToLower(token)
ids := idx.token[token]
// Don't add same ID more than once. Checking the last
// position of the []docid works because the id is
@@ -137,12 +139,12 @@ func (idx *indexStore) load() (int, error) {
}
// walk reads a file and adds it to the index. This assumes that the index is locked.
func (idx *indexStore) walk(path string, info fs.FileInfo, err error) error {
func (idx *indexStore) walk(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// skip hidden directories and files
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
@@ -150,10 +152,10 @@ func (idx *indexStore) walk(path string, info fs.FileInfo, err error) error {
}
}
// skipp all but page files
if !strings.HasSuffix(path, ".md") {
if !strings.HasSuffix(fp, ".md") {
return nil
}
p, err := loadPage(strings.TrimSuffix(path, ".md"))
p, err := loadPage(strings.TrimSuffix(filepath.ToSlash(fp), ".md"))
if err != nil {
return err
}
@@ -193,8 +195,8 @@ func (idx *indexStore) update(p *Page) {
idx.add(p)
}
// search searches the index for a query string and returns page
// names.
// search searches the index. The query string is parsed for tokens. Each token is turned to lower cased and looked up
// in the index. Each page in the result must contain all the tokens. Returns page names.
func (idx *indexStore) search(q string) []string {
idx.RLock()
defer idx.RUnlock()
@@ -203,6 +205,7 @@ func (idx *indexStore) search(q string) []string {
if len(hashtags) > 0 {
var r []docid
for _, token := range hashtags {
token = strings.ToLower(token)
if ids, ok := idx.token[token]; ok {
if r == nil {
r = ids

View File

@@ -1,4 +1,4 @@
# Welcome to Oddµ
# Welcome to Oddμ
Hello! 🙃

View File

@@ -11,8 +11,8 @@ func TestIndexAdd(t *testing.T) {
idx.reset()
idx.Lock()
defer idx.Unlock()
tag := "#hello"
id := idx.addDocument([]byte("oh hi " + tag))
tag := "hello"
id := idx.addDocument([]byte("oh hi #" + tag))
assert.Contains(t, idx.token, tag)
idx.deleteDocument(id)
assert.NotContains(t, idx.token, tag)
@@ -21,7 +21,7 @@ func TestIndexAdd(t *testing.T) {
// TestIndex relies on README.md being indexed
func TestIndex(t *testing.T) {
index.load()
q := "Oddµ"
q := "Oddμ"
pages, _ := search(q, "", "", 1, false)
assert.NotZero(t, len(pages))
for _, p := range pages {
@@ -31,10 +31,19 @@ func TestIndex(t *testing.T) {
}
}
// Lower case hashtag!
func TestSearchHashtag(t *testing.T) {
cleanup(t, "testdata/search-hashtag")
p := &Page{Name: "testdata/search-hashtag/search", Body: []byte(`# Search
I'm back in this room
Shelf, table, chair, and shelf again
Where are my glasses?
#Searching`)}
p.save()
index.load()
q := "#like_this"
pages, _ := search(q, "", "", 1, false)
pages, _ := search("#searching", "", "", 1, false)
assert.NotZero(t, len(pages))
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/google/subcommands"
"io"
"os"
"strings"
)
type linksCmd struct {
@@ -43,6 +44,11 @@ func linksCli(w io.Writer, args []string) subcommands.ExitStatus {
return subcommands.ExitSuccess
}
for _, name := range args {
if !strings.HasSuffix(name, ".md") {
fmt.Fprintf(os.Stderr, "%s does not end in '.md'\n", name)
return subcommands.ExitFailure
}
name = name[0:len(name)-3]
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(w, "Loading %s: %s\n", name, err)

View File

@@ -9,7 +9,7 @@ import (
func TestLinksCmd(t *testing.T) {
b := new(bytes.Buffer)
s := linksCli(b, []string{"README"})
s := linksCli(b, []string{"README.md"})
assert.Equal(t, subcommands.ExitSuccess, s)
x := b.String()
assert.Contains(t, x, "https://alexschroeder.ch/view/oddmu/oddmu.1\n")

108
list.go Normal file
View File

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

59
list.html Normal file
View File

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

View File

@@ -12,8 +12,8 @@ func TestListCmd(t *testing.T) {
s := listCli(b, "", nil)
assert.Equal(t, subcommands.ExitSuccess, s)
x := b.String()
assert.Contains(t, x, "README\tOddµ: A minimal wiki\n")
assert.Contains(t, x, "index\tWelcome to Oddµ\n")
assert.Contains(t, x, "README\tOddμ: A minimal wiki\n")
assert.Contains(t, x, "index\tWelcome to Oddμ\n")
}
func TestListSubdirCmd(t *testing.T) {

30
list_test.go Normal file
View File

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

View File

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

View File

@@ -40,7 +40,7 @@ ServerAdmin alex@alexschroeder.ch
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
</VirtualHost>
```
@@ -106,13 +106,13 @@ ServerAdmin alex@alexschroeder.ch
ServerName transjovian.org
ProxyPassMatch "^/((view|diff|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
RedirectMatch "^/((edit|save|add|append|upload|drop)/(.*))?$" \
RedirectMatch "^/((edit|save|add|append|upload|drop|list|delete|rename)/(.*))?$" \
"https://transjovian.org/$1"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
</VirtualHost>
```
@@ -144,7 +144,7 @@ You probably want to serve some static files as well (see *Serve static files*).
In that case, you need to use the ProxyPassMatch directive.
```
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))?$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
@@ -159,7 +159,7 @@ A workaround is to add the redirect manually and drop the question-mark:
```
RedirectMatch "^/$" "/view/index"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
@@ -197,11 +197,11 @@ htpasswd -D .htpasswd berta
```
Modify your site configuration and protect the "/edit/", "/save/", "/add/",
"/append/", "/upload/" and "/drop/" URLs with a password by adding the following
to your "<VirtualHost \*:443>" section:
"/append/", "/upload/", "/drop/", "/list/", "/delete/" and "/rename/" URLs with
a password by adding the following to your "<VirtualHost \*:443>" section:
```
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
@@ -224,17 +224,17 @@ _oddmu-filter_(7).
In the following example, ODDMU_FILTER is set to "^secret/".
http://transjovian.org/search/index?q=something does not search the "secret/"
"http://transjovian.org/search/index?q=something" does not search the "secret/"
directory and its subdirectories are excluded.
http://transjovian.org/search/secret/index?q=something searches just the
"http://transjovian.org/search/secret/index?q=something" searches just the
"secret" directory and its subdirectories.
You need to configure the web server to prevent access to the "secret/"
directory:
```
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|preview|search|archive)/secret)/">
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename|(view|preview|search|archive)/secret)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
@@ -257,8 +257,9 @@ DocumentRoot /home/oddmu
Make sure that none of the subdirectories look like the wiki paths "/view/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/",
"/search/" or "/archive/". For example, create a file called "robots.txt"
containing the following, telling all robots that they're not welcome.
"/list", "/delete/", "/rename/" "/search/" or "/archive/". For example, create a
file called "robots.txt" containing the following, telling all robots that
they're not welcome.
```
User-agent: *
@@ -268,8 +269,8 @@ Disallow: /
Your 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.
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.
The "view.html" template would start as follows:
@@ -301,7 +302,7 @@ password file mentioned above.
This requires a valid login by the user "alex" or "berta":
```
<LocationMatch "^/(edit|save|add|append|upload|drop)/intetebi/">
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename)/intetebi/">
Require user alex berta
</LocationMatch>
```
@@ -344,10 +345,10 @@ 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"
"\<style\>: The Style Information element"
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style
"<link>: The External Resource Link element"
"\<link\>: The External Resource Link element"
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link
# AUTHORS

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-EXPORT" "1" "2024-08-16"
.TH "ODDMU-EXPORT" "1" "2024-08-29"
.PP
.SH NAME
.PP
@@ -33,7 +33,7 @@ XML preamble is printed and appropriate escaping rules are used.\&
.PP
By default, the export uses the \fB\fRfeed.\&html\fB\fR template in the current directory.\&
.PP
.SH EXAMPLE
.SH EXAMPLES
.PP
Export all the pages into a big XML file:
.PP

View File

@@ -26,7 +26,7 @@ XML preamble is printed and appropriate escaping rules are used.
By default, the export uses the **feed.html** template in the current directory.
# EXAMPLE
# EXAMPLES
Export all the pages into a big XML file:

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-FILTER" "7" "2024-02-19"
.TH "ODDMU-FILTER" "7" "2024-09-30"
.PP
.SH NAME
.PP
@@ -18,22 +18,17 @@ not just a single page.\& These actions walk the directory tree, including all
subdirectories.\& In some cases, this is not desirable.\&
.PP
Sometimes, subdirectories are separate sites, like the sites of other projects
or different people.\& Essentially, the subdirectory acts as a different site.\&
Depending on how you think about it, you might not want to include those "sites"
in searches or archives of the whole site.\&
.PP
What'\&s important in this situation is whether the visitor is looking at the
"main site" (a page further up in the directory tree) or at a particular page in
a "separate site".\&
or different people.\& Depending on how you think about it, you might not want to
include those "sites" in searches or archives of the whole site.\&
.PP
Since directory tree actions always start in the directory the visitor is
currenly looking at, directory tree actions starting in a "separate site"
currently looking at, directory tree actions starting in a "separate site"
automatically act as expected.\& The action is limited to that subdirectory tree.\&
.PP
When visitors look at a page in the "main site", however, directory tree actions
must skip any sub directories that are part of a "separate site".\&
.PP
The way to identify separate sates is via the environment variable ODDMU_FILTER.\&
The way to identify separate sites is via the environment variable ODDMU_FILTER.\&
It'\&s value is a regular expression matching separate sites.\&
.PP
.SH EXAMPLES

View File

@@ -11,22 +11,17 @@ not just a single page. These actions walk the directory tree, including all
subdirectories. In some cases, this is not desirable.
Sometimes, subdirectories are separate sites, like the sites of other projects
or different people. Essentially, the subdirectory acts as a different site.
Depending on how you think about it, you might not want to include those "sites"
in searches or archives of the whole site.
What's important in this situation is whether the visitor is looking at the
"main site" (a page further up in the directory tree) or at a particular page in
a "separate site".
or different people. Depending on how you think about it, you might not want to
include those "sites" in searches or archives of the whole site.
Since directory tree actions always start in the directory the visitor is
currenly looking at, directory tree actions starting in a "separate site"
currently looking at, directory tree actions starting in a "separate site"
automatically act as expected. The action is limited to that subdirectory tree.
When visitors look at a page in the "main site", however, directory tree actions
must skip any sub directories that are part of a "separate site".
The way to identify separate sates is via the environment variable ODDMU_FILTER.
The way to identify separate sites is via the environment variable ODDMU_FILTER.
It's value is a regular expression matching separate sites.
# EXAMPLES

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-HASHTAGS" "1" "2024-08-16"
.TH "ODDMU-HASHTAGS" "1" "2024-08-29"
.PP
.SH NAME
.PP
@@ -20,7 +20,7 @@ oddmu-hashtags - count the hashtags used
The "hashtags" subcommand counts all the hashtags used and lists them, separated
by a TAB character.\&
.PP
.SH EXAMPLE
.SH EXAMPLES
.PP
List the top 10 hashtags.\& This requires 11 lines because of the header line.\&
.PP

View File

@@ -13,7 +13,7 @@ oddmu-hashtags - count the hashtags used
The "hashtags" subcommand counts all the hashtags used and lists them, separated
by a TAB character.
# EXAMPLE
# EXAMPLES
List the top 10 hashtags. This requires 11 lines because of the header line.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-HTML" "1" "2024-08-21"
.TH "ODDMU-HTML" "1" "2025-04-05"
.PP
.SH NAME
.PP
@@ -13,30 +13,30 @@ oddmu-html - render Oddmu page HTML
.PP
.SH SYNOPSIS
.PP
\fBoddmu html\fR [-view] \fIpage-name\fR
\fBoddmu html\fR [\fB\fR-template\fB\fR \fItemplate-name\fR] \fIpage-name\fR
.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.\& Use "-" as the page name if you want to read Markdown from
\fBstdin\fR.\&
The "html" subcommand opens the given Markdown file and prints the resulting
HTML to STDOUT without invoking the "view.\&html" template.\& Use "-" as the page
name if you want to read Markdown from \fBstdin\fR.\&
.PP
.SH OPTIONS
.PP
\fB-view\fR
\fB\fR-template\fB\fR \fItemplate-name\fR
.RS 4
Use the "view.\&html" template to render the page.\& Without this, the HTML
lacks html and body tags.\&
Use the given template to render the page.\& Without this, the HTML lacks
html and body tags.\& The only two options that make sense are "view.\&html"
and "static.\&html".\&
.PP
.RE
.SH EXAMPLE
.SH EXAMPLES
.PP
Generate "README.\&html" from "README.\&md":
.PP
.nf
.RS 4
oddmu html README > README\&.html
oddmu html README\&.md > README\&.html
.fi
.RE
.PP

View File

@@ -6,27 +6,27 @@ oddmu-html - render Oddmu page HTML
# SYNOPSIS
*oddmu html* [-view] _page-name_
*oddmu html* [**-template** _template-name_] _page-name_
# 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. Use "-" as the page name if you want to read Markdown from
*stdin*.
The "html" subcommand opens the given Markdown file and prints the resulting
HTML to STDOUT without invoking the "view.html" template. Use "-" as the page
name if you want to read Markdown from *stdin*.
# OPTIONS
*-view*
Use the "view.html" template to render the page. Without this, the HTML
lacks html and body tags.
**-template** _template-name_
Use the given template to render the page. Without this, the HTML lacks
html and body tags. The only two options that make sense are "view.html"
and "static.html".
# EXAMPLE
# EXAMPLES
Generate "README.html" from "README.md":
```
oddmu html README > README.html
oddmu html README.md > README.html
```
Alternatively:

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-LINKS" "1" "2024-08-15"
.TH "ODDMU-LINKS" "1" "2025-04-05"
.PP
.SH NAME
.PP
@@ -17,8 +17,8 @@ oddmu-links - list outgoing links for pages
.PP
.SH DESCRIPTION
.PP
The "links" subcommand lists outgoing links for one or more page names.\& Use "-"
as the page name if you want to read Markdown from \fBstdin\fR.\&
The "links" subcommand lists outgoing links for one or more Markdown files.\& Use
"-" as the page name if you want to read Markdown from \fBstdin\fR.\&
.PP
.SH SEE ALSO
.PP

View File

@@ -10,8 +10,8 @@ oddmu-links - list outgoing links for pages
# DESCRIPTION
The "links" subcommand lists outgoing links for one or more page names. Use "-"
as the page name if you want to read Markdown from *stdin*.
The "links" subcommand lists outgoing links for one or more Markdown files. Use
"-" as the page name if you want to read Markdown from *stdin*.
# SEE ALSO

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-LIST" "1" "2024-08-16"
.TH "ODDMU-LIST" "1" "2024-08-29"
.PP
.SH NAME
.PP
@@ -31,7 +31,7 @@ subdirectory are listed, and the directory is stripped from the page name.\&
Limit the list to a particular directory.\&
.PP
.RE
.SH EXAMPLE
.SH EXAMPLES
.PP
Create list of links to pages in the "dad" directory, filter it for date pages
(starting with "2"), format it as a list of links and sort in reverse order.\&

View File

@@ -22,7 +22,7 @@ subdirectory are listed, and the directory is stripped from the page name.
*-dir* _string_
Limit the list to a particular directory.
# EXAMPLE
# EXAMPLES
Create list of links to pages in the "dad" directory, filter it for date pages
(starting with "2"), format it as a list of links and sort in reverse order.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-MISSING" "1" "2024-08-16"
.TH "ODDMU-MISSING" "1" "2024-08-29"
.PP
.SH NAME
.PP
@@ -26,7 +26,7 @@ that start with a slash "/" and links that start with a known URL schema
.PP
Notably, links that start with ".\&.\&/" are reported as missing.\&
.PP
.SH EXAMPLE
.SH EXAMPLES
.PP
Looking for broken links:
.PP

View File

@@ -19,7 +19,7 @@ that start with a slash "/" and links that start with a known URL schema
Notably, links that start with "../" are reported as missing.
# EXAMPLE
# EXAMPLES
Looking for broken links:

View File

@@ -5,19 +5,19 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-NGINX" "5" "2024-05-09"
.TH "ODDMU-NGINX" "5" "2025-03-16"
.PP
.SH NAME
.PP
oddmu-nginx - how to setup Nginx as a reverse proxy for Oddmu
oddmu-nginx - how to setup nginx as a reverse proxy for Oddmu
.PP
.SH 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.\&
.PP
This page explains how to setup NGINX on Debian to act as a reverse proxy for
Oddmu.\& Once this is done, you can use NGINX to provide HTTPS, request users to
This page explains how to setup nginx on Debian to act as a reverse proxy for
Oddmu.\& Once this is done, you can use nginx to provide HTTPS, request users to
authenticate themselves, and so on.\&
.PP
.SH CONFIGURATION
@@ -27,7 +27,7 @@ section.\& Add a new \fIlocation\fR section after the existing \fIlocation\fR se
.PP
.nf
.RS 4
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
proxy_pass http://localhost:8080;
}
.fi
@@ -53,7 +53,7 @@ location ~ ^/(view|diff|search)/ {
proxy_pass http://localhost:8080;
}
# password required
location ~ ^/(edit|save|add|append|upload|drop|archive)/ {
location ~ ^/(edit|save|add|append|upload|drop|list|delete|rename|archive)/ {
auth_basic "Oddmu author";
auth_basic_user_file /etc/nginx/conf\&.d/htpasswd;
proxy_pass http://localhost:8080;
@@ -97,7 +97,7 @@ server configuration.\& On a Debian system, that'\&d be in
.PP
.nf
.RS 4
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
proxy_pass http://unix:/run/oddmu/oddmu\&.sock:;
}
.fi

View File

@@ -2,15 +2,15 @@ ODDMU-NGINX(5)
# NAME
oddmu-nginx - how to setup Nginx as a reverse proxy for Oddmu
oddmu-nginx - how to setup nginx 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.
This page explains how to setup NGINX on Debian to act as a reverse proxy for
Oddmu. Once this is done, you can use NGINX to provide HTTPS, request users to
This page explains how to setup nginx on Debian to act as a reverse proxy for
Oddmu. Once this is done, you can use nginx to provide HTTPS, request users to
authenticate themselves, and so on.
# CONFIGURATION
@@ -19,7 +19,7 @@ The site is defined in "/etc/nginx/sites-available/default", in the _server_
section. Add a new _location_ section after the existing _location_ section:
```
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
proxy_pass http://localhost:8080;
}
```
@@ -43,7 +43,7 @@ location ~ ^/(view|diff|search)/ {
proxy_pass http://localhost:8080;
}
# password required
location ~ ^/(edit|save|add|append|upload|drop|archive)/ {
location ~ ^/(edit|save|add|append|upload|drop|list|delete|rename|archive)/ {
auth_basic "Oddmu author";
auth_basic_user_file /etc/nginx/conf.d/htpasswd;
proxy_pass http://localhost:8080;
@@ -81,7 +81,7 @@ server configuration. On a Debian system, that'd be in
"/etc/nginx/sites-available/default".
```
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
proxy_pass http://unix:/run/oddmu/oddmu.sock:;
}
```

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-NOTIFY" "1" "2024-02-17"
.TH "ODDMU-NOTIFY" "1" "2025-04-05"
.PP
.SH NAME
.PP
@@ -17,8 +17,8 @@ oddmu-notify - add links to changes.\&md, index.\&md, and hashtag pages
.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.\&
The "notify" subcommand takes all the Markdown files provided and adds links to
these pages from other pages.\&
.PP
A new link is added to the \fBchanges\fR page in the current directory if it doesn'\&t
exist.\& The current date of the machine Oddmu is running on is used as the
@@ -49,7 +49,7 @@ using the asterisk ('\&*'\&).\& If no such list exists, a new one is started at
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
.SH EXAMPLES
.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
@@ -57,7 +57,7 @@ it exists):
.PP
.nf
.RS 4
oddmu notify 2023-11-05-climate
oddmu notify 2023-11-05-climate\&.md
.fi
.RE
.PP

View File

@@ -10,8 +10,8 @@ oddmu-notify - add links to changes.md, index.md, and hashtag pages
# DESCRIPTION
The "notify" subcommand takes all the page names provided (without the ".md"
extension) and adds links to it from other pages.
The "notify" subcommand takes all the Markdown files provided and adds links to
these pages from other pages.
A new link is added to the *changes* page in the current directory if it doesn't
exist. The current date of the machine Oddmu is running on is used as the
@@ -42,14 +42,14 @@ 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
# EXAMPLES
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
oddmu notify 2023-11-05-climate.md
```
The changes file might look as follows:

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-RELEASES" "7" "2024-08-24"
.TH "ODDMU-RELEASES" "7" "2025-04-06"
.PP
.SH NAME
.PP
@@ -15,6 +15,67 @@ oddmu-releases - what'\&s new?\&
.PP
This page lists user-visible features and template changes to consider.\&
.PP
.SS 1.16 (2025)
.PP
Add support for WebP images for uploading and resizing.\&
.PP
You need to change {{.\&Name}} to {{.\&Path}} in HTML templates.\& If you don'\&t do
this, your page names (i.\&e.\& filenames for pages) may not include a comma, a
semicolon or a questionmark.\& This fix was necessary because file uploads of
filenames with non-ASCII characters ended up double-encoded.\&
.PP
Improved the example themes.\& The chat theme got better list styling and better
upload functionality with automatic "add" button; the plain theme got rocket
links via JavaScript; the alexschroeder.\&ch theme got a preview button and better
image support for upload and search; the transjovian.\&org theme got better image
support for upload.\&
.PP
Switch the \fIhtml\fR, \fIlink\fR, \fInotify\fR and \fItoc\fR subcommand to take filenames
(including the `.\&md` suffix) instead of page names (without the `.\&md` suffix).\&
.PP
.SS 1.15 (2025)
.PP
Fix the hashtag detection.\& This was necessary to cut down on the many false
positives.\& They were most obvious with the \fIhashtags\fR subcommand.\& Now the
Markdown parser is used at startup to index the pages, making startup slower
(about twice as long with my blog).\& The Markdown parser is also used to parse
search terms (where it makes little difference).\&
.PP
Fix the timestamp for backup files.\& This was necessary because the diff didn'\&t
work as intended.\&
.PP
.SS 1.14 (2024)
.PP
Add \fIlist\fR, \fIdelete\fR and \fIrename\fR actions.\&
.PP
This requires a change to your web server setup if you are using a it as a
reverse proxy because you need to pass these new actions along to Oddmu,
together with appropriate permission checks.\&
.PP
See \fIoddmu-apache\fR(5) or \fIoddmu-nginx\fR(5) for example.\&
.PP
In addition to that, you might want a link to the \fIlist\fR action from one of the
existing templates.\& For example, from upload.\&html:
.PP
.nf
.RS 4
<p>You can rename and delete files <a href="/list/{{\&.Dir}}">from the file list</a>\&.
.fi
.RE
.PP
The following line was added to the "preview.\&html" and "edit.\&html" template:
.PP
.nf
.RS 4
<base href="/view/{{\&.Dir}}">
.fi
.RE
.PP
You might want to do that as well, if you have your own.\& Without this, links in
the preview cannot be followed as they all point to \fB/preview\fR instead of
\fB/view\fR and the link to the list of changes cannot be followed from the edit
page: it leads to editing the list of changes.\&
.PP
.SS 1.13 (2024)
.PP
Add \fIexport\fR subcommand.\&

View File

@@ -8,6 +8,63 @@ oddmu-releases - what's new?
This page lists user-visible features and template changes to consider.
## 1.16 (2025)
Add support for WebP images for uploading and resizing.
You need to change {{.Name}} to {{.Path}} in HTML templates. If you don't do
this, your page names (i.e. filenames for pages) may not include a comma, a
semicolon or a questionmark. This fix was necessary because file uploads of
filenames with non-ASCII characters ended up double-encoded.
Improved the example themes. The chat theme got better list styling and better
upload functionality with automatic "add" button; the plain theme got rocket
links via JavaScript; the alexschroeder.ch theme got a preview button and better
image support for upload and search; the transjovian.org theme got better image
support for upload.
Switch the _html_, _link_, _notify_ and _toc_ subcommand to take filenames
(including the `.md` suffix) instead of page names (without the `.md` suffix).
## 1.15 (2025)
Fix the hashtag detection. This was necessary to cut down on the many false
positives. They were most obvious with the _hashtags_ subcommand. Now the
Markdown parser is used at startup to index the pages, making startup slower
(about twice as long with my blog). The Markdown parser is also used to parse
search terms (where it makes little difference).
Fix the timestamp for backup files. This was necessary because the diff didn't
work as intended.
## 1.14 (2024)
Add _list_, _delete_ and _rename_ actions.
This requires a change to your web server setup if you are using a it as a
reverse proxy because you need to pass these new actions along to Oddmu,
together with appropriate permission checks.
See _oddmu-apache_(5) or _oddmu-nginx_(5) for example.
In addition to that, you might want a link to the _list_ action from one of the
existing templates. For example, from upload.html:
```
<p>You can rename and delete files <a href="/list/{{.Dir}}">from the file list</a>.
```
The following line was added to the "preview.html" and "edit.html" template:
```
<base href="/view/{{.Dir}}">
```
You might want to do that as well, if you have your own. Without this, links in
the preview cannot be followed as they all point to */preview* instead of
*/view* and the link to the list of changes cannot be followed from the edit
page: it leads to editing the list of changes.
## 1.13 (2024)
Add _export_ subcommand.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-REPLACE" "1" "2024-08-16"
.TH "ODDMU-REPLACE" "1" "2025-03-05"
.PP
.SH NAME
.PP
@@ -36,13 +36,13 @@ the term is a regular expression and the replacement can contain
backreferences ($1, $2, $3, etc.\&) to capture groups.\&
.PP
.RE
.SH EXAMPLE
.SH EXAMPLES
.PP
Replace "Oddmu" in the Markdown files of the current directory:
.PP
.nf
.RS 4
oddmu replace Oddmu Oddµ
oddmu replace Oddmu Oddμ
.fi
.RE
.PP

View File

@@ -25,12 +25,12 @@ the current directory and its subdirectories.
the term is a regular expression and the replacement can contain
backreferences ($1, $2, $3, etc.) to capture groups.
# EXAMPLE
# EXAMPLES
Replace "Oddmu" in the Markdown files of the current directory:
```
oddmu replace Oddmu Oddµ
oddmu replace Oddmu Oddμ
```
Result:

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-SEARCH" "1" "2024-08-16"
.TH "ODDMU-SEARCH" "1" "2025-03-05"
.PP
.SH NAME
.PP
@@ -54,7 +54,7 @@ shown.\& This option allows you to view other pages.\&
Ignore pagination and just print a long list of results.\&
.PP
.RE
.SH EXAMPLE
.SH EXAMPLES
.PP
Search for the two words "Alex" and "Schroeder".\& All of the following are
equivalent: Alex Schroeder, Schroeder Alex, "Alex Schroeder", "Schroeder Alex".\&
@@ -65,7 +65,7 @@ The ordering of terms does not matter.\&
~/src/oddmu $ oddmu search Alex Schroeder
Search for Alex Schroeder, page 1: 3 results
* [Alex Schroeder theme](themes/alexschroeder\&.ch/README)
* [Oddµ: A minimal wiki](README)
* [Oddμ: A minimal wiki](README)
* [Themes](themes/index)
.fi
.RE

View File

@@ -39,7 +39,7 @@ scored.
*-all*
Ignore pagination and just print a long list of results.
# EXAMPLE
# EXAMPLES
Search for the two words "Alex" and "Schroeder". All of the following are
equivalent: Alex Schroeder, Schroeder Alex, "Alex Schroeder", "Schroeder Alex".
@@ -49,7 +49,7 @@ The ordering of terms does not matter.
~/src/oddmu $ oddmu search Alex Schroeder
Search for Alex Schroeder, page 1: 3 results
* [Alex Schroeder theme](themes/alexschroeder.ch/README)
* [Oddµ: A minimal wiki](README)
* [Oddμ: A minimal wiki](README)
* [Themes](themes/index)
```

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-SEARCH" "7" "2024-02-19"
.TH "ODDMU-SEARCH" "7" "2025-03-05"
.PP
.SH NAME
.PP
@@ -57,9 +57,9 @@ exactly (without the leading '\&#'\&) is listed first, even if it doesn'\&t cont
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
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

View File

@@ -44,9 +44,9 @@ 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
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

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-STATIC" "1" "2024-03-12"
.TH "ODDMU-STATIC" "1" "2024-08-29"
.PP
.SH NAME
.PP
@@ -51,7 +51,7 @@ then again, who knows.\& A SQLite file, for example, would change in-place, and
therefore making changes to it in the destination directory would change the
original, too.\&
.PP
.SH EXAMPLE
.SH EXAMPLES
.PP
Generate a static copy of the site, but only loading language detection for
German and English, significantly reducing the time it takes to generate the

View File

@@ -44,7 +44,7 @@ then again, who knows. A SQLite file, for example, would change in-place, and
therefore making changes to it in the destination directory would change the
original, too.
# EXAMPLE
# EXAMPLES
Generate a static copy of the site, but only loading language detection for
German and English, significantly reducing the time it takes to generate the

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-TEMPLATES" "5" "2024-07-21" "File Formats Manual"
.TH "ODDMU-TEMPLATES" "5" "2024-08-30" "File Formats Manual"
.PP
.SH NAME
.PP
@@ -13,9 +13,8 @@ oddmu-templates - how to write the templates
.PP
.SH SYNOPSIS
.PP
These files act as HTML templates: \fIadd.\&html\fR, \fIdiff.\&html\fR, \fIedit.\&html\fR,
\fIfeed.\&html\fR, \fIpreview.\&html\fR, \fIsearch.\&html\fR, \fIstatic.\&html\fR, \fIupload.\&html\fR and
\fIview.\&html\fR.\& They contain special placeholders in double bracers {{like this}}.\&
Some HTML files act as templates.\& They contain special placeholders in double
bracers {{like this}}.\&
.PP
.SH DESCRIPTION
.PP
@@ -32,6 +31,8 @@ placeholders.\&
.IP \(bu 4
\fIfeed.\&html\fR uses a \fIfeed\fR
.IP \(bu 4
\fIlist.\&html\fR uses a \fIlist\fR
.IP \(bu 4
\fIpreview.\&html\fR uses a \fIpage\fR
.IP \(bu 4
\fIsearch.\&html\fR uses a \fIsearch\fR
@@ -108,6 +109,30 @@ An item is a page plus a date.\& All the properties of a page can be used (see
.PP
\fI{{.\&Date}}\fR is the date of the last update to the page, in RFC 822 format.\&
.PP
.SS List
.PP
The list contains a directory name and an array of files.\&
.PP
\fI{{.\&Dir}}\fR is the directory name that is being listed.\&
.PP
\fI{{.\&Files}}\fR is the array of files.\& To refer to them, you need to use a \fI{{range
Files}}\fR\fI{{end}}\fR construct.\&
.PP
Each file has the following attributes:
.PP
\fI{{.\&Name}}\fR is the filename.\& The ".\&md" suffix for Markdown files is part of the
name (unlike page names).\&
.PP
\fI{{.\&Title}}\fR is the page title, if the file in question is a Markdown file.\&
.PP
\fI{{.\&IsDir}}\fR is a boolean used to indicate that this file is a directory.\&
.PP
\fI{{.\&IsUp}}\fR is a boolean used to indicate the entry for the parent directory
(the first file in the array, unless the directory being listed is the top
directory).\& The filename of this file is ".\&.\&".\&
.PP
\fI{{.\&Date}}\fR is the last modification date of the file.\&
.PP
.SS Search
.PP
\fI{{.\&Query}}\fR is the query string.\&
@@ -200,7 +225,7 @@ result is added to the "article" element for each snippet.\&
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
.SH EXAMPLES
.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.\&

View File

@@ -6,9 +6,8 @@ oddmu-templates - how to write the templates
# SYNOPSIS
These files act as HTML templates: _add.html_, _diff.html_, _edit.html_,
_feed.html_, _preview.html_, _search.html_, _static.html_, _upload.html_ and
_view.html_. They contain special placeholders in double bracers {{like this}}.
Some HTML files act as templates. They contain special placeholders in double
bracers {{like this}}.
# DESCRIPTION
@@ -19,6 +18,7 @@ placeholders.
- _diff.html_ uses a _page_
- _edit.html_ uses a _page_
- _feed.html_ uses a _feed_
- _list.html_ uses a _list_
- _preview.html_ uses a _page_
- _search.html_ uses a _search_
- _static.html_ uses a _page_
@@ -85,6 +85,30 @@ An item is a page plus a date. All the properties of a page can be used (see
_{{.Date}}_ is the date of the last update to the page, in RFC 822 format.
## List
The list contains a directory name and an array of files.
_{{.Dir}}_ is the directory name that is being listed.
_{{.Files}}_ is the array of files. To refer to them, you need to use a _{{range
.Files}}_ … _{{end}}_ construct.
Each file has the following attributes:
_{{.Name}}_ is the filename. The ".md" suffix for Markdown files is part of the
name (unlike page names).
_{{.Title}}_ is the page title, if the file in question is a Markdown file.
_{{.IsDir}}_ is a boolean used to indicate that this file is a directory.
_{{.IsUp}}_ is a boolean used to indicate the entry for the parent directory
(the first file in the array, unless the directory being listed is the top
directory). The filename of this file is "..".
_{{.Date}}_ is the last modification date of the file.
## Search
_{{.Query}}_ is the query string.
@@ -177,7 +201,7 @@ result is added to the "article" element for each snippet.
point, the language isn't known, so "en" is used for the "html" element and no
language is used for the "textarea" element.
# EXAMPLE
# EXAMPLES
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.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-TOC" "1" "2024-08-15"
.TH "ODDMU-TOC" "1" "2025-04-05"
.PP
.SH NAME
.PP
@@ -13,13 +13,12 @@ oddmu-toc - print the table of contents (toc) for pages
.PP
.SH SYNOPSIS
.PP
\fBoddmu toc\fR \fIpage names.\&.\&.\&\fR
\fBoddmu toc\fR \fIpage names.\&.\&.\&\fR
.PP
.SH DESCRIPTION
.PP
The "toc" subcommand prints the table of contents for one or more page
names.\& Use "-" as the page name if you want to read Markdown from
\fBstdin\fR.\&
The "toc" subcommand prints the table of contents for one or more Markdown
files.\& Use "-" as the page name if you want to read Markdown from \fBstdin\fR.\&
.PP
This can be useful for very long pages that need a table of contents
at the beginning.\&

View File

@@ -6,13 +6,12 @@ oddmu-toc - print the table of contents (toc) for pages
# SYNOPSIS
*oddmu toc* _page names..._
*oddmu toc* _page names..._
# DESCRIPTION
The "toc" subcommand prints the table of contents for one or more page
names. Use "-" as the page name if you want to read Markdown from
*stdin*.
The "toc" subcommand prints the table of contents for one or more Markdown
files. Use "-" as the page name if you want to read Markdown from *stdin*.
This can be useful for very long pages that need a table of contents
at the beginning.

188
man/oddmu-webdav.5 Normal file
View File

@@ -0,0 +1,188 @@
.\" Generated by scdoc 1.11.3
.\" 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-WEBDAV" "5" "2024-09-25"
.PP
.SH NAME
.PP
oddmu-webdav - how to setup Web-DAV using Apache for Oddmu
.PP
.SH DESCRIPTION
.PP
With the Apache Web-DAV module enabled, users can mount the wiki as a remote
file system and edit files using their favourite text editor.\& If you want to
offer users direct file access to the wiki, this can be accomplished via ssh,
sftp or Web-DAV.\&
.PP
The benefit of using the Apache Web-DAV module is that access has to be
configured only once.\&
.PP
.SH CONFIGURATION
.PP
In the following example, "data" is not an action provided by Oddmu but an
actual directory for Oddmu files.\& In the example below,
"/home/alex/campaignwiki.\&org/data" is both the document root for static files
and the data directory for Oddmu.\& This is the directory where Oddmu needs to
run.\& When users request the "/data" path, authentication is required but the
request is not proxied to Oddmu since the "ProxyPassMatch" directive doesn'\&t
handle "/data".\& Instead, Apache gets to handle it.\& Since "data" is part of all
the "LocationMatch" directives, credentials are required to save (PUT) files.\&
.PP
"Dav On" enables Web-DAV for the "knochentanz" wiki.\& It is enabled for all the
actions, but since only "/data" is handled by Apache, this has no effect for all
the other actions, allowing us to specify the required users only once.\&
.PP
.nf
.RS 4
MDomain campaignwiki\&.org
<VirtualHost *:80>
ServerName campaignwiki\&.org
Redirect permanent / https://campaignwiki\&.org/
</VirtualHost>
<VirtualHost *:443>
ServerAdmin alex@campaignwiki\&.org
ServerName campaignwiki\&.org
DocumentRoot /home/alex/campaignwiki\&.org
<Directory /home/alex/campaignwiki\&.org>
Options Includes Indexes MultiViews SymLinksIfOwnerMatch
AllowOverride All
Require all granted
</Directory>
SSLEngine on
ProxyPassMatch
"^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|search|archive/\&.+)/(\&.*))$"
"unix:/home/oddmu/campaignwiki\&.sock|http://localhost/$1"
# /archive only for subdirectories
Redirect "/archive/data\&.zip" "/view/archive"
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
Require user admin alex
</LocationMatch>
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete|archive)/knochentanz">
Require user admin alex knochentanz
Dav On
</LocationMatch>
</VirtualHost>
.fi
.RE
.PP
In order for this to work, you must enable the mod_dav_fs module.\& This
automatically enables to the mod_dav module, too.\& Restart the server after
installing enabling a module.\&
.PP
.nf
.RS 4
sudo a2enmod mod_dav_fs
sudo apachectl restart
.fi
.RE
.PP
Check the permissions for the data directory.\& If the Oddmu service uses the
"oddmu" user and Apache uses the "www-data" user, you could add the data
directory to the "www-data" group and give it write permissions:
.PP
.nf
.RS 4
sudo chown oddmu:www-data /home/alex/campaignwiki\&.org/data/knochentanz
sudo chmod g+w /home/alex/campaignwiki\&.org/data/knochentanz
.fi
.RE
.PP
.SH EXAMPLES
.PP
Web-DAV clients are often implemented such that they only work with servers that
exactly match their assumptions.\& If you'\&re trying to use \fIgvfs\fR(7), the Windows
File Explorer or the macOS Finder to edit Oddmu pages using Web-DAV, you'\&re on
your own.\&
.PP
This section has examples sessions using tools that work.\&
.PP
.SS cadaver
.PP
Here'\&s how to use \fIcadaver\fR(1).\& The "edit" command uses the editor specified in
the EDITOR environment variable.\& In this example, that'\&s
"emacsclient --alternate-editor= ".\&
.PP
.nf
.RS 4
cadaver https://campaignwiki\&.org/data/knochentanz/
Authentication required for Password Required on server `campaignwiki\&.org\&':
Username: knochentanz
Password:
dav:/data/knochentanz/> edit index\&.md
Locking `index\&.md\&': succeeded\&.
Downloading `/data/knochentanz/index\&.md\&' to /tmp/cadaver-edit-fHTllt\&.md
Progress: [=============================>] 100\&.0% of 2725 bytes succeeded\&.
Running editor: `emacsclient --alternate-editor= /tmp/cadaver-edit-fHTllt\&.md\&'\&.\&.\&.
Waiting for Emacs\&.\&.\&.
Changes were made\&.
Uploading changes to `/data/knochentanz/index\&.md\&'
Progress: [=============================>] 100\&.0% of 2726 bytes succeeded\&.
Unlocking `index\&.md\&': succeeded\&.
.fi
.RE
.PP
.SS curl and hdav
.PP
Here'\&s how to use \fIcurl\fR(1) to get the file from the public "/view" location and
how to use \fIhdav\fR(1) to put the file to the protected "/data" location.\& In this
example, \fIed\fR(1) is used to append the word "test" to the file.\&
.PP
.nf
.RS 4
alex@melanobombus ~> curl --output index\&.md https://campaignwiki\&.org/view/knochentanz/index\&.md
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 2726 100 2726 0 0 36662 0 --:--:-- --:--:-- --:--:-- 37861
alex@melanobombus ~> ed index\&.md
2726
a
test
\&.
w
2731
q
alex@melanobombus ~> hdav put index\&.md https://campaignwiki\&.org/data/knochentanz/index\&.md --username knochentanz
hDAV version 1\&.3\&.4, Copyright (C) 2012-2016 Clint Adams
hDAV comes with ABSOLUTELY NO WARRANTY\&.
This is free software, and you are welcome to redistribute it
under certain conditions\&.
Password for knochentanz at URL https://campaignwiki\&.org/data/knochentanz/index\&.md: ********
.fi
.RE
.PP
.SS davfs2
.PP
Here'\&s how to use \fIdavfs2\fR(1) using \fImount\fR(1).\& Now the whole wiki is mounted
and can be edited like local files.\& In this example, \fIecho\fR(1) and redirection
is used to append the word "test" to a file.\&
.PP
.nf
.RS 4
alex@melanobombus ~> mkdir knochentanz
alex@melanobombus ~> sudo mount -t davfs -o username=knochentanz,uid=alex
https://campaignwiki\&.org/data/knochentanz/ knochentanz/
Password: ********
alex@melanobombus ~> echo test >> knochentanz/index\&.md
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-apache\fR(5)
.PP
"Apache Module mod_dav".\&
https://httpd.\&apache.\&org/docs/current/mod/mod_dav.\&html
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

169
man/oddmu-webdav.5.txt Normal file
View File

@@ -0,0 +1,169 @@
ODDMU-WEBDAV(5)
# NAME
oddmu-webdav - how to setup Web-DAV using Apache for Oddmu
# DESCRIPTION
With the Apache Web-DAV module enabled, users can mount the wiki as a remote
file system and edit files using their favourite text editor. If you want to
offer users direct file access to the wiki, this can be accomplished via ssh,
sftp or Web-DAV.
The benefit of using the Apache Web-DAV module is that access has to be
configured only once.
# CONFIGURATION
In the following example, "data" is not an action provided by Oddmu but an
actual directory for Oddmu files. In the example below,
"/home/alex/campaignwiki.org/data" is both the document root for static files
and the data directory for Oddmu. This is the directory where Oddmu needs to
run. When users request the "/data" path, authentication is required but the
request is not proxied to Oddmu since the "ProxyPassMatch" directive doesn't
handle "/data". Instead, Apache gets to handle it. Since "data" is part of all
the "LocationMatch" directives, credentials are required to save (PUT) files.
"Dav On" enables Web-DAV for the "knochentanz" wiki. It is enabled for all the
actions, but since only "/data" is handled by Apache, this has no effect for all
the other actions, allowing us to specify the required users only once.
```
MDomain campaignwiki.org
<VirtualHost *:80>
ServerName campaignwiki.org
Redirect permanent / https://campaignwiki.org/
</VirtualHost>
<VirtualHost *:443>
ServerAdmin alex@campaignwiki.org
ServerName campaignwiki.org
DocumentRoot /home/alex/campaignwiki.org
<Directory /home/alex/campaignwiki.org>
Options Includes Indexes MultiViews SymLinksIfOwnerMatch
AllowOverride All
Require all granted
</Directory>
SSLEngine on
ProxyPassMatch \
"^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|search|archive/.+)/(.*))$" \
"unix:/home/oddmu/campaignwiki.sock|http://localhost/$1"
# /archive only for subdirectories
Redirect "/archive/data.zip" "/view/archive"
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
Require user admin alex
</LocationMatch>
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete|archive)/knochentanz">
Require user admin alex knochentanz
Dav On
</LocationMatch>
</VirtualHost>
```
In order for this to work, you must enable the mod_dav_fs module. This
automatically enables to the mod_dav module, too. Restart the server after
installing enabling a module.
```
sudo a2enmod mod_dav_fs
sudo apachectl restart
```
Check the permissions for the data directory. If the Oddmu service uses the
"oddmu" user and Apache uses the "www-data" user, you could add the data
directory to the "www-data" group and give it write permissions:
```
sudo chown oddmu:www-data /home/alex/campaignwiki.org/data/knochentanz
sudo chmod g+w /home/alex/campaignwiki.org/data/knochentanz
```
# EXAMPLES
Web-DAV clients are often implemented such that they only work with servers that
exactly match their assumptions. If you're trying to use _gvfs_(7), the Windows
File Explorer or the macOS Finder to edit Oddmu pages using Web-DAV, you're on
your own.
This section has examples sessions using tools that work.
## cadaver
Here's how to use _cadaver_(1). The "edit" command uses the editor specified in
the EDITOR environment variable. In this example, that's
"emacsclient --alternate-editor= ".
```
cadaver https://campaignwiki.org/data/knochentanz/
Authentication required for Password Required on server `campaignwiki.org':
Username: knochentanz
Password:
dav:/data/knochentanz/> edit index.md
Locking `index.md': succeeded.
Downloading `/data/knochentanz/index.md' to /tmp/cadaver-edit-fHTllt.md
Progress: [=============================>] 100.0% of 2725 bytes succeeded.
Running editor: `emacsclient --alternate-editor= /tmp/cadaver-edit-fHTllt.md'...
Waiting for Emacs...
Changes were made.
Uploading changes to `/data/knochentanz/index.md'
Progress: [=============================>] 100.0% of 2726 bytes succeeded.
Unlocking `index.md': succeeded.
```
## curl and hdav
Here's how to use _curl_(1) to get the file from the public "/view" location and
how to use _hdav_(1) to put the file to the protected "/data" location. In this
example, _ed_(1) is used to append the word "test" to the file.
```
alex@melanobombus ~> curl --output index.md https://campaignwiki.org/view/knochentanz/index.md
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 2726 100 2726 0 0 36662 0 --:--:-- --:--:-- --:--:-- 37861
alex@melanobombus ~> ed index.md
2726
a
test
.
w
2731
q
alex@melanobombus ~> hdav put index.md https://campaignwiki.org/data/knochentanz/index.md --username knochentanz
hDAV version 1.3.4, Copyright (C) 2012-2016 Clint Adams
hDAV comes with ABSOLUTELY NO WARRANTY.
This is free software, and you are welcome to redistribute it
under certain conditions.
Password for knochentanz at URL https://campaignwiki.org/data/knochentanz/index.md: ********
```
## davfs2
Here's how to use _davfs2_(1) using _mount_(1). Now the whole wiki is mounted
and can be edited like local files. In this example, _echo_(1) and redirection
is used to append the word "test" to a file.
```
alex@melanobombus ~> mkdir knochentanz
alex@melanobombus ~> sudo mount -t davfs -o username=knochentanz,uid=alex \
https://campaignwiki.org/data/knochentanz/ knochentanz/
Password: ********
alex@melanobombus ~> echo test >> knochentanz/index.md
```
# SEE ALSO
_oddmu_(1), _oddmu-apache_(5)
"Apache Module mod_dav".
https://httpd.apache.org/docs/current/mod/mod_dav.html
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -5,18 +5,20 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "1" "2024-08-16"
.TH "ODDMU" "1" "2025-03-14"
.PP
.SH NAME
.PP
oddmu - a wiki server
.PP
Oddmu is sometimes written Oddµ because µ is the letter mu.\&
Oddmu is sometimes written Oddμ because μ is the letter mu.\&
.PP
.SH SYNOPSIS
.PP
\fBoddmu\fR
.PP
\fBoddmu\fR \fIsubcommand\fR [\fIarguments\fR.\&.\&.\&]
.PP
.SH DESCRIPTION
.PP
Oddmu can be used as a static site generator, turning Markdown files into HTML
@@ -75,6 +77,12 @@ directory:
.IP \(bu 4
\fI/drop/dir/name\fR saves an upload
.IP \(bu 4
\fI/list/dir/\fR lists the files in a directory
.IP \(bu 4
\fI/delete/dir/name\fR deletes a file or directory
.IP \(bu 4
\fI/rename/dir/name?\&name=new\fR renames a file or directory
.IP \(bu 4
\fI/search/dir/?\&q=term\fR to search for a term
.IP \(bu 4
\fI/archive/dir/name.\&zip\fR to download a zip file of a directory
@@ -214,20 +222,32 @@ to generate the HTML for a single page, see \fIoddmu-html\fR(1)
to generate the HTML for the entire site, using Oddmu as a static site
generator, see \fIoddmu-static\fR(1)
.IP \(bu 4
to search a regular expression and replace it across all files, see
\fIoddmu-replace\fR(1)
to export the HTML for the entire site in one big feed, see \fIoddmu-export\fR(1)
.IP \(bu 4
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 search a regular expression and replace it across all files, see
\fIoddmu-replace\fR(1)
.IP \(bu 4
to learn what the most popular hashtags are, see \fIoddmu-hashtags\fR(1)
.IP \(bu 4
to print a table of contents (TOC) for a page, see \fIoddmu-toc\fR(1)
.IP \(bu 4
to list the outgoing links for a page, see \fIoddmu-links\fR(1)
.IP \(bu 4
to find missing pages (local links that go nowhere), see \fIoddmu-missing\fR(1)
.IP \(bu 4
to list all the pages with name and title, see \fIoddmu-list\fR(1)
.IP \(bu 4
to add links to changes, index and hashtag pages to pages you created locally,
see \fIoddmu-notify\fR(1)
.IP \(bu 4
to display build information, see \fIoddmu-version\fR(1)
.PD
.PP
.SH EXAMPLE
.SH EXAMPLES
.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
@@ -268,9 +288,10 @@ it, it can'\&t be a page name.\& Filenames can contain slashes and Oddmu creates
subdirectories as necessary.\&
.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.\&
and file uploads, the old file is 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 backup also gets an
updated timestamp so that subsequent edits don'\&t immediately overwrite it.\&
.PP
The \fBindex\fR page is the default page.\& People visiting the "root" of the site are
redirected to "/view/index".\&
@@ -359,6 +380,8 @@ If you run Oddmu as a web server:
.IP \(bu 4
\fIoddmu-nginx\fR(5), on how to set up freenginx as a reverse proxy
.IP \(bu 4
\fIoddmu-webdav\fR(5), on how to set up Apache as a Web-DAV server
.IP \(bu 4
\fIoddmu.\&service\fR(5), on how to run the service under systemd
.PD
.PP

View File

@@ -4,12 +4,14 @@ ODDMU(1)
oddmu - a wiki server
Oddmu is sometimes written Oddµ because µ is the letter mu.
Oddmu is sometimes written Oddμ because μ is the letter mu.
# SYNOPSIS
*oddmu*
*oddmu* _subcommand_ [_arguments_...]
# DESCRIPTION
Oddmu can be used as a static site generator, turning Markdown files into HTML
@@ -53,6 +55,9 @@ directory:
- _/append/dir/name_ appends an addition to a page
- _/upload/dir/name_ shows a form to upload a file
- _/drop/dir/name_ saves an upload
- _/list/dir/_ lists the files in a directory
- _/delete/dir/name_ deletes a file or directory
- _/rename/dir/name?name=new_ renames a file or directory
- _/search/dir/?q=term_ to search for a term
- _/archive/dir/name.zip_ to download a zip file of a directory
@@ -174,16 +179,22 @@ Oddmu 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 search a regular expression and replace it across all files, see
_oddmu-replace_(1)
- to export the HTML for the entire site in one big feed, see _oddmu-export_(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 search a regular expression and replace it across all files, see
_oddmu-replace_(1)
- to learn what the most popular hashtags are, see _oddmu-hashtags_(1)
- to print a table of contents (TOC) for a page, see _oddmu-toc_(1)
- to list the outgoing links for a page, see _oddmu-links_(1)
- to find missing pages (local links that go nowhere), see _oddmu-missing_(1)
- to list all the pages with name and title, see _oddmu-list_(1)
- to add links to changes, index and hashtag pages to pages you created locally,
see _oddmu-notify_(1)
- to display build information, see _oddmu-version_(1)
# EXAMPLE
# EXAMPLES
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
@@ -220,9 +231,10 @@ it, it can't be a page name. Filenames can contain slashes and Oddmu creates
subdirectories as necessary.
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.
and file uploads, the old file is 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 backup also gets an
updated timestamp so that subsequent edits don't immediately overwrite it.
The *index* page is the default page. People visiting the "root" of the site are
redirected to "/view/index".
@@ -300,6 +312,7 @@ If you run Oddmu as a web server:
- _oddmu-apache_(5), on how to set up Apache as a reverse proxy
- _oddmu-nginx_(5), on how to set up freenginx as a reverse proxy
- _oddmu-webdav_(5), on how to set up Apache as a Web-DAV server
- _oddmu.service_(5), on how to run the service under systemd
If you run Oddmu as a static site generator or pages offline and sync them with

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "5" "2024-08-17" "File Formats Manual"
.TH "ODDMU" "5" "2025-03-05" "File Formats Manual"
.PP
.SH NAME
.PP
@@ -28,7 +28,7 @@ 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!\&
.PP
There are three Oddµ-specific extensions: local links, hashtags and fediverse
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
@@ -89,12 +89,12 @@ Internet
.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
"@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
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".\&
@@ -117,7 +117,7 @@ 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
a space is required between the last # and the text for headings
.IP \(bu 4
you can specify an id for headings ({#id})
.IP \(bu 4
@@ -126,12 +126,12 @@ trailing backslashes turn into line breaks
.PP
.SH FEEDS
.PP
Every file can be viewed as feed by using the extension ".\&rss".\& The feed items
Every file can be viewed as a 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
Below is an example 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.\&
@@ -153,7 +153,8 @@ Recent posts:
.fi
.RE
.PP
The feed contains at most 10 items, starting at the top.\&
The feed contains at most 10 items, starting at the top.\& Thus, new items must be
added at the beginning of the list.\&
.PP
.SH PERCENT ENCODING
.PP

View File

@@ -19,7 +19,7 @@ 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
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.
@@ -74,12 +74,12 @@ 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
"\@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
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".
@@ -96,18 +96,18 @@ The Markdown processor comes with a few extensions:
- 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
- a space is required between the last # and the text for headings
- you can specify an id for headings ({#id})
- trailing backslashes turn into line breaks
# FEEDS
Every file can be viewed as feed by using the extension ".rss". The feed items
Every file can be viewed as a 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
Below is an example 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.
@@ -127,7 +127,8 @@ Recent posts:
* [Monophysitism](monophysitism)
```
The feed contains at most 10 items, starting at the top.
The feed contains at most 10 items, starting at the top. Thus, new items must be
added at the beginning of the list.
# PERCENT ENCODING

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU.SERVICE" "5" "2024-08-23"
.TH "ODDMU.SERVICE" "5" "2025-03-14"
.PP
.SH NAME
.PP
@@ -94,8 +94,8 @@ sudo mkdir /run/oddmu
.fi
.RE
.PP
The unit file for the service defines where the "oddmu" is and where the data
directory is.\& These are the lines you most likely have to take care of:
The unit file for the service defines where the Oddmu binary is and where the
data directory is.\& These are the lines you most likely have to take care of:
.PP
.nf
.RS 4
@@ -152,6 +152,9 @@ Environment="ODDMU_LANGUAGES=de,en"
.fi
.RE
.PP
Make sure to change the "ExecStart" entry so that it points to your copy of the
Oddmu binary.\&
.PP
Since this is a user service, the same user can edit the files using their
favourite text editor.\&
.PP

View File

@@ -75,8 +75,8 @@ the directory or change the file name.
sudo mkdir /run/oddmu
```
The unit file for the service defines where the "oddmu" is and where the data
directory is. These are the lines you most likely have to take care of:
The unit file for the service defines where the Oddmu binary is and where the
data directory is. These are the lines you most likely have to take care of:
```
ExecStart=/home/oddmu/oddmu
@@ -125,6 +125,9 @@ WorkingDirectory=/home/alex/wiki
Environment="ODDMU_LANGUAGES=de,en"
```
Make sure to change the "ExecStart" entry so that it points to your copy of the
Oddmu binary.
Since this is a user service, the same user can edit the files using their
favourite text editor.

View File

@@ -3,6 +3,12 @@ use strict;
use warnings;
my $literal = 0;
while (<>) {
# switch literal style
$literal = !$literal if /^```$/;
if ($literal) {
print;
next;
}
# bold
s/\*([^*]+)\*/**$1**/g;
# link to oddmu man pages (before italics)
@@ -10,18 +16,16 @@ while (<>) {
# italic
s/\b_([^_]+)_\b/*$1*/g;
# move all H1 headers to H2
s/^# /## /;
s/^# (.*)/"## ".ucfirst(lc($1))/e;
# the new H1 title
s/^([A-Z.-]*\([1-9]\))( ".*")?$/# $1/;
s/^([A-Z.-]*\([1-9]\))( ".*")?$/"# ".lc($1)/e;
# quoted URLs
s/"(http.*?)"/`$1`/g;
# quoted wiki links
s/"(\[\[[^]]*\]\])"/`$1`/g;
# quoted Markdown links
s/"(\[.*?\]\(.*?\))"/`$1`/g;
# switch literal style
$literal = !$literal if /^```$/;
# protect hashtags except within literal blocks
s/#([^ #])/\\#$1/ unless $literal;
# protect hashtags
s/#([^ #])/\\#$1/;
print;
}

View File

@@ -7,23 +7,27 @@ import (
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"testing"
)
// Does oddmu(1) link to all the other man pages?
func TestManPages(t *testing.T) {
b, err := os.ReadFile("man/oddmu.1.txt")
main := string(b)
assert.NoError(t, err)
filepath.Walk("man", func(path string, info fs.FileInfo, err error) error {
count := 0
filepath.Walk("man", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(path, ".txt") &&
path != "man/oddmu.1.txt" {
s := strings.TrimPrefix(path, "man/")
if strings.HasSuffix(fp, ".txt") &&
fp != "man/oddmu.1.txt" {
count++
s := strings.TrimPrefix(fp, "man/")
s = strings.TrimSuffix(s, ".txt")
i := strings.LastIndex(s, ".")
ref := "_" + s[:i] + "_(" + s[i+1:] + ")"
@@ -31,40 +35,99 @@ func TestManPages(t *testing.T) {
}
return nil
})
assert.Greater(t, count, 0, "no man pages were found")
}
func TestReadme(t *testing.T) {
b, err := os.ReadFile("README.md")
main := string(b)
// Does oddmu-templates(5) mention all the templates?
func TestManTemplates(t *testing.T) {
b, err := os.ReadFile("man/oddmu-templates.5.txt")
man := string(b)
assert.NoError(t, err)
filepath.Walk("man", func(path string, info fs.FileInfo, err error) error {
count := 0
filepath.Walk(".", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(path, ".txt") {
s := strings.TrimPrefix(path, "man/")
if strings.HasSuffix(fp, ".html") {
count++
assert.Contains(t, man, fp, fp)
}
if fp != "." && info.IsDir() {
return filepath.SkipDir
}
return nil
})
assert.Greater(t, count, 0, "no templates were found")
}
// Does oddmu(1) mention all the actions? We're not going to parse the go file and make sure to catch them all. I tried
// it, and it's convoluted.
func TestManActions(t *testing.T) {
b, err := os.ReadFile("man/oddmu.1.txt")
assert.NoError(t, err)
main := string(b)
b, err = os.ReadFile("wiki.go")
assert.NoError(t, err)
wiki := string(b)
count := 0
// this doesn't match the root handler
re := regexp.MustCompile(`\.HandleFunc\("(/[a-z]+/)", makeHandler\([a-z]+Handler, (true|false)\)\)`)
for _, match := range re.FindAllStringSubmatch(wiki, -1) {
count++
var path string
if match[2] == "true" {
path = "_" + match[1] + "dir/name"
} else {
path = "_" + match[1] + "dir/"
}
assert.Contains(t, main, path, path)
}
assert.Greater(t, count, 0, "no handlers were found")
// root handler is manual
assert.Contains(t, main, "\n- _/_", "root")
}
// Does the README link to all the man pages and all the Go source files,
// excluding the command and test files?
func TestReadme(t *testing.T) {
b, err := os.ReadFile("README.md")
readme := string(b)
assert.NoError(t, err)
count := 0
filepath.Walk("man", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(fp, ".txt") {
count++
s := strings.TrimPrefix(fp, "man/")
s = strings.TrimSuffix(s, ".txt")
i := strings.LastIndex(s, ".")
ref := "[" + s[:i] + "(" + s[i+1:] + ")]"
assert.Contains(t, main, ref, ref)
assert.Contains(t, readme, ref, ref)
}
return nil
})
filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
assert.Greater(t, count, 0, "no man pages were found")
count = 0
filepath.Walk(".", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(path, ".go") &&
!strings.HasSuffix(path, "_test.go") &&
!strings.HasSuffix(path, "_cmd.go") {
s := strings.TrimPrefix(path, "./")
if strings.HasSuffix(fp, ".go") &&
!strings.HasSuffix(fp, "_test.go") &&
!strings.HasSuffix(fp, "_cmd.go") {
count++
s := strings.TrimPrefix(fp, "./")
ref := "`" + s + "`"
assert.Contains(t, main, ref, ref)
assert.Contains(t, readme, ref, ref)
}
return nil
})
assert.Greater(t, count, 0, "no source pages were found")
}
// Does the README document all the dependecies, checking all the all the packages with names containing a period?
func TestDocumentDependencies(t *testing.T) {
b, err := os.ReadFile("README.md")
readme := string(b)
@@ -83,6 +146,7 @@ func TestDocumentDependencies(t *testing.T) {
}
}
}
assert.Greater(t, len(imports), 0, "no imports found")
sort.Slice(imports, func(i, j int) bool { return len(imports[i]) < len(imports[j]) })
IMPORT:
for _, name := range imports {

View File

@@ -7,6 +7,7 @@ import (
"github.com/google/subcommands"
"io"
"os"
"strings"
)
type notifyCmd struct {
@@ -32,6 +33,11 @@ func (cmd *notifyCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface
func notifyCli(w io.Writer, args []string) subcommands.ExitStatus {
index.load()
for _, name := range args {
if !strings.HasSuffix(name, ".md") {
fmt.Fprintf(os.Stderr, "%s does not end in '.md'\n", name)
return subcommands.ExitFailure
}
name = name[0:len(name)-3]
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(w, "Loading %s: %s\n", name, err)

BIN
oddmu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

60
page.go
View File

@@ -64,7 +64,7 @@ func nameEscape(s string) string {
// carriage return characters ("\r"). Page.Title and Page.Html are not saved. There is no caching. Before removing or
// writing a file, the old copy is renamed to a backup, appending "~". Errors are not logged but returned.
func (p *Page) save() error {
fp := filepath.FromSlash(p.Name + ".md")
fp := filepath.FromSlash(p.Name) + ".md"
watches.ignore(fp)
s := bytes.ReplaceAll(p.Body, []byte{'\r'}, []byte{})
if len(s) == 0 {
@@ -88,9 +88,10 @@ func (p *Page) save() error {
return os.WriteFile(fp, 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~". This expects a file path. Use filepath.FromSlash(path) if necessary.
// 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~". This expects a filepath. The backup file gets its modification time set
// to now so that subsequent edits don't immediately overwrite it again.
func backup(fp string) error {
_, err := os.Stat(fp)
if err != nil {
@@ -99,7 +100,12 @@ func backup(fp string) error {
bp := fp + "~"
fi, err := os.Stat(bp)
if err != nil || time.Since(fi.ModTime()).Minutes() >= 60 {
return os.Rename(fp, bp)
err = os.Rename(fp, bp)
if err != nil {
return err
}
ts := time.Now()
return os.Chtimes(bp, ts, ts)
}
return nil
}
@@ -107,13 +113,13 @@ func backup(fp string) error {
// loadPage loads a Page given a name. The path 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 the file content. The Page.Html remains
// undefined (there is no caching).
func loadPage(path string) (*Page, error) {
path = strings.TrimPrefix(path, "./") // result of a filepath.TreeWalk starting with "."
body, err := os.ReadFile(filepath.FromSlash(path + ".md"))
func loadPage(name string) (*Page, error) {
name = strings.TrimPrefix(name, "./") // result of a path.TreeWalk starting with "."
body, err := os.ReadFile(filepath.FromSlash(name) + ".md")
if err != nil {
return nil, err
}
return &Page{Title: path, Name: path, Body: body}, nil
return &Page{Title: name, Name: name, Body: body}, nil
}
// handleTitle extracts the title from a Page and sets Page.Title, if any. If replace is true, the page title is also
@@ -133,7 +139,6 @@ func (p *Page) handleTitle(replace bool) {
// summarize sets Page.Html to an extract.
func (p *Page) summarize(q string) {
t := p.plainText()
p.Name = nameEscape(p.Name)
p.Html = sanitizeStrict(snippets(q, t))
}
@@ -143,10 +148,37 @@ func (p *Page) IsBlog() bool {
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.
const upperhex = "0123456789ABCDEF"
// Path returns the page name with semicolon, comma and questionmark escaped because html/template doesn't escape those.
// This is suitable for use in HTML templates.
func (p *Page) Path() string {
s := p.Name
n := strings.Count(s, ";") + strings.Count(s, ",") + strings.Count(s, "?")
if n == 0 {
return p.Name
}
t := make([]byte, len(s) + 2*n)
j := 0
for i := 0; i < len(s); i++ {
switch s[i] {
case ';', ',', '?':
t[j] = '%'
t[j+1] = upperhex[s[i]>>4]
t[j+2] = upperhex[s[i]&15]
j += 3
default:
t[j] = s[i]
j++
}
}
return string(t);
}
// Dir returns the directory part of the page name. It's either the empty string if the page is in the Oddmu working
// directory, or it ends in a slash. This is used to create the upload link in "view.html", for example.
func (p *Page) Dir() string {
d := filepath.Dir(p.Name)
d := path.Dir(p.Name)
if d == "." {
return ""
}
@@ -156,7 +188,7 @@ func (p *Page) Dir() string {
// Base returns the basename of the page name: no directory. This is used to create the upload link in "view.html", for
// example.
func (p *Page) Base() string {
n := filepath.Base(p.Name)
n := path.Base(p.Name)
if n == "." {
return ""
}

View File

@@ -57,7 +57,7 @@ And untouchable`)}
p = &Page{Name: "testdata/parents/children/something/other"}
// "testdata/parents/children/something/index" is a sibling and doesn't count!
parents := p.Parents()
assert.Equal(t, "Welcome to Oddµ", parents[0].Title)
assert.Equal(t, "Welcome to Oddμ", parents[0].Title)
assert.Equal(t, "../../../../index", parents[0].Url)
assert.Equal(t, "…", parents[1].Title)
assert.Equal(t, "../../../index", parents[1].Url)

View File

@@ -8,7 +8,6 @@ import (
"github.com/gomarkdown/markdown/parser"
"net/url"
"path"
"path/filepath"
)
// wikiLink returns an inline parser function. This indirection is
@@ -37,9 +36,13 @@ func wikiLink(fn func(p *parser.Parser, data []byte, offset int) (int, ast.Node)
// hashtag returns an inline parser function. This indirection is
// required because we want to receive an array of hashtags found.
// The hashtags in the array keep their case.
func hashtag() (func(p *parser.Parser, data []byte, offset int) (int, ast.Node), *[]string) {
hashtags := make([]string, 0)
return func(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
if p.InsideLink {
return 0, nil
}
data = data[offset:]
i := 0
n := len(data)
@@ -64,15 +67,16 @@ func hashtag() (func(p *parser.Parser, data []byte, offset int) (int, ast.Node),
// @webfinger@accounts. It also uses the CommonExtensions and Block Attributes, and no MathJax ($).
func wikiParser() (*parser.Parser, *[]string) {
extensions := (parser.CommonExtensions | parser.AutoHeadingIDs | parser.Attributes) & ^parser.MathJax
parser := parser.NewWithExtensions(extensions)
prev := parser.RegisterInline('[', nil)
parser.RegisterInline('[', wikiLink(prev))
p := parser.NewWithExtensions(extensions)
prev := p.RegisterInline('[', nil)
p.RegisterInline('[', wikiLink(prev))
fn, hashtags := hashtag()
parser.RegisterInline('#', fn)
p.RegisterInline('#', fn)
if useWebfinger {
parser.RegisterInline('@', accountLink)
p.RegisterInline('@', accountLink)
parser.EscapeChars = append(parser.EscapeChars, '@')
}
return parser, hashtags
return p, hashtags
}
// wikiRenderer is a Renderer for Markdown that adds lazy loading of images and disables fractions support. Remember
@@ -90,7 +94,6 @@ func (p *Page) renderHtml() {
parser, hashtags := wikiParser()
renderer := wikiRenderer()
maybeUnsafeHTML := markdown.ToHTML(p.Body, parser, renderer)
p.Name = nameEscape(p.Name)
p.Html = unsafeBytes(maybeUnsafeHTML)
p.Hashtags = *hashtags
}
@@ -124,7 +127,7 @@ func (p *Page) plainText() string {
// images returns an array of ImageData.
func (p *Page) images() []ImageData {
dir := path.Dir(filepath.ToSlash(p.Name))
dir := p.Dir()
images := make([]ImageData, 0)
parser := parser.New()
doc := markdown.Parse(p.Body, parser)
@@ -147,6 +150,13 @@ func (p *Page) images() []ImageData {
return images
}
// hashtags returns an array of hashtags
func hashtags(s []byte) []string {
parser, hashtags := wikiParser()
markdown.Parse(s, parser)
return *hashtags
}
// toString for a node returns the text nodes' literals, concatenated. There is no whitespace added so the expectation
// is that there is only one child node. Otherwise, there may be a space missing between the literals, depending on the
// exact child nodes they belong to.

View File

@@ -51,11 +51,13 @@ I am cold, alone</p>
func TestPageHtmlHashtagCornerCases(t *testing.T) {
p := &Page{Body: []byte(`#
ok # #o #ok`)}
ok # #o #ok
[oh #ok \#nok](ok)`)}
p.renderHtml()
r := `<p>#</p>
<p>ok # <a class="tag" href="/search/?q=%23o">#o</a> <a class="tag" href="/search/?q=%23ok">#ok</a></p>
<p>ok # <a class="tag" href="/search/?q=%23o">#o</a> <a class="tag" href="/search/?q=%23ok">#ok</a>
<a href="ok">oh #ok #nok</a></p>
`
assert.Equal(t, r, string(p.Html))
}
@@ -109,3 +111,26 @@ func TestNoFractions(t *testing.T) {
p.renderHtml()
assert.Contains(t, string(p.Html), "1/6")
}
// webfinger
func TestAt(t *testing.T) {
// enable webfinger
useWebfinger = true
// prevent lookups
accounts.Lock()
accounts.uris = make(map[string]string)
accounts.uris["alex@alexschroeder.ch"] = "https://social.alexschroeder.ch/@alex";
accounts.Unlock()
// test account
p := &Page{Body: []byte(`My fedi handle is @alex@alexschroeder.ch.`)}
p.renderHtml()
assert.Contains(t,string(p.Html),
`My fedi handle is <a class="account" href="https://social.alexschroeder.ch/@alex" title="@alex@alexschroeder.ch">@alex</a>.`)
// test escaped account
p = &Page{Body: []byte(`My fedi handle is \@alex@alexschroeder.ch. \`)}
p.renderHtml()
assert.Contains(t,string(p.Html),
`My fedi handle is @alex@alexschroeder.ch.`)
// disable webfinger
useWebfinger = false
}

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<base href="/view/{{.Dir}}">
<title>Preview: {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
@@ -27,12 +28,12 @@ img { max-width: 100%; }
<hr>
<section id="edit">
<h2>Editing {{.Title}}</h2>
<form action="/save/{{.Name}}" method="POST">
<form action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" lang="{{.Language}}" autofocus>{{printf "# %s\n\n%s" .Title .Body}}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Save">
<button formaction="/preview/{{.Name}}" type="submit">Preview</button>
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<button formaction="/preview/{{.Path}}" type="submit">Preview</button>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</section>
</body>

17
preview_test.go Normal file
View File

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

View File

@@ -55,12 +55,12 @@ func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) sub
}
repl := []byte(args[1])
changes := 0
err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
err := filepath.Walk(".", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// skip hidden directories and files
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
@@ -68,10 +68,10 @@ func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) sub
}
}
// skipp all but page files
if !strings.HasSuffix(path, ".md") {
if !strings.HasSuffix(fp, ".md") {
return nil
}
body, err := os.ReadFile(path)
body, err := os.ReadFile(fp)
if err != nil {
return err
}
@@ -79,15 +79,15 @@ func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) sub
if !slices.Equal(result, body) {
changes++
if isConfirmed {
fmt.Fprintln(w, path)
_ = os.Rename(path, path+"~")
err = os.WriteFile(path, result, 0644)
fmt.Fprintln(w, fp)
_ = os.Rename(fp, fp + "~")
err = os.WriteFile(fp, result, 0644)
if err != nil {
return err
}
} else {
edits := myers.ComputeEdits(span.URIFromPath(path+"~"), string(body), string(result))
diff := fmt.Sprint(gotextdiff.ToUnified(path+"~", path, string(body), edits))
edits := myers.ComputeEdits(span.URIFromPath(fp + "~"), string(body), string(result))
diff := fmt.Sprint(gotextdiff.ToUnified(fp + "~", fp, string(body), edits))
fmt.Fprintln(w, diff)
}
}

View File

@@ -38,11 +38,11 @@ button { background-color: #eee; color: inherit; border-radius: 4px; border-widt
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{range .Items}}
<article lang="{{.Language}}">
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
{{range .Images}}
<p class="image"><a href="/view/{{.Name}}"><img loading="lazy" src="/view/{{.Name}}"></a><br/>{{.Html}}
<p class="image"><a href="/view/{{.Path}}"><img loading="lazy" src="/view/{{.Path}}"></a><br/>{{.Html}}
{{end}}
</article>
{{end}}

View File

@@ -9,6 +9,7 @@ import (
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
)
@@ -18,6 +19,8 @@ type searchCmd struct {
page int
all bool
extract bool
files bool
quiet bool
}
func (cmd *searchCmd) SetFlags(f *flag.FlagSet) {
@@ -25,12 +28,14 @@ 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.BoolVar(&cmd.files, "files", false, "show just the filenames")
f.BoolVar(&cmd.quiet, "quiet", false, "suppress summary line at the top")
}
func (*searchCmd) Name() string { return "search" }
func (*searchCmd) Synopsis() string { return "search pages and print a list of links" }
func (*searchCmd) Usage() string {
return `search [-dir string] [-page <n>|-all] [-extract] <terms>:
return `search [-dir string] [-page <n>|-all] [-extract|-files] [-quiet] <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
@@ -39,24 +44,24 @@ func (*searchCmd) Usage() string {
}
func (cmd *searchCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return searchCli(os.Stdout, cmd.dir, cmd.page, cmd.all, cmd.extract, false, f.Args())
return searchCli(os.Stdout, cmd, 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, dir string, n int, all, extract bool, quiet bool, args []string) subcommands.ExitStatus {
dir, err := checkDir(dir)
func searchCli(w io.Writer, cmd *searchCmd, args []string) subcommands.ExitStatus {
dir, err := checkDir(cmd.dir)
if err != nil {
return subcommands.ExitFailure
}
index.reset()
index.load()
q := strings.Join(args, " ")
items, more := search(q, dir, "", n, true)
if !quiet {
items, more := search(q, dir, "", cmd.page, true)
if !cmd.quiet {
fmt.Fprint(os.Stderr, "Search for ", q)
if !all {
fmt.Fprint(os.Stderr, ", page ", n)
if !cmd.all {
fmt.Fprint(os.Stderr, ", page ", cmd.page)
}
fmt.Fprint(os.Stderr, ": ", len(items))
if len(items) == 1 {
@@ -65,8 +70,13 @@ func searchCli(w io.Writer, dir string, n int, all, extract bool, quiet bool, ar
fmt.Fprint(os.Stderr, " results\n")
}
}
if extract {
if cmd.extract {
searchExtract(w, items)
} else if cmd.files {
for _, p := range items {
name := filepath.FromSlash(p.Name) + ".md\n"
fmt.Fprintf(w, name)
}
} else {
for _, p := range items {
name := p.Name

View File

@@ -9,11 +9,11 @@ import (
func TestSearchCmd(t *testing.T) {
b := new(bytes.Buffer)
s := searchCli(b, "", 1, false, false, true, []string{"oddµ"})
s := searchCli(b, &searchCmd{quiet: true}, []string{"oddμ"})
assert.Equal(t, subcommands.ExitSuccess, s)
r := `* [Oddµ: A minimal wiki](README)
r := `* [Oddμ: A minimal wiki](README)
* [Themes](themes/index)
* [Welcome to Oddµ](index)
* [Welcome to Oddμ](index)
`
assert.Equal(t, r, b.String())
}
@@ -26,7 +26,7 @@ that before we type and speak
we hear that moment`)}
p.save()
b := new(bytes.Buffer)
s := searchCli(b, "testdata/search", 1, false, false, true, []string{"speak"})
s := searchCli(b, &searchCmd{dir: "testdata/search", quiet: true}, []string{"speak"})
assert.Equal(t, subcommands.ExitSuccess, s)
r := `* [Wait](wait)
`

View File

@@ -61,7 +61,7 @@ func TestSearch(t *testing.T) {
index.load()
data := url.Values{}
data.Set("q", "oddµ")
data.Set("q", "oddμ")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
assert.Contains(t, body, "Welcome")
@@ -177,12 +177,12 @@ func TestTitleSearch(t *testing.T) {
index.load()
items, more := search("title:readme", "", "", 1, false)
assert.Equal(t, 0, len(items), "no page found")
assert.Equal(t, 1, len(items), "just one page found") // themes/plain/README
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.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)

View File

@@ -84,7 +84,7 @@ func staticCli(source, target string, jobs int, quiet bool) subcommands.ExitStat
func staticWalk(source, target string, tasks chan (args), stop chan (error)) {
// The error returned here is what's in the stop channel but at the very end, a worker might return an error
// even though the walk is already done. This is why we cannot rely on the return value of the walk.
filepath.Walk(source, func(path string, info fs.FileInfo, err error) error {
filepath.Walk(source, func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
@@ -92,9 +92,8 @@ func staticWalk(source, target string, tasks chan (args), stop chan (error)) {
case err := <-stop:
return err
default:
base := filepath.Base(path)
// skip hidden directories and files
if path != "." && strings.HasPrefix(base, ".") {
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
@@ -102,28 +101,28 @@ func staticWalk(source, target string, tasks chan (args), stop chan (error)) {
}
}
// skip backup files, avoid recursion
if strings.HasSuffix(path, "~") || strings.HasPrefix(path, target) {
if strings.HasSuffix(fp, "~") || strings.HasPrefix(fp, target) {
return nil
}
// determine the actual target: if source is a/ and target is b/ and path is a/file, then the
// target is b/file
var actual_target string
var actualTarget string
if source == "." {
actual_target = filepath.Join(target, path)
actualTarget = filepath.Join(target, fp)
} else {
if !strings.HasPrefix(path, source) {
return fmt.Errorf("%s is not a subdirectory of %s", path, source)
if !strings.HasPrefix(fp, source) {
return fmt.Errorf("%s is not a subdirectory of %s", fp, source)
}
actual_target = filepath.Join(target, path[len(source):])
actualTarget = filepath.Join(target, fp[len(source):])
}
// recreate subdirectories
if info.IsDir() {
return os.Mkdir(actual_target, 0755)
return os.Mkdir(actualTarget, 0755)
}
// do the task if the target file doesn't exist or if the source file is newer
other, err := os.Stat(actual_target)
other, err := os.Stat(actualTarget)
if err != nil || info.ModTime().After(other.ModTime()) {
tasks <- args{source: path, target: actual_target, info: info}
tasks <- args{source: fp, target: actualTarget, info: info}
}
return nil
}
@@ -211,7 +210,6 @@ func staticPage(source, target string) (*Page, error) {
}
renderer := html.NewRenderer(opts)
maybeUnsafeHTML := markdown.Render(doc, renderer)
p.Name = nameEscape(p.Name)
p.Html = unsafeBytes(maybeUnsafeHTML)
p.Hashtags = *hashtags
return p, write(p, target, "", "static.html")
@@ -221,7 +219,7 @@ func staticPage(source, target string) (*Page, error) {
func staticFeed(source, target string, p *Page, ti time.Time) error {
// render feed, maybe
base := filepath.Base(source)
_, ok := index.token["#"+strings.ToLower(base)]
_, ok := index.token[strings.ToLower(base)]
if base == "index" || ok {
f := feed(p, ti)
if len(f.Items) > 0 {
@@ -257,22 +255,22 @@ func staticLinks(node ast.Node, entering bool) ast.WalkStatus {
}
// write a page or feed with an appropriate template to a specific destination, overwriting it.
func write(data any, path, prefix, templateFile string) error {
file, err := os.Create(path)
func write(data any, fp, prefix, templateFile string) error {
file, err := os.Create(fp)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot create %s: %s\n", path, err)
fmt.Fprintf(os.Stderr, "Cannot create %s: %s\n", fp, err)
return err
}
_, err = file.Write([]byte(prefix))
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot write prefix %s: %s\n", path, err)
fmt.Fprintf(os.Stderr, "Cannot write prefix %s: %s\n", fp, err)
return err
}
templates.RLock()
defer templates.RUnlock()
err = templates.template[templateFile].Execute(file, data)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", templateFile, path, err)
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", templateFile, fp, err)
return err
}
return nil

View File

@@ -5,7 +5,6 @@ import (
"io/fs"
"log"
"net/http"
"path"
"path/filepath"
"slices"
"strings"
@@ -15,17 +14,17 @@ import (
// templateFiles are the various HTML template files used. These files must exist in the root directory for Oddmu to be
// able to generate HTML output. This always requires a template.
var templateFiles = []string{"edit.html", "add.html", "view.html", "preview.html",
"diff.html", "search.html", "static.html", "upload.html", "feed.html"}
"diff.html", "search.html", "static.html", "upload.html", "feed.html",
"list.html" }
// templateStore controls access to map of parsed HTML templates. Make sure to lock and unlock as appropriate. See
// renderTemplate and loadTemplates.
type templateStore struct {
sync.RWMutex
// template is a map of parsed HTML templates. The key is their path name. By default, the map only contains
// template is a map of parsed HTML templates. The key is their filepath name. By default, the map only contains
// top-level templates like "view.html". Subdirectories may contain their own templates which override the
// templates in the root directory. If so, they are paths like "dir/view.html", not filepaths. Use
// filepath.ToSlash() if necessary.
// templates in the root directory. If so, they are filepaths like "dir/view.html".
template map[string]*template.Template
}
@@ -57,8 +56,7 @@ func loadTemplate(fp string, info fs.FileInfo, err error) error {
log.Println("Cannot parse template:", fp, err)
// ignore error
} else {
// log.Println("Parse template:", path)
templates.template[filepath.ToSlash(fp)] = t
templates.template[fp] = t
}
}
return nil
@@ -74,7 +72,7 @@ func updateTemplate(fp string) {
} else {
templates.Lock()
defer templates.Unlock()
templates.template[filepath.ToSlash(fp)] = t
templates.template[fp] = t
log.Println("Parse template:", fp)
}
}
@@ -86,7 +84,7 @@ func removeTemplate(fp string) {
filepath.Dir(fp) != "." {
templates.Lock()
defer templates.Unlock()
delete(templates.template, filepath.ToSlash(fp))
delete(templates.template, fp)
log.Println("Discard template:", fp)
}
}
@@ -98,7 +96,7 @@ func renderTemplate(w http.ResponseWriter, dir, tmpl string, data any) {
base := tmpl + ".html"
templates.RLock()
defer templates.RUnlock()
t := templates.template[path.Join(dir, base)]
t := templates.template[filepath.Join(dir, base)]
if t == nil {
t = templates.template[base]
}

View File

@@ -6,6 +6,11 @@ download:
rsync --archive --delete --itemize-changes sibirocobombus:campaignwiki.org/data/'*.html' campaignwiki.org/
rsync --archive --delete --itemize-changes sibirocobombus.root:/home/oddmu/'*.html' transjovian.org/
# (ediff-directories "alexschroeder.ch" "/ssh:sibirocobombus:alexschroeder.ch/wiki/" "html$")
# (ediff-directories "flying-carpet.ch" "/ssh:sibirocobombus.root|sudo:claudia@sibirocobombus.root:/home/alex/flying-carpet.ch/wiki/" "html$")
# (ediff-directories "campaignwiki.org" "/ssh:sibirocobombus:campaignwiki.org/data/" "html$")
# (ediff-directories "transjovian.org" "/ssh:sibirocobombus.root:/home/oddmu/" "html$")
upload:
rsync --archive --delete --itemize-changes --exclude=Makefile --exclude='*~' . sibirocobombus:alexschroeder.ch/wiki/oddmu/themes/

View File

@@ -6,14 +6,37 @@
<meta name="viewport" content="width=device-width">
<title>Add to {{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
<script>
window.addEventListener("load", () => {
let t = document.getElementsByTagName('textarea').item(0);
t.addEventListener("keydown", (event) => {
if (event.ctrlKey && !event.altKey && !event.metaKey) {
let ch;
if (event.key == "i") {
ch = ["*", "*"];
} else if (event.key == "b") {
ch = ["**", "**"];
} else if (event.key == "k") {
ch = ["[", "]()"];
}
if (ch) {
event.preventDefault();
let s = t.value.substring(t.selectionStart, t.selectionEnd);
t.setRangeText(ch[0] + s + ch[1]);
}
}
});
});
</script>
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<form action="/append/{{.Path}}" method="POST">
<p>Use <tt>Control+I</tt> for italics, <tt>Control+B</tt> for bold, <tt>Control+k</tt> for link.</p>
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="{{.Language}}" autofocus required>{{if .IsBlog}}**{{.Today}}**. {{end}}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Add">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -9,11 +9,11 @@
</head>
<body>
<header>
<a href="/view/{{.Name}}">Back</a>
<a href="/view/{{.Path}}">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>
<p>This is the diff between <a href="/view/{{.Path}}.md~">the backup</a> and <a href="/view/{{.Path}}.md">the current copy</a>.</p>
<pre class="diff">
{{.Diff}}
</pre>

View File

@@ -4,18 +4,43 @@
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<base href="/view/{{.Dir}}">
<title>Editing {{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
<script>
window.addEventListener("load", () => {
let t = document.getElementsByTagName('textarea').item(0);
t.addEventListener("keydown", (event) => {
if (event.ctrlKey && !event.altKey && !event.metaKey) {
let ch;
if (event.key == "i") {
ch = ["*", "*"];
} else if (event.key == "b") {
ch = ["**", "**"];
} else if (event.key == "k") {
ch = ["[", "]()"];
}
if (ch) {
event.preventDefault();
let s = t.value.substring(t.selectionStart, t.selectionEnd);
t.setRangeText(ch[0] + s + ch[1]);
}
}
});
});
</script>
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Name}}" method="POST">
<form action="/save/{{.Path}}" method="POST">
<p>Use <tt>Control+I</tt> for italics, <tt>Control+B</tt> for bold, <tt>Control+k</tt> for link.</p>
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="{{.Language}}" autofocus>{{ or .Body (printf "# %s " .Today) | printf "%s" }}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Save">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<button formaction="/preview/{{.Path}}" type="submit">Preview</button>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -5,18 +5,18 @@
<link>https://alexschroeder.ch/</link>
<managingEditor>alex@alexschroeder.ch (Alex Schroeder)</managingEditor>
<webMaster>alex@alexschroeder.ch (Alex Schroeder)</webMaster>
<atom:link href="https://alexschroeder.ch/view/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
<atom:link href="https://alexschroeder.ch/view/{{.Path}}.rss" rel="self" type="application/rss+xml"/>
<description>This is the digital garden of Alex Schroeder.</description>
<image>
<url>https://alexschroeder.ch/view/logo.jpg</url>
<url>https://alexschroeder.ch/pics/alex-and-beard.jpg</url>
<title>{{.Title}}</title>
<link>https://alexschroeder.ch/</link>
</image>
{{range .Items}}
<item>
<title>{{.Title}}</title>
<link>https://alexschroeder.ch/view/{{.Name}}</link>
<guid>https://alexschroeder.ch/view/{{.Name}}</guid>
<link>https://alexschroeder.ch/view/{{.Path}}</link>
<guid>https://alexschroeder.ch/view/{{.Path}}</guid>
<description>{{.Html}}</description>
<pubDate>{{.Date}}</pubDate>
{{range .Hashtags}}

View File

@@ -27,9 +27,12 @@
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{range .Items}}
<article lang="{{.Language}}">
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
{{range .Images}}
<p class="image"><a href="/view/{{.Path}}"><img loading="lazy" src="/view/{{.Path}}"></a><br/>{{.Html}}
{{end}}
</article>
{{end}}
<p>

View File

@@ -69,15 +69,21 @@ window.addEventListener('load', uploadFiles.init);
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a>
{{if .Image}}
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"><br>
<tt>Link: ![]({{.Last}})</tt>
Links:<tt>{{range .Actual}}<br>![]({{.}}){{end}}</tt>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{end}}
<form id="add" action="/append/{{.Dir}}{{.Base}}" method="POST">
<input type="hidden" name="body" value="{{range .Actual}}![]({{.}})
{{end}}">
<p>Append this to <a href="/view/{{.Dir}}{{.Base}}">{{.Title}}</a>?
<input type="submit" value="Add">
</form>
{{end}}
<form id="upload" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>When uploading a picture from a phone, its filename is going to be something like IMG_1234.JPG.
Please provide your own filename. End the base name with "-1" to auto-increment.
Use <tt>.jpg</tt> or <tt>.png</tt> as the extension if you want to resize the picture.
Use <tt>.jpg</tt>, <tt>.png</tt> or <tt>.webp</tt> as the extension if you want to resize the picture.
<p><label for="name">Filename:</label>
<input id="name" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
@@ -85,7 +91,7 @@ window.addEventListener('load', uploadFiles.init);
Feel free to specify a max width of 1200 pixels, for example.
<p><label for="maxwidth">Max width:</label>
<input id="maxwidth" name="maxwidth" value="{{.MaxWidth}}" type="number" min="10" placeholder="1200">
<p>If the filename you provided above ends in <tt>.jpg</tt>, you can specify a quality.
<p>If the filename you provided above ends in <tt>.jpg</tt> or <tt>.webp</tt>, you can specify a quality.
Typically, a quality of 60 is not too bad and a quality of 90 is more than enough.
<p><label for="quality">Quality:</label>
<input id="quality" name="quality" value="{{.Quality}}" type="number" min="1" max="99" placeholder="75">

View File

@@ -6,16 +6,16 @@
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{.Title}}" href="/view/{{.Name}}.rss" />
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{.Title}}" href="/view/{{.Path}}.rss" />
</head>
<body>
<header id="view">
<a href="#main">Skip</a>
<a href="index">Home</a>
<a href="changes">Changes</a>
<a href="/edit/{{.Name}}" accesskey="e">Edit</a>
<a href="/add/{{.Name}}" accesskey="a">Add</a>
<a href="/diff/{{.Name}}" accesskey="d">Diff</a>
<a href="/edit/{{.Path}}" accesskey="e">Edit</a>
<a href="/add/{{.Path}}" accesskey="a">Add</a>
<a href="/diff/{{.Path}}" accesskey="d">Diff</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>

View File

@@ -12,11 +12,11 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<form action="/append/{{.Path}}" 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>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -15,11 +15,11 @@ pre { white-space: normal; background-color: white; border: 1px solid #eee; padd
</head>
<body>
<header>
<a href="/view/{{.Name}}">Back</a>
<a href="/view/{{.Path}}">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>
<p>This is the diff between <a href="/view/{{.Path}}.md~">the backup</a> and <a href="/view/{{.Path}}.md">the current copy</a>.</p>
<pre>
{{.Diff}}
</pre>

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