forked from mirror/oddmu
Compare commits
175 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b9c1babc3 | ||
|
|
ce64d04dde | ||
|
|
de5bd2d23e | ||
|
|
29842fe685 | ||
|
|
4042be68f3 | ||
|
|
87846e15b9 | ||
|
|
1390d82e29 | ||
|
|
bb5bd1c629 | ||
|
|
777c498700 | ||
|
|
803025f56a | ||
|
|
dce66ec5a1 | ||
|
|
b6d596cb08 | ||
|
|
3be26b9af1 | ||
|
|
197f9b78f1 | ||
|
|
0ebcd1a4ef | ||
|
|
9591913acc | ||
|
|
2a82435b92 | ||
|
|
844f623f26 | ||
|
|
e9b128d98c | ||
|
|
1ce8182571 | ||
|
|
fefc00e2a2 | ||
|
|
b44821d6de | ||
|
|
1174369e8a | ||
|
|
b31745a5e4 | ||
|
|
ce70c97b6a | ||
|
|
0f2dd71449 | ||
|
|
7074995d9a | ||
|
|
d2552b2f68 | ||
|
|
e231412bdb | ||
|
|
0481d9003c | ||
|
|
7060f8a027 | ||
|
|
1cc5fcb823 | ||
|
|
f392d18dc9 | ||
|
|
6ab51afa30 | ||
|
|
a73328ca2e | ||
|
|
ee3afc3384 | ||
|
|
160ebd71e2 | ||
|
|
8900725737 | ||
|
|
f58476bba5 | ||
|
|
95a29264a5 | ||
|
|
dcb0cc7f51 | ||
|
|
51e47dacd9 | ||
|
|
eb44880e8e | ||
|
|
c3e0cdc50d | ||
|
|
3c08c316d5 | ||
|
|
0da22a69da | ||
|
|
be3ca7903d | ||
|
|
07ecd1d27c | ||
|
|
1a7a271669 | ||
|
|
7247906942 | ||
|
|
73c33042ee | ||
|
|
3ff1b4d218 | ||
|
|
c4ebb7321b | ||
|
|
098fc1ba19 | ||
|
|
541567425f | ||
|
|
e12d145ce5 | ||
|
|
cdf2c01512 | ||
|
|
4a961de768 | ||
|
|
8325e50546 | ||
|
|
8a58d0a9f9 | ||
|
|
e63317e103 | ||
|
|
c97028cf9e | ||
|
|
a4ea3232f8 | ||
|
|
ea36761cbb | ||
|
|
8b57b2d9c0 | ||
|
|
8d49e01282 | ||
|
|
8194f03d89 | ||
|
|
bbb98921b4 | ||
|
|
0415fdc27f | ||
|
|
d17b3f0512 | ||
|
|
ad67738053 | ||
|
|
be1e656c04 | ||
|
|
c7952b30de | ||
|
|
72365e788a | ||
|
|
cda595c4f4 | ||
|
|
3e27e920ad | ||
|
|
832f14ba89 | ||
|
|
149e8dde25 | ||
|
|
b0561e37cc | ||
|
|
02e62a87b8 | ||
|
|
403732ae3d | ||
|
|
2736e76d07 | ||
|
|
14bc2d0da4 | ||
|
|
9d43a8af40 | ||
|
|
10554bd2ec | ||
|
|
4af07d4002 | ||
|
|
2dcac930d2 | ||
|
|
5bc399283b | ||
|
|
d4dbe4e8df | ||
|
|
f2276a969b | ||
|
|
d0ceb4cec5 | ||
|
|
f8cf57898c | ||
|
|
f29b7ed016 | ||
|
|
c371cc9030 | ||
|
|
c25d1898ac | ||
|
|
f660ee0f10 | ||
|
|
270f190cce | ||
|
|
1ee5b80d48 | ||
|
|
9f1c0ff90b | ||
|
|
f10ac34424 | ||
|
|
d3b6ea30a6 | ||
|
|
d42021521b | ||
|
|
f99a092d8c | ||
|
|
1ce5ec71ff | ||
|
|
d39d9c198d | ||
|
|
40998cf584 | ||
|
|
68293398b0 | ||
|
|
6e74c69dfc | ||
|
|
7ba64186f3 | ||
|
|
62faaee25a | ||
|
|
4db9c4041d | ||
|
|
5eb238bf06 | ||
|
|
602d4f5b18 | ||
|
|
951c7fcd87 | ||
|
|
7c4d3380fb | ||
|
|
ab53463601 | ||
|
|
70a4c8d476 | ||
|
|
fcd69274b1 | ||
|
|
adffb463ba | ||
|
|
541725ecd7 | ||
|
|
43411f2f59 | ||
|
|
77ee92a872 | ||
|
|
ab1d0fbd07 | ||
|
|
22c24e7c13 | ||
|
|
ce2751ccd7 | ||
|
|
a21934a904 | ||
|
|
562e34cb13 | ||
|
|
c248f12c96 | ||
|
|
aa482e002b | ||
|
|
99a863771f | ||
|
|
a47ae1b8d6 | ||
|
|
d25c5d1925 | ||
|
|
4b1b2e02bb | ||
|
|
4edb544f2d | ||
|
|
c17825306e | ||
|
|
8246f03be9 | ||
|
|
7221d7484f | ||
|
|
1cc6771d58 | ||
|
|
8183d39eb3 | ||
|
|
ef3a9d5e9b | ||
|
|
58ba30e1b4 | ||
|
|
806ee6d270 | ||
|
|
2d2439c0c3 | ||
|
|
f549dd9ea6 | ||
|
|
e5dcd068d2 | ||
|
|
aa516bbcc0 | ||
|
|
be4c1ba4e5 | ||
|
|
d8138e92c4 | ||
|
|
09ea5da1e5 | ||
|
|
95d3573b10 | ||
|
|
876a170899 | ||
|
|
22337d93c4 | ||
|
|
2fa7a8855b | ||
|
|
528ae1c54b | ||
|
|
17b519071f | ||
|
|
ca59a1ae5f | ||
|
|
fbe105bef8 | ||
|
|
1f07ad867a | ||
|
|
34b2afad94 | ||
|
|
b274e6ba55 | ||
|
|
856f1ac235 | ||
|
|
58a2f8b841 | ||
|
|
b87302b683 | ||
|
|
243dd66317 | ||
|
|
3c1dfce4ac | ||
|
|
8319a6438f | ||
|
|
9ee2af6093 | ||
|
|
153a179d92 | ||
|
|
d9797aac75 | ||
|
|
005500457e | ||
|
|
2635d5f852 | ||
|
|
a79f4558b6 | ||
|
|
d1c2b8e27c | ||
|
|
dd939e2c86 | ||
|
|
475c7071ba |
27
Makefile
27
Makefile
@@ -10,11 +10,17 @@ help:
|
||||
@echo make test
|
||||
@echo " runs the tests"
|
||||
@echo
|
||||
@echo make upload
|
||||
@echo " this is how I upgrade my server"
|
||||
@echo make docs
|
||||
@echo " create man pages from text files"
|
||||
@echo
|
||||
@echo go build
|
||||
@echo " just build it"
|
||||
@echo
|
||||
@echo make install
|
||||
@echo " install the files to ~/.local"
|
||||
@echo
|
||||
@echo make upload
|
||||
@echo " this is how I upgrade my server"
|
||||
|
||||
run:
|
||||
go run .
|
||||
@@ -24,5 +30,18 @@ test:
|
||||
|
||||
upload:
|
||||
go build
|
||||
rsync --itemize-changes --archive oddmu oddmu.service *.html README.md sibirocobombus.root:/home/oddmu/
|
||||
ssh sibirocobombus.root "systemctl restart oddmu; systemctl restart alex"
|
||||
rsync --itemize-changes --archive oddmu sibirocobombus.root:/home/oddmu/
|
||||
ssh sibirocobombus.root "systemctl restart oddmu; systemctl restart alex; systemctl restart claudia"
|
||||
@echo Changes to the template files need careful consideration
|
||||
|
||||
docs:
|
||||
cd man; make
|
||||
|
||||
install:
|
||||
make docs
|
||||
for n in 1 5 7; do install -D -t $$HOME/.local/share/man/man$$n man/*.$$n; done
|
||||
go build
|
||||
install -D -t $$HOME/.local/bin oddmu
|
||||
|
||||
missing:
|
||||
for f in man/*.txt; do grep --quiet "$$f" README.md || echo $$f is not in the README; done
|
||||
|
||||
516
README.md
516
README.md
@@ -1,163 +1,98 @@
|
||||
# Oddµ: A minimal wiki
|
||||
|
||||
This program runs a wiki. It serves all the Markdown files (ending in
|
||||
`.md`) into web pages and allows you to edit them. If your files don't
|
||||
provide their own title (`# title`), the file name (without `.md`) is
|
||||
used for the title. Subdirectories are created as necessary.
|
||||
This program helps you run a minimal wiki. There is no version
|
||||
history. It's well suited as a *secondary* medium: collaboration and
|
||||
conversation happens elsewhere, in chat, on social media. The wiki
|
||||
serves as the text repository that results from these discussions.
|
||||
|
||||
This is a minimal wiki. There is no version history. It's well suited
|
||||
as a *secondary* medium: collaboration and conversation happens
|
||||
elsewhere, in chat, on social media. The wiki serves as the text
|
||||
repository that results from these discussions.
|
||||
If you're the only user and it just runs on your laptop, then you can
|
||||
think of it as a [memex](https://en.wikipedia.org/wiki/Memex), a
|
||||
memory extender.
|
||||
|
||||
The wiki lists no recent changes. The expectation is that the people
|
||||
that care were involved in the discussions beforehand.
|
||||
Oddµ can be used as a web server behind a reverse proxy such as Apache
|
||||
or it can be used as a static site generator.
|
||||
|
||||
The wiki also produces no feed. The assumption is that announcements
|
||||
are made on social media: blogs, news aggregators, discussion forums,
|
||||
the fediverse, but humans. There is no need for bots.
|
||||
When Oddµ runs as a web server, it serves all the Markdown files
|
||||
(ending in `.md`) as web pages and allows you to edit them.
|
||||
|
||||
As you'll see below, the idea is that the webserver handles as many
|
||||
tasks as possible. It logs requests, does rate limiting, handles
|
||||
encryption, gets the certificates, and so on. The web server acts as a
|
||||
reverse proxy and the wiki ends up being a content management system
|
||||
with almost no structure – or endless malleability, depending on your
|
||||
point of view.
|
||||
If your files don't provide their own title (`# title`), the file name
|
||||
(without `.md`) is used for the title. Subdirectories are created as
|
||||
necessary.
|
||||
|
||||
And last but not least: µ is the letter mu, so Oddµ is usually written
|
||||
Oddmu. 🙃
|
||||
Oddµ uses a [Markdown library](https://github.com/gomarkdown/markdown)
|
||||
to generate the web pages from Markdown. Oddmu adds the following
|
||||
extensions: local links `[[like this]]`, hashtags `#Like_This` and
|
||||
fediverse account links like `@alex@alexschroeder.ch`.
|
||||
|
||||
## Markdown
|
||||
The [lingua](https://github.com/pemistahl/lingua-go) library detects
|
||||
languages in order to get hyphenation right.
|
||||
|
||||
This wiki uses a [Markdown
|
||||
library](https://github.com/gomarkdown/markdown) to generate the web
|
||||
pages from Markdown. There is no additional wiki markup. Most
|
||||
importantly, double square brackets are not a link. If you're used to
|
||||
that, it'll be strange as you need to repeat the name: `[like
|
||||
this](like this)`.
|
||||
The standard [html/template](https://pkg.go.dev/html/template) library
|
||||
is used to generate HTML.
|
||||
|
||||
The Markdown processor comes with a few extensions, some of which are
|
||||
enable by default:
|
||||
## Documentation
|
||||
|
||||
* emphasis markers inside words are ignored
|
||||
* tables are supported
|
||||
* fenced code blocks are supported
|
||||
* autolinking of "naked" URLs are supported
|
||||
* strikethrough using two tildes is supported (`~~like this~~`)
|
||||
* it is strict about prefix heading rules
|
||||
* you can specify an id for headings (`{#id}`)
|
||||
* trailing backslashes turn into line breaks
|
||||
* definition lists are supported
|
||||
* MathJax is supported (but needs a separte setup)
|
||||
This project uses man(1) pages. They are generated from text files
|
||||
using [scdoc](https://git.sr.ht/~sircmpwn/scdoc). These are the files
|
||||
available:
|
||||
|
||||
A table with footers and a columnspan:
|
||||
[oddmu(1)](/oddmu.git/blob/main/man/oddmu.1.txt): This man page has a
|
||||
short introduction to Oddmu, its configuration via templates and
|
||||
environment variables, plus points to the other man pages.
|
||||
|
||||
```text
|
||||
Name | Age
|
||||
--------|------
|
||||
Bob ||
|
||||
Alice | 23
|
||||
========|======
|
||||
Total | 23
|
||||
```
|
||||
[oddmu(5)](/oddmu.git/blob/main/man/oddmu.5.txt): This man page talks
|
||||
about the Markdown and includes some examples for the non-standard
|
||||
features such as table markup. It also talks about the Oddmu
|
||||
extensions to Markdown: wiki links, hashtags and fediverse account
|
||||
links. Local links must use percent encoding for page names so there
|
||||
is a section about percent encoding. The man page also explains how
|
||||
feeds are generated.
|
||||
|
||||
A definition list:
|
||||
[oddmu-list(1)](/oddmu.git/blob/main/man/oddmu-list.1.txt): This man
|
||||
page documents the "list" subcommand which you can use to get page
|
||||
names and page titles.
|
||||
|
||||
```text
|
||||
Cat
|
||||
: Fluffy animal everyone likes
|
||||
[oddmu-search(1)](/oddmu.git/blob/main/man/oddmu-search.1.txt): This
|
||||
man page documents the "search" subcommand which you can use to build
|
||||
indexes – lists of page links. These are important for feeds.
|
||||
|
||||
Internet
|
||||
: Vector of transmission for pictures of cats
|
||||
```
|
||||
[oddmu-search(7)](/oddmu.git/blob/main/man/oddmu-search.7.txt): This
|
||||
man page documents how search and scoring work.
|
||||
|
||||
There is another extension made: hashtags link to searches for the
|
||||
hashtag. Hashtags are separate from titles because there is no space
|
||||
after the hash. Use the underscore to use hashtags consisting of
|
||||
multiple words.
|
||||
[oddmu-replace(1)](/oddmu.git/blob/main/man/oddmu-replace.1.txt): This
|
||||
man page documents the "replace" subcommand to make mass changes to
|
||||
the files much like find(1), grep(1) and sed(1) or perl(1).
|
||||
|
||||
```
|
||||
# Title
|
||||
[oddmu-missing(1)](/oddmu.git/blob/main/man/oddmu-missing.1.txt): This
|
||||
man page documents the "missing" subcommand to list local links that
|
||||
don't point to any existing pages or files.
|
||||
|
||||
Text
|
||||
[oddmu-html(1)](/oddmu.git/blob/main/man/oddmu-html.1.txt): This man
|
||||
page documents the "html" subcommand to generate HTML from Markdown
|
||||
pages from the command line.
|
||||
|
||||
#Tag #Another_Tag
|
||||
```
|
||||
[oddmu-static(1)](/oddmu.git/blob/main/man/oddmu-static.1.txt): This
|
||||
man page documents the "static" subcommand to generate an entire
|
||||
static website from the command line, avoiding the need to run Oddmu
|
||||
as a server. Also great for archiving.
|
||||
|
||||
## Templates
|
||||
[oddmu-notify(1)](/oddmu.git/blob/main/man/oddmu-notify.1.txt): This
|
||||
man page documents the "notify" subcommand to add links to hashtag
|
||||
pages, index and changes for a given page. This is useful when you
|
||||
edit the Markdown files locally.
|
||||
|
||||
The template files are the HTML files in the working directory:
|
||||
`add.html`, `edit.html`, `search.html`, `upload.html` and `view.html`.
|
||||
Feel free to change the templates and restart the server. The first
|
||||
change you should make is to replace the email address in `view.html`.
|
||||
😄
|
||||
[oddmu-templates(5)](/oddmu.git/blob/main/man/oddmu-templates.5.txt):
|
||||
This man page documents how the templates can be changed (how they
|
||||
*must* be changed) and lists the attributes available for the various
|
||||
templates.
|
||||
|
||||
See [Structuring the web
|
||||
with HTML](https://developer.mozilla.org/en-US/docs/Learn/HTML) to
|
||||
learn more about HTML.
|
||||
[oddmu-apache(5)](/oddmu.git/blob/main/man/oddmu-apache.5.txt): This
|
||||
man page documents how to set up the web server for various common
|
||||
tasks such as using logins to limit what visitors can edit.
|
||||
|
||||
Modifying the styles in the templates would be another good start to
|
||||
get a feel for it. See [Learn to style HTML using
|
||||
CSS](https://developer.mozilla.org/en-US/docs/Learn/CSS) to learn more
|
||||
about style sheets.
|
||||
|
||||
The templates can refer to the following properties of a page:
|
||||
|
||||
`{{.Title}}` is the page title. If the page doesn't provide its own
|
||||
title, the page name is used.
|
||||
|
||||
`{{.Name}}` is the page name, escaped for use in URLs. More
|
||||
specifically, it is URI escaped except for the slashes. The page name
|
||||
doesn't include the `.md` extension.
|
||||
|
||||
`{{.Html}}` is the rendered Markdown, as HTML.
|
||||
|
||||
`{{printf "%s" .Body}}` is the Markdown, as a string (the data itself
|
||||
is a byte array and that's why we need to call `printf`).
|
||||
|
||||
For the `search.html` template only:
|
||||
|
||||
`{{.Results}}` indicates if there were any search results.
|
||||
|
||||
`{{.Items}}` is an array of pages, each containing a search result. A
|
||||
search result is a page (with the properties seen above). Thus, to
|
||||
refer to them, you need to use a `{{range .Items}}` … `{{end}}`
|
||||
construct.
|
||||
|
||||
For search results, `{{.Html}}` is the rendered Markdown of a page
|
||||
summary, as HTML.
|
||||
|
||||
`{{.Score}}` is a numerical score for search results.
|
||||
|
||||
The `upload.html` template cannot refer to anything.
|
||||
|
||||
When calling the `save` action, the page name is take from the URL and
|
||||
the page content is taken from the `body` form parameter. To
|
||||
illustrate, here's how to edit a page using `curl`:
|
||||
|
||||
```sh
|
||||
curl --form body="Did you bring a towel?" \
|
||||
http://localhost:8080/save/welcome
|
||||
```
|
||||
|
||||
The wiki uses the standard
|
||||
[html/template](https://pkg.go.dev/html/template) library to do this.
|
||||
There's more information on writing templates in the documentation for
|
||||
the [text/template](https://pkg.go.dev/text/template) library.
|
||||
|
||||
## Non-English hyphenation
|
||||
|
||||
Automatic hyphenation by the browser requires two things: The style
|
||||
sheet must indicate `hyphen: auto` for an HTML element such as `body`,
|
||||
and that element must have a `lang` set (usually a two letter language
|
||||
code such as `de` for German). This happens in the template files,
|
||||
such as `view.html` and `search.html`.
|
||||
|
||||
Oddmu uses the [lingua](github.com/pemistahl/lingua-go) library to
|
||||
detect languages. If you know that you're only going to use a small
|
||||
number of languages – or just a single language! – you can set the
|
||||
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO
|
||||
639-1 codes, e.g. "en" or "en,de,fr,pt".
|
||||
[oddmu.service(5)](/oddmu.git/blob/main/man/oddmu.service.5.txt): This
|
||||
man page documents how to setup a systemd unit and have it manage
|
||||
Oddmu. “Great configurability brings great burdens.”
|
||||
|
||||
## Building
|
||||
|
||||
@@ -165,7 +100,7 @@ environment variable ODDMU_LANGUAGES to a comma-separated list of ISO
|
||||
go build
|
||||
```
|
||||
|
||||
## Test
|
||||
## Running
|
||||
|
||||
The working directory is where pages are saved and where templates are
|
||||
loaded from. You need a copy of the template files in this directory.
|
||||
@@ -176,289 +111,42 @@ go run .
|
||||
```
|
||||
|
||||
The program serves the local directory as a wiki on port 8080. Point
|
||||
your browser to http://localhost:8080/ to get started. This is
|
||||
equivalent to http://localhost:8080/view/index – the first page
|
||||
you'll create, most likely.
|
||||
|
||||
If you ran it in the source directory, try
|
||||
http://localhost:8080/view/README – this serves the README file you're
|
||||
currently reading.
|
||||
|
||||
You can change the port by setting the ODDMU_PORT environment
|
||||
variable.
|
||||
|
||||
## Deploying it using systemd
|
||||
|
||||
As root, on your server:
|
||||
|
||||
```sh
|
||||
adduser --system --home /home/oddmu oddmu
|
||||
```
|
||||
|
||||
Copy all the files into `/home/oddmu` to your server: `oddmu`,
|
||||
`oddmu.service`, and all the template files ending in `.html`.
|
||||
|
||||
Edit the `oddmu.service` file. These are the three lines you most
|
||||
likely have to take care of:
|
||||
|
||||
```
|
||||
ExecStart=/home/oddmu/oddmu
|
||||
WorkingDirectory=/home/oddmu
|
||||
Environment="ODDMU_PORT=8080"
|
||||
Environment="ODDMU_LANGUAGES=en,de"
|
||||
```
|
||||
|
||||
Install the service file and enable it:
|
||||
|
||||
```sh
|
||||
ln -s /home/oddmu/oddmu.service /etc/systemd/system/
|
||||
systemctl enable --now oddmu
|
||||
```
|
||||
|
||||
Check the log:
|
||||
|
||||
```sh
|
||||
journalctl --unit oddmu
|
||||
```
|
||||
|
||||
Follow the log:
|
||||
|
||||
```sh
|
||||
journalctl --follow --unit oddmu
|
||||
```
|
||||
|
||||
Edit the first page using `lynx`:
|
||||
|
||||
```sh
|
||||
lynx http://localhost:8080/view/index
|
||||
```
|
||||
|
||||
## Web server setup
|
||||
|
||||
HTTPS is not part of the wiki. You probably want to configure this in
|
||||
your webserver. I guess you could use stunnel, too. If you're using
|
||||
Apache, you might have set up a site like I did, below. In my case,
|
||||
that'd be `/etc/apache2/sites-enabled/500-transjovian.conf`:
|
||||
|
||||
```apache
|
||||
MDomain transjovian.org
|
||||
MDCertificateAgreement accepted
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName transjovian.org
|
||||
RewriteEngine on
|
||||
RewriteRule ^/(.*) https://%{HTTP_HOST}/$1 [redirect]
|
||||
</VirtualHost>
|
||||
<VirtualHost *:443>
|
||||
ServerAdmin alex@alexschroeder.ch
|
||||
ServerName transjovian.org
|
||||
SSLEngine on
|
||||
ProxyPassMatch ^/(search|(view|edit|save|add|append|upload|drop)/(.*))?$ http://localhost:8080/$1
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
First, it manages the domain, getting the necessary certificates. It
|
||||
redirects regular HTTP traffic from port 80 to port 443. It turns on
|
||||
the SSL engine for port 443. It proxies the requests for the wiki to
|
||||
port 8080.
|
||||
|
||||
Thus, this is what happens:
|
||||
|
||||
* The user tells the browser to visit `transjovian.org`
|
||||
* The browser sends a request for `http://transjovian.org` (on port 80)
|
||||
* Apache redirects this to `https://transjovian.org/` by default (now on port 443)
|
||||
* This is proxied to `http://transjovian.org:8080/` (no encryption, on port 8080)
|
||||
|
||||
Restart the server, gracefully:
|
||||
|
||||
```
|
||||
apachectl graceful
|
||||
```
|
||||
|
||||
To serve both HTTP and HTTPS, don't redirect from the first virtual
|
||||
host to the second – instead just proxy to the wiki like you did for
|
||||
the second virtual host: use a copy of the `ProxyPassMatch` directive
|
||||
instead of `RewriteEngine on` and `RewriteRule`.
|
||||
|
||||
## Access
|
||||
|
||||
Access control is not part of the wiki. By default, the wiki is
|
||||
editable by all. This is most likely not what you want unless you're
|
||||
running it stand-alone, unconnected to the Internet.
|
||||
|
||||
You probably want to configure this in your webserver. If you're using
|
||||
Apache, you might have set up a site like the following.
|
||||
|
||||
Create a new password file called `.htpasswd` and add the user "alex":
|
||||
|
||||
```sh
|
||||
cd /home/oddmu
|
||||
htpasswd -c .htpasswd alex
|
||||
```
|
||||
|
||||
To add more users, don't use the `-c` option or you will overwrite it!
|
||||
|
||||
To add another user:
|
||||
|
||||
```sh
|
||||
htpasswd .htpasswd berta
|
||||
```
|
||||
|
||||
To delete remove a user:
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```apache
|
||||
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
|
||||
AuthType Basic
|
||||
AuthName "Password Required"
|
||||
AuthUserFile /home/oddmu/.htpasswd
|
||||
Require valid-user
|
||||
</LocationMatch>
|
||||
```
|
||||
|
||||
## Serve static files
|
||||
|
||||
If you want to serve static files as well, add a document root to your
|
||||
webserver configuration. Using Apache, for example:
|
||||
|
||||
```apache
|
||||
DocumentRoot /home/oddmu/static
|
||||
<Directory /home/oddmu/static>
|
||||
Require all granted
|
||||
</Directory>
|
||||
```
|
||||
|
||||
Create this directory, making sure to give it a permission that your
|
||||
webserver can read (world readable file, world readable and executable
|
||||
directory). Populate it with files.
|
||||
|
||||
Make sure that none of the static files look like the wiki paths
|
||||
`/view/`, `/edit/`, `/save/`, `/add/`, `/append/`, `/upload/`, `/drop/`
|
||||
or `/search`. For example, create a file called `robots.txt`
|
||||
containing the following, tellin all robots that they're not welcome.
|
||||
|
||||
```text
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
```
|
||||
|
||||
You site now serves `/robots.txt` without interfering with the wiki,
|
||||
and without needing a wiki page.
|
||||
|
||||
[Wikipedia](https://en.wikipedia.org/wiki/Robot_exclusion_standard)
|
||||
has more information.
|
||||
|
||||
## Different logins for different access rights
|
||||
|
||||
What if you have a site with various subdirectories and each
|
||||
subdirectory is for a different group of friends? You can set this up
|
||||
using your webserver. One way to do this is to require specific
|
||||
usernames (which must have a password in the password file mentioned
|
||||
above.
|
||||
|
||||
This requires a valid login by the user "alex" or "berta":
|
||||
|
||||
```apache
|
||||
<LocationMatch "^/(edit|save|add|append|upload|drop)/intetebi/">
|
||||
Require user alex berta
|
||||
</LocationMatch>
|
||||
```
|
||||
|
||||
## Private wikis
|
||||
|
||||
Based on the above, you can prevent people from reading the wiki, too.
|
||||
The `LocationMatch` must cover the `/view/` URLs. In order to protect
|
||||
*everything*, use a [Location directive](https://httpd.apache.org/docs/current/mod/core.html#location)
|
||||
that matches everything:
|
||||
|
||||
```apache
|
||||
<Location />
|
||||
AuthType Basic
|
||||
AuthName "Password Required"
|
||||
AuthUserFile /home/oddmu/.htpasswd
|
||||
Require valid-user
|
||||
</Location>
|
||||
```
|
||||
|
||||
## Virtual hosting
|
||||
|
||||
Virtual hosting in this context means that the program serves two
|
||||
different sites for two different domains from the same machine. Oddmu
|
||||
doesn't support that, but your webserver does. Therefore, start an
|
||||
Oddmu instance for every domain name, each listening on a different
|
||||
port. Then set up your web server such that ever domain acts as a
|
||||
reverse proxy to a different Oddmu instance.
|
||||
|
||||
## Understanding search
|
||||
|
||||
The index indexes trigrams. Each group of three characters is a
|
||||
trigram. A document with content "This is a test" is turned to lower
|
||||
case and indexed under the trigrams "thi", "his", "is ", "s i", " is",
|
||||
"is ", "s a", " a ", "a t", " te", "tes", "est".
|
||||
|
||||
Each query is split into words and then processed the same way. A
|
||||
query with the words "this test" is turned to lower case and produces
|
||||
the trigrams "thi", "his", "tes", "est". This means that the word
|
||||
order is not considered when searching for documents.
|
||||
|
||||
This also means that there is no stemming. Searching for "testing"
|
||||
won't find "This is a test" because there are no matches for the
|
||||
trigrams "sti", "tin", "ing".
|
||||
|
||||
These trigrams are looked up in the index, resulting in the list of
|
||||
documents. Each document found is then scored. Each of the following
|
||||
increases the score by one point:
|
||||
|
||||
- the entire phrase matches
|
||||
- a word matches
|
||||
- a word matches at the beginning of a word
|
||||
- a word matches at the end of a word
|
||||
- a word matches as a whole word
|
||||
|
||||
A document with content "This is a test" when searched with the phrase
|
||||
"this test" therefore gets a score of 8: the entire phrase does not
|
||||
match but each word gets four points.
|
||||
|
||||
Trigrams are sometimes strange: In a text containing the words "main"
|
||||
and "rail", a search for "mail" returns a match because the trigrams
|
||||
"mai" and "ail" are found. In this situation, the result has a score
|
||||
of 0.
|
||||
|
||||
## Limitations
|
||||
|
||||
Page titles are filenames with `.md` appended. If your filesystem
|
||||
cannot handle it, it can't be a page name.
|
||||
|
||||
The pages are indexed as the server starts and the index is kept in
|
||||
memory. If you have a ton of pages, this surely wastes a lot of
|
||||
memory.
|
||||
|
||||
Files may not end with a tilde (`~`) – these are backup files.
|
||||
|
||||
You cannot edit uploaded files. If you upload a file called
|
||||
`hello.txt` and attempt to edit it by using `/edit/hello.txt` you will
|
||||
create a page with the name `hello.txt.md` instead.
|
||||
|
||||
You cannot delete uploaded files via the web.
|
||||
your browser to http://localhost:8080/ to use it.
|
||||
|
||||
## Bugs
|
||||
|
||||
If you spot any, [contact](https://alexschroeder.ch/wiki/Contact) me.
|
||||
|
||||
## Source
|
||||
|
||||
If you're interested in making changes to the code, here's a
|
||||
high-level introduction to the various source files.
|
||||
|
||||
- *_test.go are the test files; a few library functions are defined in
|
||||
wiki_test.go.
|
||||
- *_cmd.go are the files implementing the various subcommands with
|
||||
matching names
|
||||
- accounts.go implements the webfinger code to fetch fediverse account
|
||||
link destinations with the URI provided by webfinger
|
||||
- add_append.go implements the /add and /append handlers
|
||||
- diff.go implements the /diff handler
|
||||
- edit_save.go implements the /edit and /save handlers
|
||||
- feed.go implements the feed for a page based on the links it lists
|
||||
- highlight.go implements the bold tags for matches when showing
|
||||
search results
|
||||
- index.go implements the index of all the hashtags
|
||||
- languages.go implements the language detection
|
||||
- page.go implements the page loading and saving
|
||||
- parser.go implements the Markdown parsing
|
||||
- score.go implements the page scoring when showing search results
|
||||
- search.go implements the /search handler
|
||||
- snippets.go implements the page summaries for search results
|
||||
- tokenizer.go implements the various tokenizers used
|
||||
- upload_drop.go implements the /upload and /drop handlers
|
||||
- view.go implements the /view handler
|
||||
- wiki.go implements the main function
|
||||
|
||||
## References
|
||||
|
||||
[Writing Web Applications](https://golang.org/doc/articles/wiki/)
|
||||
provided the initial code for this wiki.
|
||||
|
||||
For the proxy stuff, see
|
||||
[Apache: mod_proxy](https://httpd.apache.org/docs/current/mod/mod_proxy.html).
|
||||
|
||||
For the usernames and password stuff, see
|
||||
[Apache: Authentication and Authorization](https://httpd.apache.org/docs/current/howto/auth.html).
|
||||
|
||||
6
TODO.md
6
TODO.md
@@ -1,6 +0,0 @@
|
||||
Upload files should use path info so that we can use Apache to
|
||||
restrict access to directories.
|
||||
|
||||
Automatically scale or process files.
|
||||
|
||||
Post by Delta Chat? That is, allow certain encrypted emails to post.
|
||||
163
accounts.go
Normal file
163
accounts.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// useWebfinger indicates whether Oddmu looks up the profile pages of
|
||||
// fediverse accounts. To enable this, set the environment variable
|
||||
// ODDMU_WEBFINGER to "1".
|
||||
var useWebfinger = false
|
||||
|
||||
// Accounts contains the map used to set the usernames. Make sure to
|
||||
// lock and unlock as appropriate.
|
||||
type Accounts struct {
|
||||
sync.RWMutex
|
||||
|
||||
// uris is a map, mapping account names likes
|
||||
// "@alex@alexschroeder.ch" to URIs like
|
||||
// "https://social.alexschroeder.ch/@alex".
|
||||
uris map[string]string
|
||||
}
|
||||
|
||||
// accounts holds the global mapping of accounts to profile URIs.
|
||||
var accounts Accounts
|
||||
|
||||
// initAccounts sets up the accounts map. This is called once at
|
||||
// startup and therefore does not need to be locked. On ever restart,
|
||||
// this map starts empty and is slowly repopulated as pages are
|
||||
// visited.
|
||||
func initAccounts() {
|
||||
if os.Getenv("ODDMU_WEBFINGER") == "1" {
|
||||
accounts.uris = make(map[string]string)
|
||||
useWebfinger = true
|
||||
}
|
||||
}
|
||||
|
||||
// account links a social media account like @account@domain to a
|
||||
// profile page like https://domain/user/account. Any account seen for
|
||||
// the first time uses a best guess profile URI. It is also looked up
|
||||
// using webfinger, in parallel. See lookUpAccountUri. If the lookup
|
||||
// succeeds, the best guess is replaced with the new URI so on
|
||||
// subsequent requests, the URI is correct.
|
||||
func account(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
|
||||
data = data[offset:]
|
||||
i := 1 // skip @ of username
|
||||
n := len(data)
|
||||
d := 0
|
||||
for i < n && (data[i] >= 'a' && data[i] <= 'z' ||
|
||||
data[i] >= 'A' && data[i] <= 'Z' ||
|
||||
data[i] >= '0' && data[i] <= '9' ||
|
||||
data[i] == '@' ||
|
||||
data[i] == '.' ||
|
||||
data[i] == '_' ||
|
||||
data[i] == '-') {
|
||||
if data[i] == '@' {
|
||||
if d != 0 {
|
||||
// more than one @ is invalid
|
||||
return 0, nil
|
||||
} else {
|
||||
d = i + 1 // skip @ of domain
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
for i > 1 && (data[i-1] == '.' ||
|
||||
data[i-1] == '-') {
|
||||
i--
|
||||
}
|
||||
if i == 0 || d == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
user := data[0 : d-1] // includes @
|
||||
domain := data[d:i] // excludes @
|
||||
account := data[1:i] // excludes @
|
||||
accounts.RLock()
|
||||
uri, ok := accounts.uris[string(account)]
|
||||
defer accounts.RUnlock()
|
||||
if !ok {
|
||||
log.Printf("Looking up %s\n", account)
|
||||
uri = "https://" + string(domain) + "/users/" + string(user[1:])
|
||||
accounts.uris[string(account)] = uri // prevent more lookings
|
||||
go lookUpAccountUri(string(account), string(domain))
|
||||
}
|
||||
link := &ast.Link{
|
||||
AdditionalAttributes: []string{`class="account"`},
|
||||
Destination: []byte(uri),
|
||||
Title: data[0:i],
|
||||
}
|
||||
ast.AppendChild(link, &ast.Text{Leaf: ast.Leaf{Literal: data[0 : d-1]}})
|
||||
return i, link
|
||||
}
|
||||
|
||||
// lookUpAccountUri is called for accounts that haven't been seen
|
||||
// before. It calls webfinger and parses the JSON. If possible, it
|
||||
// extracts the link to the profile page and replaces the entry in
|
||||
// accounts.
|
||||
func lookUpAccountUri(account, domain string) {
|
||||
uri := "https://" + domain + "/.well-known/webfinger"
|
||||
resp, err := http.Get(uri + "?resource=acct:" + account)
|
||||
if err != nil {
|
||||
log.Printf("Failed to look up %s: %s", account, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read from %s: %s", account, err)
|
||||
return
|
||||
}
|
||||
var wf WebFinger
|
||||
err = json.Unmarshal([]byte(body), &wf)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse the JSON from %s: %s", account, err)
|
||||
return
|
||||
}
|
||||
uri, err = parseWebFinger(body)
|
||||
if err != nil {
|
||||
log.Printf("Could not find profile URI for %s: %s", account, err)
|
||||
}
|
||||
log.Printf("Found profile for %s: %s", account, uri)
|
||||
accounts.Lock()
|
||||
defer accounts.Unlock()
|
||||
accounts.uris[account] = uri
|
||||
}
|
||||
|
||||
// Link a link in the WebFinger JSON.
|
||||
type Link struct {
|
||||
Rel string `json:"rel"`
|
||||
Type string `json:"type"`
|
||||
Href string `json:"href"`
|
||||
}
|
||||
|
||||
// WebFinger is a structure used to unmarshall JSON.
|
||||
type WebFinger struct {
|
||||
Subject string `json:"subject"`
|
||||
Aliases []string `json:"aliases"`
|
||||
Links []Link `json:"links"`
|
||||
}
|
||||
|
||||
// parseWebFinger parses the web finger JSON and returns the profile
|
||||
// page URI. For unmarshalling the JSON, it uses the Link and
|
||||
// WebFinger structs.
|
||||
func parseWebFinger(body []byte) (string, error) {
|
||||
var wf WebFinger
|
||||
err := json.Unmarshal(body, &wf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, link := range wf.Links {
|
||||
if link.Rel == "http://webfinger.net/rel/profile-page" &&
|
||||
link.Type == "text/html" {
|
||||
return link.Href, nil
|
||||
}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
45
accounts_test.go
Normal file
45
accounts_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// This causes network access!
|
||||
// func TestPageAccount(t *testing.T) {
|
||||
// initAccounts()
|
||||
// p := &Page{Body: []byte(`@alex, @alex@alexschroeder.ch said`)}
|
||||
// p.renderHtml()
|
||||
// r := `<p>@alex, <a href="https://alexschroeder.ch/users/alex">@alex</a> said</p>
|
||||
// `
|
||||
// assert.Equal(t, r, string(p.Html))
|
||||
// }
|
||||
|
||||
func TestWebfingerParsing(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"subject": "acct:Gargron@mastodon.social",
|
||||
"aliases": [
|
||||
"https://mastodon.social/@Gargron",
|
||||
"https://mastodon.social/users/Gargron"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"rel": "http://webfinger.net/rel/profile-page",
|
||||
"type": "text/html",
|
||||
"href": "https://mastodon.social/@Gargron"
|
||||
},
|
||||
{
|
||||
"rel": "self",
|
||||
"type": "application/activity+json",
|
||||
"href": "https://mastodon.social/users/Gargron"
|
||||
},
|
||||
{
|
||||
"rel": "http://ostatus.org/schema/1.0/subscribe",
|
||||
"template": "https://mastodon.social/authorize_interaction?uri={uri}"
|
||||
}
|
||||
]
|
||||
}`)
|
||||
uri, err := parseWebFinger(body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://mastodon.social/@Gargron", uri)
|
||||
}
|
||||
9
add.html
9
add.html
@@ -1,19 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>Add to {{.Title}}</title>
|
||||
<style>
|
||||
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background: #ffe; }
|
||||
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
|
||||
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
|
||||
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee; }
|
||||
form, textarea { width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Adding to {{.Title}}</h1>
|
||||
<form action="/append/{{.Name}}" method="POST">
|
||||
<textarea name="body" rows="20" cols="80" placeholder="Text" autofocus required></textarea>
|
||||
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="" autofocus required></textarea>
|
||||
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
|
||||
<p><input type="submit" value="Add">
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -23,15 +25,32 @@ func appendHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
body := r.FormValue("body")
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
p = &Page{Title: name, Name: name, Body: []byte(body)}
|
||||
p = &Page{Name: name, Body: []byte(body)}
|
||||
} else {
|
||||
p.handleTitle(false)
|
||||
p.Body = append(p.Body, []byte(body)...)
|
||||
p.append([]byte(body))
|
||||
}
|
||||
p.handleTitle(false)
|
||||
err = p.save()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if r.FormValue("notify") == "on" {
|
||||
err = p.notify()
|
||||
if err != nil {
|
||||
log.Println("notify:", err)
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/view/"+name, http.StatusFound)
|
||||
}
|
||||
|
||||
func (p *Page) append(body []byte) {
|
||||
// ensure an empty line at the end
|
||||
if bytes.HasSuffix(p.Body, []byte("\n\n")) {
|
||||
} else if bytes.HasSuffix(p.Body, []byte("\n")) {
|
||||
p.Body = append(p.Body, '\n')
|
||||
} else {
|
||||
p.Body = append(p.Body, '\n', '\n')
|
||||
}
|
||||
p.Body = append(p.Body, body...)
|
||||
}
|
||||
|
||||
@@ -6,14 +6,26 @@ import (
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// wipes testdata
|
||||
func TestEmptyLineAdd(t *testing.T) {
|
||||
p := &Page{Name: "testdata/add/fire", Body: []byte(`# Coal
|
||||
Black rocks light as foam
|
||||
Shaking, puring, shoveling`)}
|
||||
p.append([]byte("Into the oven"))
|
||||
assert.Equal(t, string(p.Body), `# Coal
|
||||
Black rocks light as foam
|
||||
Shaking, puring, shoveling
|
||||
|
||||
Into the oven`)
|
||||
}
|
||||
|
||||
func TestAddAppend(t *testing.T) {
|
||||
_ = os.RemoveAll("testdata")
|
||||
cleanup(t, "testdata/add")
|
||||
index.load()
|
||||
|
||||
p := &Page{Name: "testdata/fire", Body: []byte(`# Fire
|
||||
|
||||
p := &Page{Name: "testdata/add/fire", Body: []byte(`# Fire
|
||||
Orange sky above
|
||||
Reflects a distant fire
|
||||
It's not `)}
|
||||
@@ -23,14 +35,39 @@ It's not `)}
|
||||
data.Set("body", "barbecue")
|
||||
|
||||
assert.Regexp(t, regexp.MustCompile("a distant fire"),
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/fire", nil))
|
||||
assert.HTTPBody(makeHandler(viewHandler, true),
|
||||
"GET", "/view/testdata/add/fire", nil))
|
||||
assert.NotRegexp(t, regexp.MustCompile("a distant fire"),
|
||||
assert.HTTPBody(makeHandler(addHandler, true), "GET", "/add/testdata/fire", nil))
|
||||
HTTPRedirectTo(t, makeHandler(appendHandler, true), "POST", "/append/testdata/fire", data, "/view/testdata/fire")
|
||||
assert.Regexp(t, regexp.MustCompile("It’s not barbecue"),
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/fire", nil))
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
assert.HTTPBody(makeHandler(addHandler, true),
|
||||
"GET", "/add/testdata/add/fire", nil))
|
||||
HTTPRedirectTo(t, makeHandler(appendHandler, true),
|
||||
"POST", "/append/testdata/add/fire", data, "/view/testdata/add/fire")
|
||||
assert.Regexp(t, regexp.MustCompile(`not</p>\s*<p>barbecue`),
|
||||
assert.HTTPBody(makeHandler(viewHandler, true),
|
||||
"GET", "/view/testdata/add/fire", nil))
|
||||
}
|
||||
|
||||
func TestAddAppendChanges(t *testing.T) {
|
||||
cleanup(t, "testdata/append")
|
||||
today := time.Now().Format(time.DateOnly)
|
||||
p := &Page{Name: "testdata/append/" + today + "-water", Body: []byte(`# Water
|
||||
Sunlight dancing fast
|
||||
Blue and green and pebbles gray
|
||||
`)}
|
||||
p.save()
|
||||
data := url.Values{}
|
||||
data.Set("body", "Stand in cold water")
|
||||
data.Add("notify", "on")
|
||||
HTTPRedirectTo(t, makeHandler(appendHandler, true),
|
||||
"POST", "/append/testdata/append/"+today+"-water",
|
||||
data, "/view/testdata/append/"+today+"-water")
|
||||
// The changes.md file was created
|
||||
s, err := os.ReadFile("testdata/append/changes.md")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "# Changes\n\n## "+today+"\n* [Water]("+today+"-water)\n", string(s))
|
||||
// Link added to index.md file
|
||||
s, err = os.ReadFile("testdata/append/index.md")
|
||||
assert.NoError(t, err)
|
||||
// New index contains just the link
|
||||
assert.Equal(t, string(s), "* [Water]("+today+"-water)\n")
|
||||
}
|
||||
|
||||
193
changes.go
Normal file
193
changes.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// notify adds a link to the "changes" page, the "index" page, as well as to all the existing hashtag pages. The link to
|
||||
// the "index" page is only added if the page being edited is a blog page for the current year. The link to existing
|
||||
// hashtag pages is only added for blog pages. If the "changes" page does not exist, it is created. If the hashtag page
|
||||
// does not exist, it is not. Hashtag pages are considered optional. If the page that's being edited is in a
|
||||
// subdirectory, then the "changes", "index" and hashtag pages of that particular subdirectory are affected. Every
|
||||
// subdirectory is treated like a potentially independent wiki.
|
||||
func (p *Page) notify() error {
|
||||
p.handleTitle(false)
|
||||
if p.Title == "" {
|
||||
p.Title = p.Name
|
||||
}
|
||||
esc := nameEscape(path.Base(p.Name))
|
||||
link := "* [" + p.Title + "](" + esc + ")\n"
|
||||
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(` + esc + `\)\n`)
|
||||
dir := path.Dir(p.Name)
|
||||
if dir != "." {
|
||||
err := os.MkdirAll(dir, 0755)
|
||||
if err != nil {
|
||||
log.Printf("Creating directory %s failed: %s", dir, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
err := addLinkWithDate(path.Join(dir, "changes"), link, re)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.isBlog() {
|
||||
// Add to the index only if the blog post is for the current year
|
||||
if strings.HasPrefix(path.Base(p.Name), time.Now().Format("2006")) {
|
||||
err := addLink(path.Join(dir, "index"), true, link, re)
|
||||
if err != nil {
|
||||
log.Printf("Updating index in %s failed: %s", dir, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
p.renderHtml() // to set hashtags
|
||||
for _, hashtag := range p.Hashtags {
|
||||
err := addLink(path.Join(dir, hashtag), false, link, re)
|
||||
if err != nil {
|
||||
log.Printf("Updating hashtag %s in %s failed: %s", hashtag, dir, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addLinkWithDate adds the link to a page, with date header for today. If a match already exists, it is removed. If
|
||||
// this leaves a date header without any links, it is removed as well. If a list is found, the link is added at the top
|
||||
// of the list. Lists must use the asterisk, not the minus character.
|
||||
func addLinkWithDate(name, link string, re *regexp.Regexp) error {
|
||||
date := time.Now().Format(time.DateOnly)
|
||||
org := ""
|
||||
c, err := loadPage(name)
|
||||
if err != nil {
|
||||
// create a new page
|
||||
c = &Page{Name: name, Body: []byte("# Changes\n\n## " + date + "\n" + link)}
|
||||
} else {
|
||||
org = string(c.Body)
|
||||
// remove the old match, if one exists
|
||||
loc := re.FindIndex(c.Body)
|
||||
if loc != nil {
|
||||
r := c.Body[:loc[0]]
|
||||
if loc[1] < len(c.Body) {
|
||||
r = append(r, c.Body[loc[1]:]...)
|
||||
}
|
||||
c.Body = r
|
||||
if loc[0] >= 14 && len(c.Body) >= loc[0]+15 {
|
||||
// remove the preceding date if there are now two dates following each other
|
||||
re := regexp.MustCompile(`(?m)^## (\d\d\d\d-\d\d-\d\d)\n\n## (\d\d\d\d-\d\d-\d\d)\n`)
|
||||
if re.Match(c.Body[loc[0]-14 : loc[0]+15]) {
|
||||
c.Body = append(c.Body[0:loc[0]-14], c.Body[loc[0]+1:]...)
|
||||
}
|
||||
} else if len(c.Body) == loc[0] {
|
||||
// remove a trailing date
|
||||
re := regexp.MustCompile(`## (\d\d\d\d-\d\d-\d\d)\n`)
|
||||
if re.Match(c.Body[loc[0]-14 : loc[0]]) {
|
||||
c.Body = c.Body[0 : loc[0]-14]
|
||||
}
|
||||
}
|
||||
}
|
||||
// locate the beginning of the list to insert the line
|
||||
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n`)
|
||||
loc = re.FindIndex(c.Body)
|
||||
if loc == nil {
|
||||
// if no list was found, use the end of the page
|
||||
loc = []int{len(c.Body)}
|
||||
}
|
||||
// start with new page content
|
||||
r := []byte("")
|
||||
// check if there is a date right before the insertion point
|
||||
addDate := true
|
||||
if loc[0] >= 14 {
|
||||
re := regexp.MustCompile(`(?m)^## (\d\d\d\d-\d\d-\d\d)\n`)
|
||||
m := re.Find(c.Body[loc[0]-14 : loc[0]])
|
||||
if m == nil {
|
||||
// not a date: insert date, don't move insertion point
|
||||
} else if string(c.Body[loc[0]-11:loc[0]-1]) == date {
|
||||
// if the date is our date, don't add it, don't move insertion point
|
||||
addDate = false
|
||||
} else {
|
||||
// if the date is not out date, move the insertion point
|
||||
loc[0] -= 14
|
||||
}
|
||||
}
|
||||
// append up to the insertion point
|
||||
r = append(r, c.Body[:loc[0]]...)
|
||||
// append date, if necessary
|
||||
if addDate {
|
||||
// ensure paragraph break
|
||||
if len(r) > 0 && r[len(r)-1] != '\n' {
|
||||
r = append(r, '\n')
|
||||
}
|
||||
if len(r) > 1 && r[len(r)-2] != '\n' {
|
||||
r = append(r, '\n')
|
||||
}
|
||||
r = append(r, []byte("## ")...)
|
||||
r = append(r, []byte(date)...)
|
||||
r = append(r, '\n')
|
||||
}
|
||||
// append link
|
||||
r = append(r, []byte(link)...)
|
||||
// if we just added a date, add an empty line after the single-element list
|
||||
if len(c.Body) > loc[0] && c.Body[loc[0]] != '*' {
|
||||
r = append(r, '\n')
|
||||
}
|
||||
// append the rest
|
||||
r = append(r, c.Body[loc[0]:]...)
|
||||
c.Body = r
|
||||
}
|
||||
// only save if something changed
|
||||
if string(c.Body) != org {
|
||||
return c.save()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addLink adds a link to a named page, if the page exists and doesn't contain the link. If the link exists but with a
|
||||
// different title, the title is fixed.
|
||||
func addLink(name string, mandatory bool, link string, re *regexp.Regexp) error {
|
||||
c, err := loadPage(name)
|
||||
if err != nil {
|
||||
if (mandatory) {
|
||||
c = &Page{Name: name, Body: []byte(link)}
|
||||
return c.save()
|
||||
} else {
|
||||
// Skip non-existing files: no error
|
||||
return nil
|
||||
}
|
||||
}
|
||||
org := string(c.Body)
|
||||
// if a link exists, that's the place to insert the new link (in which case loc[0] and loc[1] differ)
|
||||
loc := re.FindIndex(c.Body)
|
||||
// if no link exists, find a good place to insert it
|
||||
if loc == nil {
|
||||
// locate the beginning of the list to insert the line
|
||||
re = regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n`)
|
||||
loc = re.FindIndex(c.Body)
|
||||
if loc == nil {
|
||||
// if no list was found, use the end of the page
|
||||
m := len(c.Body)
|
||||
loc = []int{m, m}
|
||||
} else {
|
||||
// if a list item was found, use just the beginning as insertion point
|
||||
loc[1] = loc[0]
|
||||
}
|
||||
}
|
||||
// start with new page content
|
||||
r := []byte("")
|
||||
// append up to the insertion point
|
||||
r = append(r, c.Body[:loc[0]]...)
|
||||
// append link
|
||||
r = append(r, []byte(link)...)
|
||||
// append the rest
|
||||
r = append(r, c.Body[loc[1]:]...)
|
||||
c.Body = r
|
||||
// only save if something changed
|
||||
if string(c.Body) != org {
|
||||
return c.save()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
206
changes_test.go
Normal file
206
changes_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Note TestEditSaveChanges and TestAddAppendChanges.
|
||||
|
||||
func TestChanges(t *testing.T) {
|
||||
cleanup(t, "testdata/washing")
|
||||
today := time.Now().Format(time.DateOnly)
|
||||
p := &Page{Name: "testdata/washing/" + today + "-machine",
|
||||
Body: []byte(`# Washing machine
|
||||
Churning growling thing
|
||||
Water spraying in a box
|
||||
Out of sight and dark`)}
|
||||
p.notify()
|
||||
// Link added to changes.md file
|
||||
s, err := os.ReadFile("testdata/washing/changes.md")
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, string(s), "[Washing machine]("+today+"-machine)")
|
||||
// Link added to index.md file
|
||||
s, err = os.ReadFile("testdata/washing/index.md")
|
||||
assert.NoError(t, err)
|
||||
// New index contains just the link
|
||||
assert.Equal(t, string(s), "* [Washing machine]("+today+"-machine)\n")
|
||||
}
|
||||
|
||||
func TestChangesWithHashtag(t *testing.T) {
|
||||
cleanup(t, "testdata/changes")
|
||||
intro := "# Haiku\n"
|
||||
line := "* [Hotel room](2023-10-27-hotel)\n"
|
||||
h := &Page{Name: "testdata/changes/Haiku", Body: []byte(intro)}
|
||||
h.save()
|
||||
p := &Page{Name: "testdata/changes/2023-10-27-hotel",
|
||||
Body: []byte(`# Hotel room
|
||||
White linen and white light
|
||||
Wooden floor and painted walls
|
||||
Home away from home
|
||||
|
||||
#Haiku #Poetry`)}
|
||||
p.notify()
|
||||
s, err := os.ReadFile("testdata/changes/changes.md")
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, string(s), line)
|
||||
s, err = os.ReadFile("testdata/changes/Haiku.md")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, intro+line, string(s))
|
||||
assert.NoFileExists(t, "testdata/changes/Poetry.md")
|
||||
}
|
||||
|
||||
func TestChangesWithList(t *testing.T) {
|
||||
cleanup(t, "testdata/changes")
|
||||
intro := "# Changes\n\nThis is a paragraph.\n\n"
|
||||
d := "## " + time.Now().Format(time.DateOnly) + "\n"
|
||||
line := "* [a change](change)\n"
|
||||
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
|
||||
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+d+line), 0644))
|
||||
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
|
||||
p.notify()
|
||||
s, err := os.ReadFile("testdata/changes/changes.md")
|
||||
assert.NoError(t, err)
|
||||
new_line := "* [testdata/changes/alex](alex)\n"
|
||||
// new line was added at the beginning of the list
|
||||
assert.Equal(t, intro+d+new_line+line, string(s))
|
||||
}
|
||||
|
||||
func TestChangesWithOldList(t *testing.T) {
|
||||
cleanup(t, "testdata/changes")
|
||||
intro := "# Changes\n\nThis is a paragraph.\n\n"
|
||||
line := "* [a change](change)\n"
|
||||
y := "## " + time.Now().Add(-24*time.Hour).Format(time.DateOnly) + "\n"
|
||||
d := "## " + time.Now().Format(time.DateOnly) + "\n"
|
||||
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
|
||||
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+y+line), 0644))
|
||||
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
|
||||
p.notify()
|
||||
s, err := os.ReadFile("testdata/changes/changes.md")
|
||||
assert.NoError(t, err)
|
||||
new_line := "* [testdata/changes/alex](alex)\n"
|
||||
// new line was added at the beginning of the list
|
||||
assert.Equal(t, intro+d+new_line+"\n"+y+line, string(s))
|
||||
}
|
||||
|
||||
func TestChangesWithOldDisappearingListAtTheEnd(t *testing.T) {
|
||||
cleanup(t, "testdata/changes")
|
||||
intro := "# Changes\n\nThis is a paragraph.\n\n"
|
||||
line := "* [a change](alex)\n"
|
||||
y := "## " + time.Now().Add(-24*time.Hour).Format(time.DateOnly) + "\n"
|
||||
d := "## " + time.Now().Format(time.DateOnly) + "\n"
|
||||
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
|
||||
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+y+line), 0644))
|
||||
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
|
||||
p.notify()
|
||||
s, err := os.ReadFile("testdata/changes/changes.md")
|
||||
assert.NoError(t, err)
|
||||
new_line := "* [testdata/changes/alex](alex)\n"
|
||||
// new line was added at the beginning of the list, with the new date, and the old date disappeared
|
||||
assert.Equal(t, intro+d+new_line, string(s))
|
||||
}
|
||||
|
||||
func TestChangesWithOldDisappearingListInTheMiddle(t *testing.T) {
|
||||
cleanup(t, "testdata/changes")
|
||||
intro := "# Changes\n\nThis is a paragraph.\n\n"
|
||||
line := "* [a change](alex)\n"
|
||||
other := "* [other change](whatever)\n"
|
||||
yy := "## " + time.Now().Add(-48*time.Hour).Format(time.DateOnly) + "\n"
|
||||
y := "## " + time.Now().Add(-24*time.Hour).Format(time.DateOnly) + "\n"
|
||||
d := "## " + time.Now().Format(time.DateOnly) + "\n"
|
||||
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
|
||||
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+y+line+"\n"+yy+other), 0644))
|
||||
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
|
||||
p.notify()
|
||||
s, err := os.ReadFile("testdata/changes/changes.md")
|
||||
assert.NoError(t, err)
|
||||
new_line := "* [testdata/changes/alex](alex)\n"
|
||||
// new line was added at the beginning of the list, with the new date, and the old date disappeared
|
||||
assert.Equal(t, intro+d+new_line+"\n"+yy+other, string(s))
|
||||
}
|
||||
|
||||
func TestChangesWithListAtTheTop(t *testing.T) {
|
||||
cleanup(t, "testdata/changes")
|
||||
line := "* [a change](change)\n"
|
||||
d := "## " + time.Now().Format(time.DateOnly) + "\n"
|
||||
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
|
||||
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(line), 0644))
|
||||
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
|
||||
p.notify()
|
||||
s, err := os.ReadFile("testdata/changes/changes.md")
|
||||
assert.NoError(t, err)
|
||||
new_line := "* [testdata/changes/alex](alex)\n"
|
||||
// new line was added at the top, no error due to missing introduction
|
||||
assert.Equal(t, d+new_line+line, string(s))
|
||||
}
|
||||
|
||||
func TestChangesWithNoList(t *testing.T) {
|
||||
cleanup(t, "testdata/changes")
|
||||
intro := "# Changes\n\nThis is a paragraph."
|
||||
d := "## " + time.Now().Format(time.DateOnly) + "\n"
|
||||
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
|
||||
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro), 0644))
|
||||
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
|
||||
p.notify()
|
||||
s, err := os.ReadFile("testdata/changes/changes.md")
|
||||
assert.NoError(t, err)
|
||||
new_line := "* [testdata/changes/alex](alex)\n"
|
||||
// into is still there and a new list was started
|
||||
assert.Equal(t, intro+"\n\n"+d+new_line, string(s))
|
||||
}
|
||||
|
||||
func TestChangesWithUpdate(t *testing.T) {
|
||||
cleanup(t, "testdata/changes")
|
||||
intro := "# Changes\n\nThis is a paragraph.\n\n"
|
||||
other := "* [other change](whatever)\n"
|
||||
d := "## " + time.Now().Format(time.DateOnly) + "\n"
|
||||
line := "* [a change](alex)\n"
|
||||
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
|
||||
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+d+other+line), 0644))
|
||||
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
|
||||
p.notify()
|
||||
s, err := os.ReadFile("testdata/changes/changes.md")
|
||||
assert.NoError(t, err)
|
||||
new_line := "* [testdata/changes/alex](alex)\n"
|
||||
// the change was already listed, but now it moved up and has a new title
|
||||
assert.Equal(t, intro+d+new_line+other, string(s))
|
||||
}
|
||||
|
||||
func TestChangesWithNoChangeToTheOrder(t *testing.T) {
|
||||
cleanup(t, "testdata/changes")
|
||||
intro := "# Changes\n\nThis is a paragraph.\n\n"
|
||||
d := "## " + time.Now().Format(time.DateOnly) + "\n"
|
||||
line := "* [a change](alex)\n"
|
||||
other := "* [other change](whatever)\n"
|
||||
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
|
||||
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+d+line+other), 0644))
|
||||
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
|
||||
p.notify()
|
||||
s, err := os.ReadFile("testdata/changes/changes.md")
|
||||
assert.NoError(t, err)
|
||||
new_line := "* [testdata/changes/alex](alex)\n"
|
||||
// the change was already listed at the top, so just use the new title
|
||||
assert.Equal(t, intro+d+new_line+other, string(s))
|
||||
// since the file has changed, a backup was necessary
|
||||
assert.FileExists(t, "testdata/changes/changes.md~")
|
||||
}
|
||||
|
||||
func TestChangesWithNoChanges(t *testing.T) {
|
||||
cleanup(t, "testdata/changes")
|
||||
intro := "# Changes\n\nThis is a paragraph.\n\n"
|
||||
d := "## " + time.Now().Format(time.DateOnly) + "\n"
|
||||
line := "* [a change](alex)\n"
|
||||
other := "* [other change](whatever)\n"
|
||||
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
|
||||
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+d+line+other), 0644))
|
||||
p := &Page{Name: "testdata/changes/alex", Body: []byte("# a change\nHallo!")}
|
||||
p.notify()
|
||||
s, err := os.ReadFile("testdata/changes/changes.md")
|
||||
assert.NoError(t, err)
|
||||
// the change was already listed at the top, so no change was necessary
|
||||
assert.Equal(t, intro+d+line+other, string(s))
|
||||
// since the file hasn't changed, no backup was necessary
|
||||
assert.NoFileExists(t, "testdata/changes/changes.md~")
|
||||
}
|
||||
36
commands.go
36
commands.go
@@ -1,36 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func commands() {
|
||||
if len(os.Args) == 3 && os.Args[1] == "html" {
|
||||
p, err := loadPage(os.Args[2]);
|
||||
if err != nil {
|
||||
fmt.Println(err);
|
||||
} else {
|
||||
p.renderHtml();
|
||||
fmt.Println(p.Html);
|
||||
}
|
||||
} else if len(os.Args) > 2 && os.Args[1] == "search" {
|
||||
index.load()
|
||||
for _, q := range os.Args[2:] {
|
||||
items := search(q)
|
||||
fmt.Printf("Search %s: %d results\n", q, len(items))
|
||||
for _, p := range items {
|
||||
fmt.Printf("* %s (%d)\n", p.Title, p.Score)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Unknown command: %v\n", os.Args[1:])
|
||||
fmt.Print("Without any arguments, serves a wiki.\n")
|
||||
fmt.Print(" Environment variable ODDMUSE_PORT controls the port.\n")
|
||||
fmt.Print(" Environment variable ODDMUSE_LANGAUGES controls the languages detected.\n")
|
||||
fmt.Print("html PAGENAME\n")
|
||||
fmt.Print(" Print the HTML of the page.\n")
|
||||
fmt.Print("search TERMS\n")
|
||||
fmt.Print(" Print the titles of the page with score.\n")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,6 @@ func TestLoadAndSearch(t *testing.T) {
|
||||
index.reset()
|
||||
go index.load()
|
||||
q := "Oddµ"
|
||||
pages := search(q)
|
||||
pages, _ := search(q, "", 1, false)
|
||||
assert.Zero(t, len(pages))
|
||||
}
|
||||
|
||||
66
diff.go
Normal file
66
diff.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
"html"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func diffHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
p.handleTitle(true)
|
||||
p.renderHtml()
|
||||
renderTemplate(w, "diff", p)
|
||||
}
|
||||
|
||||
// Diff computes the diff for a page. At this point, renderHtml has already been called so the Name is escaped.
|
||||
func (p *Page) Diff() template.HTML {
|
||||
name, err := url.PathUnescape(p.Name)
|
||||
if err != nil {
|
||||
return template.HTML("Cannot unescape " + p.Name)
|
||||
}
|
||||
a := name + ".md~"
|
||||
t1, err := os.ReadFile(a)
|
||||
if err != nil {
|
||||
return template.HTML("Cannot read " + a + ", so the page is new.")
|
||||
}
|
||||
b := name + ".md"
|
||||
t2, err := os.ReadFile(b)
|
||||
if err != nil {
|
||||
return template.HTML("Cannot read " + b + ", so the page was deleted.")
|
||||
}
|
||||
dmp := diffmatchpatch.New()
|
||||
diffs := dmp.DiffMain(string(t1), string(t2), false)
|
||||
return template.HTML(diff2html(dmp.DiffCleanupSemantic(diffs)))
|
||||
}
|
||||
|
||||
func diff2html(diffs []diffmatchpatch.Diff) string {
|
||||
var buff bytes.Buffer
|
||||
for _, item := range diffs {
|
||||
text := strings.ReplaceAll(html.EscapeString(item.Text), "\n", "<br>")
|
||||
switch item.Type {
|
||||
case diffmatchpatch.DiffInsert:
|
||||
_, _ = buff.WriteString("<ins>")
|
||||
_, _ = buff.WriteString(text)
|
||||
_, _ = buff.WriteString("</ins>")
|
||||
case diffmatchpatch.DiffDelete:
|
||||
_, _ = buff.WriteString("<del>")
|
||||
_, _ = buff.WriteString(text)
|
||||
_, _ = buff.WriteString("</del>")
|
||||
case diffmatchpatch.DiffEqual:
|
||||
_, _ = buff.WriteString("<span>")
|
||||
_, _ = buff.WriteString(text)
|
||||
_, _ = buff.WriteString("</span>")
|
||||
}
|
||||
}
|
||||
return buff.String()
|
||||
}
|
||||
29
diff.html
Normal file
29
diff.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{.Language}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
html { max-width: 70ch; padding: 1ch; margin: auto; color: #ddd; background-color: #222; }
|
||||
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
|
||||
body { hyphens: auto; }
|
||||
del { background-color: #fab }
|
||||
ins { background-color: #af8 }
|
||||
pre { white-space: normal; color: #222; background-color: #ddd; border: 1px solid #eee; padding: 1ch }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a href="/view/{{.Name}}">Back</a>
|
||||
</header>
|
||||
<main id="main">
|
||||
<h1>{{.Title}}</h1>
|
||||
<p>This is the diff between <a href="/view/{{.Name}}.md~">the backup</a> and <a href="/view/{{.Name}}.md">the current copy</a>.</p>
|
||||
<pre>
|
||||
{{.Diff}}
|
||||
</pre>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
96
diff_test.go
Normal file
96
diff_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDiff(t *testing.T) {
|
||||
cleanup(t, "testdata/diff")
|
||||
index.load()
|
||||
s := `# Bread
|
||||
|
||||
The oven breathes
|
||||
Fills us with the thought of bread
|
||||
Oh so fresh, so warm.`
|
||||
r := `# Bread
|
||||
|
||||
The oven whispers
|
||||
Fills us with the thought of bread
|
||||
Oh so fresh, so warm.`
|
||||
p := &Page{Name: "testdata/diff/bread", Body: []byte(s)}
|
||||
p.save()
|
||||
p.Body = []byte(r)
|
||||
p.save()
|
||||
body := assert.HTTPBody(makeHandler(diffHandler, true),
|
||||
"GET", "/diff/testdata/diff/bread", nil)
|
||||
assert.Contains(t, body, `<del>breathe</del>`)
|
||||
assert.Contains(t, body, `<ins>whisper</ins>`)
|
||||
}
|
||||
|
||||
func TestDiffPercentEncoded(t *testing.T) {
|
||||
cleanup(t, "testdata/diff")
|
||||
index.load()
|
||||
s := `# Coup de Gras
|
||||
|
||||
Playing D&D
|
||||
We talk about a killing
|
||||
Mispronouncing words`
|
||||
r := `# Coup de Grace
|
||||
|
||||
Playing D&D
|
||||
We talk about a killing
|
||||
Mispronouncing words`
|
||||
p := &Page{Name: "testdata/diff/coup de grace", Body: []byte(s)}
|
||||
p.save()
|
||||
p.Body = []byte(r)
|
||||
p.save()
|
||||
body := assert.HTTPBody(makeHandler(diffHandler, true),
|
||||
"GET", "/diff/testdata/diff/coup%20de%20grace", nil)
|
||||
assert.Contains(t, body, `<del>s</del>`)
|
||||
assert.Contains(t, body, `<ins>ce</ins>`)
|
||||
}
|
||||
|
||||
func TestDiffBackup(t *testing.T) {
|
||||
cleanup(t, "testdata/backup")
|
||||
s := `# Cold Rooms
|
||||
|
||||
I shiver at home
|
||||
the monitor glares and moans
|
||||
fear or cold, who knows?`
|
||||
r := `# Cold Rooms
|
||||
|
||||
I shiver at home
|
||||
the monitor glares and moans
|
||||
I hate the machine!`
|
||||
u := `# Cold Rooms
|
||||
|
||||
I shiver at home
|
||||
the monitor glares and moans
|
||||
my grey heart grows cold`
|
||||
p := &Page{Name: "testdata/backup/cold", Body: []byte(s)}
|
||||
p.save()
|
||||
p = &Page{Name: "testdata/backup/cold", Body: []byte(r)}
|
||||
p.save()
|
||||
body := string(p.Diff())
|
||||
// diff from s to r:
|
||||
assert.Contains(t, body, `<del>fear or cold, who knows?</del>`)
|
||||
assert.Contains(t, body, `<ins>I hate the machine!</ins>`)
|
||||
p = &Page{Name: "testdata/backup/cold", Body: []byte(u)}
|
||||
p.save()
|
||||
body = string(p.Diff())
|
||||
// diff from s to u since r was not 60 min or older
|
||||
assert.Contains(t, body, `<del>fear or cold, who knows?</del>`)
|
||||
assert.Contains(t, body, `<ins>my grey heart grows cold</ins>`)
|
||||
// set timestamp 2h in the past
|
||||
ts := time.Now().Add(-2 * time.Hour)
|
||||
assert.NoError(t, os.Chtimes("testdata/backup/cold.md~", ts, ts))
|
||||
p = &Page{Name: "testdata/backup/cold", Body: []byte(r)}
|
||||
p.save()
|
||||
body = string(p.Diff())
|
||||
// diff from u to r:
|
||||
assert.Contains(t, body, `<del>my grey heart grows cold</del>`)
|
||||
assert.Contains(t, body, `<ins>I hate the machine!</ins>`)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>Editing {{.Title}}</title>
|
||||
<style>
|
||||
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background: #ffe; }
|
||||
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
|
||||
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
|
||||
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee; }
|
||||
form, textarea { width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -15,7 +17,8 @@ form, textarea { width: 100%; }
|
||||
<form action="/save/{{.Name}}" method="POST">
|
||||
<textarea name="body" rows="20" cols="80" placeholder="# Title
|
||||
|
||||
Text" autofocus>{{printf "%s" .Body}}</textarea>
|
||||
Text" lang="" autofocus>{{printf "%s" .Body}}</textarea>
|
||||
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
|
||||
<p><input type="submit" value="Save">
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -28,5 +29,11 @@ func saveHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if r.FormValue("notify") == "on" {
|
||||
err = p.notify()
|
||||
if err != nil {
|
||||
log.Println("notify:", err)
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/view/"+name, http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -4,24 +4,58 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// wipes testdata
|
||||
func TestEditSave(t *testing.T) {
|
||||
_ = os.RemoveAll("testdata")
|
||||
cleanup(t, "testdata/save")
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("body", "Hallo!")
|
||||
|
||||
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/alex", nil, "/edit/testdata/alex")
|
||||
assert.HTTPStatusCode(t, makeHandler(editHandler, true), "GET", "/edit/testdata/alex", nil, 200)
|
||||
HTTPRedirectTo(t, makeHandler(saveHandler, true), "POST", "/save/testdata/alex", data, "/view/testdata/alex")
|
||||
assert.Regexp(t, regexp.MustCompile("Hallo!"),
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/alex", nil))
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
// View of the non-existing page redirects to the edit page
|
||||
HTTPRedirectTo(t, makeHandler(viewHandler, true),
|
||||
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
|
||||
// Edit page can be fetched
|
||||
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
|
||||
"GET", "/edit/testdata/save/alex", nil, 200)
|
||||
// Posting to the save URL saves a page
|
||||
HTTPRedirectTo(t, makeHandler(saveHandler, true),
|
||||
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
|
||||
// Page now contains the text
|
||||
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, true),
|
||||
"GET", "/view/testdata/save/alex", nil),
|
||||
"Hallo!")
|
||||
// Delete the page and you're sent to the empty page
|
||||
data.Set("body", "")
|
||||
HTTPRedirectTo(t, makeHandler(saveHandler, true),
|
||||
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
|
||||
// Viewing the non-existing page redirects to the edit page (like in the beginning)
|
||||
HTTPRedirectTo(t, makeHandler(viewHandler, true),
|
||||
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
|
||||
}
|
||||
|
||||
func TestEditSaveChanges(t *testing.T) {
|
||||
cleanup(t, "testdata/notification")
|
||||
data := url.Values{}
|
||||
data.Set("body", "Hallo!")
|
||||
data.Add("notify", "on")
|
||||
today := time.Now().Format("2006-01-02")
|
||||
// Posting to the save URL saves a page
|
||||
HTTPRedirectTo(t, makeHandler(saveHandler, true),
|
||||
"POST", "/save/testdata/notification/" + today,
|
||||
data, "/view/testdata/notification/" + today)
|
||||
// The changes.md file was created
|
||||
s, err := os.ReadFile("testdata/notification/changes.md")
|
||||
assert.NoError(t, err)
|
||||
d := time.Now().Format(time.DateOnly)
|
||||
assert.Equal(t, "# Changes\n\n## "+d+
|
||||
"\n* [testdata/notification/"+today+"]("+today+")\n",
|
||||
string(s))
|
||||
// Link added to index.md file
|
||||
s, err = os.ReadFile("testdata/notification/index.md")
|
||||
assert.NoError(t, err)
|
||||
// New index contains just the link
|
||||
assert.Equal(t, string(s), "* [testdata/notification/"+today+"]("+today+")\n")
|
||||
}
|
||||
|
||||
72
feed.go
Normal file
72
feed.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"html/template"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Item struct {
|
||||
Page
|
||||
Date string
|
||||
}
|
||||
|
||||
type Feed struct {
|
||||
Item
|
||||
Items []Item
|
||||
}
|
||||
|
||||
func feed(p *Page, ti time.Time) *Feed {
|
||||
feed := new(Feed)
|
||||
feed.Name = p.Name
|
||||
feed.Title = p.Title
|
||||
feed.Date = ti.Format(time.RFC1123Z)
|
||||
parser, _ := wikiParser()
|
||||
doc := markdown.Parse(p.Body, parser)
|
||||
items := make([]Item, 0)
|
||||
inListItem := false
|
||||
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
|
||||
// set the flag if we're in a list item
|
||||
listItem, ok := node.(*ast.ListItem)
|
||||
if ok && listItem.BulletChar == '*' {
|
||||
inListItem = entering
|
||||
return ast.GoToNext
|
||||
}
|
||||
// if we're not in a list item, continue
|
||||
if !inListItem || !entering {
|
||||
return ast.GoToNext
|
||||
}
|
||||
// if we're in a link and it's local
|
||||
link, ok := node.(*ast.Link)
|
||||
if !ok || bytes.Contains(link.Destination, []byte("//")) {
|
||||
return ast.GoToNext
|
||||
}
|
||||
name := path.Join(path.Dir(p.Name), string(link.Destination))
|
||||
fi, err := os.Stat(name + ".md")
|
||||
if err != nil {
|
||||
return ast.GoToNext
|
||||
}
|
||||
p2, err := loadPage(name)
|
||||
if err != nil {
|
||||
return ast.GoToNext
|
||||
}
|
||||
p2.handleTitle(false)
|
||||
p2.renderHtml()
|
||||
it := Item{Date: fi.ModTime().Format(time.RFC1123Z)}
|
||||
it.Title = p2.Title
|
||||
it.Name = p2.Name
|
||||
it.Html = template.HTML(template.HTMLEscaper(p2.Html))
|
||||
it.Hashtags = p2.Hashtags
|
||||
items = append(items, it)
|
||||
if len(items) >= 10 {
|
||||
return ast.Terminate
|
||||
}
|
||||
return ast.GoToNext
|
||||
})
|
||||
feed.Items = items
|
||||
return feed
|
||||
}
|
||||
28
feed.html
Normal file
28
feed.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
|
||||
<channel>
|
||||
<docs>http://blogs.law.harvard.edu/tech/rss</docs>
|
||||
<title>{{.Title}}</title>
|
||||
<link>https://example.org/</link>
|
||||
<managingEditor>jupiter@transjovian.org (Ashivom Bandaralum)</managingEditor>
|
||||
<webMaster>jupiter@transjovian.org (Ashivom Bandaralum)</webMaster>
|
||||
<atom:link href="https://example.org/view/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
|
||||
<description>This is the digital garden of Ashivom Bandaralum.</description>
|
||||
<image>
|
||||
<url>https://example.org/view/logo.jpg</url>
|
||||
<title>{{.Title}}</title>
|
||||
<link>https://example.org/</link>
|
||||
</image>
|
||||
{{range .Items}}
|
||||
<item>
|
||||
<title>{{.Title}}</title>
|
||||
<link>https://example.org/view/{{.Name}}</link>
|
||||
<guid>https://example.org/view/{{.Name}}</guid>
|
||||
<description>{{.Html}}</description>
|
||||
<pubDate>{{.Date}}</pubDate>
|
||||
{{range .Hashtags}}
|
||||
<category>{{.}}</category>
|
||||
{{end}}
|
||||
</item>
|
||||
{{end}}
|
||||
</channel>
|
||||
</rss>
|
||||
49
feed_test.go
Normal file
49
feed_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFeed(t *testing.T) {
|
||||
assert.Contains(t,
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/index.rss", nil),
|
||||
"Welcome to Oddµ")
|
||||
}
|
||||
|
||||
func TestFeedItems(t *testing.T) {
|
||||
cleanup(t, "testdata/feed")
|
||||
index.load()
|
||||
|
||||
p1 := &Page{Name: "testdata/feed/cactus", Body: []byte(`# Cactus
|
||||
Green head and white hair
|
||||
A bench in the evening sun
|
||||
Unmoved by the news
|
||||
|
||||
#Succulent`)}
|
||||
p1.save()
|
||||
|
||||
p2 := &Page{Name: "testdata/feed/dragon", Body: []byte(`# Dragon
|
||||
My palm tree grows straight
|
||||
Up and up to touch the sky
|
||||
Ignoring the roof
|
||||
|
||||
#Palmtree`)}
|
||||
p2.save()
|
||||
|
||||
p3 := &Page{Name: "testdata/feed/plants", Body: []byte(`# Plants
|
||||
Writing poems about plants.
|
||||
|
||||
* [My Cactus](cactus)
|
||||
* [My Dragon Tree](dragon)`)}
|
||||
p3.save()
|
||||
|
||||
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/feed/plants.rss", nil)
|
||||
assert.Contains(t, body, "<title>Plants</title>")
|
||||
assert.Contains(t, body, "<title>Cactus</title>")
|
||||
assert.Contains(t, body, "<title>Dragon</title>")
|
||||
assert.Contains(t, body, "<h1>Cactus</h1>")
|
||||
assert.Contains(t, body, "<h1>Dragon</h1>")
|
||||
assert.Contains(t, body, "<category>Succulent</category>")
|
||||
assert.Contains(t, body, "<category>Palmtree</category>")
|
||||
}
|
||||
27
go.mod
27
go.mod
@@ -3,21 +3,36 @@ module alexschroeder.ch/cgit/oddmu
|
||||
go 1.21.0
|
||||
|
||||
require (
|
||||
github.com/dgryski/go-trigram v0.0.0-20160407183937-79ec494e1ad0
|
||||
github.com/gomarkdown/markdown v0.0.0-20230912175223-14b07df9d538
|
||||
github.com/microcosm-cc/bluemonday v1.0.25
|
||||
github.com/anthonynsimon/bild v0.13.0
|
||||
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd
|
||||
github.com/charmbracelet/lipgloss v0.9.1
|
||||
github.com/gomarkdown/markdown v0.0.0-20231115200524-a660076da3fd
|
||||
github.com/google/subcommands v1.2.0
|
||||
github.com/hexops/gotextdiff v1.0.3
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/pemistahl/lingua-go v1.4.0
|
||||
github.com/sergi/go-diff v1.3.1
|
||||
github.com/stretchr/testify v1.8.4
|
||||
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.15.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/shopspring/decimal v1.3.1 // indirect
|
||||
golang.org/x/exp v0.0.0-20221106115401-f9659909a136 // indirect
|
||||
golang.org/x/net v0.12.0 // indirect
|
||||
golang.org/x/image v0.14.0 // indirect
|
||||
golang.org/x/net v0.19.0 // indirect
|
||||
golang.org/x/sys v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
100
go.sum
100
go.sum
@@ -1,38 +1,108 @@
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/anthonynsimon/bild v0.13.0 h1:mN3tMaNds1wBWi1BrJq0ipDBhpkooYfu7ZFSMhXt1C8=
|
||||
github.com/anthonynsimon/bild v0.13.0/go.mod h1:tpzzp0aYkAsMi1zmfhimaDyX1xjn2OUc1AJZK/TF0AE=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd h1:SxkQeH4jjXT0zMgiRgkiIQjIvWfe9vXuTAmE3cfcQrU=
|
||||
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd/go.mod h1:p1sbxRy+MY71fEWHcfRmerC8WUYXDFCExF9A7aXwp98=
|
||||
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
|
||||
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-trigram v0.0.0-20160407183937-79ec494e1ad0 h1:b+7JSiBM+hnLQjP/lXztks5hnLt1PS46hktG9VOJgzo=
|
||||
github.com/dgryski/go-trigram v0.0.0-20160407183937-79ec494e1ad0/go.mod h1:qzKC/DpcxK67zaSHdCmIv3L9WJViHVinYXN2S7l3RM8=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12 h1:uK3X/2mt4tbSGoHvbLBHUny7CKiuwUip3MArtukol4E=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230912175223-14b07df9d538 h1:ePDpFu7l0QUV46/9A7icfL2wvIOzTJLCWh4RO2NECzE=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230912175223-14b07df9d538/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20231115200524-a660076da3fd h1:PppHBegd3uPZ3Y/Iax/2mlCFJm1w4Qf/zP1MdW4ju2o=
|
||||
github.com/gomarkdown/markdown v0.0.0-20231115200524-a660076da3fd/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pemistahl/lingua-go v1.4.0 h1:ifYhthrlW7iO4icdubwlduYnmwU37V1sbNrwhKBR4rM=
|
||||
github.com/pemistahl/lingua-go v1.4.0/go.mod h1:ECuM1Hp/3hvyh7k8aWSqNCPlTxLemFZsRjocUf3KgME=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
|
||||
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
golang.org/x/exp v0.0.0-20221106115401-f9659909a136 h1:Fq7F/w7MAa1KJ5bt2aJ62ihqp9HDcRuyILskkpIAurw=
|
||||
golang.org/x/exp v0.0.0-20221106115401-f9659909a136/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
|
||||
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8=
|
||||
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
|
||||
golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
|
||||
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
57
html_cmd.go
Normal file
57
html_cmd.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/google/subcommands"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type htmlCmd struct {
|
||||
useTemplate bool
|
||||
}
|
||||
|
||||
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>:
|
||||
Render a page as HTML.
|
||||
`
|
||||
}
|
||||
|
||||
func (cmd *htmlCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.BoolVar(&cmd.useTemplate, "view", false, "use the 'view.html' template.")
|
||||
}
|
||||
|
||||
func (cmd *htmlCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
return htmlCli(os.Stdout, cmd.useTemplate, f.Args())
|
||||
}
|
||||
|
||||
func htmlCli(w io.Writer, useTemplate bool, args []string) subcommands.ExitStatus {
|
||||
for _, arg := range args {
|
||||
p, err := loadPage(arg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", arg, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
initAccounts()
|
||||
if useTemplate {
|
||||
p.handleTitle(true)
|
||||
p.renderHtml()
|
||||
t := "view.html"
|
||||
templates := loadTemplates()
|
||||
err := templates.ExecuteTemplate(w, t, p)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", t, arg, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
} else {
|
||||
// do not handle title
|
||||
p.renderHtml()
|
||||
fmt.Fprintln(w, p.Html)
|
||||
}
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
24
html_cmd_test.go
Normal file
24
html_cmd_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/google/subcommands"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHtmlCmd(t *testing.T) {
|
||||
b := new(bytes.Buffer)
|
||||
s := htmlCli(b, false, []string{"index"})
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
r := `<h1>Welcome to Oddµ</h1>
|
||||
|
||||
<p>Hello! 🙃</p>
|
||||
|
||||
<p>Check out the <a href="README">README</a>.</p>
|
||||
|
||||
<p>Or <a href="test">create a new page</a>.</p>
|
||||
|
||||
`
|
||||
assert.Equal(t, r, b.String())
|
||||
}
|
||||
206
index.go
206
index.go
@@ -1,39 +1,98 @@
|
||||
// Read Artem Krylysov's blog post on full text search as an
|
||||
// introduction.
|
||||
// https://artem.krylysov.com/blog/2020/07/28/lets-build-a-full-text-search-engine/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
trigram "github.com/dgryski/go-trigram"
|
||||
"golang.org/x/exp/constraints"
|
||||
"io/fs"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type docid uint
|
||||
|
||||
// Index contains the two maps used for search. Make sure to lock and
|
||||
// unlock as appropriate.
|
||||
type Index struct {
|
||||
sync.RWMutex
|
||||
|
||||
// index is a struct containing the trigram index for search.
|
||||
// It is generated at startup and updated after every page
|
||||
// edit. The index is case-insensitive.
|
||||
index trigram.Index
|
||||
// next_id is the number of the next document added to the index
|
||||
next_id docid
|
||||
|
||||
// documents is a map, mapping document ids of the index to
|
||||
// page names.
|
||||
documents map[trigram.DocID]string
|
||||
// index is an inverted index mapping tokens to document ids.
|
||||
token map[string][]docid
|
||||
|
||||
// documents is a map, mapping document ids to page names.
|
||||
documents map[docid]string
|
||||
|
||||
// titles is a map, mapping page names to titles.
|
||||
titles map[string]string
|
||||
}
|
||||
|
||||
// idx is the global Index per wiki.
|
||||
var index Index
|
||||
|
||||
// reset resets the Index. This assumes that the index is locked!
|
||||
func (idx *Index) reset() {
|
||||
idx.index = nil
|
||||
idx.token = nil
|
||||
idx.documents = nil
|
||||
idx.titles = nil
|
||||
}
|
||||
|
||||
// addDocument adds the text as a new document. This assumes that the
|
||||
// index is locked!
|
||||
func (idx *Index) addDocument(text []byte) docid {
|
||||
id := idx.next_id
|
||||
idx.next_id++
|
||||
for _, token := range hashtags(text) {
|
||||
ids := idx.token[token]
|
||||
// Don't add same ID more than once. Checking the last
|
||||
// position of the []docid works because the id is
|
||||
// always a new one, i.e. the last one, if at all.
|
||||
if ids != nil && ids[len(ids)-1] == id {
|
||||
continue
|
||||
}
|
||||
idx.token[token] = append(ids, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// deleteDocument deletes the text as a new document. The id can no
|
||||
// longer be used. This assumes that the index is locked!
|
||||
func (idx *Index) deleteDocument(text []byte, id docid) {
|
||||
for _, token := range hashtags(text) {
|
||||
ids := index.token[token]
|
||||
// Tokens can appear multiple times in a text but they
|
||||
// can only be deleted once. deleted.
|
||||
if ids == nil {
|
||||
continue
|
||||
}
|
||||
// If the token appears only in this document, remove
|
||||
// the whole entry.
|
||||
if len(ids) == 1 && ids[0] == id {
|
||||
delete(index.token, token)
|
||||
continue
|
||||
}
|
||||
// Otherwise, remove the token from the index.
|
||||
i := sort.Search(len(ids), func(i int) bool { return ids[i] >= id })
|
||||
if i != -1 && i < len(ids) && ids[i] == id {
|
||||
copy(ids[i:], ids[i+1:])
|
||||
index.token[token] = ids[:len(ids)-1]
|
||||
continue
|
||||
}
|
||||
// If none of the above, then our docid wasn't
|
||||
// indexed. This shouldn't happen, either.
|
||||
log.Printf("The index for token %s does not contain doc id %d", token, id)
|
||||
}
|
||||
delete(index.documents, id)
|
||||
}
|
||||
|
||||
// add reads a file and adds it to the index. This must happen while
|
||||
// the idx is locked, which is true when called from loadIndex.
|
||||
// the idx is locked.
|
||||
func (idx *Index) add(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -47,8 +106,11 @@ func (idx *Index) add(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := idx.index.Add(strings.ToLower(string(p.Body)))
|
||||
p.handleTitle(false)
|
||||
|
||||
id := idx.addDocument(p.Body)
|
||||
idx.documents[id] = p.Name
|
||||
idx.titles[p.Name] = p.Title
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -57,8 +119,9 @@ func (idx *Index) add(path string, info fs.FileInfo, err error) error {
|
||||
func (idx *Index) load() (int, error) {
|
||||
idx.Lock()
|
||||
defer idx.Unlock()
|
||||
idx.index = make(trigram.Index)
|
||||
idx.documents = make(map[trigram.DocID]string)
|
||||
idx.token = make(map[string][]docid)
|
||||
idx.documents = make(map[docid]string)
|
||||
idx.titles = make(map[string]string)
|
||||
err := filepath.Walk(".", idx.add)
|
||||
if err != nil {
|
||||
idx.reset()
|
||||
@@ -68,15 +131,23 @@ func (idx *Index) load() (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// dump prints the index to the log for debugging. Must already be readlocked.
|
||||
func (idx *Index) dump() {
|
||||
index.RLock()
|
||||
defer index.RUnlock()
|
||||
for token, ids := range idx.token {
|
||||
log.Printf("%s: %v", token, ids)
|
||||
}
|
||||
}
|
||||
|
||||
// updateIndex updates the index for a single page. The old text is
|
||||
// loaded from the disk and removed from the index first, if it
|
||||
// exists.
|
||||
func (p *Page) updateIndex() {
|
||||
index.Lock()
|
||||
defer index.Unlock()
|
||||
var id trigram.DocID
|
||||
// This function does not rely on files actually existing, so
|
||||
// let's quickly find the document id.
|
||||
var id docid
|
||||
// Reverse lookup! At least it's in memory.
|
||||
for docId, name := range index.documents {
|
||||
if name == p.Name {
|
||||
id = docId
|
||||
@@ -84,29 +155,100 @@ func (p *Page) updateIndex() {
|
||||
}
|
||||
}
|
||||
if id == 0 {
|
||||
id = index.index.Add(strings.ToLower(string(p.Body)))
|
||||
id = index.addDocument(p.Body)
|
||||
index.documents[id] = p.Name
|
||||
index.titles[p.Name] = p.Title
|
||||
} else {
|
||||
o, err := loadPage(p.Name)
|
||||
if err == nil {
|
||||
index.index.Delete(strings.ToLower(string(o.Body)), id)
|
||||
if o, err := loadPage(p.Name); err == nil {
|
||||
index.deleteDocument(o.Body, id)
|
||||
}
|
||||
index.index.Insert(strings.ToLower(string(p.Body)), id)
|
||||
// Do not reuse the old id. We need a new one for
|
||||
// indexing to work.
|
||||
id = index.addDocument(p.Body)
|
||||
// The page name stays the same but the title may have
|
||||
// changed.
|
||||
index.documents[id] = p.Name
|
||||
p.handleTitle(false)
|
||||
index.titles[p.Name] = p.Title
|
||||
}
|
||||
}
|
||||
|
||||
func searchDocuments(q string) []string {
|
||||
words := strings.Fields(strings.ToLower(q))
|
||||
var trigrams []trigram.T
|
||||
for _, word := range words {
|
||||
trigrams = trigram.Extract(word, trigrams)
|
||||
// removeFromIndex removes the page from the index. Do this when
|
||||
// deleting a page.
|
||||
func (p *Page) removeFromIndex() {
|
||||
index.Lock()
|
||||
defer index.Unlock()
|
||||
var id docid
|
||||
// Reverse lookup! At least it's in memory.
|
||||
for docId, name := range index.documents {
|
||||
if name == p.Name {
|
||||
id = docId
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == 0 {
|
||||
log.Printf("Page %s is not indexed", p.Name)
|
||||
return
|
||||
}
|
||||
o, err := loadPage(p.Name)
|
||||
if err != nil {
|
||||
log.Printf("Page %s cannot removed from the index: %s", p.Name, err)
|
||||
return
|
||||
}
|
||||
index.deleteDocument(o.Body, id)
|
||||
}
|
||||
|
||||
// search searches the index for a query string and returns page
|
||||
// names.
|
||||
func (idx *Index) search(q string) []string {
|
||||
index.RLock()
|
||||
ids := index.index.QueryTrigrams(trigrams)
|
||||
names := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
names[i] = index.documents[id]
|
||||
defer index.RUnlock()
|
||||
names := make([]string, 0)
|
||||
hashtags := hashtags([]byte(q))
|
||||
if len(hashtags) > 0 {
|
||||
var r []docid
|
||||
for _, token := range hashtags {
|
||||
if ids, ok := idx.token[token]; ok {
|
||||
if r == nil {
|
||||
r = ids
|
||||
} else {
|
||||
r = intersection(r, ids)
|
||||
}
|
||||
} else {
|
||||
// Token doesn't exist therefore abort search.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for _, id := range r {
|
||||
names = append(names, idx.documents[id])
|
||||
}
|
||||
} else {
|
||||
for _, name := range idx.documents {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
index.RUnlock()
|
||||
return names
|
||||
}
|
||||
|
||||
// intersection returns the set intersection between a and b.
|
||||
// a and b have to be sorted in ascending order and contain no duplicates.
|
||||
func intersection[T constraints.Ordered](a []T, b []T) []T {
|
||||
maxLen := len(a)
|
||||
if len(b) > maxLen {
|
||||
maxLen = len(b)
|
||||
}
|
||||
r := make([]T, 0, maxLen)
|
||||
var i, j int
|
||||
for i < len(a) && j < len(b) {
|
||||
if a[i] < b[j] {
|
||||
i++
|
||||
} else if a[i] > b[j] {
|
||||
j++
|
||||
} else {
|
||||
r = append(r, a[i])
|
||||
i++
|
||||
j++
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
4
index.md
4
index.md
@@ -2,4 +2,6 @@
|
||||
|
||||
Hello! 🙃
|
||||
|
||||
Check out the [README](README).
|
||||
Check out the [[README]].
|
||||
|
||||
Or [create a new page](test).
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -11,31 +10,31 @@ import (
|
||||
func TestIndex(t *testing.T) {
|
||||
index.load()
|
||||
q := "Oddµ"
|
||||
pages := search(q)
|
||||
pages, _ := search(q, "", 1, false)
|
||||
assert.NotZero(t, len(pages))
|
||||
for _, p := range pages {
|
||||
assert.NotContains(t, p.Title, "<b>")
|
||||
assert.True(t, strings.Contains(string(p.Body), q) || strings.Contains(string(p.Title), q))
|
||||
assert.NotZero(t, p.Score)
|
||||
assert.NotZero(t, p.Score, "Score %d for %s", p.Score, p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHashtag(t *testing.T) {
|
||||
index.load()
|
||||
q := "#Another_Tag"
|
||||
pages := search(q)
|
||||
q := "#like_this"
|
||||
pages, _ := search(q, "", 1, false)
|
||||
assert.NotZero(t, len(pages))
|
||||
}
|
||||
|
||||
func TestIndexUpdates(t *testing.T) {
|
||||
name := "test"
|
||||
_ = os.Remove(name + ".md")
|
||||
cleanup(t, "testdata/update")
|
||||
name := "testdata/update/test"
|
||||
index.load()
|
||||
p := &Page{Name: name, Body: []byte("This is a test.")}
|
||||
p := &Page{Name: name, Body: []byte("#Old Name\nThis is a test.")}
|
||||
p.save()
|
||||
|
||||
// Find the phrase
|
||||
pages := search("This is a test")
|
||||
pages, _ := search("This is a test", "", 1, false)
|
||||
found := false
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
@@ -46,7 +45,7 @@ func TestIndexUpdates(t *testing.T) {
|
||||
assert.True(t, found)
|
||||
|
||||
// Find the phrase, case insensitive
|
||||
pages = search("this is a test")
|
||||
pages, _ = search("this is a test", "", 1, false)
|
||||
found = false
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
@@ -57,7 +56,7 @@ func TestIndexUpdates(t *testing.T) {
|
||||
assert.True(t, found)
|
||||
|
||||
// Find some words
|
||||
pages = search("this test")
|
||||
pages, _ = search("this test", "", 1, false)
|
||||
found = false
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
@@ -68,9 +67,9 @@ func TestIndexUpdates(t *testing.T) {
|
||||
assert.True(t, found)
|
||||
|
||||
// Update the page and no longer find it with the old phrase
|
||||
p = &Page{Name: name, Body: []byte("Guvf vf n grfg.")}
|
||||
p = &Page{Name: name, Body: []byte("# New page\nGuvf vf n grfg.")}
|
||||
p.save()
|
||||
pages = search("This is a test")
|
||||
pages, _ = search("This is a test", "", 1, false)
|
||||
found = false
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
@@ -81,7 +80,7 @@ func TestIndexUpdates(t *testing.T) {
|
||||
assert.False(t, found)
|
||||
|
||||
// Find page using a new word
|
||||
pages = search("Guvf")
|
||||
pages, _ = search("Guvf", "", 1, false)
|
||||
found = false
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
@@ -91,7 +90,8 @@ func TestIndexUpdates(t *testing.T) {
|
||||
}
|
||||
assert.True(t, found)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = os.Remove(name + ".md")
|
||||
})
|
||||
// Make sure the title was updated
|
||||
index.RLock()
|
||||
defer index.RUnlock()
|
||||
assert.Equal(t, index.titles[name], "New page")
|
||||
}
|
||||
|
||||
71
list_cmd.go
Normal file
71
list_cmd.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/google/subcommands"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type listCmd struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
func (cmd *listCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.StringVar(&cmd.dir, "dir", "", "list only pages within this sub-directory")
|
||||
}
|
||||
|
||||
func (*listCmd) Name() string { return "list" }
|
||||
func (*listCmd) Synopsis() string { return "List pages with name and title." }
|
||||
func (*listCmd) Usage() string {
|
||||
return `list [-dir string]:
|
||||
List all pages with name and title, separated by a tabulator.
|
||||
`
|
||||
}
|
||||
|
||||
func (cmd *listCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
return listCli(os.Stdout, cmd.dir, f.Args())
|
||||
}
|
||||
|
||||
// listCli runs the list command on the command line. It is used
|
||||
// here with an io.Writer for easy testing.
|
||||
func listCli(w io.Writer, dir string, args []string) subcommands.ExitStatus {
|
||||
dir, err := checkDir(dir)
|
||||
if err != nil {
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
index.load()
|
||||
index.RLock()
|
||||
defer index.RUnlock()
|
||||
for name, title := range index.titles {
|
||||
if strings.HasPrefix(name, dir) {
|
||||
name = strings.Replace(name, dir, "", 1)
|
||||
fmt.Fprintf(w, "%s\t%s\n", name, title)
|
||||
}
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
// checkDir returns an error if the directory doesn't exist. If if exists, it returns a copy ending in a slash.
|
||||
func checkDir (dir string) (string, error) {
|
||||
if dir != "" {
|
||||
fi, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return "", err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
fmt.Println("This is not a sub-directory:", dir)
|
||||
return "", err
|
||||
}
|
||||
dir = filepath.ToSlash(dir);
|
||||
if (!strings.HasSuffix(dir, "/")) {
|
||||
dir += "/"
|
||||
}
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
31
list_cmd_test.go
Normal file
31
list_cmd_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/google/subcommands"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListCmd(t *testing.T) {
|
||||
b := new(bytes.Buffer)
|
||||
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")
|
||||
}
|
||||
|
||||
func TestListSubdirCmd(t *testing.T) {
|
||||
cleanup(t, "testdata/list")
|
||||
p := &Page{Name: "testdata/list/red", Body: []byte(`# Red
|
||||
Shifting darkness waits
|
||||
I open my eyes in fear
|
||||
And see the red dot`)}
|
||||
p.save()
|
||||
b := new(bytes.Buffer)
|
||||
s := listCli(b, "testdata/list", nil)
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
x := b.String()
|
||||
assert.Contains(t, x, "red\tRed\n")
|
||||
}
|
||||
6
man/Makefile
Normal file
6
man/Makefile
Normal file
@@ -0,0 +1,6 @@
|
||||
docs: oddmu-apache.5 oddmu-html.1 oddmu-missing.1 oddmu-notify.1 \
|
||||
oddmu-replace.1 oddmu-search.1 oddmu-search.7 oddmu-static.1 \
|
||||
oddmu-list.1 oddmu-templates.5 oddmu.1 oddmu.5 oddmu.service.5
|
||||
|
||||
oddmu%: oddmu%.txt
|
||||
scdoc < $< > $@
|
||||
254
man/oddmu-apache.5
Normal file
254
man/oddmu-apache.5
Normal file
@@ -0,0 +1,254 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-APACHE" "5" "2024-01-17"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-apache - how to setup Apache as a reverse proxy for Oddmu
|
||||
.PP
|
||||
.SS DESCRIPTION
|
||||
.PP
|
||||
The oddmu program serves the current working directory as a wiki on port 8080.\&
|
||||
This is an unpriviledged port so an ordinary user account can do this.\&
|
||||
Alternatively, you can reverse proxy HTTP over a Unix-domain socket,
|
||||
as shown later.\&
|
||||
.PP
|
||||
The best way to protect the wiki against vandalism and spam is to use a regular
|
||||
web server as reverse proxy.\& This page explains how to setup Apache on Debian to
|
||||
do this.\&
|
||||
.PP
|
||||
.SS CONFIGURATION
|
||||
.PP
|
||||
HTTPS is not part of Oddmu.\& You probably want to configure this in your
|
||||
webserver.\& I guess you could use stunnel, too.\& If you'\&re using Apache, you can
|
||||
use "mod_md" to manage your domain.\&
|
||||
.PP
|
||||
In the example below, the site is configured in a file called
|
||||
"/etc/apache2/sites-available/500-transjovian.\&conf" and a link poins there from
|
||||
"/etc/apache2/sites-enabled".\& Create this link using \fIa2ensite\fR(1).\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
MDomain transjovian\&.org
|
||||
MDCertificateAgreement accepted
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName transjovian\&.org
|
||||
RewriteEngine on
|
||||
RewriteRule ^/(\&.*) https://%{HTTP_HOST}/$1 [redirect]
|
||||
</VirtualHost>
|
||||
<VirtualHost *:443>
|
||||
ServerAdmin alex@alexschroeder\&.ch
|
||||
ServerName transjovian\&.org
|
||||
SSLEngine on
|
||||
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(\&.*))?$" "http://localhost:8080/$1"
|
||||
</VirtualHost>
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
First, it manages the domain, getting the necessary certificates.\& It redirects
|
||||
regular HTTP traffic from port 80 to port 443.\& It turns on the SSL engine for
|
||||
port 443.\& It proxies the requests for Oddmu to port 8080.\& Importantly, it
|
||||
doesn'\&t send \fIall\fR the requests to Oddmu.\& This allows us to still host static
|
||||
files using the web server.\&
|
||||
.PP
|
||||
This is what happens:
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
The user tells the browser to visit "transjovian.\&org"
|
||||
.IP \(bu 4
|
||||
The browser sends a request for "http://transjovian.\&org" (on port 80)
|
||||
.IP \(bu 4
|
||||
Apache redirects this to "https://transjovian.\&org/" by default (now on port 443)
|
||||
.IP \(bu 4
|
||||
This is proxied to "http://transjovian.\&org:8080/" (now on port 8080)
|
||||
.PD
|
||||
.PP
|
||||
Restart the server, gracefully:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
apachectl graceful
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
To serve both HTTP and HTTPS, don'\&t redirect from the first virtual host to the
|
||||
second – instead just proxy to the wiki like you did for the second virtual
|
||||
host: use a copy of the "ProxyPassMatch" directive instead of "RewriteEngine on"
|
||||
and "RewriteRule".\&
|
||||
.PP
|
||||
.SS Using a Unix-domain Socket
|
||||
.PP
|
||||
Instead of having Oddmu listen on a TCP port, you can have it listen on a
|
||||
Unix-domain socket.\& This requires socket activation.\& An example of configuring
|
||||
the service is given in \fIoddmu.\&service(5)\fR.\&
|
||||
.PP
|
||||
On the Apache side, you only need to change ProxyMatch directives.\& For instance:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(\&.*))?$"
|
||||
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Now, all traffic between the web server and the wiki goes over the socket at
|
||||
"/run/oddmu/oddmu.\&sock".\&
|
||||
.PP
|
||||
.SS Access
|
||||
.PP
|
||||
Access control is not part of Oddmu.\& By default, the wiki is editable by all.\&
|
||||
This is most likely not what you want unless you'\&re running it stand-alone,
|
||||
unconnected to the Internet – a person memex on your laptop, for example.\&
|
||||
.PP
|
||||
The following instructions create user accounts with passwords just for Oddmu.\&
|
||||
These users are not real users on the web server and don'\&t have access to a
|
||||
shell, mail, or any other service.\&
|
||||
.PP
|
||||
Create a new password file called ".\&htpasswd" and add the user "alex".\& The "-c"
|
||||
flag creates the file.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
cd /home/oddmu
|
||||
htpasswd -c \&.htpasswd alex
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
To add more users, don'\&t use the "-c" option or you will overwrite the existing
|
||||
file.\& To add another user, use no option at all.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
htpasswd \&.htpasswd berta
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
To remove a user, use the "-D" option.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
htpasswd -D \&.htpasswd berta
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Modify your site configuration and protect the "/edit/", "/save/", "/add/",
|
||||
"/append/", "/upload/" and "/drop/" URLs with a password by adding the following
|
||||
to your "<VirtualHost *:443>" section:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
|
||||
AuthType Basic
|
||||
AuthName "Password Required"
|
||||
AuthUserFile /home/oddmu/\&.htpasswd
|
||||
Require valid-user
|
||||
</LocationMatch>
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SS Serve static files
|
||||
.PP
|
||||
If you want to serve static files as well, add a document root to your webserver
|
||||
configuration.\& In this case, the document root is the directory where all the
|
||||
data files are.\& Apache does not serve files such as ".\&htpasswd".\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
DocumentRoot /home/oddmu
|
||||
<Directory /home/oddmu>
|
||||
Require all granted
|
||||
</Directory>
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Make sure that none of the subdirectories look like the wiki paths "/view/",
|
||||
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/" or
|
||||
"/search/".\& For example, create a file called "robots.\&txt" containing the
|
||||
following, telling all robots that they'\&re not welcome.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
You site now serves "/robots.\&txt" without interfering with the wiki, and without
|
||||
needing a wiki page.\&
|
||||
.PP
|
||||
Another option would be to create a CSS file and use it with a <link> element in
|
||||
all the templates instead of relying on the <style> element.\&
|
||||
.PP
|
||||
.SS Different logins for different access rights
|
||||
.PP
|
||||
What if you have a site with various subdirectories and each subdirectory is for
|
||||
a different group of friends?\& You can set this up using your webserver.\& One way
|
||||
to do this is to require specific usernames (which must have a password in the
|
||||
password file mentioned above.\&
|
||||
.PP
|
||||
This requires a valid login by the user "alex" or "berta":
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
<LocationMatch "^/(edit|save|add|append|upload|drop)/intetebi/">
|
||||
Require user alex berta
|
||||
</LocationMatch>
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SS Private wikis
|
||||
.PP
|
||||
Based on the above, you can prevent people from \fIreading\fR the wiki.\& The
|
||||
"LocationMatch" must cover all the URLs in order to protect everything.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
<Location />
|
||||
AuthType Basic
|
||||
AuthName "Password Required"
|
||||
AuthUserFile /home/oddmu/\&.htpasswd
|
||||
Require valid-user
|
||||
</Location>
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SS Virtual hosting
|
||||
.PP
|
||||
Virtual hosting in this context means that the program serves two different
|
||||
sites for two different domains from the same machine.\& Oddmu doesn'\&t support
|
||||
that, but your webserver does.\& Therefore, start an Oddmu instance for every
|
||||
domain name, each listening on a different port.\& Then set up your web server
|
||||
such that ever domain acts as a reverse proxy to a different Oddmu instance.\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1)
|
||||
.PP
|
||||
"Apache Core Features".\&
|
||||
https://httpd.\&apache.\&org/docs/current/mod/core.\&html
|
||||
.PP
|
||||
"Apache: Authentication and Authorization".\&
|
||||
https://httpd.\&apache.\&org/docs/current/howto/auth.\&html
|
||||
.PP
|
||||
"Apache Module mod_proxy".\&
|
||||
https://httpd.\&apache.\&org/docs/current/mod/mod_proxy.\&html
|
||||
.PP
|
||||
"Robot exclusion standard" on Wikipedia.\&
|
||||
https://en.\&wikipedia.\&org/wiki/Robot_exclusion_standard
|
||||
.PP
|
||||
"<style>: The Style Information element"
|
||||
https://developer.\&mozilla.\&org/en-US/docs/Web/HTML/Element/style
|
||||
.PP
|
||||
"<link>: The External Resource Link element"
|
||||
https://developer.\&mozilla.\&org/en-US/docs/Web/HTML/Element/link
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
219
man/oddmu-apache.5.txt
Normal file
219
man/oddmu-apache.5.txt
Normal file
@@ -0,0 +1,219 @@
|
||||
ODDMU-APACHE(5)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-apache - how to setup Apache as a reverse proxy for Oddmu
|
||||
|
||||
## DESCRIPTION
|
||||
|
||||
The oddmu program serves the current working directory as a wiki on port 8080.
|
||||
This is an unpriviledged port so an ordinary user account can do this.
|
||||
Alternatively, you can reverse proxy HTTP over a Unix-domain socket,
|
||||
as shown later.
|
||||
|
||||
The best way to protect the wiki against vandalism and spam is to use a regular
|
||||
web server as reverse proxy. This page explains how to setup Apache on Debian to
|
||||
do this.
|
||||
|
||||
## CONFIGURATION
|
||||
|
||||
HTTPS is not part of Oddmu. You probably want to configure this in your
|
||||
webserver. I guess you could use stunnel, too. If you're using Apache, you can
|
||||
use "mod_md" to manage your domain.
|
||||
|
||||
In the example below, the site is configured in a file called
|
||||
"/etc/apache2/sites-available/500-transjovian.conf" and a link poins there from
|
||||
"/etc/apache2/sites-enabled". Create this link using _a2ensite_(1).
|
||||
|
||||
```
|
||||
MDomain transjovian.org
|
||||
MDCertificateAgreement accepted
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName transjovian.org
|
||||
RewriteEngine on
|
||||
RewriteRule ^/(.*) https://%{HTTP_HOST}/$1 [redirect]
|
||||
</VirtualHost>
|
||||
<VirtualHost *:443>
|
||||
ServerAdmin alex@alexschroeder.ch
|
||||
ServerName transjovian.org
|
||||
SSLEngine on
|
||||
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(.*))?$" "http://localhost:8080/$1"
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
First, it manages the domain, getting the necessary certificates. It redirects
|
||||
regular HTTP traffic from port 80 to port 443. It turns on the SSL engine for
|
||||
port 443. It proxies the requests for Oddmu to port 8080. Importantly, it
|
||||
doesn't send _all_ the requests to Oddmu. This allows us to still host static
|
||||
files using the web server.
|
||||
|
||||
This is what happens:
|
||||
|
||||
- The user tells the browser to visit "transjovian.org"
|
||||
- The browser sends a request for "http://transjovian.org" (on port 80)
|
||||
- Apache redirects this to "https://transjovian.org/" by default (now on port 443)
|
||||
- This is proxied to "http://transjovian.org:8080/" (now on port 8080)
|
||||
|
||||
Restart the server, gracefully:
|
||||
|
||||
```
|
||||
apachectl graceful
|
||||
```
|
||||
|
||||
To serve both HTTP and HTTPS, don't redirect from the first virtual host to the
|
||||
second – instead just proxy to the wiki like you did for the second virtual
|
||||
host: use a copy of the "ProxyPassMatch" directive instead of "RewriteEngine on"
|
||||
and "RewriteRule".
|
||||
|
||||
## Using a Unix-domain Socket
|
||||
|
||||
Instead of having Oddmu listen on a TCP port, you can have it listen on a
|
||||
Unix-domain socket. This requires socket activation. An example of configuring
|
||||
the service is given in _oddmu.service(5)_.
|
||||
|
||||
On the Apache side, you only need to change ProxyMatch directives. For instance:
|
||||
|
||||
```
|
||||
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(.*))?$" \
|
||||
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
|
||||
```
|
||||
|
||||
Now, all traffic between the web server and the wiki goes over the socket at
|
||||
"/run/oddmu/oddmu.sock".
|
||||
|
||||
## Access
|
||||
|
||||
Access control is not part of Oddmu. By default, the wiki is editable by all.
|
||||
This is most likely not what you want unless you're running it stand-alone,
|
||||
unconnected to the Internet – a person memex on your laptop, for example.
|
||||
|
||||
The following instructions create user accounts with passwords just for Oddmu.
|
||||
These users are not real users on the web server and don't have access to a
|
||||
shell, mail, or any other service.
|
||||
|
||||
Create a new password file called ".htpasswd" and add the user "alex". The "-c"
|
||||
flag creates the file.
|
||||
|
||||
```
|
||||
cd /home/oddmu
|
||||
htpasswd -c .htpasswd alex
|
||||
```
|
||||
|
||||
To add more users, don't use the "-c" option or you will overwrite the existing
|
||||
file. To add another user, use no option at all.
|
||||
|
||||
```
|
||||
htpasswd .htpasswd berta
|
||||
```
|
||||
|
||||
To remove a user, use the "-D" option.
|
||||
|
||||
```
|
||||
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:
|
||||
|
||||
```
|
||||
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
|
||||
AuthType Basic
|
||||
AuthName "Password Required"
|
||||
AuthUserFile /home/oddmu/.htpasswd
|
||||
Require valid-user
|
||||
</LocationMatch>
|
||||
```
|
||||
|
||||
## Serve static files
|
||||
|
||||
If you want to serve static files as well, add a document root to your webserver
|
||||
configuration. In this case, the document root is the directory where all the
|
||||
data files are. Apache does not serve files such as ".htpasswd".
|
||||
|
||||
```
|
||||
DocumentRoot /home/oddmu
|
||||
<Directory /home/oddmu>
|
||||
Require all granted
|
||||
</Directory>
|
||||
```
|
||||
|
||||
Make sure that none of the subdirectories look like the wiki paths "/view/",
|
||||
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/" or
|
||||
"/search/". For example, create a file called "robots.txt" containing the
|
||||
following, telling all robots that they're not welcome.
|
||||
|
||||
```
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
```
|
||||
|
||||
You site now serves "/robots.txt" without interfering with the wiki, and without
|
||||
needing a wiki page.
|
||||
|
||||
Another option would be to create a CSS file and use it with a <link> element in
|
||||
all the templates instead of relying on the <style> element.
|
||||
|
||||
## Different logins for different access rights
|
||||
|
||||
What if you have a site with various subdirectories and each subdirectory is for
|
||||
a different group of friends? You can set this up using your webserver. One way
|
||||
to do this is to require specific usernames (which must have a password in the
|
||||
password file mentioned above.
|
||||
|
||||
This requires a valid login by the user "alex" or "berta":
|
||||
|
||||
```
|
||||
<LocationMatch "^/(edit|save|add|append|upload|drop)/intetebi/">
|
||||
Require user alex berta
|
||||
</LocationMatch>
|
||||
```
|
||||
|
||||
## Private wikis
|
||||
|
||||
Based on the above, you can prevent people from _reading_ the wiki. The
|
||||
"LocationMatch" must cover all the URLs in order to protect everything.
|
||||
|
||||
```
|
||||
<Location />
|
||||
AuthType Basic
|
||||
AuthName "Password Required"
|
||||
AuthUserFile /home/oddmu/.htpasswd
|
||||
Require valid-user
|
||||
</Location>
|
||||
```
|
||||
|
||||
## Virtual hosting
|
||||
|
||||
Virtual hosting in this context means that the program serves two different
|
||||
sites for two different domains from the same machine. Oddmu doesn't support
|
||||
that, but your webserver does. Therefore, start an Oddmu instance for every
|
||||
domain name, each listening on a different port. Then set up your web server
|
||||
such that ever domain acts as a reverse proxy to a different Oddmu instance.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1)
|
||||
|
||||
"Apache Core Features".
|
||||
https://httpd.apache.org/docs/current/mod/core.html
|
||||
|
||||
"Apache: Authentication and Authorization".
|
||||
https://httpd.apache.org/docs/current/howto/auth.html
|
||||
|
||||
"Apache Module mod_proxy".
|
||||
https://httpd.apache.org/docs/current/mod/mod_proxy.html
|
||||
|
||||
"Robot exclusion standard" on Wikipedia.
|
||||
https://en.wikipedia.org/wiki/Robot_exclusion_standard
|
||||
|
||||
"<style>: The Style Information element"
|
||||
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style
|
||||
|
||||
"<link>: The External Resource Link element"
|
||||
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
53
man/oddmu-html.1
Normal file
53
man/oddmu-html.1
Normal file
@@ -0,0 +1,53 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-HTML" "1" "2023-10-09"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-html - render Oddmu page HTML from the command-line
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu html\fR [-view] \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.\&
|
||||
.PP
|
||||
.SH OPTIONS
|
||||
.PP
|
||||
\fB-view\fR
|
||||
.RS 4
|
||||
Use the "view.\&html" template to render the page.\& Without this, the HTML
|
||||
lacks html and body tags.\&
|
||||
.PP
|
||||
.RE
|
||||
.SH EXAMPLE
|
||||
.PP
|
||||
Generate the HTML for "README.\&md":
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
oddmu html README
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH ENVIRONMENT
|
||||
.PP
|
||||
The ODDMU_WEBFINGER environment variable has no effect in this situation.\&
|
||||
Fediverse accounts are not linked to their profile pages.\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
42
man/oddmu-html.1.txt
Normal file
42
man/oddmu-html.1.txt
Normal file
@@ -0,0 +1,42 @@
|
||||
ODDMU-HTML(1)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-html - render Oddmu page HTML from the command-line
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu html* [-view] _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.
|
||||
|
||||
# OPTIONS
|
||||
|
||||
*-view*
|
||||
Use the "view.html" template to render the page. Without this, the HTML
|
||||
lacks html and body tags.
|
||||
|
||||
# EXAMPLE
|
||||
|
||||
Generate the HTML for "README.md":
|
||||
|
||||
```
|
||||
oddmu html README
|
||||
```
|
||||
|
||||
# ENVIRONMENT
|
||||
|
||||
The ODDMU_WEBFINGER environment variable has no effect in this situation.
|
||||
Fediverse accounts are not linked to their profile pages.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
56
man/oddmu-list.1
Normal file
56
man/oddmu-list.1
Normal file
@@ -0,0 +1,56 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-LIST" "1" "2023-12-20"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-list - list page names and titles from the command-line
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu list\fR [-dir string]
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The "list" subcommand lists page names and their titles, separated by a TAB
|
||||
character.\& This saves you from opening and parsing all the files yourself if you
|
||||
need the page titles.\&
|
||||
.PP
|
||||
If a directory is provided, only files from the tree starting at that
|
||||
subdirectory are listed, and the directory is stripped from the page name.\&
|
||||
.PP
|
||||
.SH OPTIONS
|
||||
.PP
|
||||
\fB-dir\fR \fIstring\fR
|
||||
.RS 4
|
||||
Limit the list to a particular directory.\&
|
||||
.PP
|
||||
.RE
|
||||
.SH EXAMPLE
|
||||
.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.\&
|
||||
This is a list of links you could append to "dad/index.\&md" if it doesn'\&t already
|
||||
have a list of links.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
oddmu list -dir dad
|
||||
| grep \&'^2\&'
|
||||
| awk -F "t" -e \&'{ print "* [" $2 "](" $1 ")" }\&'
|
||||
| sort -r
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1), \fIoddmu-search\fR(1)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
45
man/oddmu-list.1.txt
Normal file
45
man/oddmu-list.1.txt
Normal file
@@ -0,0 +1,45 @@
|
||||
ODDMU-LIST(1)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-list - list page names and titles from the command-line
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu list* [-dir string]
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The "list" subcommand lists page names and their titles, separated by a TAB
|
||||
character. This saves you from opening and parsing all the files yourself if you
|
||||
need the page titles.
|
||||
|
||||
If a directory is provided, only files from the tree starting at that
|
||||
subdirectory are listed, and the directory is stripped from the page name.
|
||||
|
||||
# OPTIONS
|
||||
|
||||
*-dir* _string_
|
||||
Limit the list to a particular directory.
|
||||
|
||||
# EXAMPLE
|
||||
|
||||
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.
|
||||
This is a list of links you could append to "dad/index.md" if it doesn't already
|
||||
have a list of links.
|
||||
|
||||
```
|
||||
oddmu list -dir dad \
|
||||
| grep '^2' \
|
||||
| awk -F "\t" -e '{ print "* [" $2 "](" $1 ")" }' \
|
||||
| sort -r
|
||||
```
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1), _oddmu-search_(1)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
57
man/oddmu-missing.1
Normal file
57
man/oddmu-missing.1
Normal file
@@ -0,0 +1,57 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-MISSING" "1" "2023-11-05"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-missing - list missing pages from the command-line
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu missing\fR
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The "missing" subcommand lists pages and their local links that are missing.\&
|
||||
.PP
|
||||
Any links that seem like they might point outside the wiki are ignored: links
|
||||
that start with a slash "/" and links that start with a known URL schema
|
||||
(currently: "http:", "https:", "ftp:", "mailto:", "gopher:", "gemini:",
|
||||
"finger:").\&
|
||||
.PP
|
||||
Notably, links that start with ".\&.\&/" are reported as missing.\&
|
||||
.PP
|
||||
.SH EXAMPLE
|
||||
.PP
|
||||
Looking for broken links:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
oddmu missing
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Result:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
Page Missing
|
||||
README github\&.com/pemistahl/lingua-go
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
This shows how the README file had a link where the URL was missing the scheme
|
||||
"https://".\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1), \fIoddmu-replace\fR(1), \fIoddmu-missing\fR(7)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
46
man/oddmu-missing.1.txt
Normal file
46
man/oddmu-missing.1.txt
Normal file
@@ -0,0 +1,46 @@
|
||||
ODDMU-MISSING(1)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-missing - list missing pages from the command-line
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu missing*
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The "missing" subcommand lists pages and their local links that are missing.
|
||||
|
||||
Any links that seem like they might point outside the wiki are ignored: links
|
||||
that start with a slash "/" and links that start with a known URL schema
|
||||
(currently: "http:", "https:", "ftp:", "mailto:", "gopher:", "gemini:",
|
||||
"finger:").
|
||||
|
||||
Notably, links that start with "../" are reported as missing.
|
||||
|
||||
# EXAMPLE
|
||||
|
||||
Looking for broken links:
|
||||
|
||||
```
|
||||
oddmu missing
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```
|
||||
Page Missing
|
||||
README github.com/pemistahl/lingua-go
|
||||
```
|
||||
|
||||
This shows how the README file had a link where the URL was missing the scheme
|
||||
"https://".
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1), _oddmu-replace_(1), _oddmu-missing_(7)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
108
man/oddmu-notify.1
Normal file
108
man/oddmu-notify.1
Normal file
@@ -0,0 +1,108 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-NOTIFY" "1" "2024-01-17"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-notify - add links to changes.\&md, index.\&md, and hashtag pages
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu notify\fR \fIpage names.\&.\&.\&\fR
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The "notify" subcommand takes all the page names provided (without the ".\&md"
|
||||
extension) and adds links to it from other pages.\&
|
||||
.PP
|
||||
A new link is added to the \fBchanges\fR page in the current directory if it doesn'\&t
|
||||
exist.\& The current date of the machine Oddmu is running on is used as the
|
||||
heading.\& If the requested link already exists on the changes page, it is moved
|
||||
up to the current date.\& If that leaves an old date without any links, that date
|
||||
heading is removed.\&
|
||||
.PP
|
||||
A page whose name starts with an ISO date (YYYY-MM-DD, e.\&g.\& "2023-10-28") is
|
||||
called a \fBblog\fR page.\&
|
||||
.PP
|
||||
A link is created from the \fBindex\fR page in the current directory to blog pages
|
||||
if and only if the blog pages are from the current year.\& The idea is that the
|
||||
front page contains a lot of links to blog posts but eventually the blog post
|
||||
links are moved onto archive pages (one per year, for example), or simply
|
||||
deleted.\& As when editing older pages, links to those pages should not get added
|
||||
to the index as if those older pages were new again.\& A link on the changes page
|
||||
is enough.\&
|
||||
.PP
|
||||
For every \fBhashtag\fR used on the pages named, another link might be created.\& If a
|
||||
page named like the hashtag exists, a backlink is added to it.\& A hashtag
|
||||
consists of a number sign ('\&#'\&) followed by Unicode letters, numbers or the
|
||||
underscore ('\&_'\&).\& Thus, a hashtag ends with punctuation or whitespace.\&
|
||||
.PP
|
||||
If a link already exists but it'\&s title is no longer correct, it is updated.\&
|
||||
.PP
|
||||
New links added for blog pages are added at the top of the first unnumbered list
|
||||
using the asterisk ('\&*'\&).\& If no such list exists, a new one is started at the
|
||||
bottom of the page.\& This allows you to have a different unnumbered list further
|
||||
up on the page, as long as it uses the minus for items ('\&-'\&).\&
|
||||
.PP
|
||||
.SH EXAMPLE
|
||||
.PP
|
||||
After writing the file "2023-11-05-climate.\&md" containing the hashtag
|
||||
"#Climate", add links to it from "index.\&md", "changes.\&md", and "Climate.\&md" (if
|
||||
it exists):
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
oddmu notify 2023-11-05-climate
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
The changes file might look as follows:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
# Changes
|
||||
|
||||
This page lists all the changes made to the wiki\&.
|
||||
|
||||
## 2023-11-05
|
||||
|
||||
* [Global warming](2023-11-05-climate)
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
The index file might look as follows:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
# Blog
|
||||
|
||||
This page links to all the blog posts\&.
|
||||
|
||||
* [Global warming](2023-11-05-climate)
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
The hashtag file might look as follows:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
# Climate
|
||||
|
||||
This page links to all the blog posts tagged #Climate\&.
|
||||
|
||||
* [Global warming](2023-11-05-climate)
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
93
man/oddmu-notify.1.txt
Normal file
93
man/oddmu-notify.1.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
ODDMU-NOTIFY(1)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-notify - add links to changes.md, index.md, and hashtag pages
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu notify* _page names..._
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The "notify" subcommand takes all the page names provided (without the ".md"
|
||||
extension) and adds links to it from other pages.
|
||||
|
||||
A new link is added to the *changes* page in the current directory if it doesn't
|
||||
exist. The current date of the machine Oddmu is running on is used as the
|
||||
heading. If the requested link already exists on the changes page, it is moved
|
||||
up to the current date. If that leaves an old date without any links, that date
|
||||
heading is removed.
|
||||
|
||||
A page whose name starts with an ISO date (YYYY-MM-DD, e.g. "2023-10-28") is
|
||||
called a *blog* page.
|
||||
|
||||
A link is created from the *index* page in the current directory to blog pages
|
||||
if and only if the blog pages are from the current year. The idea is that the
|
||||
front page contains a lot of links to blog posts but eventually the blog post
|
||||
links are moved onto archive pages (one per year, for example), or simply
|
||||
deleted. As when editing older pages, links to those pages should not get added
|
||||
to the index as if those older pages were new again. A link on the changes page
|
||||
is enough.
|
||||
|
||||
For every *hashtag* used on the pages named, another link might be created. If a
|
||||
page named like the hashtag exists, a backlink is added to it. A hashtag
|
||||
consists of a number sign ('#') followed by Unicode letters, numbers or the
|
||||
underscore ('\_'). Thus, a hashtag ends with punctuation or whitespace.
|
||||
|
||||
If a link already exists but it's title is no longer correct, it is updated.
|
||||
|
||||
New links added for blog pages are added at the top of the first unnumbered list
|
||||
using the asterisk ('\*'). If no such list exists, a new one is started at the
|
||||
bottom of the page. This allows you to have a different unnumbered list further
|
||||
up on the page, as long as it uses the minus for items ('-').
|
||||
|
||||
# EXAMPLE
|
||||
|
||||
After writing the file "2023-11-05-climate.md" containing the hashtag
|
||||
"#Climate", add links to it from "index.md", "changes.md", and "Climate.md" (if
|
||||
it exists):
|
||||
|
||||
```
|
||||
oddmu notify 2023-11-05-climate
|
||||
```
|
||||
|
||||
The changes file might look as follows:
|
||||
|
||||
```
|
||||
# Changes
|
||||
|
||||
This page lists all the changes made to the wiki.
|
||||
|
||||
## 2023-11-05
|
||||
|
||||
* [Global warming](2023-11-05-climate)
|
||||
```
|
||||
|
||||
The index file might look as follows:
|
||||
|
||||
```
|
||||
# Blog
|
||||
|
||||
This page links to all the blog posts.
|
||||
|
||||
* [Global warming](2023-11-05-climate)
|
||||
```
|
||||
|
||||
The hashtag file might look as follows:
|
||||
|
||||
```
|
||||
# Climate
|
||||
|
||||
This page links to all the blog posts tagged #Climate.
|
||||
|
||||
* [Global warming](2023-11-05-climate)
|
||||
```
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
105
man/oddmu-replace.1
Normal file
105
man/oddmu-replace.1
Normal file
@@ -0,0 +1,105 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-REPLACE" "1" "2023-11-24"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-replace - replace text in Oddmu pages from the command-line
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu replace\fR [-confirm] [-regexp] \fIterm\fR \fIreplacement\fR
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The "replace" subcommand does a search and replace on all the Markdown files in
|
||||
the current directory and its subdirectories.\&
|
||||
.PP
|
||||
.SH OPTIONS
|
||||
.PP
|
||||
\fB-confirm\fR
|
||||
.RS 4
|
||||
By default, the replacement doesn'\&t save the changes made.\& Instead, a
|
||||
unified diff is produced and printed.\& Given this option, the changed
|
||||
Markdown files are saved to disk.\&
|
||||
.PP
|
||||
.RE
|
||||
\fB-regexp\fR
|
||||
.RS 4
|
||||
By default, the term to be replaced is just a string.\& With this flag,
|
||||
the term is a regular expression and the replacement can contain
|
||||
backreferences ($1, $2, $3, etc.\&) to capture groups.\&
|
||||
.PP
|
||||
.RE
|
||||
.SH EXAMPLE
|
||||
.PP
|
||||
Replace "Oddmu" in the Markdown files of the current directory:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
oddmu replace Oddmu Oddµ
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Result:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
--- README\&.md~
|
||||
+++ README\&.md
|
||||
|
||||
(diff omitted)
|
||||
|
||||
1 file would be changed\&.
|
||||
This is a dry run\&. Use -confirm to make it happen\&.
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH NOTES
|
||||
.PP
|
||||
This is the equivalent of using \fIsed\fR(1) with the --quiet, --regexp-extended,
|
||||
--in-place=~ and --expression command with the s command
|
||||
"s/regexp/replacement/g" except that it prints a unified diff per default
|
||||
instead of making any changes and the regexp rules differ slightly.\&
|
||||
.PP
|
||||
The search is case-sensitive.\& To make it case-insensitive, search for a regular
|
||||
expression that sets the case-insensitive flag, e.\&g.\& "(?\&i)oddmu".\&
|
||||
.PP
|
||||
.SH SECURITY
|
||||
.PP
|
||||
Consider creating a backup before doing replacements!\&
|
||||
.PP
|
||||
The following Bash script creates a copy of the current directory using hard
|
||||
links.\& If you'\&re in a directory called "wiki", it creates a sibling directory
|
||||
called "wiki-2023-11-24" (using the current date) full of links.\& This takes
|
||||
little space and time.\& It works as a backup as long as you don'\&t use an
|
||||
application that edits files in place.\& Most programs overwrite old files by
|
||||
creating new files with the same name, so you should be safe.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
#!/usr/bin/bash
|
||||
d=$(basename $(pwd))
|
||||
t=$(date --iso-8601)
|
||||
echo Creating a snapshot of $d in \&.\&./$d-$t
|
||||
rsync --link-dest "\&.\&./$d" --archive \&. "\&.\&./$d-$t/"
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
The above wouldn'\&t work for database files, for example.\& There, the database
|
||||
changes the file in place thus the file is changed in the backup directory as
|
||||
well.\& For Oddmu and the usual text editors, it works.\& If you use Emacs, don'\&t
|
||||
set \fIbackup-by-copying\fR, \fIbackup-by-copying-when-linked\fR and related variables.\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1), \fIoddmu-search\fR(7)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
88
man/oddmu-replace.1.txt
Normal file
88
man/oddmu-replace.1.txt
Normal file
@@ -0,0 +1,88 @@
|
||||
ODDMU-REPLACE(1)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-replace - replace text in Oddmu pages from the command-line
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu replace* [-confirm] [-regexp] _term_ _replacement_
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The "replace" subcommand does a search and replace on all the Markdown files in
|
||||
the current directory and its subdirectories.
|
||||
|
||||
# OPTIONS
|
||||
|
||||
*-confirm*
|
||||
By default, the replacement doesn't save the changes made. Instead, a
|
||||
unified diff is produced and printed. Given this option, the changed
|
||||
Markdown files are saved to disk.
|
||||
|
||||
*-regexp*
|
||||
By default, the term to be replaced is just a string. With this flag,
|
||||
the term is a regular expression and the replacement can contain
|
||||
backreferences ($1, $2, $3, etc.) to capture groups.
|
||||
|
||||
# EXAMPLE
|
||||
|
||||
Replace "Oddmu" in the Markdown files of the current directory:
|
||||
|
||||
```
|
||||
oddmu replace Oddmu Oddµ
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```
|
||||
--- README.md~
|
||||
+++ README.md
|
||||
|
||||
(diff omitted)
|
||||
|
||||
1 file would be changed.
|
||||
This is a dry run. Use -confirm to make it happen.
|
||||
```
|
||||
|
||||
# NOTES
|
||||
|
||||
This is the equivalent of using _sed_(1) with the --quiet, --regexp-extended,
|
||||
\--in-place=~ and --expression command with the s command
|
||||
"s/regexp/replacement/g" except that it prints a unified diff per default
|
||||
instead of making any changes and the regexp rules differ slightly.
|
||||
|
||||
The search is case-sensitive. To make it case-insensitive, search for a regular
|
||||
expression that sets the case-insensitive flag, e.g. "(?i)oddmu".
|
||||
|
||||
# SECURITY
|
||||
|
||||
Consider creating a backup before doing replacements!
|
||||
|
||||
The following Bash script creates a copy of the current directory using hard
|
||||
links. If you're in a directory called "wiki", it creates a sibling directory
|
||||
called "wiki-2023-11-24" (using the current date) full of links. This takes
|
||||
little space and time. It works as a backup as long as you don't use an
|
||||
application that edits files in place. Most programs overwrite old files by
|
||||
creating new files with the same name, so you should be safe.
|
||||
|
||||
```
|
||||
#!/usr/bin/bash
|
||||
d=$(basename $(pwd))
|
||||
t=$(date --iso-8601)
|
||||
echo Creating a snapshot of $d in ../$d-$t
|
||||
rsync --link-dest "../$d" --archive . "../$d-$t/"
|
||||
```
|
||||
|
||||
The above wouldn't work for database files, for example. There, the database
|
||||
changes the file in place thus the file is changed in the backup directory as
|
||||
well. For Oddmu and the usual text editors, it works. If you use Emacs, don't
|
||||
set _backup-by-copying_, _backup-by-copying-when-linked_ and related variables.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1), _oddmu-search_(7)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
77
man/oddmu-search.1
Normal file
77
man/oddmu-search.1
Normal file
@@ -0,0 +1,77 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-SEARCH" "1" "2023-12-20"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-search - search the Oddmu pages from the command-line
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu search\fR [-extract] [-page \fIn\fR] \fIterms.\&.\&.\&\fR
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The "search" subcommand searches the Markdown files in the current
|
||||
directory.\&
|
||||
.PP
|
||||
Be default, this returns a Markdown-formatted list suitable for pasting into
|
||||
Oddmu pages.\&
|
||||
.PP
|
||||
If a directory is provided, only files from the tree starting at that
|
||||
subdirectory are listed, and the directory is stripped from the page name.\&
|
||||
.PP
|
||||
See \fIoddmu-search\fR(7) for more information of how pages are searched, sorted and
|
||||
scored.\&
|
||||
.PP
|
||||
.SH OPTIONS
|
||||
.PP
|
||||
\fB-dir\fR \fIstring\fR
|
||||
.RS 4
|
||||
Limit search to a particular directory.\&
|
||||
.RE
|
||||
\fB-extract\fR
|
||||
.RS 4
|
||||
Print search extracts for interactive use from the command-line.\&
|
||||
.RE
|
||||
\fB-page\fR \fIn\fR
|
||||
.RS 4
|
||||
Search results are paginated and by default only the first page is
|
||||
shown.\& This option allows you to view other pages.\&
|
||||
.RE
|
||||
\fB-all\fR
|
||||
.RS 4
|
||||
Ignore pagination and just print a long list of results.\&
|
||||
.PP
|
||||
.RE
|
||||
.SH EXAMPLE
|
||||
.PP
|
||||
Search for "oddmu" in the Markdown files of the current directory:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
oddmu search oddmu
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Result:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
Search oddmu: 1 result
|
||||
* [Oddµ: A minimal wiki](README) (5)
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1), \fIoddmu-replace\fR(1), \fIoddmu-search\fR(7)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
58
man/oddmu-search.1.txt
Normal file
58
man/oddmu-search.1.txt
Normal file
@@ -0,0 +1,58 @@
|
||||
ODDMU-SEARCH(1)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-search - search the Oddmu pages from the command-line
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu search* [-extract] [-page _n_] _terms..._
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The "search" subcommand searches the Markdown files in the current
|
||||
directory.
|
||||
|
||||
Be default, this returns a Markdown-formatted list suitable for pasting into
|
||||
Oddmu pages.
|
||||
|
||||
If a directory is provided, only files from the tree starting at that
|
||||
subdirectory are listed, and the directory is stripped from the page name.
|
||||
|
||||
See _oddmu-search_(7) for more information of how pages are searched, sorted and
|
||||
scored.
|
||||
|
||||
# OPTIONS
|
||||
|
||||
*-dir* _string_
|
||||
Limit search to a particular directory.
|
||||
*-extract*
|
||||
Print search extracts for interactive use from the command-line.
|
||||
*-page* _n_
|
||||
Search results are paginated and by default only the first page is
|
||||
shown. This option allows you to view other pages.
|
||||
*-all*
|
||||
Ignore pagination and just print a long list of results.
|
||||
|
||||
# EXAMPLE
|
||||
|
||||
Search for "oddmu" in the Markdown files of the current directory:
|
||||
|
||||
```
|
||||
oddmu search oddmu
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```
|
||||
Search oddmu: 1 result
|
||||
* [Oddµ: A minimal wiki](README) (5)
|
||||
```
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1), _oddmu-replace_(1), _oddmu-search_(7)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
98
man/oddmu-search.7
Normal file
98
man/oddmu-search.7
Normal file
@@ -0,0 +1,98 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-SEARCH" "7" "2023-10-28"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-search - understanding the Oddmu search engine
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu search\fR \fIterms\fR.\&.\&.\&
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The wiki keeps an index of all the hash tags and page titles in memory.\& Using
|
||||
hashtags and predicates in your queries speeds them up because fewer files are
|
||||
opened.\&
|
||||
.PP
|
||||
A hashtag starts with a number sign ('\&#'\&) and contains numbers, letters, and the
|
||||
underscore ('\&_'\&).\&
|
||||
.PP
|
||||
Example: #old_school random encounter
|
||||
.PP
|
||||
The title predicate filters for pages where the term is contained in the page
|
||||
title.\&
|
||||
.PP
|
||||
Example: title:geo title:cache zürich
|
||||
.PP
|
||||
The blog predicate filters for pages where the page name begins with an ISO date
|
||||
like "2023-09-26" if true, or doesn'\&t begin with an ISO date if false.\&
|
||||
.PP
|
||||
Example: blog:false fountain
|
||||
.PP
|
||||
The sorting of all the pages does not depend on the number of matches or any
|
||||
kind of score because computing the score is expensive as this requires the page
|
||||
to be loaded from disk.\& Therefore, results are sorted by title:
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
If a page title matches the query string exactly, it gets sorted first.\&
|
||||
.IP \(bu 4
|
||||
If the page title contains the query string, it gets sorted next.\&
|
||||
.IP \(bu 4
|
||||
If the page name starts with a number, it is sorted descending.\&
|
||||
.IP \(bu 4
|
||||
All other pages follow, sorted ascending.\&
|
||||
.PD
|
||||
.PP
|
||||
The effect is that first, the pages with matches in the page title are shown,
|
||||
and then all the others.\& Within these two groups, the most recent blog posts are
|
||||
shown first.\& This assumes that blog pages start with an ISO date like
|
||||
"2023-09-16".\&
|
||||
.PP
|
||||
When searching for a hashtag, a page name (not the title!\&) matching the hashtag
|
||||
exactly (without the leading '\&#'\&) is listed first, even if it doesn'\&t contain
|
||||
the hashtag.\& It is assumed that this page offers some kind of introduction to
|
||||
people searching for the hashtag.\&
|
||||
.PP
|
||||
Example: When people click on the hashtag "#Oddµ" and a page named "Oddµ" exists
|
||||
(in other words, the file "Oddµ.\&md" exists), it is prepended to the results even
|
||||
if it doesn'\&t have the hashtag "#Oddµ" and even if it has a title of "Oddµ, a
|
||||
minimal wiki" (which wouldn'\&t be an exact match).\&
|
||||
.PP
|
||||
The score and highlighting of snippets is used to help visitors decide which
|
||||
links to click.\&
|
||||
.PP
|
||||
Each document found is scored.\& Each of the following increases the score by one
|
||||
point:
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
the entire phrase matches
|
||||
.IP \(bu 4
|
||||
a word matches
|
||||
.IP \(bu 4
|
||||
a word matches at the beginning of a word
|
||||
.IP \(bu 4
|
||||
a word matches at the end of a word
|
||||
.IP \(bu 4
|
||||
a word matches as a whole word
|
||||
.PD
|
||||
.PP
|
||||
A document with content "This is a test" when searched with the phrase "this
|
||||
test" therefore gets a score of 8: the entire phrase does not match but each
|
||||
word gets four points.\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1), \fIoddmu-search\fR(1)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
78
man/oddmu-search.7.txt
Normal file
78
man/oddmu-search.7.txt
Normal file
@@ -0,0 +1,78 @@
|
||||
ODDMU-SEARCH(7)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-search - understanding the Oddmu search engine
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu search* _terms_...
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The wiki keeps an index of all the hash tags and page titles in memory. Using
|
||||
hashtags and predicates in your queries speeds them up because fewer files are
|
||||
opened.
|
||||
|
||||
A hashtag starts with a number sign ('#') and contains numbers, letters, and the
|
||||
underscore ('\_').
|
||||
|
||||
Example: #old_school random encounter
|
||||
|
||||
The title predicate filters for pages where the term is contained in the page
|
||||
title.
|
||||
|
||||
Example: title:geo title:cache zürich
|
||||
|
||||
The blog predicate filters for pages where the page name begins with an ISO date
|
||||
like "2023-09-26" if true, or doesn't begin with an ISO date if false.
|
||||
|
||||
Example: blog:false fountain
|
||||
|
||||
The sorting of all the pages does not depend on the number of matches or any
|
||||
kind of score because computing the score is expensive as this requires the page
|
||||
to be loaded from disk. Therefore, results are sorted by title:
|
||||
|
||||
- If a page title matches the query string exactly, it gets sorted first.
|
||||
- If the page title contains the query string, it gets sorted next.
|
||||
- If the page name starts with a number, it is sorted descending.
|
||||
- All other pages follow, sorted ascending.
|
||||
|
||||
The effect is that first, the pages with matches in the page title are shown,
|
||||
and then all the others. Within these two groups, the most recent blog posts are
|
||||
shown first. This assumes that blog pages start with an ISO date like
|
||||
"2023-09-16".
|
||||
|
||||
When searching for a hashtag, a page name (not the title!) matching the hashtag
|
||||
exactly (without the leading '#') is listed first, even if it doesn't contain
|
||||
the hashtag. It is assumed that this page offers some kind of introduction to
|
||||
people searching for the hashtag.
|
||||
|
||||
Example: When people click on the hashtag "#Oddµ" and a page named "Oddµ" exists
|
||||
(in other words, the file "Oddµ.md" exists), it is prepended to the results even
|
||||
if it doesn't have the hashtag "#Oddµ" and even if it has a title of "Oddµ, a
|
||||
minimal wiki" (which wouldn't be an exact match).
|
||||
|
||||
The score and highlighting of snippets is used to help visitors decide which
|
||||
links to click.
|
||||
|
||||
Each document found is scored. Each of the following increases the score by one
|
||||
point:
|
||||
|
||||
- the entire phrase matches
|
||||
- a word matches
|
||||
- a word matches at the beginning of a word
|
||||
- a word matches at the end of a word
|
||||
- a word matches as a whole word
|
||||
|
||||
A document with content "This is a test" when searched with the phrase "this
|
||||
test" therefore gets a score of 8: the entire phrase does not match but each
|
||||
word gets four points.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1), _oddmu-search_(1)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
80
man/oddmu-static.1
Normal file
80
man/oddmu-static.1
Normal file
@@ -0,0 +1,80 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-STATIC" "1" "2024-01-17"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-static - create a static copy of the site
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu static\fR \fIdir-name\fR
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The "static" subcommand generates a static copy of the pages in the current
|
||||
directory and saves them in the given destination directory.\& The destination
|
||||
directory must not exist.\&
|
||||
.PP
|
||||
All pages (files with the ".\&md" extension) are turned into HTML files (with the
|
||||
".\&html" extension) using the "static.\&html" template.\& Links pointing to existing
|
||||
pages get ".\&html" appended.\&
|
||||
.PP
|
||||
Hidden files and directories (starting with a ".\&") and backup files (ending with
|
||||
a "~") are skipped.\&
|
||||
.PP
|
||||
All other files are \fIhard linked\fR.\& This is done to save space: on a typical blog
|
||||
the images take a lot more space than the text.\& On my blog in 2023 I had 2.\&62
|
||||
GiB of JPG files and 0.\&02 GiB of Markdown files.\& There is no point in copying
|
||||
all those images, most of the time.\&
|
||||
.PP
|
||||
Note, however: Hard links cannot span filesystems.\& A hard link is just an extra
|
||||
name for the same file.\& This is why the destination directory for the static
|
||||
site has to be on same filesystem as the current directory, if it contains any
|
||||
other place.\&
|
||||
.PP
|
||||
Furthermore, in-place editing changes the file for all names.\& Avoid editing the
|
||||
image files in the destination directory, just to be on the safe side.\&
|
||||
.PP
|
||||
.SH EXAMPLE
|
||||
.PP
|
||||
Generate a static copy of the site:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
oddmu static \&.\&./archive
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH LIMITATIONS
|
||||
.PP
|
||||
Links from files to pages do not get ".\&html" appended.\& This affects existing
|
||||
HTML or XML files including SVG files.\&
|
||||
.PP
|
||||
Links to absolute URLs (starting with "/") are not changed at all.\& It is up to
|
||||
you to migrate static folders and applications.\&
|
||||
.PP
|
||||
.SH ENVIRONMENT
|
||||
.PP
|
||||
The ODDMU_WEBFINGER environment variable has no effect in this situation.\&
|
||||
Fediverse accounts are not linked to their profile pages.\& Since the data isn'\&t
|
||||
cached, every run of this command would trigger a webfinger request for every
|
||||
fediverse account mentioned.\&
|
||||
.PP
|
||||
If the site is large, determining the language of a page slows things down.\& Set
|
||||
the ODDMU_LANGUAGES environment variable to a comma-separated list of ISO 639-1
|
||||
codes, e.\&g.\& "en" or "en,de,fr,pt" to limit the languages loaded and thereby
|
||||
speed language determination up.\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1), \fIoddmu-templates\fR(5)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
71
man/oddmu-static.1.txt
Normal file
71
man/oddmu-static.1.txt
Normal file
@@ -0,0 +1,71 @@
|
||||
ODDMU-STATIC(1)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-static - create a static copy of the site
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu static* _dir-name_
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The "static" subcommand generates a static copy of the pages in the current
|
||||
directory and saves them in the given destination directory. The destination
|
||||
directory must not exist.
|
||||
|
||||
All pages (files with the ".md" extension) are turned into HTML files (with the
|
||||
".html" extension) using the "static.html" template. Links pointing to existing
|
||||
pages get ".html" appended.
|
||||
|
||||
Hidden files and directories (starting with a ".") and backup files (ending with
|
||||
a "~") are skipped.
|
||||
|
||||
All other files are _hard linked_. This is done to save space: on a typical blog
|
||||
the images take a lot more space than the text. On my blog in 2023 I had 2.62
|
||||
GiB of JPG files and 0.02 GiB of Markdown files. There is no point in copying
|
||||
all those images, most of the time.
|
||||
|
||||
Note, however: Hard links cannot span filesystems. A hard link is just an extra
|
||||
name for the same file. This is why the destination directory for the static
|
||||
site has to be on same filesystem as the current directory, if it contains any
|
||||
other place.
|
||||
|
||||
Furthermore, in-place editing changes the file for all names. Avoid editing the
|
||||
image files in the destination directory, just to be on the safe side.
|
||||
|
||||
# EXAMPLE
|
||||
|
||||
Generate a static copy of the site:
|
||||
|
||||
```
|
||||
oddmu static ../archive
|
||||
```
|
||||
|
||||
# LIMITATIONS
|
||||
|
||||
Links from files to pages do not get ".html" appended. This affects existing
|
||||
HTML or XML files including SVG files.
|
||||
|
||||
Links to absolute URLs (starting with "/") are not changed at all. It is up to
|
||||
you to migrate static folders and applications.
|
||||
|
||||
# ENVIRONMENT
|
||||
|
||||
The ODDMU_WEBFINGER environment variable has no effect in this situation.
|
||||
Fediverse accounts are not linked to their profile pages. Since the data isn't
|
||||
cached, every run of this command would trigger a webfinger request for every
|
||||
fediverse account mentioned.
|
||||
|
||||
If the site is large, determining the language of a page slows things down. Set
|
||||
the ODDMU_LANGUAGES environment variable to a comma-separated list of ISO 639-1
|
||||
codes, e.g. "en" or "en,de,fr,pt" to limit the languages loaded and thereby
|
||||
speed language determination up.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1), _oddmu-templates_(5)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
194
man/oddmu-templates.5
Normal file
194
man/oddmu-templates.5
Normal file
@@ -0,0 +1,194 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-TEMPLATES" "5" "2023-10-29" "File Formats Manual"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-templates - how to write the templates
|
||||
.PP
|
||||
.SH SYNTAX
|
||||
.PP
|
||||
The templates can refer to the following properties of a page:
|
||||
.PP
|
||||
\fI{{.\&Title}}\fR is the page title.\& If the page doesn'\&t provide its own title, the
|
||||
page name is used.\&
|
||||
.PP
|
||||
\fI{{.\&Name}}\fR is the page name, escaped for use in URLs.\& More specifically, it is
|
||||
percent-escaped except for the slashes.\& The page name doesn'\&t include the \fI.\&md\fR
|
||||
extension.\&
|
||||
.PP
|
||||
\fI{{.\&Dir}}\fR is the page directory, percent-escaped except for the slashes.\&
|
||||
.PP
|
||||
\fI{{.\&Today}}\fR is the current date, in ISO format.\& This is useful for "new page"
|
||||
like links or forms (see \fBEXAMPLE\fR below).\&
|
||||
.PP
|
||||
For the \fIview.\&html\fR and \fIstatic.\&html\fR template:
|
||||
.PP
|
||||
\fI{{.\&Html}}\fR is the rendered Markdown, as HTML.\&
|
||||
.PP
|
||||
\fI{{.\&Hashtags}}\fR is an array of strings.\&
|
||||
.PP
|
||||
For the \fIdiff.\&html\fR template:
|
||||
.PP
|
||||
\fI{{.\&Diff}}\fR is the diff for this page.\& This is only computed on demand so it can
|
||||
be used in other templates, too.\& It probably doesn'\&t make much sense to do so,
|
||||
however.\&
|
||||
.PP
|
||||
For the \fIedit.\&html\fR template:
|
||||
.PP
|
||||
\fI{{printf "%s" .\&Body}}\fR is the Markdown, as a string (the data itself is a byte
|
||||
array and that'\&s why we need to call \fIprintf\fR).\&
|
||||
.PP
|
||||
For the \fIsearch.\&html\fR template only:
|
||||
.PP
|
||||
\fI{{.\&Previous}}\fR, \fI{{.\&Page}}\fR and \fI{{.\&Next}}\fR are the previous, current and next
|
||||
page number in the results since doing arithmetics in templates is hard.\& The
|
||||
first page number is 1.\& The last page is expensive to dermine and so that isn'\&t
|
||||
done.\&
|
||||
.PP
|
||||
\fI{{.\&More}}\fR indicates if there are any more search results.\&
|
||||
.PP
|
||||
\fI{{.\&Results}}\fR indicates if there were any search results at all.\&
|
||||
.PP
|
||||
\fI{{.\&Items}}\fR is an array of pages, each containing a search result.\& A search
|
||||
result is a page (with the properties seen above).\& Thus, to refer to them, you
|
||||
need to use a \fI{{range .\&Items}}\fR … \fI{{end}}\fR construct.\&
|
||||
.PP
|
||||
For items in the search result:
|
||||
.PP
|
||||
\fI{{.\&Html}}\fR is the rendered Markdown of a page summary, as HTML.\&
|
||||
.PP
|
||||
\fI{{.\&Score}}\fR is a numerical score for search results.\&
|
||||
.PP
|
||||
For the \fIfeed.\&html\fR template:
|
||||
.PP
|
||||
\fI{{.\&Name}}\fR is the page name, escaped for use in URLs.\&
|
||||
.PP
|
||||
\fI{{.\&Title}}\fR is the title of the underlying main page.\&
|
||||
.PP
|
||||
\fI{{.\&Date}}\fR is the date of the last update to the underlying main page, in RFC
|
||||
822 format.\&
|
||||
.PP
|
||||
\fI{{.\&Items}}\fR is an array of feed items.\&
|
||||
.PP
|
||||
For items in the feed:
|
||||
.PP
|
||||
\fI{{.\&Name}}\fR is the page name, escaped for use in URLs.\&
|
||||
.PP
|
||||
\fI{{.\&Title}}\fR is the title of the page.\&
|
||||
.PP
|
||||
\fI{{.\&Html}}\fR is the rendered Markdown, as escaped (!\&) HTML.\&
|
||||
.PP
|
||||
\fI{{.\&Hashtags}}\fR is an array of strings.\&
|
||||
.PP
|
||||
\fI{{.\&Date}}\fR, the date of the last update to this page, in RFC 822 format.\&
|
||||
.PP
|
||||
The \fIupload.\&html\fR template cannot refer to anything.\&
|
||||
.PP
|
||||
When calling the \fIsave\fR and \fIappend\fR action, the page name is taken from the URL
|
||||
path and the page content is taken from the \fIbody\fR form parameter.\& To
|
||||
illustrate, here'\&s how to edit the "welcome" page using \fIcurl\fR:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
curl --form body="Did you bring a towel?"
|
||||
http://localhost:8080/save/welcome
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
When calling the \fIsearch\fR action, the query is taken from the URL parameter \fIq\fR.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
curl http://localhost:8080/search/?q=towel
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SS Non-English hyphenation
|
||||
.PP
|
||||
Automatic hyphenation by the browser requires two things: The style sheet must
|
||||
indicate "hyphen: auto" for an HTML element such as "body", and that element
|
||||
must have a "lang" set (usually a two letter language code such as "de" for
|
||||
German).\&
|
||||
.PP
|
||||
Oddmu attempts to detect the correct language for each page.\& It assumes that
|
||||
languages are not mixed on the same page.\& If you know that you'\&re only going to
|
||||
use a small number of languages – or just a single language!\& – you can set the
|
||||
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
|
||||
codes, e.\&g.\& "en" or "en,de,fr,pt".\&
|
||||
.PP
|
||||
"view.\&html" is used the template to render a single page and so the language
|
||||
detected is added to the "html" element.\&
|
||||
.PP
|
||||
"search.\&html" is the template used to render search results and so "en" is used
|
||||
for the "html" element and the language detected for every page in the search
|
||||
result is added to the "article" element for each snippet.\&
|
||||
.PP
|
||||
"edit.\&html" and "add.\&html" are the templates used to edit a page and at that
|
||||
point, the language isn'\&t known, so "en" is used for the "html" element and no
|
||||
language is used for the "textarea" element.\&
|
||||
.PP
|
||||
.SH EXAMPLE
|
||||
.PP
|
||||
The following link in a template takes people to today'\&s page.\& If no such page
|
||||
exists, they are redirected to the edit form where it can be created.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
<a href="/view/{{\&.Today}}" accesskey="t">Today</a>
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
The following form allows people to edit the suggested page name.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
<form role="new" action="/edit/{{\&.Dir}}" method="GET">
|
||||
<label for="id">New page:</label>
|
||||
<input id="id" type="text" spellcheck="false" name="id"
|
||||
accesskey="g" value="{{\&.Today}}" required>
|
||||
<button>Edit</button>
|
||||
</form>
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH NOTES
|
||||
.PP
|
||||
The template are always used as-is, irrespective of the current directory.\&
|
||||
Therefore, a link to a specific page must be \fIabsolute\fR or it'\&ll point to a
|
||||
different page depending on the current directory.\&
|
||||
.PP
|
||||
Consider the link to "/view/index".\& No matter what page a visitor is looking,
|
||||
this takes visitors to the top "index" page.\& If the link points to "index"
|
||||
instead, it takes a visitor to the "index" page of the current directory.\&
|
||||
.PP
|
||||
Example: If a visitor is looking at "/view/projects/wiki" and follows a link to
|
||||
"index", they end up on "/view/projects/index", not on "/view/index".\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1)
|
||||
.PP
|
||||
"Structuring the web with HTML"
|
||||
https://developer.\&mozilla.\&org/en-US/docs/Learn/HTML
|
||||
.PP
|
||||
"Learn to style HTML using CSS"
|
||||
https://developer.\&mozilla.\&org/en-US/docs/Learn/CSS
|
||||
.PP
|
||||
The "text/template" library explains how to write templates from a programmer
|
||||
perspective.\& https://pkg.\&go.\&dev/text/template
|
||||
.PP
|
||||
The "html/template" library explains how the templates are made more secure in a
|
||||
HTML context.\& https://pkg.\&go.\&dev/html/template
|
||||
.PP
|
||||
"Lingua" is the library used to detect languages.\&
|
||||
https://github.\&com/pemistahl/lingua-go
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
179
man/oddmu-templates.5.txt
Normal file
179
man/oddmu-templates.5.txt
Normal file
@@ -0,0 +1,179 @@
|
||||
ODDMU-TEMPLATES(5) "File Formats Manual"
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-templates - how to write the templates
|
||||
|
||||
# SYNTAX
|
||||
|
||||
The templates can refer to the following properties of a page:
|
||||
|
||||
_{{.Title}}_ is the page title. If the page doesn't provide its own title, the
|
||||
page name is used.
|
||||
|
||||
_{{.Name}}_ is the page name, escaped for use in URLs. More specifically, it is
|
||||
percent-escaped except for the slashes. The page name doesn't include the _.md_
|
||||
extension.
|
||||
|
||||
_{{.Dir}}_ is the page directory, percent-escaped except for the slashes.
|
||||
|
||||
_{{.Today}}_ is the current date, in ISO format. This is useful for "new page"
|
||||
like links or forms (see *EXAMPLE* below).
|
||||
|
||||
For the _view.html_ and _static.html_ template:
|
||||
|
||||
_{{.Html}}_ is the rendered Markdown, as HTML.
|
||||
|
||||
_{{.Hashtags}}_ is an array of strings.
|
||||
|
||||
For the _diff.html_ template:
|
||||
|
||||
_{{.Diff}}_ is the diff for this page. This is only computed on demand so it can
|
||||
be used in other templates, too. It probably doesn't make much sense to do so,
|
||||
however.
|
||||
|
||||
For the _edit.html_ template:
|
||||
|
||||
_{{printf "%s" .Body}}_ is the Markdown, as a string (the data itself is a byte
|
||||
array and that's why we need to call _printf_).
|
||||
|
||||
For the _search.html_ template only:
|
||||
|
||||
_{{.Previous}}_, _{{.Page}}_ and _{{.Next}}_ are the previous, current and next
|
||||
page number in the results since doing arithmetics in templates is hard. The
|
||||
first page number is 1. The last page is expensive to dermine and so that isn't
|
||||
done.
|
||||
|
||||
_{{.More}}_ indicates if there are any more search results.
|
||||
|
||||
_{{.Results}}_ indicates if there were any search results at all.
|
||||
|
||||
_{{.Items}}_ is an array of pages, each containing a search result. A search
|
||||
result is a page (with the properties seen above). Thus, to refer to them, you
|
||||
need to use a _{{range .Items}}_ … _{{end}}_ construct.
|
||||
|
||||
For items in the search result:
|
||||
|
||||
_{{.Html}}_ is the rendered Markdown of a page summary, as HTML.
|
||||
|
||||
_{{.Score}}_ is a numerical score for search results.
|
||||
|
||||
For the _feed.html_ template:
|
||||
|
||||
_{{.Name}}_ is the page name, escaped for use in URLs.
|
||||
|
||||
_{{.Title}}_ is the title of the underlying main page.
|
||||
|
||||
_{{.Date}}_ is the date of the last update to the underlying main page, in RFC
|
||||
822 format.
|
||||
|
||||
_{{.Items}}_ is an array of feed items.
|
||||
|
||||
For items in the feed:
|
||||
|
||||
_{{.Name}}_ is the page name, escaped for use in URLs.
|
||||
|
||||
_{{.Title}}_ is the title of the page.
|
||||
|
||||
_{{.Html}}_ is the rendered Markdown, as escaped (!) HTML.
|
||||
|
||||
_{{.Hashtags}}_ is an array of strings.
|
||||
|
||||
_{{.Date}}_, the date of the last update to this page, in RFC 822 format.
|
||||
|
||||
The _upload.html_ template cannot refer to anything.
|
||||
|
||||
When calling the _save_ and _append_ action, the page name is taken from the URL
|
||||
path and the page content is taken from the _body_ form parameter. To
|
||||
illustrate, here's how to edit the "welcome" page using _curl_:
|
||||
|
||||
```
|
||||
curl --form body="Did you bring a towel?" \
|
||||
http://localhost:8080/save/welcome
|
||||
```
|
||||
|
||||
When calling the _search_ action, the query is taken from the URL parameter _q_.
|
||||
|
||||
```
|
||||
curl http://localhost:8080/search/?q=towel
|
||||
```
|
||||
|
||||
## Non-English hyphenation
|
||||
|
||||
Automatic hyphenation by the browser requires two things: The style sheet must
|
||||
indicate "hyphen: auto" for an HTML element such as "body", and that element
|
||||
must have a "lang" set (usually a two letter language code such as "de" for
|
||||
German).
|
||||
|
||||
Oddmu attempts to detect the correct language for each page. It assumes that
|
||||
languages are not mixed on the same page. If you know that you're only going to
|
||||
use a small number of languages – or just a single language! – you can set the
|
||||
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
|
||||
codes, e.g. "en" or "en,de,fr,pt".
|
||||
|
||||
"view.html" is used the template to render a single page and so the language
|
||||
detected is added to the "html" element.
|
||||
|
||||
"search.html" is the template used to render search results and so "en" is used
|
||||
for the "html" element and the language detected for every page in the search
|
||||
result is added to the "article" element for each snippet.
|
||||
|
||||
"edit.html" and "add.html" are the templates used to edit a page and at that
|
||||
point, the language isn't known, so "en" is used for the "html" element and no
|
||||
language is used for the "textarea" element.
|
||||
|
||||
# EXAMPLE
|
||||
|
||||
The following link in a template takes people to today's page. If no such page
|
||||
exists, they are redirected to the edit form where it can be created.
|
||||
|
||||
```
|
||||
<a href="/view/{{.Today}}" accesskey="t">Today</a>
|
||||
```
|
||||
|
||||
The following form allows people to edit the suggested page name.
|
||||
|
||||
```
|
||||
<form role="new" action="/edit/{{.Dir}}" method="GET">
|
||||
<label for="id">New page:</label>
|
||||
<input id="id" type="text" spellcheck="false" name="id"
|
||||
accesskey="g" value="{{.Today}}" required>
|
||||
<button>Edit</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
# NOTES
|
||||
|
||||
The template are always used as-is, irrespective of the current directory.
|
||||
Therefore, a link to a specific page must be _absolute_ or it'll point to a
|
||||
different page depending on the current directory.
|
||||
|
||||
Consider the link to "/view/index". No matter what page a visitor is looking,
|
||||
this takes visitors to the top "index" page. If the link points to "index"
|
||||
instead, it takes a visitor to the "index" page of the current directory.
|
||||
|
||||
Example: If a visitor is looking at "/view/projects/wiki" and follows a link to
|
||||
"index", they end up on "/view/projects/index", not on "/view/index".
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1)
|
||||
|
||||
"Structuring the web with HTML"
|
||||
https://developer.mozilla.org/en-US/docs/Learn/HTML
|
||||
|
||||
"Learn to style HTML using CSS"
|
||||
https://developer.mozilla.org/en-US/docs/Learn/CSS
|
||||
|
||||
The "text/template" library explains how to write templates from a programmer
|
||||
perspective. https://pkg.go.dev/text/template
|
||||
|
||||
The "html/template" library explains how the templates are made more secure in a
|
||||
HTML context. https://pkg.go.dev/html/template
|
||||
|
||||
"Lingua" is the library used to detect languages.
|
||||
https://github.com/pemistahl/lingua-go
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
253
man/oddmu.1
Normal file
253
man/oddmu.1
Normal file
@@ -0,0 +1,253 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU" "1" "2024-01-17"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu - a wiki server
|
||||
.PP
|
||||
Oddmu is sometimes written Oddµ because µ is the letter mu.\&
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu\fR
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The oddmu program serves the current working directory as a wiki on port 8080.\&
|
||||
Point your browser to http://localhost:8080/ to get started.\& This is equivalent
|
||||
to http://localhost:8080/view/index – the first page you'\&ll create, most likely.\&
|
||||
.PP
|
||||
If you request a page that doesn'\&t exist, oddmu tries to find a matching
|
||||
Markdown file by appending the extension ".\&md" to the page name.\& In the example
|
||||
above, the page name requested is "index" and the file name oddmu tries to read
|
||||
is "index.\&md".\& If no such file exists, oddmu offers you to create the page.\&
|
||||
.PP
|
||||
If your files don'\&t provide their own title ("# title"), the file name (without
|
||||
".\&md") is used for the page title.\&
|
||||
.PP
|
||||
Every file can be viewed as feed by using the extension ".\&rss".\& The
|
||||
feed items are based on links in bullet lists using the asterix
|
||||
("*").\&
|
||||
.PP
|
||||
Subdirectories are created as necessary.\&
|
||||
.PP
|
||||
See \fIoddmu\fR(5) for details about the page formatting.\&
|
||||
.PP
|
||||
.SH CONFIGURATION
|
||||
.PP
|
||||
The template files are the HTML files in the working directory:
|
||||
"add.\&html", "diff.\&html", "edit.\&html", "search.\&html", "upload.\&html" and
|
||||
"view.\&html".\& Feel free to change the templates and restart the server.\&
|
||||
.PP
|
||||
The first change you should make is to replace the name and email
|
||||
address in the footer of "view.\&html".\& Look for "Your Name" and
|
||||
"example.\&org".\&
|
||||
.PP
|
||||
The second change you should make is to replace the name, email
|
||||
address and domain name in "feed.\&html".\& Look for "Your Name" and
|
||||
"example.\&org".\& This second template is used to generate the RSS feeds
|
||||
(despite its ".\&html" extension).\&
|
||||
.PP
|
||||
See \fIoddmu-templates\fR(5) for more.\&
|
||||
.PP
|
||||
.SH ENVIRONMENT
|
||||
.PP
|
||||
You can change the port served by setting the ODDMU_PORT environment variable.\&
|
||||
.PP
|
||||
You can change the address served by setting the ODDMU_ADDRESS environment
|
||||
variable to either an IPv4 address or an IPv6 address.\& If ODDMU_ADDRESS is
|
||||
unset, then the program listens on all available unicast addresses, both IPv4
|
||||
and IPv6.\& Here are a few example addresses:
|
||||
.PP
|
||||
ODDMU_ADDRESS=127.\&0.\&0.\&1 # The loopback IPv4 address.\&
|
||||
ODDMU_ADDRESS=2001:db8::3:1 # An IPv6 address.\&
|
||||
.PP
|
||||
See the Socket Activation section for an alternative method of listening which
|
||||
supports Unix-domain sockets.\&
|
||||
.PP
|
||||
In order to limit language-detection to the languages you actually use, set the
|
||||
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
|
||||
codes, e.\&g.\& "en" or "en,de,fr,pt".\&
|
||||
.PP
|
||||
You can enable webfinger to link fediverse accounts to their correct profile
|
||||
pages by setting ODDMU_WEBFINGER to "1".\& See \fIoddmu\fR(5).\&
|
||||
.PP
|
||||
.SH Socket Activation
|
||||
.PP
|
||||
Instead of specifying ODDMU_ADDRESS or ODDMU_PORT, you can start the service
|
||||
through socket activation.\& The advantage of this method is that you can use a
|
||||
Unix-domain socket instead of a TCP socket, and the permissions and ownership of
|
||||
the socket are set before the program starts.\& See \fIoddmu.\&service\fR(5) and
|
||||
\fIoddmu-apache\fR(5) for an example of how to use socket activation with a
|
||||
Unix-domain socket under systemd and Apache.\&
|
||||
.PP
|
||||
.SH SECURITY
|
||||
.PP
|
||||
If the machine you are running Oddmu on is accessible from the Internet, you
|
||||
must secure your installation.\& The best way to do this is use a regular web
|
||||
server as a reverse proxy.\&
|
||||
.PP
|
||||
See \fIoddmu-apache\fR(5) for an example.\&
|
||||
.PP
|
||||
Oddmu assumes that all the users that can edit pages or upload files are trusted
|
||||
users and therefore their content is trusted.\& Therefore, Oddmu doesn'\&t perform
|
||||
HTML sanitization!\&
|
||||
.PP
|
||||
For an extra dose of security, consider using a Unix-domain socket.\&
|
||||
.PP
|
||||
.SH OPTIONS
|
||||
.PP
|
||||
The oddmu program can be run on the command-line using various subcommands.\&
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
to generate the HTML for a single page, see \fIoddmu-html\fR(1)
|
||||
.IP \(bu 4
|
||||
to generate the HTML for the entire site, using Oddmu as a static site
|
||||
generator, see \fIoddmu-static\fR(1)
|
||||
.IP \(bu 4
|
||||
to search a regular expression and replace it across all files, see
|
||||
\fIoddmu-replace\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 find missing pages (local links that go nowhere), see \fIoddmu-missing\fR(1)
|
||||
.IP \(bu 4
|
||||
to add links to changes, index and hashtag pages to pages you created locally,
|
||||
see \fIoddmu-notify\fR(1)
|
||||
.PD
|
||||
.PP
|
||||
.SH EXAMPLE
|
||||
.PP
|
||||
When saving a page, the page name is take from the URL and the page content is
|
||||
taken from the "body" form parameter.\& To illustrate, here'\&s how to edit a page
|
||||
using \fIcurl\fR(1):
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
curl --form body="Did you bring a towel?"
|
||||
http://localhost:8080/save/welcome
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH DESIGN
|
||||
.PP
|
||||
This is a minimal wiki.\& There is no version history.\& It'\&s well suited as a
|
||||
\fIsecondary\fR medium: collaboration and conversation happens elsewhere, in chat,
|
||||
on social media.\& The wiki serves as the text repository that results from these
|
||||
discussions.\&
|
||||
.PP
|
||||
The idea is that the webserver handles as many tasks as possible.\& It logs
|
||||
requests, does rate limiting, handles encryption, gets the certificates, and so
|
||||
on.\& The web server acts as a reverse proxy and the wiki ends up being a content
|
||||
management system with almost no structure – or endless malleability, depending
|
||||
on your point of view.\& See \fIoddmu-apache\fR(5).\&
|
||||
.PP
|
||||
.SH NOTES
|
||||
.PP
|
||||
Page names are filenames with ".\&md" appended.\& If your filesystem cannot handle
|
||||
it, it can'\&t be a page name.\& Filenames can contain slashes and oddmu creates
|
||||
subdirectories as necessary.\&
|
||||
.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.\&
|
||||
.PP
|
||||
The \fBindex\fR page is the default page.\& People visiting the "root" of the site are
|
||||
redirected to "/view/index".\&
|
||||
.PP
|
||||
The \fBchanges\fR page is where links to new and changed files are added.\& As an
|
||||
author, you can prevent this from happening by deselecting the checkbox "Add
|
||||
link to the list of changes.\&" The changes page can be edited like every other
|
||||
page, so it'\&s easy to undo mistakes.\&
|
||||
.PP
|
||||
Links on the changes page are grouped by date.\& When new links are added, the
|
||||
current date of the machine Oddmu is running on is used.\& If a link already
|
||||
exists on the changes page, it is moved up to the current date.\& If that leaves
|
||||
an old date without any links, that date heading is removed.\&
|
||||
.PP
|
||||
If you want to link to the changes page, you need to do this yourself.\& Add a
|
||||
link from the index, for example.\& The "view.\&html" template currently doesn'\&t do
|
||||
it.\& See \fIoddmu-templates\fR(5) if you want to add the link to the template.\&
|
||||
.PP
|
||||
A page whose name starts with an ISO date (YYYY-MM-DD, e.\&g.\& "2023-10-28") is
|
||||
called a \fBblog\fR page.\& When creating or editing blog pages, links to it are added
|
||||
from other pages.\&
|
||||
.PP
|
||||
If the blog page name starts with the current year, a link is created from the
|
||||
index page back to the blog page being created or edited.\& Again, you can prevent
|
||||
this from happening by deselecting the checkbox "Add link to the list of
|
||||
changes.\&" The index page can be edited like every other page, so it'\&s easy to
|
||||
undo mistakes.\&
|
||||
.PP
|
||||
For every \fBhashtag\fR used, another link might be created.\& If a page named like
|
||||
the hashtag exists, a backlink is added to it, linking to the new or edited blog
|
||||
page.\&
|
||||
.PP
|
||||
If a link to the new or edited blog page already exists but it'\&s title is no
|
||||
longer correct, it is updated.\&
|
||||
.PP
|
||||
New links added for blog pages are added at the top of the first unnumbered list
|
||||
using the asterisk ('\&*'\&).\& If no such list exists, a new one is started at the
|
||||
bottom of the page.\& This allows you to have a different unnumbered list further
|
||||
up on the page, as long as it uses the minus for items ('\&-'\&).\&
|
||||
.PP
|
||||
Changes made locally do not create any links on the changes page, the index page
|
||||
or on any hashtag pages.\& See \fIoddmu-notify\fR(1) for a way to add the necessary
|
||||
links to the changes page and possibly to the index and hashtag pages.\&
|
||||
.PP
|
||||
A hashtag consists of a number sign ('\&#'\&) followed by Unicode letters, numbers
|
||||
or the underscore ('\&_'\&).\& Thus, a hashtag ends with punctuation or whitespace.\&
|
||||
.PP
|
||||
The page names, titles and hashtags are loaded into memory when the server
|
||||
starts.\& If you have a lot of pages, this takes a lot of memory.\& If you change
|
||||
the files while the wiki runs, changes to names (creating, renaming or deleting
|
||||
files), titles or hashtags confuse Oddmu.\& Restart the program in order to
|
||||
resolve this.\&
|
||||
.PP
|
||||
You cannot edit uploaded files.\& If you upload a file called "hello.\&txt" and
|
||||
attempt to edit it by using "/edit/hello.\&txt" you create a page with the name
|
||||
"hello.\&txt.\&md" instead.\&
|
||||
.PP
|
||||
You cannot delete uploaded files via the web – but you can delete regular wiki
|
||||
pages by saving an empty page.\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
\fIoddmu\fR(5), about the markup syntax and how feeds are generated based on link lists
|
||||
.IP \(bu 4
|
||||
\fIoddmu.\&service\fR(5), on how to run the service under systemd
|
||||
.IP \(bu 4
|
||||
\fIoddmu-apache\fR(5), on how to set up a web server such as Apache
|
||||
.IP \(bu 4
|
||||
\fIoddmu-html\fR(1), on how to render a page from the command-line
|
||||
.IP \(bu 4
|
||||
\fIoddmu-list\fR(1), on how to list pages and titles from the command-line
|
||||
.IP \(bu 4
|
||||
\fIoddmu-missing\fR(1), on how to find broken local links from the command-line
|
||||
.IP \(bu 4
|
||||
\fIoddmu-replace\fR(1), on how to search and replace text from the command-line
|
||||
.IP \(bu 4
|
||||
\fIoddmu-search\fR(1), on how to run a search from the command-line
|
||||
.IP \(bu 4
|
||||
\fIoddmu-search\fR(7), on how search works
|
||||
.IP \(bu 4
|
||||
\fIoddmu-static\fR(1), on generating a static site from the command-line
|
||||
.IP \(bu 4
|
||||
\fIoddmu-templates\fR(5), on how to write the HTML templates
|
||||
.PD
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
223
man/oddmu.1.txt
Normal file
223
man/oddmu.1.txt
Normal file
@@ -0,0 +1,223 @@
|
||||
ODDMU(1)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu - a wiki server
|
||||
|
||||
Oddmu is sometimes written Oddµ because µ is the letter mu.
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu*
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The oddmu program serves the current working directory as a wiki on port 8080.
|
||||
Point your browser to http://localhost:8080/ to get started. This is equivalent
|
||||
to http://localhost:8080/view/index – the first page you'll create, most likely.
|
||||
|
||||
If you request a page that doesn't exist, oddmu tries to find a matching
|
||||
Markdown file by appending the extension ".md" to the page name. In the example
|
||||
above, the page name requested is "index" and the file name oddmu tries to read
|
||||
is "index.md". If no such file exists, oddmu offers you to create the page.
|
||||
|
||||
If your files don't provide their own title ("# title"), the file name (without
|
||||
".md") is used for the page title.
|
||||
|
||||
Every file can be viewed as feed by using the extension ".rss". The
|
||||
feed items are based on links in bullet lists using the asterix
|
||||
("\*").
|
||||
|
||||
Subdirectories are created as necessary.
|
||||
|
||||
See _oddmu_(5) for details about the page formatting.
|
||||
|
||||
# CONFIGURATION
|
||||
|
||||
The template files are the HTML files in the working directory:
|
||||
"add.html", "diff.html", "edit.html", "search.html", "upload.html" and
|
||||
"view.html". Feel free to change the templates and restart the server.
|
||||
|
||||
The first change you should make is to replace the name and email
|
||||
address in the footer of "view.html". Look for "Your Name" and
|
||||
"example.org".
|
||||
|
||||
The second change you should make is to replace the name, email
|
||||
address and domain name in "feed.html". Look for "Your Name" and
|
||||
"example.org". This second template is used to generate the RSS feeds
|
||||
(despite its ".html" extension).
|
||||
|
||||
See _oddmu-templates_(5) for more.
|
||||
|
||||
# ENVIRONMENT
|
||||
|
||||
You can change the port served by setting the ODDMU_PORT environment variable.
|
||||
|
||||
You can change the address served by setting the ODDMU_ADDRESS environment
|
||||
variable to either an IPv4 address or an IPv6 address. If ODDMU_ADDRESS is
|
||||
unset, then the program listens on all available unicast addresses, both IPv4
|
||||
and IPv6. Here are a few example addresses:
|
||||
|
||||
ODDMU_ADDRESS=127.0.0.1 # The loopback IPv4 address.
|
||||
ODDMU_ADDRESS=2001:db8::3:1 # An IPv6 address.
|
||||
|
||||
See the Socket Activation section for an alternative method of listening which
|
||||
supports Unix-domain sockets.
|
||||
|
||||
In order to limit language-detection to the languages you actually use, set the
|
||||
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
|
||||
codes, e.g. "en" or "en,de,fr,pt".
|
||||
|
||||
You can enable webfinger to link fediverse accounts to their correct profile
|
||||
pages by setting ODDMU_WEBFINGER to "1". See _oddmu_(5).
|
||||
|
||||
# Socket Activation
|
||||
|
||||
Instead of specifying ODDMU_ADDRESS or ODDMU_PORT, you can start the service
|
||||
through socket activation. The advantage of this method is that you can use a
|
||||
Unix-domain socket instead of a TCP socket, and the permissions and ownership of
|
||||
the socket are set before the program starts. See _oddmu.service_(5) and
|
||||
_oddmu-apache_(5) for an example of how to use socket activation with a
|
||||
Unix-domain socket under systemd and Apache.
|
||||
|
||||
# SECURITY
|
||||
|
||||
If the machine you are running Oddmu on is accessible from the Internet, you
|
||||
must secure your installation. The best way to do this is use a regular web
|
||||
server as a reverse proxy.
|
||||
|
||||
See _oddmu-apache_(5) for an example.
|
||||
|
||||
Oddmu assumes that all the users that can edit pages or upload files are trusted
|
||||
users and therefore their content is trusted. Therefore, Oddmu doesn't perform
|
||||
HTML sanitization!
|
||||
|
||||
For an extra dose of security, consider using a Unix-domain socket.
|
||||
|
||||
# OPTIONS
|
||||
|
||||
The oddmu program can be run on the command-line using various subcommands.
|
||||
|
||||
- to generate the HTML for a single page, see _oddmu-html_(1)
|
||||
- to generate the HTML for the entire site, using Oddmu as a static site
|
||||
generator, see _oddmu-static_(1)
|
||||
- to search a regular expression and replace it across all files, see
|
||||
_oddmu-replace_(1)
|
||||
- to emulate a search of the files, see _oddmu-search_(1); to understand how the
|
||||
search engine indexes pages and how it sorts and scores results, see
|
||||
_oddmu-search_(7)
|
||||
- to find missing pages (local links that go nowhere), see _oddmu-missing_(1)
|
||||
- to add links to changes, index and hashtag pages to pages you created locally,
|
||||
see _oddmu-notify_(1)
|
||||
|
||||
# EXAMPLE
|
||||
|
||||
When saving a page, the page name is take from the URL and the page content is
|
||||
taken from the "body" form parameter. To illustrate, here's how to edit a page
|
||||
using _curl_(1):
|
||||
|
||||
```
|
||||
curl --form body="Did you bring a towel?" \
|
||||
http://localhost:8080/save/welcome
|
||||
```
|
||||
|
||||
# DESIGN
|
||||
|
||||
This is a minimal wiki. There is no version history. It's well suited as a
|
||||
_secondary_ medium: collaboration and conversation happens elsewhere, in chat,
|
||||
on social media. The wiki serves as the text repository that results from these
|
||||
discussions.
|
||||
|
||||
The idea is that the webserver handles as many tasks as possible. It logs
|
||||
requests, does rate limiting, handles encryption, gets the certificates, and so
|
||||
on. The web server acts as a reverse proxy and the wiki ends up being a content
|
||||
management system with almost no structure – or endless malleability, depending
|
||||
on your point of view. See _oddmu-apache_(5).
|
||||
|
||||
# NOTES
|
||||
|
||||
Page names are filenames with ".md" appended. If your filesystem cannot handle
|
||||
it, it can't be a page name. Filenames can contain slashes and oddmu creates
|
||||
subdirectories as necessary.
|
||||
|
||||
Files may not end with a tilde ('~') – these are backup files. When saving pages
|
||||
and file uploads, the old file renamed to the backup file unless the backup file
|
||||
is less than an hour old, thus collapsing all edits made in an hour into a
|
||||
single diff when comparing backup and current version.
|
||||
|
||||
The *index* page is the default page. People visiting the "root" of the site are
|
||||
redirected to "/view/index".
|
||||
|
||||
The *changes* page is where links to new and changed files are added. As an
|
||||
author, you can prevent this from happening by deselecting the checkbox "Add
|
||||
link to the list of changes." The changes page can be edited like every other
|
||||
page, so it's easy to undo mistakes.
|
||||
|
||||
Links on the changes page are grouped by date. When new links are added, the
|
||||
current date of the machine Oddmu is running on is used. If a link already
|
||||
exists on the changes page, it is moved up to the current date. If that leaves
|
||||
an old date without any links, that date heading is removed.
|
||||
|
||||
If you want to link to the changes page, you need to do this yourself. Add a
|
||||
link from the index, for example. The "view.html" template currently doesn't do
|
||||
it. See _oddmu-templates_(5) if you want to add the link to the template.
|
||||
|
||||
A page whose name starts with an ISO date (YYYY-MM-DD, e.g. "2023-10-28") is
|
||||
called a *blog* page. When creating or editing blog pages, links to it are added
|
||||
from other pages.
|
||||
|
||||
If the blog page name starts with the current year, a link is created from the
|
||||
index page back to the blog page being created or edited. Again, you can prevent
|
||||
this from happening by deselecting the checkbox "Add link to the list of
|
||||
changes." The index page can be edited like every other page, so it's easy to
|
||||
undo mistakes.
|
||||
|
||||
For every *hashtag* used, another link might be created. If a page named like
|
||||
the hashtag exists, a backlink is added to it, linking to the new or edited blog
|
||||
page.
|
||||
|
||||
If a link to the new or edited blog page already exists but it's title is no
|
||||
longer correct, it is updated.
|
||||
|
||||
New links added for blog pages are added at the top of the first unnumbered list
|
||||
using the asterisk ('\*'). If no such list exists, a new one is started at the
|
||||
bottom of the page. This allows you to have a different unnumbered list further
|
||||
up on the page, as long as it uses the minus for items ('-').
|
||||
|
||||
Changes made locally do not create any links on the changes page, the index page
|
||||
or on any hashtag pages. See _oddmu-notify_(1) for a way to add the necessary
|
||||
links to the changes page and possibly to the index and hashtag pages.
|
||||
|
||||
A hashtag consists of a number sign ('#') followed by Unicode letters, numbers
|
||||
or the underscore ('\_'). Thus, a hashtag ends with punctuation or whitespace.
|
||||
|
||||
The page names, titles and hashtags are loaded into memory when the server
|
||||
starts. If you have a lot of pages, this takes a lot of memory. If you change
|
||||
the files while the wiki runs, changes to names (creating, renaming or deleting
|
||||
files), titles or hashtags confuse Oddmu. Restart the program in order to
|
||||
resolve this.
|
||||
|
||||
You cannot edit uploaded files. If you upload a file called "hello.txt" and
|
||||
attempt to edit it by using "/edit/hello.txt" you create a page with the name
|
||||
"hello.txt.md" instead.
|
||||
|
||||
You cannot delete uploaded files via the web – but you can delete regular wiki
|
||||
pages by saving an empty page.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
- _oddmu_(5), about the markup syntax and how feeds are generated based on link lists
|
||||
- _oddmu.service_(5), on how to run the service under systemd
|
||||
- _oddmu-apache_(5), on how to set up a web server such as Apache
|
||||
- _oddmu-html_(1), on how to render a page from the command-line
|
||||
- _oddmu-list_(1), on how to list pages and titles from the command-line
|
||||
- _oddmu-missing_(1), on how to find broken local links from the command-line
|
||||
- _oddmu-replace_(1), on how to search and replace text from the command-line
|
||||
- _oddmu-search_(1), on how to run a search from the command-line
|
||||
- _oddmu-search_(7), on how search works
|
||||
- _oddmu-static_(1), on generating a static site from the command-line
|
||||
- _oddmu-templates_(5), on how to write the HTML templates
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
211
man/oddmu.5
Normal file
211
man/oddmu.5
Normal file
@@ -0,0 +1,211 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU" "5" "2023-11-12" "File Formats Manual"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu - text formatting of wiki pages
|
||||
.PP
|
||||
.SH SYNTAX
|
||||
.PP
|
||||
The wiki pages are UTF-8 encoded Markdown files (with the ".\&md" extension).\&
|
||||
Oddmu links are regular Markdown links to page names (without the ".\&md"
|
||||
extension):
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
[link text](page-name)
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
The page name has to be percent-encoded.\& See the section "Percent Encoding".\&
|
||||
.PP
|
||||
If you link to the actual Markdown file (with the ".\&md" extension), then Oddmu
|
||||
serves the Markdown file!\&
|
||||
.PP
|
||||
There are three Oddµ-specific extensions: local links, hashtags and fediverse
|
||||
account links.\& The Markdown library used features some additional extensions,
|
||||
most importantly tables and definition lists.\&
|
||||
.PP
|
||||
.SS Local links
|
||||
.PP
|
||||
Local links use double square brackets [[like this]].\& Oddmu does not treat
|
||||
underscores like spaces, so [[like this]] and [[like_this]] link to different
|
||||
destinations and are served by different files: "like this.\&md" and
|
||||
"like_this.\&md".\&
|
||||
.PP
|
||||
.SS Hashtags
|
||||
.PP
|
||||
Hashtags are single word links to searches for themselves.\& Use the underscore to
|
||||
use hashtags consisting of multiple words.\& Hashtags are distinguished from page
|
||||
titles because there is no space after the hash.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
# Example
|
||||
|
||||
Text
|
||||
|
||||
#Tag #Another_Tag
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
When a page containing hashtags is saved, a link to that page is added to every
|
||||
page with the same name as the hashtag, if it exists.\& In the example above, if
|
||||
the file "Tag.\&md" or the file "Another_Tag.\&md" exists, a link to the Example
|
||||
page is added.\&
|
||||
.PP
|
||||
.SS Tables
|
||||
.PP
|
||||
A table with footers and a columnspan:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
Name | Age
|
||||
--------|------
|
||||
Bob ||
|
||||
Alice | 23
|
||||
========|======
|
||||
Total | 23
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SS Definition lists:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
Cat
|
||||
: Fluffy animal everyone likes
|
||||
|
||||
Internet
|
||||
: Vector of transmission for pictures of cats
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SS Fediverse account links
|
||||
.PP
|
||||
Fediverse accounts look a bit like an at sign followed by an email address, e.\&g.\&
|
||||
@alex@alexschroeder.\&ch.\& When rendering a page, these turn into a username linked
|
||||
to a profile page.\& In this case, "@alex" would be linked to
|
||||
"https://alexschroeder.\&ch/users/alex".\&
|
||||
.PP
|
||||
In many cases, this works as is.\& In reality, however, the link to the profile
|
||||
page needs to be retrieved via webfinger.\& Oddµ does that in the background, and
|
||||
as soon as the information is available, the actual profile link is used when
|
||||
pages are rendered.\& In the example above, the result would be
|
||||
"https://social.\&alexschroeder.\&ch/@alex".\&
|
||||
.PP
|
||||
As this sort of packground network activity is surprising, it is not enabled by
|
||||
default.\& Set the environment variable ODDMU_WEBFINGER to "1" in order to enable
|
||||
this.\&
|
||||
.PP
|
||||
.SS Other extensions
|
||||
.PP
|
||||
The Markdown processor comes with a few extensions:
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
emphasis markers inside words are ignored
|
||||
.IP \(bu 4
|
||||
fenced code blocks are supported
|
||||
.IP \(bu 4
|
||||
autolinking of "naked" URLs are supported
|
||||
.IP \(bu 4
|
||||
strikethrough using two tildes is supported (~~like this~~)
|
||||
.IP \(bu 4
|
||||
it is strict about prefix heading rules
|
||||
.IP \(bu 4
|
||||
you can specify an id for headings ({#id})
|
||||
.IP \(bu 4
|
||||
trailing backslashes turn into line breaks
|
||||
.PD
|
||||
.PP
|
||||
.SH FEEDS
|
||||
.PP
|
||||
Every file can be viewed as feed by using the extension ".\&rss".\& The feed items
|
||||
are based on links in bullet lists using the asterix ("*").\& The items must
|
||||
point to local pages.\& This is why the link may not contain two forward slashes
|
||||
("//").\&
|
||||
.PP
|
||||
Assume this is the index page.\& The feed would be "/view/index.\&rss".\& It would
|
||||
contain the pages "Arianism", "Donatism" and "Monophysitism" but it would not
|
||||
contain the pages "Feed" and "About" since the list items don'\&t start with an
|
||||
asterix.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
# Main Page
|
||||
|
||||
Hello and welcome! Here are some important links:
|
||||
|
||||
- [Feed](index\&.rss)
|
||||
- [About](about)
|
||||
|
||||
Recent posts:
|
||||
|
||||
* [Arianism](arianism)
|
||||
* [Donatism](donatism)
|
||||
* [Monophysitism](monophysitism)
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
The feed contains at most 10 items, starting at the top.\&
|
||||
.PP
|
||||
.SH PERCENT ENCODING
|
||||
.PP
|
||||
If you use Markdown links to local pages, you must percent-encode the link
|
||||
target.\& Any character that is not an "unreserved character" according to RFC
|
||||
3986 might need to be encoded.\& The unreserved characters are a-z, A-Z, 0-9, as
|
||||
well as the four characters '\&-'\&, '\&_'\&, '\&.\&'\& and '\&~'\&.\&
|
||||
.PP
|
||||
Percent-encoding means that each character is converted into one or more bytes,
|
||||
and each byte is represented as a percent character followed by a hexadecimal
|
||||
representation.\&
|
||||
.PP
|
||||
Realistically, what probably works best is to use a browser.\& If you type
|
||||
"http://example.\&org/Alex Schröder" into the address bar, you'\&ll get sent to the
|
||||
example domain.\& If you now copy the address and paste it back into a text
|
||||
editor, you'\&ll get "http://example.\&org/Alex%20Schr%C3%B6der" and that'\&s how
|
||||
you'\&ll learn that the Space is encoded by %20 and that the character '\&ö'\& is
|
||||
encoded by %C3%B6.\& To link to the page "Alex Schröder" you would write something
|
||||
like this: "[Alex](Alex%20Schr%C3%B6der)".\&
|
||||
.PP
|
||||
Another thing that'\&s common is that your page name contains a colon.\&
|
||||
This is legal.\& The URL parser might still reject it.\& If you run the
|
||||
"missing" subcommand, you'\&ll get to see error: "first path segment in
|
||||
URL cannot contain colon".\& The solution is to prepend ".\&/"!\&
|
||||
.PP
|
||||
Example:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
[2021-10-15 Re: Mark It Down](2021-10-15_Re:_Mark_It_Down)
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Fixed:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
[2021-10-15 Re: Mark It Down](\&./2021-10-15_Re:_Mark_It_Down)
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1), \fIoddmu-missing\fR(1)
|
||||
.PP
|
||||
This wiki uses the Go Markdown library.\&
|
||||
https://github.\&com/gomarkdown/markdown
|
||||
.PP
|
||||
For more about percent-encoding, see Wikipedia.\&
|
||||
https://en.\&wikipedia.\&org/wiki/Percent-encoding
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
181
man/oddmu.5.txt
Normal file
181
man/oddmu.5.txt
Normal file
@@ -0,0 +1,181 @@
|
||||
ODDMU(5) "File Formats Manual"
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu - text formatting of wiki pages
|
||||
|
||||
# SYNTAX
|
||||
|
||||
The wiki pages are UTF-8 encoded Markdown files (with the ".md" extension).
|
||||
Oddmu links are regular Markdown links to page names (without the ".md"
|
||||
extension):
|
||||
|
||||
```
|
||||
[link text](page-name)
|
||||
```
|
||||
|
||||
The page name has to be percent-encoded. See the section "Percent Encoding".
|
||||
|
||||
If you link to the actual Markdown file (with the ".md" extension), then Oddmu
|
||||
serves the Markdown file!
|
||||
|
||||
There are three Oddµ-specific extensions: local links, hashtags and fediverse
|
||||
account links. The Markdown library used features some additional extensions,
|
||||
most importantly tables and definition lists.
|
||||
|
||||
## Local links
|
||||
|
||||
Local links use double square brackets [[like this]]. Oddmu does not treat
|
||||
underscores like spaces, so [[like this]] and [[like_this]] link to different
|
||||
destinations and are served by different files: "like this.md" and
|
||||
"like_this.md".
|
||||
|
||||
## Hashtags
|
||||
|
||||
Hashtags are single word links to searches for themselves. Use the underscore to
|
||||
use hashtags consisting of multiple words. Hashtags are distinguished from page
|
||||
titles because there is no space after the hash.
|
||||
|
||||
```
|
||||
# Example
|
||||
|
||||
Text
|
||||
|
||||
#Tag #Another_Tag
|
||||
```
|
||||
|
||||
When a page containing hashtags is saved, a link to that page is added to every
|
||||
page with the same name as the hashtag, if it exists. In the example above, if
|
||||
the file "Tag.md" or the file "Another_Tag.md" exists, a link to the Example
|
||||
page is added.
|
||||
|
||||
## Tables
|
||||
|
||||
A table with footers and a columnspan:
|
||||
|
||||
```
|
||||
Name | Age
|
||||
--------|------
|
||||
Bob ||
|
||||
Alice | 23
|
||||
========|======
|
||||
Total | 23
|
||||
```
|
||||
|
||||
## Definition lists:
|
||||
|
||||
```
|
||||
Cat
|
||||
: Fluffy animal everyone likes
|
||||
|
||||
Internet
|
||||
: Vector of transmission for pictures of cats
|
||||
```
|
||||
|
||||
## Fediverse account links
|
||||
|
||||
Fediverse accounts look a bit like an at sign followed by an email address, e.g.
|
||||
@alex@alexschroeder.ch. When rendering a page, these turn into a username linked
|
||||
to a profile page. In this case, "@alex" would be linked to
|
||||
"https://alexschroeder.ch/users/alex".
|
||||
|
||||
In many cases, this works as is. In reality, however, the link to the profile
|
||||
page needs to be retrieved via webfinger. Oddµ does that in the background, and
|
||||
as soon as the information is available, the actual profile link is used when
|
||||
pages are rendered. In the example above, the result would be
|
||||
"https://social.alexschroeder.ch/@alex".
|
||||
|
||||
As this sort of packground network activity is surprising, it is not enabled by
|
||||
default. Set the environment variable ODDMU_WEBFINGER to "1" in order to enable
|
||||
this.
|
||||
|
||||
## Other extensions
|
||||
|
||||
The Markdown processor comes with a few extensions:
|
||||
|
||||
- emphasis markers inside words are ignored
|
||||
- fenced code blocks are supported
|
||||
- autolinking of "naked" URLs are supported
|
||||
- strikethrough using two tildes is supported (~~like this~~)
|
||||
- it is strict about prefix heading rules
|
||||
- you can specify an id for headings ({#id})
|
||||
- trailing backslashes turn into line breaks
|
||||
|
||||
# FEEDS
|
||||
|
||||
Every file can be viewed as feed by using the extension ".rss". The feed items
|
||||
are based on links in bullet lists using the asterix ("\*"). The items must
|
||||
point to local pages. This is why the link may not contain two forward slashes
|
||||
("//").
|
||||
|
||||
Assume this is the index page. The feed would be "/view/index.rss". It would
|
||||
contain the pages "Arianism", "Donatism" and "Monophysitism" but it would not
|
||||
contain the pages "Feed" and "About" since the list items don't start with an
|
||||
asterix.
|
||||
|
||||
```
|
||||
# Main Page
|
||||
|
||||
Hello and welcome! Here are some important links:
|
||||
|
||||
- [Feed](index.rss)
|
||||
- [About](about)
|
||||
|
||||
Recent posts:
|
||||
|
||||
* [Arianism](arianism)
|
||||
* [Donatism](donatism)
|
||||
* [Monophysitism](monophysitism)
|
||||
```
|
||||
|
||||
The feed contains at most 10 items, starting at the top.
|
||||
|
||||
# PERCENT ENCODING
|
||||
|
||||
If you use Markdown links to local pages, you must percent-encode the link
|
||||
target. Any character that is not an "unreserved character" according to RFC
|
||||
3986 might need to be encoded. The unreserved characters are a-z, A-Z, 0-9, as
|
||||
well as the four characters '-', '\_', '.' and '~'.
|
||||
|
||||
Percent-encoding means that each character is converted into one or more bytes,
|
||||
and each byte is represented as a percent character followed by a hexadecimal
|
||||
representation.
|
||||
|
||||
Realistically, what probably works best is to use a browser. If you type
|
||||
"http://example.org/Alex Schröder" into the address bar, you'll get sent to the
|
||||
example domain. If you now copy the address and paste it back into a text
|
||||
editor, you'll get "http://example.org/Alex%20Schr%C3%B6der" and that's how
|
||||
you'll learn that the Space is encoded by %20 and that the character 'ö' is
|
||||
encoded by %C3%B6. To link to the page "Alex Schröder" you would write something
|
||||
like this: "[Alex](Alex%20Schr%C3%B6der)".
|
||||
|
||||
Another thing that's common is that your page name contains a colon.
|
||||
This is legal. The URL parser might still reject it. If you run the
|
||||
"missing" subcommand, you'll get to see error: "first path segment in
|
||||
URL cannot contain colon". The solution is to prepend "./"!
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
[2021-10-15 Re: Mark It Down](2021-10-15_Re:_Mark_It_Down)
|
||||
```
|
||||
|
||||
Fixed:
|
||||
|
||||
```
|
||||
[2021-10-15 Re: Mark It Down](./2021-10-15_Re:_Mark_It_Down)
|
||||
```
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1), _oddmu-missing_(1)
|
||||
|
||||
This wiki uses the Go Markdown library.
|
||||
https://github.com/gomarkdown/markdown
|
||||
|
||||
For more about percent-encoding, see Wikipedia.
|
||||
https://en.wikipedia.org/wiki/Percent-encoding
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
110
man/oddmu.service.5
Normal file
110
man/oddmu.service.5
Normal file
@@ -0,0 +1,110 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU.SERVICE" "5" "2024-01-17"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu.\&service - how to setup Oddmu using systemd
|
||||
.PP
|
||||
.SS DESCRIPTION
|
||||
.PP
|
||||
Here'\&s how to setup a wiki using systemd such that it starts automatically when
|
||||
the system boots and gets restarted automatically when it crashes.\&
|
||||
.PP
|
||||
First, create a new user called "oddmu" with it'\&s own home directory but without
|
||||
a login.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
adduser --system --home /home/oddmu oddmu
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
The directory "/home/oddmu" contains the templates and all the data files.\& Copy
|
||||
all the templates files ending in ".\&html" from the source distribution to
|
||||
"/home/oddmu".\&
|
||||
.PP
|
||||
If you want to keep everything in one place, copy the binary "oddmu" and the
|
||||
service file "oddmu.\&service" to "/home/oddmu", too.\&
|
||||
.PP
|
||||
Edit the `oddmu.\&service` file.\& These are the lines you most likely have to take
|
||||
care of:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
ExecStart=/home/oddmu/oddmu
|
||||
WorkingDirectory=/home/oddmu
|
||||
Environment="ODDMU_PORT=8080"
|
||||
Environment="ODDMU_WEBFINGER=1"
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Install the service file and enable it:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
ln -s /home/oddmu/oddmu\&.service /etc/systemd/system/
|
||||
systemctl enable --now oddmu
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
You should be able to visit the wiki at http://localhost:8080/.\&
|
||||
.PP
|
||||
Check the log:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
journalctl --unit oddmu
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Follow the log:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
journalctl --follow --unit oddmu
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
For it to restart when the server reboots:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
sudo ln -sf /home/oddmu/oddmu\&.service
|
||||
/etc/systemd/system/multi-user\&.target\&.wants/
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH Socket Activation
|
||||
.PP
|
||||
Alternatively, you can let systemd handle the creation of the listening socket,
|
||||
passing it to Oddmu.\& See "oddmu-unix-domain.\&service" and
|
||||
"oddmu-unix-domain.\&socket" for a fully worked example of how to do this with a
|
||||
Unix domain socket.\& Take note of "Accept=no" in the .\&socket file and
|
||||
"StandardInput=socket" in the .\&service file.\& The option "StandardInput=socket"
|
||||
tells systemd to pass the socket to the service as its standard input.\&
|
||||
"Accept=no" tells systemd to pass a listening socket, rather than to try calling
|
||||
Oddmu for each connection.\&
|
||||
.PP
|
||||
The instructions for starting and enabling the systemd service are almost
|
||||
exactly the same as those in the previous section, with "oddmu.\&service" replaced
|
||||
by "oddmu-unix-domain.\&service".\& You'\&ll also need to run the following:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
ln -s /home/oddmu/oddmu-unix-domain\&.socket /etc/systemd/system
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1), \fIsystemd.\&exec\fR(5), \fIsystemd.\&socket(5), \fRcapabilities_(7)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
89
man/oddmu.service.5.txt
Normal file
89
man/oddmu.service.5.txt
Normal file
@@ -0,0 +1,89 @@
|
||||
ODDMU.SERVICE(5)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu.service - how to setup Oddmu using systemd
|
||||
|
||||
## DESCRIPTION
|
||||
|
||||
Here's how to setup a wiki using systemd such that it starts automatically when
|
||||
the system boots and gets restarted automatically when it crashes.
|
||||
|
||||
First, create a new user called "oddmu" with it's own home directory but without
|
||||
a login.
|
||||
|
||||
```
|
||||
adduser --system --home /home/oddmu oddmu
|
||||
```
|
||||
|
||||
The directory "/home/oddmu" contains the templates and all the data files. Copy
|
||||
all the templates files ending in ".html" from the source distribution to
|
||||
"/home/oddmu".
|
||||
|
||||
If you want to keep everything in one place, copy the binary "oddmu" and the
|
||||
service file "oddmu.service" to "/home/oddmu", too.
|
||||
|
||||
Edit the `oddmu.service` file. These are the lines you most likely have to take
|
||||
care of:
|
||||
|
||||
```
|
||||
ExecStart=/home/oddmu/oddmu
|
||||
WorkingDirectory=/home/oddmu
|
||||
Environment="ODDMU_PORT=8080"
|
||||
Environment="ODDMU_WEBFINGER=1"
|
||||
```
|
||||
|
||||
Install the service file and enable it:
|
||||
|
||||
```
|
||||
ln -s /home/oddmu/oddmu.service /etc/systemd/system/
|
||||
systemctl enable --now oddmu
|
||||
```
|
||||
|
||||
You should be able to visit the wiki at http://localhost:8080/.
|
||||
|
||||
Check the log:
|
||||
|
||||
```
|
||||
journalctl --unit oddmu
|
||||
```
|
||||
|
||||
Follow the log:
|
||||
|
||||
```
|
||||
journalctl --follow --unit oddmu
|
||||
```
|
||||
|
||||
For it to restart when the server reboots:
|
||||
|
||||
```
|
||||
sudo ln -sf /home/oddmu/oddmu.service \
|
||||
/etc/systemd/system/multi-user.target.wants/
|
||||
```
|
||||
|
||||
# Socket Activation
|
||||
|
||||
Alternatively, you can let systemd handle the creation of the listening socket,
|
||||
passing it to Oddmu. See "oddmu-unix-domain.service" and
|
||||
"oddmu-unix-domain.socket" for a fully worked example of how to do this with a
|
||||
Unix domain socket. Take note of "Accept=no" in the .socket file and
|
||||
"StandardInput=socket" in the .service file. The option "StandardInput=socket"
|
||||
tells systemd to pass the socket to the service as its standard input.
|
||||
"Accept=no" tells systemd to pass a listening socket, rather than to try calling
|
||||
Oddmu for each connection.
|
||||
|
||||
The instructions for starting and enabling the systemd service are almost
|
||||
exactly the same as those in the previous section, with "oddmu.service" replaced
|
||||
by "oddmu-unix-domain.service". You'll also need to run the following:
|
||||
|
||||
```
|
||||
ln -s /home/oddmu/oddmu-unix-domain.socket /etc/systemd/system
|
||||
```
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1), _systemd.exec_(5), _systemd.socket(5), _capabilities_(7)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
119
missing_cmd.go
Normal file
119
missing_cmd.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"github.com/google/subcommands"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type missingCmd struct {
|
||||
}
|
||||
|
||||
func (*missingCmd) Name() string { return "missing" }
|
||||
func (*missingCmd) Synopsis() string { return "list missing pages" }
|
||||
func (*missingCmd) Usage() string {
|
||||
return `missing:
|
||||
Listing pages with links to missing pages.
|
||||
`
|
||||
}
|
||||
|
||||
func (cmd *missingCmd) SetFlags(f *flag.FlagSet) {
|
||||
}
|
||||
|
||||
func (cmd *missingCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
return missingCli(os.Stdout, f.Args())
|
||||
}
|
||||
|
||||
func missingCli(w io.Writer, args []string) subcommands.ExitStatus {
|
||||
names := make(map[string]bool)
|
||||
err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filename := path
|
||||
if info.IsDir() || strings.HasPrefix(filename, ".") {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(filename, ".md") {
|
||||
name := strings.TrimSuffix(filename, ".md")
|
||||
names[name] = true
|
||||
} else {
|
||||
names[filename] = false
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(w, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
found := false
|
||||
for name, isPage := range names {
|
||||
if !isPage {
|
||||
continue
|
||||
}
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Loading %s: %s\n", name, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
for _, link := range p.links() {
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, name, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
if u.Scheme == "" && u.Path != "" && !strings.HasPrefix(u.Path, "/") {
|
||||
// feeds can work if the matching page works
|
||||
u.Path = strings.TrimSuffix(u.Path, ".rss")
|
||||
// links to the source file can work
|
||||
u.Path = strings.TrimSuffix(u.Path, ".md")
|
||||
// pages containing a colon need the ./ prefix
|
||||
u.Path = strings.TrimPrefix(u.Path, "./")
|
||||
// check whether the destinatino is a known page
|
||||
destination, err := url.PathUnescape(u.Path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot decode %s: %s\n", link, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
_, ok := names[destination]
|
||||
if !ok {
|
||||
if !found {
|
||||
fmt.Fprintln(w, "Page\tMissing")
|
||||
found = true
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\n", name, link)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
fmt.Fprintln(w, "No missing pages found.")
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
// links parses the page content and returns an array of link destinations.
|
||||
func (p *Page) links() []string {
|
||||
var links []string
|
||||
parser, _ := wikiParser()
|
||||
doc := markdown.Parse(p.Body, parser)
|
||||
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
|
||||
if entering {
|
||||
switch v := node.(type) {
|
||||
case *ast.Link:
|
||||
links = append(links, string(v.Destination))
|
||||
}
|
||||
}
|
||||
return ast.GoToNext
|
||||
})
|
||||
return links
|
||||
}
|
||||
18
missing_cmd_test.go
Normal file
18
missing_cmd_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/google/subcommands"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMissingCmd(t *testing.T) {
|
||||
b := new(bytes.Buffer)
|
||||
s := missingCli(b, nil)
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
r := `Page Missing
|
||||
index test
|
||||
`
|
||||
assert.Equal(t, r, b.String())
|
||||
}
|
||||
47
notify_cmd.go
Normal file
47
notify_cmd.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/google/subcommands"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type notifyCmd struct {
|
||||
}
|
||||
|
||||
func (*notifyCmd) Name() string { return "notify" }
|
||||
func (*notifyCmd) Synopsis() string { return "add links to changes.md, index.md, and hashtag pages" }
|
||||
func (*notifyCmd) Usage() string {
|
||||
return `notify <page name> ...:
|
||||
For each page, add entries to changes.md, index.md, and hashtag pages.
|
||||
This is useful when writing pages offline and replicates the behaviour
|
||||
triggered by the "Add link to the list of changes" checkbox, online.
|
||||
`
|
||||
}
|
||||
|
||||
func (cmd *notifyCmd) SetFlags(f *flag.FlagSet) {
|
||||
}
|
||||
|
||||
func (cmd *notifyCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
return notifyCli(os.Stdout, f.Args())
|
||||
}
|
||||
|
||||
func notifyCli(w io.Writer, args []string) subcommands.ExitStatus {
|
||||
index.load()
|
||||
for _, name := range args {
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Loading %s: %s\n", name, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
err = p.notify()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: %s\n", name, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
53
oddmu-unix-domain.service
Normal file
53
oddmu-unix-domain.service
Normal file
@@ -0,0 +1,53 @@
|
||||
[Unit]
|
||||
Description=Oddmu
|
||||
After=network.target
|
||||
Requires=oddmu-unix-domain.socket
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
StandardInput=socket
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
DynamicUser=true
|
||||
MemoryMax=256M
|
||||
MemoryHigh=128M
|
||||
ExecStart=/home/oddmu/oddmu
|
||||
WorkingDirectory=/home/oddmu
|
||||
Environment="ODDMU_PORT=8080"
|
||||
Environment="ODDMU_WEBFINGER=1"
|
||||
|
||||
# (man "systemd.exec")
|
||||
ReadWritePaths=/home/oddmu
|
||||
ProtectHostname=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RemoveIPC=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
|
||||
# Sandboxing options to harden security
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
DevicePolicy=closed
|
||||
ProtectSystem=full
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelTunables=yes
|
||||
LockPersonality=yes
|
||||
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
|
||||
|
||||
# Denying access to capabilities that should not be relevant
|
||||
# (man "capabilities")
|
||||
CapabilityBoundingSet=~CAP_RAWIO CAP_MKNOD
|
||||
CapabilityBoundingSet=~CAP_AUDIT_CONTROL CAP_AUDIT_READ CAP_AUDIT_WRITE
|
||||
CapabilityBoundingSet=~CAP_SYS_BOOT CAP_SYS_TIME CAP_SYS_MODULE CAP_SYS_PACCT
|
||||
CapabilityBoundingSet=~CAP_LEASE CAP_LINUX_IMMUTABLE CAP_IPC_LOCK
|
||||
CapabilityBoundingSet=~CAP_BLOCK_SUSPEND CAP_WAKE_ALARM
|
||||
CapabilityBoundingSet=~CAP_SYS_TTY_CONFIG
|
||||
CapabilityBoundingSet=~CAP_MAC_ADMIN CAP_MAC_OVERRIDE
|
||||
CapabilityBoundingSet=~CAP_NET_ADMIN CAP_NET_BROADCAST CAP_NET_RAW
|
||||
CapabilityBoundingSet=~CAP_SYS_ADMIN CAP_SYS_PTRACE CAP_SYSLOG
|
||||
14
oddmu-unix-domain.socket
Normal file
14
oddmu-unix-domain.socket
Normal file
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Oddmu server socket
|
||||
|
||||
[Socket]
|
||||
ListenStream=/run/oddmu/oddmu.sock
|
||||
SocketGroup=www-data
|
||||
# Systemd manages the socket, so may as well let it be owned by root.
|
||||
SocketUser=root
|
||||
# But it needs to be readable and writable by the web server.
|
||||
SocketMode=0660
|
||||
Accept=no
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
@@ -12,6 +12,7 @@ MemoryHigh=120M
|
||||
ExecStart=/home/oddmu/oddmu
|
||||
WorkingDirectory=/home/oddmu
|
||||
Environment="ODDMU_PORT=8080"
|
||||
Environment="ODDMU_WEBFINGER=1"
|
||||
|
||||
# (man "systemd.exec")
|
||||
ReadWritePaths=/home/oddmu
|
||||
@@ -21,7 +22,6 @@ RemoveIPC=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
|
||||
# Sandboxing options to harden security
|
||||
# Details for these options: https://www.freedesktop.org/software/systemd/man/systemd.exec.html
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
@@ -37,7 +37,7 @@ LockPersonality=yes
|
||||
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
|
||||
|
||||
# Denying access to capabilities that should not be relevant
|
||||
# Doc: https://man7.org/linux/man-pages/man7/capabilities.7.html
|
||||
# (man "capabilities")
|
||||
CapabilityBoundingSet=~CAP_RAWIO CAP_MKNOD
|
||||
CapabilityBoundingSet=~CAP_AUDIT_CONTROL CAP_AUDIT_READ CAP_AUDIT_WRITE
|
||||
CapabilityBoundingSet=~CAP_SYS_BOOT CAP_SYS_TIME CAP_SYS_MODULE CAP_SYS_PACCT
|
||||
|
||||
136
page.go
136
page.go
@@ -2,16 +2,16 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Page is a struct containing information about a single page. Title
|
||||
@@ -27,16 +27,24 @@ type Page struct {
|
||||
Body []byte
|
||||
Html template.HTML
|
||||
Score int
|
||||
Hashtags []string
|
||||
}
|
||||
|
||||
// santize uses bluemonday to sanitize the HTML.
|
||||
func sanitize(s string) template.HTML {
|
||||
return template.HTML(bluemonday.UGCPolicy().Sanitize(s))
|
||||
var blogRe = regexp.MustCompile(`^\d\d\d\d-\d\d-\d\d`)
|
||||
|
||||
// santizeStrict uses bluemonday to sanitize the HTML away. No elements are allowed except for the b tag because this is
|
||||
// used for snippets.
|
||||
func sanitizeStrict(s string) template.HTML {
|
||||
policy := bluemonday.StrictPolicy()
|
||||
policy.AllowElements("b")
|
||||
return template.HTML(policy.Sanitize(s))
|
||||
}
|
||||
|
||||
// santizeBytes uses bluemonday to sanitize the HTML.
|
||||
func sanitizeBytes(bytes []byte) template.HTML {
|
||||
return template.HTML(bluemonday.UGCPolicy().SanitizeBytes(bytes))
|
||||
// unsafeBytes does not use bluemonday to sanitize the HTML used for pages. This is where you make changes if you want
|
||||
// to be more lenient. If you look at the git repository, there are older versions containing the function sanitizeBytes
|
||||
// which would do elaborate checking.
|
||||
func unsafeBytes(bytes []byte) template.HTML {
|
||||
return template.HTML(bytes)
|
||||
}
|
||||
|
||||
// nameEscape returns the page name safe for use in URLs. That is,
|
||||
@@ -59,8 +67,8 @@ func (p *Page) save() error {
|
||||
filename := p.Name + ".md"
|
||||
s := bytes.ReplaceAll(p.Body, []byte{'\r'}, []byte{})
|
||||
if len(s) == 0 {
|
||||
_ = os.Rename(filename, filename + "~")
|
||||
return os.Remove(filename)
|
||||
p.removeFromIndex()
|
||||
return os.Rename(filename, filename+"~")
|
||||
}
|
||||
p.Body = s
|
||||
p.updateIndex()
|
||||
@@ -68,14 +76,26 @@ func (p *Page) save() error {
|
||||
if d != "." {
|
||||
err := os.MkdirAll(d, 0755)
|
||||
if err != nil {
|
||||
fmt.Printf("Creating directory %s failed", d)
|
||||
log.Printf("Creating directory %s failed: %s", d, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
_ = os.Rename(filename, filename + "~")
|
||||
backup(filename)
|
||||
return os.WriteFile(filename, s, 0644)
|
||||
}
|
||||
|
||||
// backup a file by renaming (!) it unless the existing backup is less than an hour old. A backup gets a tilde appended
|
||||
// to it ("~"). This is true even if the file refers to a binary file like "image.png" and most applications don't know
|
||||
// what to do with a file called "image.png~".
|
||||
func backup(filename string) error {
|
||||
backup := filename + "~"
|
||||
fi, err := os.Stat(backup)
|
||||
if err != nil || time.Since(fi.ModTime()).Minutes() >= 60 {
|
||||
return os.Rename(filename, backup)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadPage loads a Page given a name. The filename loaded is that
|
||||
// Page.Name with the ".md" extension. The Page.Title is set to the
|
||||
// Page.Name (and possibly changed, later). The Page.Body is set to
|
||||
@@ -90,9 +110,9 @@ func loadPage(name string) (*Page, error) {
|
||||
return &Page{Title: name, Name: name, Body: body, Language: ""}, nil
|
||||
}
|
||||
|
||||
// handleTitle extracts the title from a Page and sets Page.Title, if
|
||||
// any. If replace is true, the page title is also removed from
|
||||
// Page.Body. Make sure not to save this! This is only for rendering.
|
||||
// handleTitle extracts the title from a Page and sets Page.Title, if any. If replace is true, the page title is also
|
||||
// removed from Page.Body. Make sure not to save this! This is only for rendering. In a template, the title is a
|
||||
// separate attribute and is not repeated in the HTML.
|
||||
func (p *Page) handleTitle(replace bool) {
|
||||
s := string(p.Body)
|
||||
m := titleRegexp.FindStringSubmatch(s)
|
||||
@@ -104,75 +124,37 @@ func (p *Page) handleTitle(replace bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func hashtag(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
|
||||
data = data[offset:]
|
||||
i := 0
|
||||
n := len(data)
|
||||
for i < n && !parser.IsSpace(data[i]) {
|
||||
i++
|
||||
}
|
||||
if i == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
link := &ast.Link{
|
||||
Destination: append([]byte("/search?q=%23"), data[1:i]...),
|
||||
Title: data[0:i],
|
||||
}
|
||||
text := bytes.ReplaceAll(data[0:i], []byte("_"), []byte(" "))
|
||||
ast.AppendChild(link, &ast.Text{Leaf: ast.Leaf{Literal: text}})
|
||||
return i, link
|
||||
}
|
||||
|
||||
// renderHtml renders the Page.Body to HTML and sets Page.Html.
|
||||
func (p *Page) renderHtml() {
|
||||
parser := parser.New()
|
||||
parser.RegisterInline('#', hashtag)
|
||||
maybeUnsafeHTML := markdown.ToHTML(p.Body, parser, nil)
|
||||
p.Name = nameEscape(p.Name)
|
||||
p.Html = sanitizeBytes(maybeUnsafeHTML)
|
||||
p.Language = language(p.plainText())
|
||||
}
|
||||
|
||||
// plainText renders the Page.Body to plain text and returns it,
|
||||
// ignoring all the Markdown and all the newlines. The result is one
|
||||
// long single line of text.
|
||||
func (p *Page) plainText() string {
|
||||
parser := parser.New()
|
||||
doc := markdown.Parse(p.Body, parser)
|
||||
text := []byte("")
|
||||
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
|
||||
if entering && node.AsLeaf() != nil {
|
||||
text = append(text, node.AsLeaf().Literal...)
|
||||
text = append(text, []byte(" ")...)
|
||||
}
|
||||
return ast.GoToNext
|
||||
})
|
||||
// Some Markdown still contains newlines
|
||||
for i, c := range text {
|
||||
if c == '\n' {
|
||||
text[i] = ' '
|
||||
}
|
||||
}
|
||||
// Remove trailing space
|
||||
for len(text) > 0 && text[len(text)-1] == ' ' {
|
||||
text = text[0 : len(text)-1]
|
||||
}
|
||||
return string(text)
|
||||
}
|
||||
|
||||
// summarize for query string q sets Page.Html to an extract.
|
||||
func (p *Page) summarize(q string) {
|
||||
// score sets Page.Title and computes Page.Score.
|
||||
func (p *Page) score(q string) {
|
||||
p.handleTitle(true)
|
||||
p.Score = score(q, string(p.Body)) + score(q, p.Title)
|
||||
}
|
||||
|
||||
// summarize sets Page.Html to an extract and sets Page.Language.
|
||||
func (p *Page) summarize(q string) {
|
||||
t := p.plainText()
|
||||
p.Html = sanitize(snippets(q, t))
|
||||
p.Name = nameEscape(p.Name)
|
||||
p.Html = sanitizeStrict(snippets(q, t))
|
||||
p.Language = language(t)
|
||||
}
|
||||
|
||||
// isBlog returns true if the page name starts with an ISO date
|
||||
func (p *Page) isBlog() bool {
|
||||
name := path.Base(p.Name)
|
||||
return blogRe.MatchString(name)
|
||||
}
|
||||
|
||||
// Dir returns the directory the page is in. It's either the empty string if the page is in the Oddmu working directory,
|
||||
// or it ends in a slash. This is used to create the upload link in "view.html", for example.
|
||||
func (p *Page) Dir() string {
|
||||
d := filepath.Dir(p.Name)
|
||||
if d == "." {
|
||||
return ""
|
||||
}
|
||||
return d
|
||||
return d + "/"
|
||||
}
|
||||
|
||||
// Today returns the date, as a string, for use in templates.
|
||||
func (p *Page) Today() string {
|
||||
return time.Now().Format(time.DateOnly)
|
||||
}
|
||||
|
||||
65
page_test.go
65
page_test.go
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
@@ -19,73 +18,25 @@ But yearn for blue sky`)}
|
||||
assert.Regexp(t, regexp.MustCompile("^My back"), string(p.Body))
|
||||
}
|
||||
|
||||
func TestPagePlainText(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Water
|
||||
The air will not come
|
||||
To inhale is an effort
|
||||
The summer heat kills`)}
|
||||
r := "Water The air will not come To inhale is an effort The summer heat kills"
|
||||
assert.Equal(t, r, p.plainText())
|
||||
}
|
||||
|
||||
func TestPageHtml(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Sun
|
||||
Silver leaves shine bright
|
||||
They droop, boneless, weak and sad
|
||||
A cruel sun stares down`)}
|
||||
p.renderHtml()
|
||||
r := `<h1>Sun</h1>
|
||||
|
||||
<p>Silver leaves shine bright
|
||||
They droop, boneless, weak and sad
|
||||
A cruel sun stares down</p>
|
||||
`
|
||||
assert.Equal(t, r, string(p.Html))
|
||||
}
|
||||
|
||||
func TestPageHtmlHashtag(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Comet
|
||||
Stars flicker above
|
||||
Too faint to focus, so far
|
||||
I am cold, alone
|
||||
|
||||
#Haiku #Cold_Poets`)}
|
||||
p.renderHtml()
|
||||
r := `<h1>Comet</h1>
|
||||
|
||||
<p>Stars flicker above
|
||||
Too faint to focus, so far
|
||||
I am cold, alone</p>
|
||||
|
||||
<p><a href="/search?q=%23Haiku" rel="nofollow">#Haiku</a> <a href="/search?q=%23Cold_Poets" rel="nofollow">#Cold Poets</a></p>
|
||||
`
|
||||
assert.Equal(t, r, string(p.Html))
|
||||
}
|
||||
|
||||
// wipes testdata
|
||||
func TestPageDir(t *testing.T) {
|
||||
_ = os.RemoveAll("testdata")
|
||||
cleanup(t, "testdata/dir")
|
||||
index.load()
|
||||
p := &Page{Name: "testdata/moon", Body: []byte(`# Moon
|
||||
p := &Page{Name: "testdata/dir/moon", Body: []byte(`# Moon
|
||||
From bed to bathroom
|
||||
A slow shuffle in the dark
|
||||
Moonlight floods the aisle`)}
|
||||
p.save()
|
||||
|
||||
o, err := loadPage("testdata/moon")
|
||||
|
||||
o, err := loadPage("testdata/dir/moon")
|
||||
assert.NoError(t, err, "load page")
|
||||
assert.Equal(t, p.Body, o.Body)
|
||||
assert.FileExists(t, "testdata/moon.md")
|
||||
assert.FileExists(t, "testdata/dir/moon.md")
|
||||
|
||||
// Saving an empty page deletes it.
|
||||
p = &Page{Name: "testdata/moon", Body: []byte("")}
|
||||
p = &Page{Name: "testdata/dir/moon", Body: []byte("")}
|
||||
p.save()
|
||||
assert.NoFileExists(t, "testdata/moon.md")
|
||||
assert.NoFileExists(t, "testdata/dir/moon.md")
|
||||
|
||||
// But the backup still exists.
|
||||
assert.FileExists(t, "testdata/moon.md~")
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
assert.FileExists(t, "testdata/dir/moon.md~")
|
||||
}
|
||||
|
||||
122
parser.go
Normal file
122
parser.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// wikiLink returns an inline parser function. This indirection is
|
||||
// required because we want to call the previous definition in case
|
||||
// this is not a wikiLink.
|
||||
func wikiLink(p *parser.Parser, fn func(p *parser.Parser, data []byte, offset int) (int, ast.Node)) func(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
|
||||
return func(p *parser.Parser, original []byte, offset int) (int, ast.Node) {
|
||||
data := original[offset:]
|
||||
n := len(data)
|
||||
// minimum: [[X]]
|
||||
if n < 5 || data[1] != '[' {
|
||||
return fn(p, original, offset)
|
||||
}
|
||||
i := 2
|
||||
for i+1 < n && data[i] != ']' && data[i+1] != ']' {
|
||||
i++
|
||||
}
|
||||
text := data[2 : i+1]
|
||||
link := &ast.Link{
|
||||
Destination: []byte(url.PathEscape(string(text))),
|
||||
}
|
||||
ast.AppendChild(link, &ast.Text{Leaf: ast.Leaf{Literal: text}})
|
||||
return i + 3, link
|
||||
}
|
||||
}
|
||||
|
||||
// hashtag returns an inline parser function. This indirection is
|
||||
// required because we want to receive an array of hashtags found.
|
||||
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) {
|
||||
data = data[offset:]
|
||||
i := 0
|
||||
n := len(data)
|
||||
for i < n && !parser.IsSpace(data[i]) {
|
||||
i++
|
||||
}
|
||||
if i == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
hashtags = append(hashtags, string(data[1:i]))
|
||||
link := &ast.Link{
|
||||
AdditionalAttributes: []string{`class="tag"`},
|
||||
Destination: append([]byte("/search/?q=%23"), data[1:i]...),
|
||||
}
|
||||
text := bytes.ReplaceAll(data[0:i], []byte("_"), []byte(" "))
|
||||
ast.AppendChild(link, &ast.Text{Leaf: ast.Leaf{Literal: text}})
|
||||
return i, link
|
||||
}, &hashtags
|
||||
}
|
||||
|
||||
// wikiParser returns a parser with the Oddmu specific changes. Specifically: [[wiki links]], #hash_tags,
|
||||
// @webfinger@accounts. It also uses the CommonExtensions and Block Attributes, and no MathJax ($).
|
||||
func wikiParser() (*parser.Parser, *[]string) {
|
||||
extensions := (parser.CommonExtensions | parser.Attributes) & ^parser.MathJax
|
||||
parser := parser.NewWithExtensions(extensions)
|
||||
prev := parser.RegisterInline('[', nil)
|
||||
parser.RegisterInline('[', wikiLink(parser, prev))
|
||||
fn, hashtags := hashtag()
|
||||
parser.RegisterInline('#', fn)
|
||||
if useWebfinger {
|
||||
parser.RegisterInline('@', account)
|
||||
}
|
||||
return parser, hashtags
|
||||
}
|
||||
|
||||
// wikiRenderer is a Renderer for Markdown that adds lazy loading of images. This in turn requires an exception for the
|
||||
// sanitization policy!
|
||||
func wikiRenderer() *html.Renderer {
|
||||
htmlFlags := html.CommonFlags | html.LazyLoadImages
|
||||
opts := html.RendererOptions{Flags: htmlFlags}
|
||||
renderer := html.NewRenderer(opts)
|
||||
return renderer
|
||||
}
|
||||
|
||||
// renderHtml renders the Page.Body to HTML and sets Page.Html, Page.Language, Page.Hashtags, and escapes Page.Name.
|
||||
// Note: If the rendered HTML doesn't contain the attributes or elements you expect it to contain, check sanitizeBytes!
|
||||
func (p *Page) renderHtml() {
|
||||
parser, hashtags := wikiParser()
|
||||
renderer := wikiRenderer()
|
||||
maybeUnsafeHTML := markdown.ToHTML(p.Body, parser, renderer)
|
||||
p.Name = nameEscape(p.Name)
|
||||
p.Html = unsafeBytes(maybeUnsafeHTML)
|
||||
p.Language = language(p.plainText())
|
||||
p.Hashtags = *hashtags
|
||||
}
|
||||
|
||||
// plainText renders the Page.Body to plain text and returns it,
|
||||
// ignoring all the Markdown and all the newlines. The result is one
|
||||
// long single line of text.
|
||||
func (p *Page) plainText() string {
|
||||
parser := parser.New()
|
||||
doc := markdown.Parse(p.Body, parser)
|
||||
text := []byte("")
|
||||
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
|
||||
if entering && node.AsLeaf() != nil {
|
||||
text = append(text, node.AsLeaf().Literal...)
|
||||
text = append(text, []byte(" ")...)
|
||||
}
|
||||
return ast.GoToNext
|
||||
})
|
||||
// Some Markdown still contains newlines
|
||||
for i, c := range text {
|
||||
if c == '\n' {
|
||||
text[i] = ' '
|
||||
}
|
||||
}
|
||||
// Remove trailing space
|
||||
for len(text) > 0 && text[len(text)-1] == ' ' {
|
||||
text = text[0 : len(text)-1]
|
||||
}
|
||||
return string(text)
|
||||
}
|
||||
85
parser_test.go
Normal file
85
parser_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPagePlainText(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Water
|
||||
The air will not come
|
||||
To inhale is an effort
|
||||
The summer heat kills`)}
|
||||
r := "Water The air will not come To inhale is an effort The summer heat kills"
|
||||
assert.Equal(t, r, p.plainText())
|
||||
}
|
||||
|
||||
func TestPageHtml(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Sun
|
||||
Silver leaves shine bright
|
||||
They droop, boneless, weak and sad
|
||||
A cruel sun stares down`)}
|
||||
p.renderHtml()
|
||||
r := `<h1>Sun</h1>
|
||||
|
||||
<p>Silver leaves shine bright
|
||||
They droop, boneless, weak and sad
|
||||
A cruel sun stares down</p>
|
||||
`
|
||||
assert.Equal(t, r, string(p.Html))
|
||||
}
|
||||
|
||||
func TestPageHtmlHashtag(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Comet
|
||||
Stars flicker above
|
||||
Too faint to focus, so far
|
||||
I am cold, alone
|
||||
|
||||
#Haiku #Cold_Poets`)}
|
||||
p.renderHtml()
|
||||
r := `<h1>Comet</h1>
|
||||
|
||||
<p>Stars flicker above
|
||||
Too faint to focus, so far
|
||||
I am cold, alone</p>
|
||||
|
||||
<p><a class="tag" href="/search/?q=%23Haiku">#Haiku</a> <a class="tag" href="/search/?q=%23Cold_Poets">#Cold Poets</a></p>
|
||||
`
|
||||
assert.Equal(t, r, string(p.Html))
|
||||
}
|
||||
|
||||
func TestPageHtmlWikiLink(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Photos and Books
|
||||
Blue and green and black
|
||||
Sky and grass and [ragged cliffs](cliffs)
|
||||
Our [[time together]]`)}
|
||||
p.renderHtml()
|
||||
r := `<h1>Photos and Books</h1>
|
||||
|
||||
<p>Blue and green and black
|
||||
Sky and grass and <a href="cliffs">ragged cliffs</a>
|
||||
Our <a href="time%20together">time together</a></p>
|
||||
`
|
||||
assert.Equal(t, r, string(p.Html))
|
||||
}
|
||||
|
||||
func TestPageHtmlDollar(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# No $dollar$ can buy this
|
||||
Dragonfly hovers
|
||||
darts chases turns lands and rests
|
||||
A mighty jewel`)}
|
||||
p.renderHtml()
|
||||
r := `<h1>No $dollar$ can buy this</h1>
|
||||
|
||||
<p>Dragonfly hovers
|
||||
darts chases turns lands and rests
|
||||
A mighty jewel</p>
|
||||
`
|
||||
assert.Equal(t, r, string(p.Html))
|
||||
}
|
||||
|
||||
func TestLazyLoadImages(t *testing.T) {
|
||||
p := &Page{Body: []byte(``)}
|
||||
p.renderHtml()
|
||||
assert.Contains(t, string(p.Html), "lazy")
|
||||
}
|
||||
108
replace_cmd.go
Normal file
108
replace_cmd.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/google/subcommands"
|
||||
"github.com/hexops/gotextdiff"
|
||||
"github.com/hexops/gotextdiff/myers"
|
||||
"github.com/hexops/gotextdiff/span"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type replaceCmd struct {
|
||||
confirm bool
|
||||
regexp bool
|
||||
}
|
||||
|
||||
func (cmd *replaceCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.BoolVar(&cmd.confirm, "confirm", false, "do the replacement instead of just doing a dry run")
|
||||
f.BoolVar(&cmd.regexp, "regexp", false, "the search string is a regular expression")
|
||||
}
|
||||
|
||||
func (*replaceCmd) Name() string { return "replace" }
|
||||
func (*replaceCmd) Synopsis() string { return "search and replace in all the pages" }
|
||||
func (*replaceCmd) Usage() string {
|
||||
return `replace [-confirm] [-regexp] <term> <replacement>:
|
||||
Search a string or a regular expression and replace it. By default,
|
||||
this is a dry run and nothing is saved. If this is a regular
|
||||
expression, the replacement can use $1, $2, etc. to refer to capture
|
||||
groups in the regular expression.
|
||||
`
|
||||
}
|
||||
|
||||
func (cmd *replaceCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
return replaceCli(os.Stdout, cmd.confirm, cmd.regexp, f.Args())
|
||||
}
|
||||
|
||||
func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) subcommands.ExitStatus {
|
||||
if len(args) != 2 {
|
||||
fmt.Fprintln(os.Stderr, "Replace takes exactly two arguments.")
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
var re *regexp.Regexp
|
||||
if isRegexp {
|
||||
re = regexp.MustCompile(args[0])
|
||||
} else {
|
||||
re = regexp.MustCompile(regexp.QuoteMeta(args[0]))
|
||||
}
|
||||
repl := []byte(args[1])
|
||||
changes := 0
|
||||
err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() || strings.HasPrefix(path, ".") || !strings.HasSuffix(path, ".md") {
|
||||
return nil
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := re.ReplaceAll(body, repl)
|
||||
if !slices.Equal(result, body) {
|
||||
changes++
|
||||
if isConfirmed {
|
||||
fmt.Fprintln(w, path)
|
||||
_ = os.Rename(path, path+"~")
|
||||
err = os.WriteFile(path, 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))
|
||||
fmt.Fprintln(w, diff)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
if changes == 1 {
|
||||
if isConfirmed {
|
||||
fmt.Fprintln(w, "1 file was changed.")
|
||||
} else {
|
||||
fmt.Fprintln(w, "1 file would be changed.")
|
||||
}
|
||||
} else {
|
||||
if isConfirmed {
|
||||
fmt.Fprintf(w, "%d files were changed.\n", changes)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%d files would be changed.\n", changes)
|
||||
}
|
||||
}
|
||||
if !isConfirmed && changes > 0 {
|
||||
fmt.Fprintln(w, "This is a dry run. Use -confirm to make it happen.")
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
38
replace_cmd_test.go
Normal file
38
replace_cmd_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/google/subcommands"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReplaceCmd(t *testing.T) {
|
||||
cleanup(t, "testdata/replace")
|
||||
index.load()
|
||||
p := &Page{Name: "testdata/replace/pluto", Body: []byte(`# Pluto
|
||||
Out there is a rock
|
||||
And more rocks uncountable
|
||||
You are no planet`)}
|
||||
p.save()
|
||||
|
||||
r := `--- testdata/replace/pluto.md~
|
||||
+++ testdata/replace/pluto.md
|
||||
@@ -1,4 +1,4 @@
|
||||
# Pluto
|
||||
Out there is a rock
|
||||
And more rocks uncountable
|
||||
-You are no planet
|
||||
\ No newline at end of file
|
||||
+You are planetoid
|
||||
\ No newline at end of file
|
||||
|
||||
1 file would be changed.
|
||||
This is a dry run. Use -confirm to make it happen.
|
||||
`
|
||||
|
||||
b := new(bytes.Buffer)
|
||||
s := replaceCli(b, false, true, []string{`\bno planet`, `planetoid`})
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
assert.Equal(t, r, b.String())
|
||||
}
|
||||
7
score.go
7
score.go
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// score splits the query string q into terms and scores the text
|
||||
@@ -10,7 +9,7 @@ import (
|
||||
// characters quoted.
|
||||
func score(q string, s string) int {
|
||||
score := 0
|
||||
re, err := regexp.Compile("(?i)" + q)
|
||||
re, err := regexp.Compile("(?i)" + regexp.QuoteMeta(q))
|
||||
if err == nil {
|
||||
m := re.FindAllString(s, -1)
|
||||
if m != nil {
|
||||
@@ -18,8 +17,8 @@ func score(q string, s string) int {
|
||||
score += len(m)
|
||||
}
|
||||
}
|
||||
for _, v := range strings.Fields(q) {
|
||||
re, err := regexp.Compile(`(?is)(\pL?)(` + v + `)(\pL?)`)
|
||||
for _, token := range highlightTokens(q) {
|
||||
re, err := regexp.Compile(`(?is)(\pL?)(` + regexp.QuoteMeta(token) + `)(\pL?)`)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestScorePageAndMarkup(t *testing.T) {
|
||||
s := `The Transjovian Council accepts new members. If you think we'd be a good fit, apply for an account. Contact [Alex Schroeder](https://alexschroeder.ch/wiki/Contact). Mail is best. Encrypted mail is best. [Delta Chat](https://delta.chat/de/) is a messenger app that uses encrypted mail. It's the bestest best.`
|
||||
p := &Page{Title: "Test", Name: "Test", Body: []byte(s)}
|
||||
q := "wiki"
|
||||
p.summarize(q)
|
||||
p.score(q)
|
||||
// "wiki" is not visible in the plain text but the score is no affected:
|
||||
// - wiki, all, whole, beginning, end (5)
|
||||
if p.Score != 5 {
|
||||
|
||||
264
search.go
264
search.go
@@ -1,9 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -13,76 +17,226 @@ import (
|
||||
// Currently there is no pagination of results! When a page is part of
|
||||
// a search result, Body and Html are simple extracts.
|
||||
type Search struct {
|
||||
Query string
|
||||
Items []Page
|
||||
Results bool
|
||||
Query string
|
||||
Dir string
|
||||
Items []*Page
|
||||
Previous int
|
||||
Page int
|
||||
Next int
|
||||
More bool
|
||||
Results bool
|
||||
}
|
||||
|
||||
func sortItems(a, b Page) int {
|
||||
// Sort by score
|
||||
if a.Score < b.Score {
|
||||
return 1
|
||||
} else if a.Score > b.Score {
|
||||
return -1
|
||||
}
|
||||
// If the score is the same and both page names start
|
||||
// with a number (like an ISO date), sort descending.
|
||||
ra, _ := utf8.DecodeRuneInString(a.Title)
|
||||
rb, _ := utf8.DecodeRuneInString(b.Title)
|
||||
if unicode.IsNumber(ra) && unicode.IsNumber(rb) {
|
||||
if a.Title < b.Title {
|
||||
return 1
|
||||
} else if a.Title > b.Title {
|
||||
return -1
|
||||
} else {
|
||||
return 0
|
||||
// sortNames returns a sort function that sorts in three stages: 1.
|
||||
// whether the query string matches the page title; 2. descending if
|
||||
// the page titles start with a digit; 3. otherwise ascending.
|
||||
// Access to the index requires a read lock!
|
||||
func sortNames(tokens []string) func(a, b string) int {
|
||||
return func(a, b string) int {
|
||||
// If only one page contains the query string, it
|
||||
// takes precedence.
|
||||
ia := false
|
||||
ib := false
|
||||
for _, token := range tokens {
|
||||
if !ia && strings.Contains(index.titles[a], token) {
|
||||
ia = true
|
||||
}
|
||||
if !ib && strings.Contains(index.titles[b], token) {
|
||||
ib = true
|
||||
}
|
||||
}
|
||||
if ia && !ib {
|
||||
return -1
|
||||
} else if !ia && ib {
|
||||
return 1
|
||||
}
|
||||
// Page names starting with a number come first. If
|
||||
// both page names start with a number (like an ISO
|
||||
// date), sort by page name, descending.
|
||||
ra, _ := utf8.DecodeRuneInString(a)
|
||||
na := unicode.IsNumber(ra)
|
||||
rb, _ := utf8.DecodeRuneInString(b)
|
||||
nb := unicode.IsNumber(rb)
|
||||
if na && !nb {
|
||||
return -1
|
||||
} else if !na && nb {
|
||||
return 1
|
||||
} else if na && nb {
|
||||
if a < b {
|
||||
return 1
|
||||
} else if a > b {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
// Otherwise sort by title, ascending.
|
||||
if index.titles[a] < index.titles[b] {
|
||||
return -1
|
||||
} else if index.titles[a] > index.titles[b] {
|
||||
return 1
|
||||
}
|
||||
// Either the titles are equal or the index isn't
|
||||
// initialized.
|
||||
if a < b {
|
||||
return -1
|
||||
} else if a > b {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
// Otherwise sort ascending.
|
||||
if a.Title < b.Title {
|
||||
return -1
|
||||
} else if a.Title > b.Title {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// loadAndSummarize loads the pages named and summarizes them for the
|
||||
// query give.
|
||||
func loadAndSummarize(names []string, q string) []Page {
|
||||
// Load and summarize the items.
|
||||
items := make([]Page, len(names))
|
||||
for i, name := range names {
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading %s\n", name)
|
||||
} else {
|
||||
p.summarize(q)
|
||||
items[i] = *p
|
||||
}
|
||||
// itemsPerPage says how many items to print on a page of search
|
||||
// results.
|
||||
const itemsPerPage = 20
|
||||
|
||||
// search returns a sorted []Page where each page contains an extract of the actual Page.Body in its Page.Html. Page
|
||||
// size is 20. Specify either the page number to return, or that all the results should be returned. Only ask for all
|
||||
// results if runtime is not an issue, like on the command line. The boolean return value indicates whether there are
|
||||
// more results.
|
||||
func search(q string, dir string, page int, all bool) ([]*Page, bool) {
|
||||
if len(q) == 0 {
|
||||
return make([]*Page, 0), false
|
||||
}
|
||||
return items
|
||||
names := index.search(q) // hashtags or all names
|
||||
names = filterPrefix(names, dir)
|
||||
predicates, terms := predicatesAndTokens(q)
|
||||
names = filterNames(names, predicates)
|
||||
slices.SortFunc(names, sortNames(terms))
|
||||
names, keepFirst := prependQueryPage(names, dir, q)
|
||||
from := itemsPerPage * (page - 1)
|
||||
to := from + itemsPerPage - 1
|
||||
items, more := grep(terms, names, from, to, all, keepFirst)
|
||||
for _, p := range items {
|
||||
p.score(q)
|
||||
p.summarize(q)
|
||||
}
|
||||
return items, more
|
||||
}
|
||||
|
||||
// search returns a sorted []Page where each page contains an extract
|
||||
// of the actual Page.Body in its Page.Html.
|
||||
func search(q string) []Page {
|
||||
if len(q) == 0 {
|
||||
return make([]Page, 0)
|
||||
// filterPrefix filters the names by prefix. A prefix of "." means
|
||||
// that all the names are returned, since this is what path.Dir
|
||||
// returns for "no directory".
|
||||
func filterPrefix(names []string, prefix string) []string {
|
||||
if prefix == "." {
|
||||
return names
|
||||
}
|
||||
names := searchDocuments(q)
|
||||
items := loadAndSummarize(names, q)
|
||||
slices.SortFunc(items, sortItems)
|
||||
return items
|
||||
r := make([]string, 0)
|
||||
for _, name := range names {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
r = append(r, name)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// filterNames filters the names by all the predicats such as
|
||||
// "title:foo" or "blog:true".
|
||||
func filterNames(names, predicates []string) []string {
|
||||
if len(predicates) == 0 {
|
||||
return names
|
||||
}
|
||||
// the intersection requires sorted lists
|
||||
slices.Sort(names)
|
||||
index.RLock()
|
||||
defer index.RUnlock()
|
||||
for _, predicate := range predicates {
|
||||
r := make([]string, 0)
|
||||
if strings.HasPrefix(predicate, "title:") {
|
||||
token := predicate[6:]
|
||||
for _, name := range names {
|
||||
if strings.Contains(strings.ToLower(index.titles[name]), token) {
|
||||
r = append(r, name)
|
||||
}
|
||||
}
|
||||
} else if predicate == "blog:true" || predicate == "blog:false" {
|
||||
blog := predicate == "blog:true"
|
||||
re := regexp.MustCompile(`(^|/)\d\d\d\d-\d\d-\d\d`)
|
||||
for _, name := range names {
|
||||
match := re.MatchString(name)
|
||||
if blog && match || !blog && !match {
|
||||
r = append(r, name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("Unsupported predicate: %s", predicate)
|
||||
}
|
||||
names = intersection(names, r)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// grep searches the files for matches to all the tokens. It returns just a single page of results based [from:to-1] and
|
||||
// returns if there are more results. The all parameter ignores pagination (the from and to parameters). The keepFirst
|
||||
// parameter keeps the first page in the list, even if there is no match. This is used for hashtag pages.
|
||||
func grep(tokens, names []string, from, to int, all, keepFirst bool) ([]*Page, bool) {
|
||||
pages := make([]*Page, 0)
|
||||
i := 0
|
||||
NameLoop:
|
||||
for n, name := range names {
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
log.Printf("grep: cannot load %s: %s", name, err)
|
||||
continue NameLoop
|
||||
}
|
||||
if n != 0 || !keepFirst {
|
||||
body := strings.ToLower(string(p.Body))
|
||||
for _, token := range tokens {
|
||||
if !strings.Contains(body, token) {
|
||||
continue NameLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
i++
|
||||
if all || i > from {
|
||||
pages = append(pages, p)
|
||||
}
|
||||
if !all && i > to {
|
||||
return pages, true
|
||||
}
|
||||
}
|
||||
return pages, false
|
||||
}
|
||||
|
||||
// prependQueryPage prepends the query itself, if a matching page name exists. This helps if people remember the name
|
||||
// exactly, or if searching for a hashtag. This function assumes that q is not the empty string. Return wether a page
|
||||
// was prepended or not.
|
||||
func prependQueryPage(names []string, dir, q string) ([]string, bool) {
|
||||
index.RLock()
|
||||
defer index.RUnlock()
|
||||
if q[0] == '#' && !strings.Contains(q[1:], "#") {
|
||||
q = q[1:]
|
||||
}
|
||||
q = path.Join(dir, q)
|
||||
// if q exists in names, move it to the front
|
||||
i := slices.Index(names, q)
|
||||
if i == 0 {
|
||||
return names, false
|
||||
} else if i != -1 {
|
||||
r := []string{q}
|
||||
r = append(r, names[0:i]...)
|
||||
r = append(r, names[i+1:]...)
|
||||
return r, false
|
||||
}
|
||||
// otherwise, if q is a known page name, prepend it
|
||||
_, ok := index.titles[q]
|
||||
if ok {
|
||||
return append([]string{q}, names...), true
|
||||
}
|
||||
return names, false
|
||||
}
|
||||
|
||||
// searchHandler presents a search result. It uses the query string in
|
||||
// the form parameter "q" and the template "search.html". For each
|
||||
// page found, the HTML is just an extract of the actual body.
|
||||
func searchHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Search is limited to a directory and its subdirectories.
|
||||
func searchHandler(w http.ResponseWriter, r *http.Request, dir string) {
|
||||
q := r.FormValue("q")
|
||||
items := search(q)
|
||||
s := &Search{Query: q, Items: items, Results: len(items) > 0}
|
||||
page, err := strconv.Atoi(r.FormValue("page"))
|
||||
if err != nil {
|
||||
page = 1
|
||||
}
|
||||
items, more := search(q, dir, page, false)
|
||||
s := &Search{Query: q, Dir: dir, Items: items, Previous: page - 1, Page: page, Next: page + 1,
|
||||
Results: len(items) > 0, More: more}
|
||||
renderTemplate(w, "search", s)
|
||||
}
|
||||
|
||||
26
search.html
26
search.html
@@ -1,32 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>Search for {{.Query}}</title>
|
||||
<style>
|
||||
html { max-width: 65ch; padding: 2ch; margin: auto; color: #111; background: #ffe; }
|
||||
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
|
||||
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
|
||||
body { hyphens: auto; }
|
||||
header a { margin-right: 1ch; }
|
||||
form { display: inline-block; }
|
||||
input#search { width: 20ch; }
|
||||
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
|
||||
img { max-width: 20%; }
|
||||
.result { font-size: larger }
|
||||
.score { font-size: smaller; opacity: 0.8; }
|
||||
</style>
|
||||
</head>
|
||||
<body lang="en">
|
||||
<body>
|
||||
<header>
|
||||
<a href="#main">Skip navigation</a>
|
||||
<a href="/view/index">Home</a>
|
||||
<form role="search" action="/search" method="GET">
|
||||
<input type="text" value="{{.Query}}" spellcheck="false" name="q" required>
|
||||
<button>Search</button>
|
||||
<form role="search" action="/search/{{.Dir}}" method="GET">
|
||||
<label for="search">Search:</label>
|
||||
<input id="search" type="text" value="{{.Query}}" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
|
||||
<button>Go</button>
|
||||
</form>
|
||||
</header>
|
||||
<main id="main">
|
||||
<h1>Search for {{.Query}}</h1>
|
||||
{{if .Results}}
|
||||
<p>
|
||||
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">First</a>{{end}}
|
||||
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
|
||||
Page {{.Page}}
|
||||
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
|
||||
{{range .Items}}
|
||||
<article lang="{{.Language}}">
|
||||
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
|
||||
@@ -34,6 +43,11 @@ img { max-width: 20%; }
|
||||
<blockquote>{{.Html}}</blockquote>
|
||||
</article>
|
||||
{{end}}
|
||||
<p>
|
||||
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">First</a>{{end}}
|
||||
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
|
||||
Page {{.Page}}
|
||||
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
|
||||
{{else}}
|
||||
<p>No results.</p>
|
||||
{{end}}
|
||||
|
||||
97
search_cmd.go
Normal file
97
search_cmd.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/google/subcommands"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type searchCmd struct {
|
||||
dir string
|
||||
page int
|
||||
all bool
|
||||
extract bool
|
||||
}
|
||||
|
||||
func (cmd *searchCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.StringVar(&cmd.dir, "dir", "", "search only pages within this sub-directory")
|
||||
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")
|
||||
}
|
||||
|
||||
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>:
|
||||
Search for pages matching terms and print the result set as a
|
||||
Markdown list. Before searching, all the pages are indexed. Thus,
|
||||
startup is slow. The benefit is that the page order is exactly as
|
||||
when the wiki runs.
|
||||
`
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
index.load()
|
||||
q := strings.Join(args, " ")
|
||||
items, more := search(q, dir, n, true)
|
||||
if !quiet {
|
||||
fmt.Fprint(os.Stderr, "Search for ", q)
|
||||
if !all {
|
||||
fmt.Fprint(os.Stderr, ", page ", n)
|
||||
}
|
||||
fmt.Fprint(os.Stderr, ": ", len(items))
|
||||
if len(items) == 1 {
|
||||
fmt.Fprint(os.Stderr, " result\n")
|
||||
} else {
|
||||
fmt.Fprint(os.Stderr, " results\n")
|
||||
}
|
||||
}
|
||||
if extract {
|
||||
searchExtract(w, items)
|
||||
} else {
|
||||
for _, p := range items {
|
||||
name := p.Name
|
||||
if strings.HasPrefix(name, dir) {
|
||||
name = strings.Replace(name, dir, "", 1)
|
||||
}
|
||||
fmt.Fprintf(w, "* [%s](%s)\n", p.Title, name)
|
||||
}
|
||||
}
|
||||
if more {
|
||||
fmt.Fprintf(os.Stderr, "There are more results\n")
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
// searchExtract prints the search extracts to stdout with highlighting for a terminal.
|
||||
func searchExtract(w io.Writer, items []*Page) {
|
||||
heading := lipgloss.NewStyle().Bold(true).Underline(true)
|
||||
quote := lipgloss.NewStyle().PaddingLeft(4).Width(78)
|
||||
match := lipgloss.NewStyle().Bold(true)
|
||||
re := regexp.MustCompile(`<b>(.*?)</b>`)
|
||||
for _, p := range items {
|
||||
s := re.ReplaceAllString(string(p.Html), match.Render(`$1`))
|
||||
fmt.Fprintln(w, heading.Render(p.Title))
|
||||
if p.Name != p.Title {
|
||||
fmt.Fprintln(w, p.Name)
|
||||
}
|
||||
fmt.Fprintln(w, quote.Render(s))
|
||||
}
|
||||
}
|
||||
33
search_cmd_test.go
Normal file
33
search_cmd_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/google/subcommands"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchCmd(t *testing.T) {
|
||||
b := new(bytes.Buffer)
|
||||
s := searchCli(b, "", 1, false, false, true, []string{"oddµ"})
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
r := `* [Oddµ: A minimal wiki](README)
|
||||
* [Welcome to Oddµ](index)
|
||||
`
|
||||
assert.Equal(t, r, b.String())
|
||||
}
|
||||
|
||||
func TestSearchSubdirCmd(t *testing.T) {
|
||||
cleanup(t, "testdata/search")
|
||||
p := &Page{Name: "testdata/search/wait", Body: []byte(`# Wait
|
||||
We should make it so
|
||||
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"})
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
r := `* [Wait](wait)
|
||||
`
|
||||
assert.Equal(t, r, b.String())
|
||||
}
|
||||
185
search_test.go
185
search_test.go
@@ -1,14 +1,193 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"net/url"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSortNames(t *testing.T) {
|
||||
index.Lock()
|
||||
for _, s := range []string{"Alex", "Berta", "Chris", "2015-06-14", "2023-09-26"} {
|
||||
index.titles[s] = s
|
||||
}
|
||||
index.Unlock()
|
||||
terms := []string{"Z"}
|
||||
fn := sortNames(terms)
|
||||
assert.Equal(t, 1, fn("Berta", "Alex"), "B is after A")
|
||||
assert.Equal(t, -1, fn("Alex", "Berta"), "A is before B")
|
||||
assert.Equal(t, 0, fn("Berta", "Berta"), "B and B are equal")
|
||||
assert.Equal(t, -1, fn("2023-09-26", "Alex"), "numbers before letters")
|
||||
assert.Equal(t, 1, fn("Alex", "2023-09-26"), "numbers after letters")
|
||||
assert.Equal(t, -1, fn("2023-09-26", "2015-06-14"), "higher numbers before lower numbers")
|
||||
assert.Equal(t, 1, fn("2015-06-14", "2023-09-26"), "lower numbers after higher numbers")
|
||||
|
||||
names := []string{"Berta", "Chris", "Alex"}
|
||||
slices.SortFunc(names, sortNames(terms))
|
||||
assert.True(t, slices.IsSorted(names), fmt.Sprintf("Sorted: %v", names))
|
||||
}
|
||||
|
||||
func TestPrependMatches(t *testing.T) {
|
||||
index.Lock()
|
||||
for _, s := range []string{"Alex", "Berta", "Chris"} {
|
||||
index.titles[s] = s
|
||||
}
|
||||
index.Unlock()
|
||||
r := []string{"Berta", "Chris"} // does not prepend
|
||||
u := []string{"Alex", "Berta", "Chris"} // does prepend
|
||||
v, _ := prependQueryPage(r, "", "Alex")
|
||||
assert.Equal(t, u, v, "prepend q")
|
||||
v, _ = prependQueryPage(r, "", "lex")
|
||||
assert.Equal(t, r, v, "exact matches only")
|
||||
v, _ = prependQueryPage(r, "", "#Alex")
|
||||
assert.Equal(t, u, v, "prepend hashtag")
|
||||
v, _ = prependQueryPage(r, "", "#Alex #Berta")
|
||||
assert.Equal(t, r, v, "do not prepend two hashtags")
|
||||
v, _ = prependQueryPage(r, "", "#alex")
|
||||
assert.Equal(t, r, v, "do not ignore case")
|
||||
v, _ = prependQueryPage(u, "", "Alex")
|
||||
assert.Equal(t, u, v, "do not prepend q twice")
|
||||
v, _ = prependQueryPage([]string{"Berta", "Alex", "Chris"}, "", "Alex")
|
||||
assert.Equal(t, u, v, "sort q to the front")
|
||||
v, _ = prependQueryPage([]string{"Berta", "Chris", "Alex"}, "", "Alex")
|
||||
assert.Equal(t, u, v, "sort q to the front")
|
||||
}
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
data := url.Values{}
|
||||
data.Set("q", "oddµ")
|
||||
assert.Contains(t,
|
||||
assert.HTTPBody(searchHandler, "GET", "/search", data), "Welcome")
|
||||
|
||||
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
|
||||
assert.Contains(t, body, "Welcome")
|
||||
assert.Contains(t, body, `<span class="score">5</span>`)
|
||||
|
||||
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata", data)
|
||||
assert.NotContains(t, body, "Welcome")
|
||||
}
|
||||
|
||||
func TestSearchDir(t *testing.T) {
|
||||
cleanup(t, "testdata/dir")
|
||||
p := &Page{Name: "testdata/dir/dice", Body: []byte(`# Dice
|
||||
|
||||
A tiny drum roll
|
||||
Dice rolling bouncing stopping
|
||||
Where is lady luck?`)}
|
||||
p.save()
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("q", "luck")
|
||||
|
||||
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
|
||||
assert.Contains(t, body, "luck")
|
||||
|
||||
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata", data)
|
||||
assert.Contains(t, body, "luck")
|
||||
|
||||
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/dir", data)
|
||||
assert.Contains(t, body, "luck")
|
||||
|
||||
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/other", data)
|
||||
assert.Contains(t, body, "No results")
|
||||
}
|
||||
|
||||
func TestTitleSearch(t *testing.T) {
|
||||
items, more := search("title:readme", "", 1, false)
|
||||
assert.Equal(t, 0, len(items), "no page found")
|
||||
assert.False(t, more)
|
||||
|
||||
items, more = search("title:wel", "", 1, false) // README also contains "wel"
|
||||
assert.Equal(t, 1, len(items), "one page found")
|
||||
assert.Equal(t, "index", items[0].Name, "Welcome to Oddµ")
|
||||
assert.Greater(t, items[0].Score, 0, "matches result in a score")
|
||||
assert.False(t, more)
|
||||
|
||||
items, more = search("wel", "", 1, false)
|
||||
assert.Greater(t, len(items), 1, "two pages found")
|
||||
assert.False(t, more)
|
||||
}
|
||||
|
||||
func TestBlogSearch(t *testing.T) {
|
||||
cleanup(t, "testdata/grep")
|
||||
p := &Page{Name: "testdata/grep/2023-09-25", Body: []byte(`# Back then
|
||||
|
||||
I check the git log
|
||||
Was it 2015
|
||||
We met in the park?`)}
|
||||
p.save()
|
||||
|
||||
items, _ := search("blog:false", "", 1, false)
|
||||
for _, item := range items {
|
||||
assert.NotEqual(t, "Back then", item.Title, item.Name)
|
||||
}
|
||||
|
||||
items, _ = search("blog:true", "", 1, false)
|
||||
assert.Equal(t, 1, len(items), "one blog page found")
|
||||
assert.Equal(t, "Back then", items[0].Title, items[0].Name)
|
||||
}
|
||||
|
||||
func TestHashtagSearch(t *testing.T) {
|
||||
cleanup(t, "testdata/hashtag")
|
||||
|
||||
p := &Page{Name: "testdata/hashtag/Haiku", Body: []byte("# Haikus\n")}
|
||||
p.save()
|
||||
|
||||
p = &Page{Name: "testdata/hashtag/2023-10-28", Body: []byte(`# Tea
|
||||
|
||||
My tongue is on fire
|
||||
It looked so calm and peaceful
|
||||
A quick sip too quick
|
||||
|
||||
#Haiku`)}
|
||||
p.save()
|
||||
|
||||
items, _ := search("#Haiku", "testdata/hashtag", 1, false)
|
||||
assert.Equal(t, 2, len(items), "two pages found")
|
||||
assert.Equal(t, "Haikus", items[0].Title, items[0].Name)
|
||||
assert.Equal(t, "Tea", items[1].Title, items[1].Name)
|
||||
}
|
||||
|
||||
func TestSearchQuestionmark(t *testing.T) {
|
||||
cleanup(t, "testdata/question")
|
||||
p := &Page{Name: "testdata/question/Odd?", Body: []byte(`# Even?
|
||||
|
||||
We look at the plants.
|
||||
They need water. We need us.
|
||||
The silence streches.`)}
|
||||
p.save()
|
||||
data := url.Values{}
|
||||
data.Set("q", "look")
|
||||
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
|
||||
assert.Contains(t, body, "We <b>look</b>")
|
||||
assert.NotContains(t, body, "Odd?")
|
||||
assert.Contains(t, body, "Even?")
|
||||
}
|
||||
|
||||
func TestSearchPagination(t *testing.T) {
|
||||
cleanup(t, "testdata/pagination")
|
||||
index.load()
|
||||
alphabet := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
for _, r := range alphabet {
|
||||
s := fmt.Sprintf("secret%c secretX", r)
|
||||
p := &Page{Name: "testdata/pagination/" + string(r), Body: []byte(s)}
|
||||
p.save()
|
||||
}
|
||||
|
||||
items, more := search("secretA", "", 1, false)
|
||||
assert.Equal(t, 1, len(items), "one page found, %v", items)
|
||||
assert.Equal(t, "testdata/pagination/A", items[0].Name)
|
||||
assert.False(t, more)
|
||||
|
||||
items, more = search("secretX", "", 1, false)
|
||||
assert.Equal(t, itemsPerPage, len(items))
|
||||
assert.Equal(t, "testdata/pagination/A", items[0].Name)
|
||||
assert.Equal(t, "testdata/pagination/T", items[itemsPerPage-1].Name)
|
||||
assert.True(t, more)
|
||||
|
||||
items, more = search("secretX", "", 2, false)
|
||||
assert.Equal(t, 6, len(items))
|
||||
assert.Equal(t, "testdata/pagination/U", items[0].Name)
|
||||
assert.Equal(t, "testdata/pagination/Z", items[5].Name)
|
||||
assert.False(t, more)
|
||||
}
|
||||
|
||||
21
snippets.go
21
snippets.go
@@ -1,20 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// re returns a regular expression matching any word in q.
|
||||
func re(q string) (*regexp.Regexp, error) {
|
||||
q = regexp.QuoteMeta(q)
|
||||
re, err := regexp.Compile(`\s+`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
fields := highlightTokens(q)
|
||||
quoted := make([]string, len(fields))
|
||||
for i, w := range fields {
|
||||
quoted[i] = regexp.QuoteMeta(w)
|
||||
}
|
||||
words := re.ReplaceAllString(q, "|")
|
||||
re, err = regexp.Compile(`(?i)(` + words + `)`)
|
||||
re, err := regexp.Compile(`(?i)(` + strings.Join(quoted, "|") + `)`)
|
||||
if err != nil {
|
||||
log.Printf("Cannot compile %s %v: %s", q, quoted, err)
|
||||
return nil, err
|
||||
}
|
||||
return re, nil
|
||||
@@ -26,12 +27,16 @@ func snippets(q string, s string) string {
|
||||
maxsnippets := 4
|
||||
re, err := re(q)
|
||||
// If the compilation didn't work, truncate and return
|
||||
if err != nil || len(s) <= snippetlen {
|
||||
if err != nil {
|
||||
if len(s) > 400 {
|
||||
s = s[0:400] + " …"
|
||||
}
|
||||
return s
|
||||
}
|
||||
// Short cut for short pages
|
||||
if len(s) <= snippetlen {
|
||||
return highlight(q, re, s)
|
||||
}
|
||||
// show a snippet from the beginning of the document
|
||||
j := strings.LastIndex(s[:snippetlen], " ")
|
||||
if j == -1 {
|
||||
@@ -75,7 +80,7 @@ func snippets(q string, s string) string {
|
||||
to = len(s)
|
||||
}
|
||||
end := strings.LastIndex(s[:to], " ")
|
||||
if end == -1 || end <= j + wl {
|
||||
if end == -1 || end <= j+wl {
|
||||
// OK, look for a longer word
|
||||
end = strings.Index(s[to:], " ")
|
||||
if end == -1 {
|
||||
|
||||
@@ -10,7 +10,7 @@ func TestSnippets(t *testing.T) {
|
||||
|
||||
h := `We are immersed in a sea of dead people. <b>All</b> the dead that have gone before us, silent now, just … to ask ourselves – or them! – what it was <b>all</b> about. Instead we drown ourselves in no<b>is</b>e. … surrounded by false friends claiming that <b>all</b> <b>is</b> <b>well</b>. And look at us! Yes, we are <b>well</b>. …`
|
||||
|
||||
q := "all is well"
|
||||
q := "title:all is well"
|
||||
r := snippets(q, s)
|
||||
assert.Equal(t, h, r)
|
||||
}
|
||||
@@ -41,5 +41,5 @@ func TestSnippetsLong(t *testing.T) {
|
||||
assert.Equal(t,
|
||||
"VWwXetig mty8fORN UNia4NFm SQsfyFHk BLDdgVnc AcvKP2fs q8KxPH1A IaCzFj96 J0S2fqca jp3ElV9f ULIZ1aMX … Auk5RBWs tMpfMMlU p6jGYq3Z rTIBTHVM zGFwFwQi <b>j4O1AY21</b> BJnaiScY",
|
||||
snippets("j4O1AY21", s))
|
||||
|
||||
|
||||
}
|
||||
|
||||
31
static.html
Normal file
31
static.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{.Language}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
html { max-width: 65ch; padding: 1ch; margin: auto; color: #ddd; background-color: #222; }
|
||||
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
|
||||
body { hyphens: auto; }
|
||||
header a { margin-right: 1ch; }
|
||||
form { display: inline-block; }
|
||||
input#search { width: 12ch; }
|
||||
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
|
||||
footer { border-top: 1px solid #888 }
|
||||
img { max-width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="main">
|
||||
<h1>{{.Title}}</h1>
|
||||
{{.Html}}
|
||||
</main>
|
||||
<footer>
|
||||
<address>
|
||||
Send text via the long-range comms to Ashivom Bandaralum <<a href="mailto:jupiter@transjovian.org">jupiter@transjovian.org</a>>
|
||||
</address>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
162
static_cmd.go
Normal file
162
static_cmd.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/google/subcommands"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type staticCmd struct {
|
||||
}
|
||||
|
||||
func (*staticCmd) Name() string { return "static" }
|
||||
func (*staticCmd) Synopsis() string { return "generate static HTML files for all pages" }
|
||||
func (*staticCmd) Usage() string {
|
||||
return `static <dir name>:
|
||||
Create static copies in the given directory.
|
||||
`
|
||||
}
|
||||
|
||||
func (cmd *staticCmd) SetFlags(f *flag.FlagSet) {
|
||||
}
|
||||
|
||||
func (cmd *staticCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
args := f.Args()
|
||||
if len(args) != 1 {
|
||||
fmt.Println("Exactly one target directory is required")
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
return staticCli(filepath.Clean(args[0]), false)
|
||||
}
|
||||
|
||||
// staticCli generates a static site in the designated directory. The quiet flag is used to suppress output when running
|
||||
// tests.
|
||||
func staticCli(dir string, quiet bool) subcommands.ExitStatus {
|
||||
err := os.Mkdir(dir, 0755)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
initAccounts()
|
||||
if (!quiet) {
|
||||
fmt.Printf("Loaded %d languages\n", loadLanguages())
|
||||
}
|
||||
templates := loadTemplates()
|
||||
n := 0;
|
||||
err = filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
|
||||
n++
|
||||
if (!quiet && (n < 100 || n < 1000 && n % 10 == 0 || n % 100 == 0)) {
|
||||
fmt.Fprintf(os.Stdout, "\r%d", n)
|
||||
}
|
||||
return staticFile(path, dir, info, templates, err)
|
||||
})
|
||||
if (!quiet) {
|
||||
fmt.Printf("\r%d\n", n)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
// staticFile is used to walk the file trees and do the right thing for the destination directory: create
|
||||
// subdirectories, link files, render HTML files.
|
||||
func staticFile(path, dir string, info fs.FileInfo, templates *template.Template, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filename := path
|
||||
// skip "hidden" files and backup files, avoid recursion
|
||||
if strings.HasPrefix(filename, ".") ||
|
||||
strings.HasSuffix(filename, "~") ||
|
||||
strings.HasPrefix(filename, dir) {
|
||||
return nil
|
||||
}
|
||||
// recreate subdirectories
|
||||
if info.IsDir() {
|
||||
return os.Mkdir(filepath.Join(dir, filename), 0755)
|
||||
}
|
||||
// render pages
|
||||
if strings.HasSuffix(filename, ".md") {
|
||||
return staticPage(filename, dir, templates)
|
||||
}
|
||||
// remaining files are linked
|
||||
return os.Link(filename, filepath.Join(dir, filename))
|
||||
}
|
||||
|
||||
// staticPage takes the filename of a page (ending in ".md") and generates a static HTML page.
|
||||
func staticPage(filename, dir string, templates *template.Template) error {
|
||||
name := strings.TrimSuffix(filename, ".md")
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", name, err)
|
||||
return err
|
||||
}
|
||||
p.handleTitle(true)
|
||||
// instead of p.renderHtml() we do it all ourselves, appending ".html" to all the local links
|
||||
parser, hashtags := wikiParser()
|
||||
doc := markdown.Parse(p.Body, parser)
|
||||
ast.WalkFunc(doc, staticLinks)
|
||||
opts := html.RendererOptions{
|
||||
Flags: html.CommonFlags,
|
||||
}
|
||||
renderer := html.NewRenderer(opts)
|
||||
maybeUnsafeHTML := markdown.Render(doc, renderer)
|
||||
p.Name = nameEscape(p.Name)
|
||||
p.Html = unsafeBytes(maybeUnsafeHTML)
|
||||
p.Language = language(p.plainText())
|
||||
p.Hashtags = *hashtags
|
||||
return p.write(filepath.Join(dir, name+".html"), templates)
|
||||
}
|
||||
|
||||
// staticLinks checks a node and if it is a link to a local page, it appends ".html" to the link destination.
|
||||
func staticLinks(node ast.Node, entering bool) ast.WalkStatus {
|
||||
if entering {
|
||||
switch v := node.(type) {
|
||||
case *ast.Link:
|
||||
// not an absolute URL, not a full URL, not a mailto: URI
|
||||
if !bytes.HasPrefix(v.Destination, []byte("/")) &&
|
||||
!bytes.Contains(v.Destination, []byte("://")) &&
|
||||
!bytes.HasPrefix(v.Destination, []byte("mailto:")) {
|
||||
// pointing to a page file (instead of an image file, for example).
|
||||
fn, err := url.PathUnescape(string(v.Destination))
|
||||
if err != nil {
|
||||
return ast.GoToNext
|
||||
}
|
||||
_, err = os.Stat(fn + ".md")
|
||||
if err != nil {
|
||||
return ast.GoToNext
|
||||
}
|
||||
v.Destination = append(v.Destination, []byte(".html")...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ast.GoToNext
|
||||
}
|
||||
|
||||
func (p *Page) write(destination string, templates *template.Template) error {
|
||||
t := "static.html"
|
||||
f, err := os.Create(destination)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot create %s.html: %s\n", destination, err)
|
||||
return err
|
||||
}
|
||||
err = templates.ExecuteTemplate(f, t, p)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", t, destination, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
19
static_cmd_test.go
Normal file
19
static_cmd_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/google/subcommands"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStatusCmd(t *testing.T) {
|
||||
cleanup(t, "testdata/static")
|
||||
s := staticCli("testdata/static", true)
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
// pages
|
||||
assert.FileExists(t, "testdata/static/index.html")
|
||||
assert.FileExists(t, "testdata/static/README.html")
|
||||
// regular files
|
||||
assert.FileExists(t, "testdata/static/static_cmd.go")
|
||||
assert.FileExists(t, "testdata/static/static_cmd_test.go")
|
||||
}
|
||||
95
tokenizer.go
Normal file
95
tokenizer.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// lowercaseFilter returns a slice of lower case tokens.
|
||||
func lowercaseFilter(tokens []string) []string {
|
||||
r := make([]string, len(tokens))
|
||||
for i, token := range tokens {
|
||||
r[i] = strings.ToLower(token)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// tokenizeWithPredicates returns a slice of tokens for the given
|
||||
// text, including punctuation. Use this to begin tokenizing the query
|
||||
// string.
|
||||
func tokenizeOnWhitespace(q string) []string {
|
||||
return strings.Fields(q)
|
||||
}
|
||||
|
||||
// predicateFilter returns two slices of tokens: the first with
|
||||
// predicates, the other without predicates. Use this for query
|
||||
// string tokens.
|
||||
func predicateFilter(tokens []string) ([]string, []string) {
|
||||
with := make([]string, 0)
|
||||
without := make([]string, 0)
|
||||
for _, token := range tokens {
|
||||
if strings.Contains(token, ":") {
|
||||
with = append(with, token)
|
||||
} else {
|
||||
without = append(without, token)
|
||||
}
|
||||
}
|
||||
return with, without
|
||||
}
|
||||
|
||||
// predicatesAndTokens returns two slices of tokens: the first with
|
||||
// predicates, the other without predicates, all of them lower case.
|
||||
// Use this for query strings.
|
||||
func predicatesAndTokens(q string) ([]string, []string) {
|
||||
tokens := tokenizeOnWhitespace(q)
|
||||
tokens = lowercaseFilter(tokens)
|
||||
return predicateFilter(tokens)
|
||||
}
|
||||
|
||||
// noPredicateFilter returns a slice of tokens: the predicates without
|
||||
// the predicate, and all the others. That is: "foo:bar baz" is turned
|
||||
// into ["bar", "baz"] and the predicate "foo:" is dropped.
|
||||
func noPredicateFilter(tokens []string) []string {
|
||||
r := make([]string, 0)
|
||||
for _, token := range tokens {
|
||||
parts := strings.Split(token, ":")
|
||||
r = append(r, parts[len(parts)-1])
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// highlightTokens returns the tokens to highlight, including title
|
||||
// predicates.
|
||||
func highlightTokens(q string) []string {
|
||||
tokens := tokenizeOnWhitespace(q)
|
||||
tokens = lowercaseFilter(tokens)
|
||||
return noPredicateFilter(tokens)
|
||||
}
|
||||
|
||||
// hashtags returns a slice of hashtags. Use this to extract hashtags
|
||||
// from a page body.
|
||||
func hashtags(s []byte) []string {
|
||||
hashtags := make([]string, 0)
|
||||
for {
|
||||
i := bytes.IndexRune(s, '#')
|
||||
if i == -1 {
|
||||
return hashtags
|
||||
}
|
||||
from := i
|
||||
i++
|
||||
for {
|
||||
r, n := utf8.DecodeRune(s[i:])
|
||||
if n > 0 && (unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_') {
|
||||
i += n
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if i > from+1 { // not just "#"
|
||||
hashtags = append(hashtags, string(bytes.ToLower(s[from:i])))
|
||||
}
|
||||
s = s[i:]
|
||||
}
|
||||
}
|
||||
16
tokenizer_test.go
Normal file
16
tokenizer_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashtags(t *testing.T) {
|
||||
assert.EqualValues(t, []string{"#truth"}, hashtags([]byte("This is boring. #Truth")), "hashtags")
|
||||
}
|
||||
|
||||
func TestTokensAndPredicates(t *testing.T) {
|
||||
predicates, terms := predicatesAndTokens("foo title:bar")
|
||||
assert.EqualValues(t, []string{"foo"}, terms)
|
||||
assert.EqualValues(t, []string{"title:bar"}, predicates)
|
||||
}
|
||||
37
upload.html
37
upload.html
@@ -6,15 +6,42 @@
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>Upload File</title>
|
||||
<style>
|
||||
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background: #ffe; }
|
||||
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
|
||||
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
|
||||
body { hyphens: auto; }
|
||||
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee; }
|
||||
form, textarea { width: 100%; }
|
||||
label { display: inline-block; width: 20ch }
|
||||
.last { max-width: 20% }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body lang="en">
|
||||
<h1>Upload File</h1>
|
||||
<form action="/drop/{{.}}" method="POST" enctype="multipart/form-data">
|
||||
<input type="text" name="name" placeholder="image.jpg" autofocus required>
|
||||
<p><input type="file" name="file" required>
|
||||
{{if ne .Last ""}}
|
||||
<p>Previous upload: <a href="/view/{{.Last}}">{{.Last}}</a></p>
|
||||
{{if .Image}}
|
||||
<p><img class="last" src="/view/{{.Last}}"></p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<form action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
|
||||
<p>When uploading pictures from a phone, its filename is going to be something cryptic like IMG_1234.JPG.
|
||||
Please provide your own filename. End the filename with "-1" to auto-increment.
|
||||
<p><label for="text">Filename to use:</label>
|
||||
<input id="text" 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.
|
||||
Sadly, resizing only works for JPG and PNG files. Luckily, most pictures from a phone camera are JPG images.
|
||||
Feel free to specify a max width of 1200 pixels, for example.
|
||||
<p><label for="maxwidth">Max width:</label>
|
||||
<input id="maxwidth" name="maxwidth" value="{{.MaxWidth}}" type="number" min="10" placeholder="1200">
|
||||
<p>If the uploaded file is a JPEG-encoded picture, like most pictures from a phone, you can specify a quality.
|
||||
Typically, a quality of 60 is not too bad and a quality of 90 is more than enough.
|
||||
<p><label for="quality">Quality:</label>
|
||||
<input id="quality" name="quality" value="{{.Quality}}" type="number" min="1" max="99" placeholder="75">
|
||||
<p>Finally, pick the file or photo to upload.
|
||||
Picture metadata is only removed if the picture gets resized.
|
||||
Providing a new max width is recommended for all pictures.
|
||||
<p><label for="file">Pick file to upload:</label>
|
||||
<input type="file" name="file" required>
|
||||
<p><input type="submit" value="Save">
|
||||
<a href="/view/index"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
|
||||
153
upload_drop.go
153
upload_drop.go
@@ -1,21 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/anthonynsimon/bild/imgio"
|
||||
"github.com/anthonynsimon/bild/transform"
|
||||
"github.com/bashdrew/goheif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// uploadHandler uses the "upload.html" template to enable uploads.
|
||||
// The file is saved using the saveUploadHandler.
|
||||
func uploadHandler(w http.ResponseWriter, r *http.Request, dir string) {
|
||||
renderTemplate(w, "upload", dir)
|
||||
type Upload struct {
|
||||
Dir string
|
||||
Name string
|
||||
Last string
|
||||
Image bool
|
||||
MaxWidth string
|
||||
Quality string
|
||||
}
|
||||
|
||||
// dropHandler takes the "name" form field and the "file" form
|
||||
// file and saves the file under the given name. The browser is
|
||||
// redirected to the view of that file.
|
||||
var lastRe = regexp.MustCompile(`^(.*)([0-9]+)(.*)$`)
|
||||
|
||||
// uploadHandler uses the "upload.html" template to enable uploads. The file is saved using the dropHandler. URL
|
||||
// parameters are used to copy name, maxwidth and quality from the previous upload. If the previous name contains a
|
||||
// number, this is incremented by one.
|
||||
func uploadHandler(w http.ResponseWriter, r *http.Request, dir string) {
|
||||
data := &Upload{Dir: dir}
|
||||
maxwidth := r.FormValue("maxwidth")
|
||||
if maxwidth != "" {
|
||||
data.MaxWidth = maxwidth
|
||||
}
|
||||
quality := r.FormValue("quality")
|
||||
if quality != "" {
|
||||
data.Quality = quality
|
||||
}
|
||||
last := r.FormValue("last")
|
||||
if last != "" {
|
||||
ext := strings.ToLower(filepath.Ext(last))
|
||||
switch ext {
|
||||
case ".png", ".jpg", ".jpeg":
|
||||
data.Image = true
|
||||
}
|
||||
data.Last = path.Join(dir, last)
|
||||
m := lastRe.FindStringSubmatch(last)
|
||||
if m != nil {
|
||||
n, err := strconv.Atoi(m[2])
|
||||
if err == nil {
|
||||
data.Name = m[1] + strconv.Itoa(n+1) + m[3]
|
||||
}
|
||||
}
|
||||
}
|
||||
renderTemplate(w, "upload", data)
|
||||
}
|
||||
|
||||
// dropHandler takes the "name" form field and the "file" form file and saves the file under the given name. The browser
|
||||
// is redirected to the view of that file.
|
||||
func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
|
||||
d := path.Dir(dir)
|
||||
// ensure the directory exists
|
||||
@@ -28,29 +74,90 @@ func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
|
||||
http.Error(w, "file exists", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
filename := r.FormValue("name")
|
||||
data := url.Values{}
|
||||
name := r.FormValue("name")
|
||||
data.Set("last", name)
|
||||
filename := filepath.Base(name)
|
||||
if filename == "." || filepath.Dir(name) != "." {
|
||||
http.Error(w, "no filename", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
// backup an existing file with the same name
|
||||
_, err = os.Stat(filename)
|
||||
if err != nil {
|
||||
os.Rename(filename, filename + "~")
|
||||
}
|
||||
backup(filename)
|
||||
// create the new file
|
||||
dst, err := os.Create(d + "/" + filename)
|
||||
path := d + "/" + filename
|
||||
dst, err := os.Create(path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/view/"+filename, http.StatusFound)
|
||||
// if a resize was requested
|
||||
maxwidth := r.FormValue("maxwidth")
|
||||
if len(maxwidth) > 0 {
|
||||
mw, err := strconv.Atoi(maxwidth)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data.Add("maxwidth", maxwidth)
|
||||
// determine how the file will be written
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
var encoder imgio.Encoder
|
||||
switch ext {
|
||||
case ".png":
|
||||
encoder = imgio.PNGEncoder()
|
||||
case ".jpg", ".jpeg":
|
||||
q := jpeg.DefaultQuality
|
||||
quality := r.FormValue("quality")
|
||||
if len(quality) > 0 {
|
||||
q, err = strconv.Atoi(quality)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data.Add("quality", quality)
|
||||
}
|
||||
encoder = imgio.JPEGEncoder(q)
|
||||
default:
|
||||
http.Error(w, "Resizing images requires a .png, .jpg or .jpeg extension for the filename", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// try and decode the data in various formats
|
||||
img, err := jpeg.Decode(file)
|
||||
if err != nil {
|
||||
img, err = png.Decode(file)
|
||||
}
|
||||
if err != nil {
|
||||
img, err = goheif.Decode(file)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "The image could not be decoded (only PNG, JPG and HEIC formats are supported for resizing)", http.StatusInternalServerError)
|
||||
}
|
||||
rect := img.Bounds()
|
||||
width := rect.Max.X - rect.Min.X
|
||||
if width > mw {
|
||||
height := (rect.Max.Y - rect.Min.Y) * mw / width
|
||||
img = transform.Resize(img, mw, height, transform.Linear)
|
||||
if err := imgio.Save(path, img, encoder); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "The file is too small for this", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// just copy the bytes
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/upload/"+d+"/?"+data.Encode(), http.StatusFound)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,34 +3,154 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// wipes testdata
|
||||
func TestUpload(t *testing.T) {
|
||||
_ = os.RemoveAll("testdata")
|
||||
cleanup(t, "testdata/files")
|
||||
// for uploads, the directory is not created automatically
|
||||
os.MkdirAll("testdata", 0755)
|
||||
assert.HTTPStatusCode(t, makeHandler(uploadHandler, false), "GET", "/upload/", nil, 200)
|
||||
os.MkdirAll("testdata/files", 0755)
|
||||
assert.HTTPStatusCode(t, makeHandler(uploadHandler, false), "GET", "/upload/testdata/files/", nil, 200)
|
||||
form := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(form)
|
||||
field, err := writer.CreateFormField("name")
|
||||
assert.NoError(t, err)
|
||||
_, err = field.Write([]byte("testdata/ok.txt"))
|
||||
_, err = field.Write([]byte("ok.txt"))
|
||||
assert.NoError(t, err)
|
||||
file, err := writer.CreateFormFile("file", "example.txt");
|
||||
file, err := writer.CreateFormFile("file", "example.txt")
|
||||
assert.NoError(t, err)
|
||||
file.Write([]byte("Hello!"))
|
||||
err = writer.Close()
|
||||
assert.NoError(t, err)
|
||||
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/",
|
||||
writer.FormDataContentType(), form, "/view/testdata/ok.txt")
|
||||
assert.Regexp(t, regexp.MustCompile("Hello!"),
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/ok.txt", nil))
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/files/",
|
||||
writer.FormDataContentType(), form, "/upload/testdata/files/?last=ok.txt")
|
||||
assert.Contains(t,
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/files/ok.txt", nil),
|
||||
"Hello!")
|
||||
}
|
||||
|
||||
func TestUploadPng(t *testing.T) {
|
||||
cleanup(t, "testdata/png")
|
||||
// for uploads, the directory is not created automatically
|
||||
os.MkdirAll("testdata/png", 0755)
|
||||
form := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(form)
|
||||
field, _ := writer.CreateFormField("name")
|
||||
field.Write([]byte("ok.png"))
|
||||
file, _ := writer.CreateFormFile("file", "ok.png")
|
||||
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
|
||||
png.Encode(file, img)
|
||||
writer.Close()
|
||||
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/png/",
|
||||
writer.FormDataContentType(), form, "/upload/testdata/png/?last=ok.png")
|
||||
}
|
||||
|
||||
func TestUploadJpg(t *testing.T) {
|
||||
cleanup(t, "testdata/jpg")
|
||||
// for uploads, the directory is not created automatically
|
||||
os.MkdirAll("testdata/jpg", 0755)
|
||||
form := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(form)
|
||||
field, _ := writer.CreateFormField("name")
|
||||
field.Write([]byte("ok.jpg"))
|
||||
file, _ := writer.CreateFormFile("file", "ok.jpg")
|
||||
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
|
||||
jpeg.Encode(file, img, &jpeg.Options{Quality: 90})
|
||||
writer.Close()
|
||||
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/jpg/",
|
||||
writer.FormDataContentType(), form, "/upload/testdata/jpg/?last=ok.jpg")
|
||||
}
|
||||
|
||||
func TestUploadMultiple(t *testing.T) {
|
||||
cleanup(t, "testdata/multi")
|
||||
p := &Page{Name: "testdata/multi/culture", Body: []byte(`# Culture
|
||||
|
||||
The road has walls
|
||||
Iron gates and tree tops
|
||||
But here: jasmin dreams`)}
|
||||
p.save()
|
||||
|
||||
// check location for upload
|
||||
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/multi/culture", nil)
|
||||
assert.Contains(t, body, `href="/upload/testdata/multi/"`)
|
||||
|
||||
// check location for drop
|
||||
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", "/upload/testdata/multi/", nil)
|
||||
assert.Contains(t, body, `action="/drop/testdata/multi/"`)
|
||||
|
||||
// actually do the upload
|
||||
form := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(form)
|
||||
field, _ := writer.CreateFormField("name")
|
||||
field.Write([]byte("2023-10-02-hike-1.jpg"))
|
||||
field, _ = writer.CreateFormField("maxwidth")
|
||||
field.Write([]byte("15"))
|
||||
field, _ = writer.CreateFormField("quality")
|
||||
field.Write([]byte("50"))
|
||||
file, _ := writer.CreateFormFile("file", "ok.jpg")
|
||||
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
|
||||
jpeg.Encode(file, img, &jpeg.Options{Quality: 90})
|
||||
writer.Close()
|
||||
location := HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/testdata/multi/",
|
||||
writer.FormDataContentType(), form)
|
||||
url, _ := url.Parse(location)
|
||||
assert.Equal(t, "/upload/testdata/multi/", url.Path, "Redirect to upload location")
|
||||
values := url.Query()
|
||||
assert.Equal(t, "2023-10-02-hike-1.jpg", values.Get("last"))
|
||||
assert.Equal(t, "15", values.Get("maxwidth"))
|
||||
assert.Equal(t, "50", values.Get("quality"))
|
||||
|
||||
// check the result page
|
||||
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", url.Path, values)
|
||||
assert.Contains(t, body, `value="2023-10-02-hike-2.jpg"`)
|
||||
assert.Contains(t, body, `value="15"`)
|
||||
assert.Contains(t, body, `value="50"`)
|
||||
assert.Contains(t, body, `src="/view/testdata/multi/2023-10-02-hike-1.jpg"`)
|
||||
}
|
||||
|
||||
func TestUploadDir(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, os.Remove("test.md"))
|
||||
assert.NoError(t, os.Remove("test.jpg"))
|
||||
})
|
||||
p := &Page{Name: "test", Body: []byte(`# Test
|
||||
|
||||
Eyes are an abyss
|
||||
We stare into each other
|
||||
There is no answer`)}
|
||||
p.save()
|
||||
|
||||
// check location for upload
|
||||
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/test", nil)
|
||||
assert.Contains(t, body, `href="/upload/"`)
|
||||
|
||||
// check location for drop
|
||||
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", "/upload/", nil)
|
||||
assert.Contains(t, body, `action="/drop/"`)
|
||||
|
||||
// actually do the upload
|
||||
form := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(form)
|
||||
field, _ := writer.CreateFormField("name")
|
||||
field.Write([]byte("test.jpg"))
|
||||
file, _ := writer.CreateFormFile("file", "ok.jpg")
|
||||
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
|
||||
jpeg.Encode(file, img, &jpeg.Options{Quality: 90})
|
||||
writer.Close()
|
||||
location := HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/",
|
||||
writer.FormDataContentType(), form)
|
||||
url, _ := url.Parse(location)
|
||||
assert.Equal(t, "/upload/", url.Path, "Redirect to upload location")
|
||||
values := url.Query()
|
||||
assert.Equal(t, "test.jpg", values.Get("last"))
|
||||
|
||||
// check the result page
|
||||
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", url.Path, values)
|
||||
assert.Contains(t, body, `src="/view/test.jpg"`)
|
||||
}
|
||||
|
||||
81
view.go
81
view.go
@@ -1,8 +1,11 @@
|
||||
package main
|
||||
|
||||
import(
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rootHandler just redirects to /view/index.
|
||||
@@ -10,23 +13,77 @@ func rootHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/view/index", http.StatusFound)
|
||||
}
|
||||
|
||||
// viewHandler serves existing files (including markdown files with
|
||||
// the .md extension). If the requested file does not exist, a page
|
||||
// with the same name is loaded. This means adding the .md extension
|
||||
// and using the "view.html" template to render the HTML. Both
|
||||
// attempts fail, the browser is redirected to an edit page.
|
||||
// viewHandler serves pages. If the requested URL maps to an existing file, it is served. If the requested URL maps to a
|
||||
// directory, the browser is redirected to the index page. If the requested URL ends in ".rss" and the corresponding
|
||||
// file ending with ".md" exists, a feed is generated and the "feed.html" template is used (it is used to generate a RSS
|
||||
// 2.0 feed, no matter what the template's extension is). If the requested URL maps to a page name, the corresponding
|
||||
// file (ending in ".md") is loaded and served using the "view.html" template. If none of the above, the browser is
|
||||
// redirected to an edit page.
|
||||
//
|
||||
// Caching: a 304 NOT MODIFIED is returned if the request has an If-Modified-Since header that matches the file's
|
||||
// modification time, truncated to one second. Truncation is required because the file's modtime has sub-second
|
||||
// precision and the HTTP timestamp for the Last-Modified header has not.
|
||||
func viewHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
body, err := os.ReadFile(name)
|
||||
file := true
|
||||
rss := false
|
||||
if name == "" {
|
||||
name = "."
|
||||
}
|
||||
fn := name
|
||||
fi, err := os.Stat(fn)
|
||||
if err != nil {
|
||||
file = false
|
||||
if strings.HasSuffix(fn, ".rss") {
|
||||
rss = true
|
||||
name = fn[0 : len(fn)-4]
|
||||
fn = name
|
||||
}
|
||||
fn += ".md"
|
||||
fi, err = os.Stat(fn)
|
||||
} else if fi.IsDir() {
|
||||
http.Redirect(w, r, path.Join("/view", name, "index"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
h, ok := r.Header["If-Modified-Since"]
|
||||
if ok {
|
||||
ti, err := http.ParseTime(h[0])
|
||||
if err == nil && !fi.ModTime().Truncate(time.Second).After(ti) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat))
|
||||
}
|
||||
if r.Method == http.MethodHead {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/edit/"+name, http.StatusFound)
|
||||
}
|
||||
if file {
|
||||
body, err := os.ReadFile(fn)
|
||||
if err != nil {
|
||||
// This is an internal error because os.Stat
|
||||
// says there is a file. Non-existent files
|
||||
// are treated like pages.
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
w.Write(body)
|
||||
return
|
||||
}
|
||||
p, err := loadPage(name)
|
||||
if err == nil {
|
||||
p.handleTitle(true)
|
||||
p.renderHtml()
|
||||
renderTemplate(w, "view", p)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/edit/"+name, http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/edit/"+name, http.StatusFound)
|
||||
p.handleTitle(true)
|
||||
if rss {
|
||||
it := feed(p, fi.ModTime())
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>`))
|
||||
renderTemplate(w, "feed", it)
|
||||
return
|
||||
}
|
||||
p.renderHtml()
|
||||
renderTemplate(w, "view", p)
|
||||
}
|
||||
|
||||
26
view.html
26
view.html
@@ -1,29 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="{{.Language}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background: #ffe; }
|
||||
html { max-width: 70ch; padding: 1ch; margin: auto; color: #ddd; background-color: #222; }
|
||||
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
|
||||
input, button { color: #222; background-color: #ddd; border: 1px solid #eee; }
|
||||
body { hyphens: auto; }
|
||||
header a { margin-right: 1ch; }
|
||||
form { display: inline-block; }
|
||||
input#search { width: 12ch; }
|
||||
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
|
||||
footer { border-top: 1px solid #888 }
|
||||
img { max-width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body lang="{{.Language}}">
|
||||
<body>
|
||||
<header>
|
||||
<a href="#main">Skip navigation</a>
|
||||
<a href="/view/index">Home</a>
|
||||
<a href="/edit/{{.Name}}">Edit</a>
|
||||
<a href="/add/{{.Name}}">Add</a>
|
||||
<a href="/upload/{{.Dir}}">Upload</a>
|
||||
<form role="search" action="/search" method="GET">
|
||||
<input type="text" spellcheck="false" name="q" required>
|
||||
<button>Search</button>
|
||||
<a href="/edit/{{.Name}}" accesskey="e">Edit</a>
|
||||
<a href="/add/{{.Name}}" accesskey="a">Add</a>
|
||||
<a href="/diff/{{.Name}}" accesskey="d">Diff</a>
|
||||
<a href="/upload/{{.Dir}}" accesskey="u">Upload</a>
|
||||
<form role="search" action="/search/{{.Dir}}" method="GET">
|
||||
<label for="search">Search:</label>
|
||||
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
|
||||
<input type="submit" value="Go"/>
|
||||
</form>
|
||||
</header>
|
||||
<main id="main">
|
||||
@@ -32,7 +38,7 @@ img { max-width: 100%; }
|
||||
</main>
|
||||
<footer>
|
||||
<address>
|
||||
Comments? Send mail to Alex Schroeder <<a href="mailto:alex@alexschroeder.ch">alex@alexschroeder.ch</a>>
|
||||
Send text via the long-range comms to Ashivom Bandaralum <<a href="mailto:jupiter@transjovian.org">jupiter@transjovian.org</a>>
|
||||
</address>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
99
view_test.go
99
view_test.go
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
@@ -17,38 +19,97 @@ func TestViewHandler(t *testing.T) {
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/index", nil))
|
||||
}
|
||||
|
||||
// wipes testdata
|
||||
func TestPageTitleWithAmp(t *testing.T) {
|
||||
_ = os.RemoveAll("testdata")
|
||||
func TestViewHandlerDir(t *testing.T) {
|
||||
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/", nil, "/view/index")
|
||||
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/man", nil, "/view/man/index")
|
||||
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/man/", nil, "/view/man/index")
|
||||
}
|
||||
|
||||
p := &Page{Name: "testdata/Rock & Roll", Body: []byte("Dancing")}
|
||||
// relies on index.md in the current directory!
|
||||
func TestViewHandlerWithId(t *testing.T) {
|
||||
data := make(url.Values)
|
||||
data.Set("id", "index")
|
||||
assert.Regexp(t, regexp.MustCompile("Welcome to Oddµ"),
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/", data))
|
||||
}
|
||||
|
||||
func TestPageTitleWithAmp(t *testing.T) {
|
||||
cleanup(t, "testdata/amp")
|
||||
|
||||
p := &Page{Name: "testdata/amp/Rock & Roll", Body: []byte("Dancing")}
|
||||
p.save()
|
||||
|
||||
assert.Regexp(t, regexp.MustCompile("Rock & Roll"),
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/Rock%20%26%20Roll", nil))
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil))
|
||||
|
||||
p = &Page{Name: "testdata/Rock & Roll", Body: []byte("# Sex & Drugs & Rock'n'Roll\nOh no!")}
|
||||
p = &Page{Name: "testdata/amp/Rock & Roll", Body: []byte("# Sex & Drugs & Rock'n'Roll\nOh no!")}
|
||||
p.save()
|
||||
|
||||
assert.Regexp(t, regexp.MustCompile("Sex & Drugs"),
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/Rock%20%26%20Roll", nil))
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil))
|
||||
}
|
||||
|
||||
func TestPageTitleWithQuestionMark(t *testing.T) {
|
||||
_ = os.RemoveAll("testdata")
|
||||
cleanup(t, "testdata/q")
|
||||
|
||||
p := &Page{Name: "testdata/How about no?", Body: []byte("No means no")}
|
||||
p := &Page{Name: "testdata/q/How about no?", Body: []byte("No means no")}
|
||||
p.save()
|
||||
|
||||
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/How%20about%20no%3F", nil)
|
||||
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/q/How%20about%20no%3F", nil)
|
||||
assert.Contains(t, body, "No means no")
|
||||
assert.Contains(t, body, "<a href=\"/edit/testdata/How%20about%20no%3F\">Edit</a>")
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
assert.Contains(t, body, "<a href=\"/edit/testdata/q/How%20about%20no%3F\" accesskey=\"e\">Edit</a>")
|
||||
}
|
||||
|
||||
func TestFileLastModified(t *testing.T) {
|
||||
cleanup(t, "testdata/file-mod")
|
||||
assert.NoError(t, os.Mkdir("testdata/file-mod", 0755))
|
||||
assert.NoError(t, os.WriteFile("testdata/file-mod/now.txt", []byte(`
|
||||
A spider sitting
|
||||
Unmoving and still
|
||||
In the autumn chill
|
||||
`), 0644))
|
||||
fi, err := os.Stat("testdata/file-mod/now.txt")
|
||||
assert.NoError(t, err)
|
||||
h := makeHandler(viewHandler, true)
|
||||
assert.Equal(t, []string{fi.ModTime().UTC().Format(http.TimeFormat)},
|
||||
HTTPHeaders(h, "GET", "/view/testdata/file-mod/now.txt", nil, "Last-Modified"))
|
||||
HTTPStatusCodeIfModifiedSince(t, h, "/view/testdata/file-mod/now.txt", fi.ModTime())
|
||||
}
|
||||
|
||||
// wipes testdata
|
||||
func TestPageLastModified(t *testing.T) {
|
||||
cleanup(t, "testdata/page-mod")
|
||||
p := &Page{Name: "testdata/page-mod/now", Body: []byte(`
|
||||
The sky glows softly
|
||||
Sadly, the birds are quiet
|
||||
I like spring better
|
||||
`)}
|
||||
p.save()
|
||||
fi, err := os.Stat("testdata/page-mod/now.md")
|
||||
assert.NoError(t, err)
|
||||
h := makeHandler(viewHandler, true)
|
||||
assert.Equal(t, []string{fi.ModTime().UTC().Format(http.TimeFormat)},
|
||||
HTTPHeaders(h, "GET", "/view/testdata/page-mod/now", nil, "Last-Modified"))
|
||||
HTTPStatusCodeIfModifiedSince(t, h, "/view/testdata/page-mod/now", fi.ModTime())
|
||||
}
|
||||
|
||||
func TestPageHead(t *testing.T) {
|
||||
cleanup(t, "testdata/head")
|
||||
p := &Page{Name: "testdata/head/peace", Body: []byte(`
|
||||
No urgent typing
|
||||
No todos, no list, no queue.
|
||||
Just me and the birds.
|
||||
`)}
|
||||
p.save()
|
||||
fi, err := os.Stat("testdata/head/peace.md")
|
||||
assert.NoError(t, err)
|
||||
h := makeHandler(viewHandler, true)
|
||||
assert.Equal(t, []string(nil),
|
||||
HTTPHeaders(h, "HEAD", "/view/testdata/head/war", nil, "Last-Modified"))
|
||||
assert.Equal(t, []string(nil),
|
||||
HTTPHeaders(h, "GET", "/view/testdata/head/war", nil, "Last-Modified"))
|
||||
assert.Equal(t, []string{fi.ModTime().UTC().Format(http.TimeFormat)},
|
||||
HTTPHeaders(h, "HEAD", "/view/testdata/head/peace", nil, "Last-Modified"))
|
||||
assert.Equal(t, "",
|
||||
assert.HTTPBody(h, "HEAD", "/view/testdata/head/peace", nil))
|
||||
}
|
||||
|
||||
157
wiki.go
157
wiki.go
@@ -1,59 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/google/subcommands"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Templates are parsed at startup.
|
||||
var templates = template.Must(
|
||||
template.ParseFiles("edit.html", "add.html", "view.html",
|
||||
"search.html", "upload.html"))
|
||||
|
||||
// validPath is a regular expression where the second group matches a
|
||||
// page, so when the editHandler is called, a URL path of "/edit/foo"
|
||||
// results in the editHandler being called with title "foo". The
|
||||
// regular expression doesn't define the handlers (this happens in the
|
||||
// main function).
|
||||
// validPath is a regular expression where the second group matches a page, so when the editHandler is called, a URL
|
||||
// path of "/edit/foo" results in the editHandler being called with title "foo". The regular expression doesn't define
|
||||
// the handlers (this happens in the main function).
|
||||
var validPath = regexp.MustCompile("^/([^/]+)/(.*)$")
|
||||
|
||||
// titleRegexp is a regular expression matching a level 1 header line
|
||||
// in a Markdown document. The first group matches the actual text and
|
||||
// is used to provide an title for pages. If no title exists in the
|
||||
// document, the page name is used instead.
|
||||
// titleRegexp is a regular expression matching a level 1 header line in a Markdown document. The first group matches
|
||||
// the actual text and is used to provide an title for pages. If no title exists in the document, the page name is used
|
||||
// instead.
|
||||
var titleRegexp = regexp.MustCompile("(?m)^#\\s*(.*)\n+")
|
||||
|
||||
// renderTemplate is the helper that is used render the templates with
|
||||
// data.
|
||||
// renderTemplate is the helper that is used render the templates with data. If the templates cannot be found, that's
|
||||
// fatal.
|
||||
func renderTemplate(w http.ResponseWriter, tmpl string, data any) {
|
||||
templates := loadTemplates()
|
||||
err := templates.ExecuteTemplate(w, tmpl+".html", data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// makeHandler returns a handler that uses the URL path without the
|
||||
// first path element as its argument, e.g. if the URL path is
|
||||
// /edit/foo/bar, the editHandler is called with "foo/bar" as its
|
||||
// argument. This uses the second group from the validPath regular
|
||||
// expression. The boolean argument indicates whether the following
|
||||
// path is required. When false, a URL /upload/ is OK.
|
||||
// makeHandler returns a handler that uses the URL path without the first path element as its argument, e.g. if the URL
|
||||
// path is /edit/foo/bar, the editHandler is called with "foo/bar" as its argument. This uses the second group from the
|
||||
// validPath regular expression. The boolean argument indicates whether the following path is required. When false, a
|
||||
// URL like /upload/ is OK. The argument can also be provided using a form parameter, i.e. call /edit/?id=foo/bar.
|
||||
func makeHandler(fn func(http.ResponseWriter, *http.Request, string), required bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
m := validPath.FindStringSubmatch(r.URL.Path)
|
||||
if m != nil && (!required || len(m[2]) > 0) {
|
||||
fn(w, r, m[2])
|
||||
} else {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "Cannot parse form", 400)
|
||||
return
|
||||
}
|
||||
id := r.Form.Get("id")
|
||||
if m != nil {
|
||||
fn(w, r, id)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// getPort returns the environment variable ODDMU_PORT or the default
|
||||
// port, "8080".
|
||||
// getPort returns the environment variable ODDMU_PORT or the default port, "8080".
|
||||
func getPort() string {
|
||||
port := os.Getenv("ODDMU_PORT")
|
||||
if port == "" {
|
||||
@@ -62,43 +69,111 @@ func getPort() string {
|
||||
return port
|
||||
}
|
||||
|
||||
// scheduleLoadIndex calls index.load and prints some messages before
|
||||
// and after. For testing, call index.load directly and skip the
|
||||
// messages.
|
||||
// When stdin is a socket, getListener returns a listener that listens
|
||||
// on the socket passed as stdin. This allows systemd-style socket
|
||||
// activation.
|
||||
// Otherwise, getListener returns a net.Listener listening on the address from
|
||||
// ODDMU_ADDRESS and the port from ODDMU_PORT.
|
||||
// ODDMU_ADDRESS may be either an IPV4 address or an IPv6 address.
|
||||
// If ODDMU_ADDRESS is unspecified, then the
|
||||
// listener listens on all available unicast addresses, both IPv4 and IPv6.
|
||||
func getListener() (net.Listener, error) {
|
||||
address := os.Getenv("ODDMU_ADDRESS")
|
||||
port := getPort()
|
||||
|
||||
stat, err := os.Stdin.Stat()
|
||||
if stat == nil {
|
||||
return nil, err
|
||||
}
|
||||
if stat.Mode().Type() == fs.ModeSocket {
|
||||
// Listening socket passed on stdin, through systemd socket
|
||||
// activation or similar:
|
||||
log.Println("Serving a wiki on a listening socket passed by systemd.")
|
||||
return net.FileListener(os.Stdin)
|
||||
}
|
||||
if strings.ContainsRune(address, ':') {
|
||||
address = fmt.Sprintf("[%s]:%s", address, port)
|
||||
} else {
|
||||
address = fmt.Sprintf("%s:%s", address, port)
|
||||
}
|
||||
log.Printf("Serving a wiki at address %s", address)
|
||||
return net.Listen("tcp", address)
|
||||
}
|
||||
|
||||
// scheduleLoadIndex calls index.load and prints some messages before and after. For testing, call index.load directly
|
||||
// and skip the messages.
|
||||
func scheduleLoadIndex() {
|
||||
fmt.Print("Indexing pages\n")
|
||||
log.Print("Indexing pages")
|
||||
n, err := index.load()
|
||||
if err == nil {
|
||||
fmt.Printf("Indexed %d pages\n", n)
|
||||
log.Printf("Indexed %d pages", n)
|
||||
} else {
|
||||
fmt.Println("Indexing failed")
|
||||
log.Printf("Indexing failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleLoadLanguages calls loadLanguages and prints some messages before
|
||||
// and after. For testing, call loadLanguages directly and skip the
|
||||
// messages.
|
||||
// scheduleLoadLanguages calls loadLanguages and prints some messages before and after. For testing, call loadLanguages
|
||||
// directly and skip the messages.
|
||||
func scheduleLoadLanguages() {
|
||||
fmt.Print("Loading languages\n")
|
||||
log.Print("Loading languages")
|
||||
n := loadLanguages()
|
||||
fmt.Printf("Loaded %d languages\n", n)
|
||||
log.Printf("Loaded %d languages", n)
|
||||
}
|
||||
|
||||
// loadTemplates loads the templates. These aren't always required. If the templates are required and cannot be loaded,
|
||||
// this a fatal error and the program exits.
|
||||
func loadTemplates() *template.Template {
|
||||
templates, err := template.ParseFiles("edit.html", "add.html", "view.html",
|
||||
"diff.html", "search.html", "static.html", "upload.html", "feed.html")
|
||||
if err != nil {
|
||||
log.Println("Templates:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return templates
|
||||
}
|
||||
|
||||
func serve() {
|
||||
http.HandleFunc("/", rootHandler)
|
||||
http.HandleFunc("/view/", makeHandler(viewHandler, true))
|
||||
http.HandleFunc("/diff/", makeHandler(diffHandler, true))
|
||||
http.HandleFunc("/edit/", makeHandler(editHandler, true))
|
||||
http.HandleFunc("/save/", makeHandler(saveHandler, true))
|
||||
http.HandleFunc("/add/", makeHandler(addHandler, true))
|
||||
http.HandleFunc("/append/", makeHandler(appendHandler, true))
|
||||
http.HandleFunc("/upload/", makeHandler(uploadHandler, false))
|
||||
http.HandleFunc("/drop/", makeHandler(dropHandler, false))
|
||||
http.HandleFunc("/search", searchHandler)
|
||||
http.HandleFunc("/search/", makeHandler(searchHandler, false))
|
||||
go scheduleLoadIndex()
|
||||
go scheduleLoadLanguages()
|
||||
port := getPort()
|
||||
fmt.Printf("Serving a wiki on port %s\n", port)
|
||||
http.ListenAndServe(":"+port, nil)
|
||||
initAccounts()
|
||||
listener, err := getListener()
|
||||
if listener == nil {
|
||||
log.Println(err)
|
||||
} else {
|
||||
err := http.Serve(listener, nil)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// commands does the command line parsing in case Oddmu is called with some arguments. Without any arguments, the wiki
|
||||
// server is started. At this point we already know that there is at least one subcommand.
|
||||
func commands() {
|
||||
subcommands.Register(subcommands.HelpCommand(), "")
|
||||
subcommands.Register(subcommands.FlagsCommand(), "")
|
||||
subcommands.Register(subcommands.CommandsCommand(), "")
|
||||
subcommands.Register(&htmlCmd{}, "")
|
||||
subcommands.Register(&listCmd{}, "")
|
||||
subcommands.Register(&staticCmd{}, "")
|
||||
subcommands.Register(&searchCmd{}, "")
|
||||
subcommands.Register(&replaceCmd{}, "")
|
||||
subcommands.Register(&missingCmd{}, "")
|
||||
subcommands.Register(¬ifyCmd{}, "")
|
||||
|
||||
flag.Parse()
|
||||
ctx := context.Background()
|
||||
os.Exit(int(subcommands.Execute(ctx)))
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
60
wiki_test.go
60
wiki_test.go
@@ -6,8 +6,11 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPHeaders is a helper that returns HTTP headers of the response. It returns
|
||||
@@ -48,10 +51,8 @@ func HTTPRedirectTo(t *testing.T, handler http.HandlerFunc, method, url string,
|
||||
return isRedirectCode
|
||||
}
|
||||
|
||||
// HTTPUploadAndRedirectTo checks that the request results in a redirect and it
|
||||
// checks the destination of the redirect. It returns whether the
|
||||
// request did in fact result in a redirect.
|
||||
func HTTPUploadAndRedirectTo(t *testing.T, handler http.HandlerFunc, url, contentType string, body *bytes.Buffer, destination string) bool {
|
||||
// HTTPUploadLocation returns the location header after an upload.
|
||||
func HTTPUploadLocation(t *testing.T, handler http.HandlerFunc, url, contentType string, body *bytes.Buffer) string {
|
||||
w := httptest.NewRecorder()
|
||||
req, err := http.NewRequest("POST", url, body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
@@ -61,7 +62,52 @@ func HTTPUploadAndRedirectTo(t *testing.T, handler http.HandlerFunc, url, conten
|
||||
isRedirectCode := code >= http.StatusMultipleChoices && code <= http.StatusTemporaryRedirect
|
||||
assert.True(t, isRedirectCode, "Expected HTTP redirect status code for %q but received %d", url, code)
|
||||
headers := w.Result().Header["Location"]
|
||||
assert.True(t, len(headers) == 1 && headers[0] == destination,
|
||||
"Expected HTTP redirect location %s for %q but received %v", destination, url, headers)
|
||||
return isRedirectCode
|
||||
assert.True(t, len(headers) == 1, "Expected a single redirect header but got %d locations", len(headers))
|
||||
return headers[0]
|
||||
}
|
||||
|
||||
// HTTPUploadAndRedirectTo checks that the request results in a redirect and it
|
||||
// checks the destination of the redirect. It returns whether the
|
||||
// request did in fact result in a redirect.
|
||||
func HTTPUploadAndRedirectTo(t *testing.T, handler http.HandlerFunc, url, contentType string, body *bytes.Buffer, destination string) {
|
||||
location := HTTPUploadLocation(t, handler, url, contentType, body)
|
||||
assert.Equal(t, destination, location,
|
||||
"Expected HTTP redirect location %s for %q but received %s", destination, url, location)
|
||||
}
|
||||
|
||||
// HTTPStatusCodeIfModifiedSince checks that the request results in a
|
||||
// 304 response for the given time.
|
||||
func HTTPStatusCodeIfModifiedSince(t *testing.T, handler http.HandlerFunc, url string, ti time.Time) {
|
||||
w := httptest.NewRecorder()
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("If-Modified-Since", ti.UTC().Format(http.TimeFormat))
|
||||
handler(w, req)
|
||||
assert.Equal(t, http.StatusNotModified, w.Code)
|
||||
}
|
||||
|
||||
// cleanup deletes a directory mentioned and removes all pages in that directory from the index.
|
||||
func cleanup(t *testing.T, dir string) {
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(dir)
|
||||
index.Lock()
|
||||
defer index.Unlock()
|
||||
for name := range index.titles {
|
||||
if strings.HasPrefix(name, dir) {
|
||||
delete(index.titles, name)
|
||||
}
|
||||
}
|
||||
ids := []docid{}
|
||||
for id, name := range index.documents {
|
||||
if strings.HasPrefix(name, dir) {
|
||||
delete(index.documents, id)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
for hashtag, docs := range index.token {
|
||||
index.token[hashtag] = slices.DeleteFunc(ids, func(id docid) bool {
|
||||
return slices.Contains(docs, id)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user