forked from mirror/oddmu
Compare commits
245 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 | ||
|
|
16b475ea7f | ||
|
|
0a7eaa455a | ||
|
|
4cad4a988a | ||
|
|
cc58980ec0 | ||
|
|
068bc21eea | ||
|
|
f60cd09267 | ||
|
|
92a52d2c97 | ||
|
|
7f0b371570 | ||
|
|
a44d903775 | ||
|
|
1ca8e6f3aa | ||
|
|
93197f94bf | ||
|
|
a7861edbad | ||
|
|
d4090ab146 | ||
|
|
4da0ba8d94 | ||
|
|
941ceeaf6c | ||
|
|
c2cf2b121c | ||
|
|
383bb88218 | ||
|
|
1b8c590ced | ||
|
|
ab014a1ba8 | ||
|
|
cd661a2357 | ||
|
|
2060d323a6 | ||
|
|
30df5fb9e1 | ||
|
|
21ec558a2b | ||
|
|
22db61c73a | ||
|
|
3bdd05f083 | ||
|
|
154b6805c4 | ||
|
|
a3373fec6f | ||
|
|
ebaadc111a | ||
|
|
afa9907863 | ||
|
|
b57afc17ca | ||
|
|
ad010249d6 | ||
|
|
b86eee7136 | ||
|
|
55be27b2d1 | ||
|
|
565a3b2831 | ||
|
|
302da8b212 | ||
|
|
3f69eadafc | ||
|
|
78c640278d | ||
|
|
285574d262 | ||
|
|
80e2522f4a | ||
|
|
471cd3c6ec | ||
|
|
da361284e8 | ||
|
|
6215d2a842 | ||
|
|
47c727c00d | ||
|
|
91381e474c | ||
|
|
4e5aa70529 | ||
|
|
b7048bd5a9 | ||
|
|
41be47dc03 | ||
|
|
44b92cc3e0 | ||
|
|
025d993eb7 | ||
|
|
1209c2b209 | ||
|
|
5d3aa45ddb | ||
|
|
f93177def5 | ||
|
|
aeb53148e7 | ||
|
|
4bce6fcb38 | ||
|
|
92cc1ad883 | ||
|
|
378330cbce | ||
|
|
ad472f9db1 | ||
|
|
b4f861a24e | ||
|
|
e97e5c7e6c | ||
|
|
0a4eabee3d | ||
|
|
fcd4d9136d | ||
|
|
103007be48 | ||
|
|
4afffbc409 | ||
|
|
9e6d59cefa | ||
|
|
2a4902b1b4 | ||
|
|
efc54f1524 | ||
|
|
8fc5bd30e3 | ||
|
|
40855ea442 | ||
|
|
29af9a4cfa | ||
|
|
146f4c9f57 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
/oddmu
|
||||
test.md
|
||||
/testdata/
|
||||
|
||||
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"
|
||||
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
|
||||
|
||||
370
README.md
370
README.md
@@ -1,48 +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.
|
||||
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 probably makes
|
||||
sense to only use it as one person or in very small groups.
|
||||
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.
|
||||
|
||||
This wiki only uses 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)`.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
If your files don't provide their own title (`# title`), the file name
|
||||
is used for the title.
|
||||
(without `.md`) is used for the title. Subdirectories are created as
|
||||
necessary.
|
||||
|
||||
µ 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`.
|
||||
|
||||
## Templates
|
||||
The [lingua](https://github.com/pemistahl/lingua-go) library detects
|
||||
languages in order to get hyphenation right.
|
||||
|
||||
Feel free to change the templates `view.html` and `edit.html` and
|
||||
restart the server. Modifying the styles in the templates would be a
|
||||
good start to get a feel for it.
|
||||
The standard [html/template](https://pkg.go.dev/html/template) library
|
||||
is used to generate HTML.
|
||||
|
||||
The templates can refer to the following properties of a page:
|
||||
## Documentation
|
||||
|
||||
`{{.Title}}` is the page title. If the page doesn't provide its own
|
||||
title, the page name is used.
|
||||
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:
|
||||
|
||||
`{{.Name}}` is the page name. The page name doesn't include the `.md`
|
||||
extension.
|
||||
[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.
|
||||
|
||||
`{{.Html}}` is the rendered Markdown, as HTML.
|
||||
[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.
|
||||
|
||||
`{{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`).
|
||||
[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.
|
||||
|
||||
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`:
|
||||
[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.
|
||||
|
||||
```sh
|
||||
curl --form body="Did you bring a towel?" \
|
||||
http://localhost:8080/save/welcome
|
||||
```
|
||||
[oddmu-search(7)](/oddmu.git/blob/main/man/oddmu-search.7.txt): This
|
||||
man page documents how search and scoring work.
|
||||
|
||||
[oddmu-replace(1)](/oddmu.git/blob/main/man/oddmu-replace.1.txt): This
|
||||
man page documents the "replace" subcommand to make mass changes to
|
||||
the files much like find(1), grep(1) and sed(1) or perl(1).
|
||||
|
||||
[oddmu-missing(1)](/oddmu.git/blob/main/man/oddmu-missing.1.txt): This
|
||||
man page documents the "missing" subcommand to list local links that
|
||||
don't point to any existing pages or files.
|
||||
|
||||
[oddmu-html(1)](/oddmu.git/blob/main/man/oddmu-html.1.txt): This man
|
||||
page documents the "html" subcommand to generate HTML from Markdown
|
||||
pages from the command line.
|
||||
|
||||
[oddmu-static(1)](/oddmu.git/blob/main/man/oddmu-static.1.txt): This
|
||||
man page documents the "static" subcommand to generate an entire
|
||||
static website from the command line, avoiding the need to run Oddmu
|
||||
as a server. Also great for archiving.
|
||||
|
||||
[oddmu-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.
|
||||
|
||||
[oddmu-templates(5)](/oddmu.git/blob/main/man/oddmu-templates.5.txt):
|
||||
This man page documents how the templates can be changed (how they
|
||||
*must* be changed) and lists the attributes available for the various
|
||||
templates.
|
||||
|
||||
[oddmu-apache(5)](/oddmu.git/blob/main/man/oddmu-apache.5.txt): This
|
||||
man page documents how to set up the web server for various common
|
||||
tasks such as using logins to limit what visitors can edit.
|
||||
|
||||
[oddmu.service(5)](/oddmu.git/blob/main/man/oddmu.service.5.txt): This
|
||||
man page documents how to setup a systemd unit and have it manage
|
||||
Oddmu. “Great configurability brings great burdens.”
|
||||
|
||||
## Building
|
||||
|
||||
@@ -50,7 +100,7 @@ curl --form body="Did you bring a towel?" \
|
||||
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.
|
||||
@@ -61,244 +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.
|
||||
your browser to http://localhost:8080/ to use it.
|
||||
|
||||
If you ran it in the source directory, try
|
||||
http://localhost:8080/view/README – this serves the README file you're
|
||||
currently reading.
|
||||
## Bugs
|
||||
|
||||
## Deploying it using systemd
|
||||
If you spot any, [contact](https://alexschroeder.ch/wiki/Contact) me.
|
||||
|
||||
As root, on your server:
|
||||
## Source
|
||||
|
||||
```sh
|
||||
adduser --system --home /home/oddmu oddmu
|
||||
```
|
||||
If you're interested in making changes to the code, here's a
|
||||
high-level introduction to the various source files.
|
||||
|
||||
Copy all the files into `/home/oddmu` to your server: `oddmu`, `oddmu.service`, `view.html` and `edit.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"
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
RewriteEngine on
|
||||
RewriteRule ^/$ http://%{HTTP_HOST}:8080/view/index [redirect]
|
||||
RewriteRule ^/(view|edit|save|search)/(.*) http://%{HTTP_HOST}:8080/$1/$2 [proxy]
|
||||
</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 redirects `/` to `/view/index` and any
|
||||
path that starts with `/view/`, `/edit/`, `/save/` or `/search/` is
|
||||
proxied to port 8080 where the Oddmu program can handle it.
|
||||
|
||||
Thus, this is what happens:
|
||||
|
||||
* The user tells the browser to visit `http://transjovian.org` (on port 80)
|
||||
* Apache redirects this to `http://transjovian.org/` by default (still on port 80)
|
||||
* Our first virtual host redirects this to `https://transjovian.org/` (encrypted, on port 443)
|
||||
* Our second virtual host redirects this to `https://transjovian.org/wiki/view/index` (still on port 443)
|
||||
* This is proxied to `http://transjovian.org:8080/view/index` (no on port 8080, without encryption)
|
||||
* The wiki converts `index.md` to HTML, adds it to the template, and serves it.
|
||||
|
||||
Restart the server, gracefully:
|
||||
|
||||
```
|
||||
apachectl graceful
|
||||
```
|
||||
|
||||
## 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/` and `/save/`
|
||||
URLs with a password by adding the following to your `<VirtualHost
|
||||
*:443>` section:
|
||||
|
||||
```apache
|
||||
<LocationMatch "^/(edit|save)/">
|
||||
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/` 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.
|
||||
|
||||
## Customization (with recompilation)
|
||||
|
||||
The Markdown parser can be customized and
|
||||
[extensions](https://pkg.go.dev/github.com/gomarkdown/markdown/parser#Extensions)
|
||||
can be added. There's an example in the
|
||||
[usage](https://github.com/gomarkdown/markdown#usage) section. You'll
|
||||
need to make changes to the `viewHandler` yourself.
|
||||
|
||||
### Render Gemtext
|
||||
|
||||
In a first approximation, Gemtext is valid Markdown except for the
|
||||
rocket links (`=>`). Here's how to modify the `loadPage` so that a
|
||||
`.gmi` file is loaded if no `.md` is found, and the rocket links are
|
||||
translated into Markdown:
|
||||
|
||||
```go
|
||||
func loadPage(name string) (*Page, error) {
|
||||
filename := name + ".md"
|
||||
body, err := os.ReadFile(filename)
|
||||
if err == nil {
|
||||
return &Page{Title: name, Name: name, Body: body}, nil
|
||||
}
|
||||
filename = name + ".gmi"
|
||||
body, err = os.ReadFile(filename)
|
||||
if err == nil {
|
||||
return &Page{Title: name, Name: name, Body: body}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
```
|
||||
|
||||
There is a small problem, however: By default, Markdown expects an
|
||||
empty line before a list begins. The following change to `renderHtml`
|
||||
uses the `NoEmptyLineBeforeBlock` extension for the parser:
|
||||
|
||||
```go
|
||||
func (p* Page) renderHtml() {
|
||||
// Here is where a new extension is added!
|
||||
extensions := parser.CommonExtensions | parser.NoEmptyLineBeforeBlock
|
||||
markdownParser := parser.NewWithExtensions(extensions)
|
||||
maybeUnsafeHTML := markdown.ToHTML(p.Body, markdownParser, nil)
|
||||
html := bluemonday.UGCPolicy().SanitizeBytes(maybeUnsafeHTML)
|
||||
p.Html = template.HTML(html);
|
||||
}
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
Page titles are filenames with `.md` appended. If your filesystem
|
||||
cannot handle it, it can't be a page title. Specifically, *no slashes*
|
||||
in filenames.
|
||||
|
||||
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.
|
||||
- *_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).
|
||||
|
||||
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)
|
||||
}
|
||||
24
add.html
Normal file
24
add.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>Add to {{.Title}}</title>
|
||||
<style>
|
||||
html { max-width: 70ch; padding: 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" 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>
|
||||
</body>
|
||||
</html>
|
||||
56
add_append.go
Normal file
56
add_append.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// addHandler uses the "add.html" template to present an empty edit
|
||||
// page. What you type there is appended to the page using the
|
||||
// appendHandler.
|
||||
func addHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
p = &Page{Title: name, Name: name}
|
||||
} else {
|
||||
p.handleTitle(false)
|
||||
}
|
||||
renderTemplate(w, "add", p)
|
||||
}
|
||||
|
||||
// appendHandler takes the "body" form parameter and appends it. The
|
||||
// browser is redirected to the page view.
|
||||
func appendHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
body := r.FormValue("body")
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
p = &Page{Name: name, Body: []byte(body)}
|
||||
} else {
|
||||
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...)
|
||||
}
|
||||
73
add_append_test.go
Normal file
73
add_append_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEmptyLineAdd(t *testing.T) {
|
||||
p := &Page{Name: "testdata/add/fire", Body: []byte(`# Coal
|
||||
Black rocks light as foam
|
||||
Shaking, puring, shoveling`)}
|
||||
p.append([]byte("Into the oven"))
|
||||
assert.Equal(t, string(p.Body), `# Coal
|
||||
Black rocks light as foam
|
||||
Shaking, puring, shoveling
|
||||
|
||||
Into the oven`)
|
||||
}
|
||||
|
||||
func TestAddAppend(t *testing.T) {
|
||||
cleanup(t, "testdata/add")
|
||||
index.load()
|
||||
|
||||
p := &Page{Name: "testdata/add/fire", Body: []byte(`# Fire
|
||||
Orange sky above
|
||||
Reflects a distant fire
|
||||
It's not `)}
|
||||
p.save()
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("body", "barbecue")
|
||||
|
||||
assert.Regexp(t, regexp.MustCompile("a distant fire"),
|
||||
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/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~")
|
||||
}
|
||||
15
concurrency_test.go
Normal file
15
concurrency_test.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Use go test -race to see whether this is a race condition.
|
||||
func TestLoadAndSearch(t *testing.T) {
|
||||
index.reset()
|
||||
go index.load()
|
||||
q := "Oddµ"
|
||||
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>`)
|
||||
}
|
||||
14
edit.html
14
edit.html
@@ -1,22 +1,26 @@
|
||||
<!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>
|
||||
<body>
|
||||
<h1>Editing {{.Title}}</h1>
|
||||
|
||||
<form action="/save/{{.Name}}" method="POST">
|
||||
<div><textarea name="body" rows="20" cols="80">{{printf "%s" .Body}}</textarea></div>
|
||||
<textarea name="body" rows="20" cols="80" placeholder="# Title
|
||||
|
||||
Text" lang="" autofocus>{{printf "%s" .Body}}</textarea>
|
||||
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
|
||||
<p><input type="submit" value="Save">
|
||||
<a href="/view/{{.Name}}"><button>Cancel</button></a></p>
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
39
edit_save.go
Normal file
39
edit_save.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// editHandler uses the "edit.html" template to present an edit page.
|
||||
// When editing, the page title is not overriden by a title in the
|
||||
// text. Instead, the page name is used. The edit is saved using the
|
||||
// saveHandler.
|
||||
func editHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
p = &Page{Title: name, Name: name}
|
||||
} else {
|
||||
p.handleTitle(false)
|
||||
}
|
||||
renderTemplate(w, "edit", p)
|
||||
}
|
||||
|
||||
// saveHandler takes the "body" form parameter and saves it. The
|
||||
// browser is redirected to the page view.
|
||||
func saveHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
body := r.FormValue("body")
|
||||
p := &Page{Name: name, Body: []byte(body)}
|
||||
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)
|
||||
}
|
||||
61
edit_save_test.go
Normal file
61
edit_save_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEditSave(t *testing.T) {
|
||||
cleanup(t, "testdata/save")
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("body", "Hallo!")
|
||||
|
||||
// View of the non-existing page redirects to the edit page
|
||||
HTTPRedirectTo(t, makeHandler(viewHandler, true),
|
||||
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
|
||||
// Edit page can be fetched
|
||||
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
|
||||
"GET", "/edit/testdata/save/alex", nil, 200)
|
||||
// Posting to the save URL saves a page
|
||||
HTTPRedirectTo(t, makeHandler(saveHandler, true),
|
||||
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
|
||||
// Page now contains the text
|
||||
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, true),
|
||||
"GET", "/view/testdata/save/alex", nil),
|
||||
"Hallo!")
|
||||
// Delete the page and you're sent to the empty page
|
||||
data.Set("body", "")
|
||||
HTTPRedirectTo(t, makeHandler(saveHandler, true),
|
||||
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
|
||||
// Viewing the non-existing page redirects to the edit page (like in the beginning)
|
||||
HTTPRedirectTo(t, makeHandler(viewHandler, true),
|
||||
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
|
||||
}
|
||||
|
||||
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>")
|
||||
}
|
||||
33
go.mod
33
go.mod
@@ -3,13 +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-20230716120725-531d2d74bc12
|
||||
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/gorilla/css v1.0.0 // indirect
|
||||
golang.org/x/net v0.12.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // 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/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
|
||||
)
|
||||
|
||||
116
go.sum
116
go.sum
@@ -1,12 +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/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/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/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=
|
||||
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
|
||||
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||
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/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-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/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=
|
||||
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/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=
|
||||
|
||||
42
highlight.go
42
highlight.go
@@ -1,45 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// highlight splits the query string q into terms and highlights them
|
||||
// using the bold tag. Return the highlighted string and a score.
|
||||
func highlight (q string, s string) (string, int) {
|
||||
c := 0
|
||||
re, err := regexp.Compile("(?i)" + q)
|
||||
if err == nil {
|
||||
m := re.FindAllString(s, -1)
|
||||
if m != nil {
|
||||
// Score increases for each full match of q.
|
||||
c += len(m)
|
||||
}
|
||||
}
|
||||
for _, v := range strings.Split(q, " ") {
|
||||
if len(v) == 0 {
|
||||
continue
|
||||
}
|
||||
re, err := regexp.Compile(`(?is)(\pL?)(` + v + `)(\pL?)`)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
r := make(map[string]string)
|
||||
for _, m := range re.FindAllStringSubmatch(s, -1) {
|
||||
// Term matched increases the score.
|
||||
c++
|
||||
// Terms matching at the beginning and
|
||||
// end of words and matching entire
|
||||
// words increase the score further.
|
||||
if len(m[1]) == 0 { c++ }
|
||||
if len(m[3]) == 0 { c++ }
|
||||
if len(m[1]) == 0 && len(m[3]) == 0 { c++ }
|
||||
r[m[2]] = "<b>" + m[2] + "</b>"
|
||||
}
|
||||
for old, new := range r {
|
||||
s = strings.ReplaceAll(s, old, new)
|
||||
}
|
||||
}
|
||||
return s, c
|
||||
// using the bold tag. Return the highlighted string.
|
||||
// This assumes that q already has all its meta characters quoted.
|
||||
func highlight(q string, re *regexp.Regexp, s string) string {
|
||||
s = re.ReplaceAllString(s, "<b>$1</b>")
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -15,49 +15,29 @@ A wave of car noise hits me
|
||||
No birds to be heard.`
|
||||
|
||||
q := "window"
|
||||
r, c := highlight(q, s)
|
||||
re, _ := re(q)
|
||||
r := highlight(q, re, s)
|
||||
if r != h {
|
||||
t.Logf("The highlighting is wrong in 「%s」", r)
|
||||
t.Fail()
|
||||
}
|
||||
// Score:
|
||||
// - q itself
|
||||
// - the single token
|
||||
// - the beginning of a word
|
||||
if c != 3 {
|
||||
t.Logf("%s score is %d", q, c)
|
||||
t.Fail()
|
||||
}
|
||||
q = "windows"
|
||||
_, c = highlight(q, s)
|
||||
// Score:
|
||||
// - q itself
|
||||
// - the single token
|
||||
// - the beginning of a word
|
||||
// - the end of a word
|
||||
// - the whole word
|
||||
if c != 5 {
|
||||
t.Logf("%s score is %d", q, c)
|
||||
t.Fail()
|
||||
}
|
||||
q = "car noise"
|
||||
_, c = highlight(q, s)
|
||||
// Score:
|
||||
// - car noise (+1)
|
||||
// - car, with beginning, end, whole word (+4)
|
||||
// - noise, with beginning, end, whole word (+4)
|
||||
if c != 9 {
|
||||
t.Logf("%s score is %d", q, c)
|
||||
t.Fail()
|
||||
}
|
||||
q = "noise car"
|
||||
_, c = highlight(q, s)
|
||||
// Score:
|
||||
// - the car token
|
||||
// - the noise token
|
||||
// - each with beginning, end and whole token (3 each)
|
||||
if c != 8 {
|
||||
t.Logf("%s score is %d", q, c)
|
||||
}
|
||||
|
||||
func TestOverlap(t *testing.T) {
|
||||
|
||||
s := `Sit with me my love
|
||||
Kids shout and so do parents
|
||||
I hear the fountain`
|
||||
|
||||
h := `Sit with me my love
|
||||
Kids <b>shout</b> and so do parents
|
||||
I hear the fountain`
|
||||
|
||||
q := "shout out"
|
||||
re, _ := re(q)
|
||||
r := highlight(q, re, s)
|
||||
if r != h {
|
||||
t.Logf("The highlighting is wrong in 「%s」", r)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
254
index.go
Normal file
254
index.go
Normal file
@@ -0,0 +1,254 @@
|
||||
// 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 (
|
||||
"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
|
||||
|
||||
// next_id is the number of the next document added to the index
|
||||
next_id docid
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
var index Index
|
||||
|
||||
// reset resets the Index. This assumes that the index is locked!
|
||||
func (idx *Index) reset() {
|
||||
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.
|
||||
func (idx *Index) add(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filename := path
|
||||
if info.IsDir() || strings.HasPrefix(filename, ".") || !strings.HasSuffix(filename, ".md") {
|
||||
return nil
|
||||
}
|
||||
name := strings.TrimSuffix(filename, ".md")
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.handleTitle(false)
|
||||
|
||||
id := idx.addDocument(p.Body)
|
||||
idx.documents[id] = p.Name
|
||||
idx.titles[p.Name] = p.Title
|
||||
return nil
|
||||
}
|
||||
|
||||
// load loads all the pages and indexes them. This takes a while.
|
||||
// It returns the number of pages indexed.
|
||||
func (idx *Index) load() (int, error) {
|
||||
idx.Lock()
|
||||
defer idx.Unlock()
|
||||
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()
|
||||
return 0, err
|
||||
}
|
||||
n := len(idx.documents)
|
||||
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 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 {
|
||||
id = index.addDocument(p.Body)
|
||||
index.documents[id] = p.Name
|
||||
index.titles[p.Name] = p.Title
|
||||
} else {
|
||||
if o, err := loadPage(p.Name); err == nil {
|
||||
index.deleteDocument(o.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
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
6
index.md
6
index.md
@@ -1,3 +1,7 @@
|
||||
# Welcome to Oddµ
|
||||
|
||||
Hello! 🙃
|
||||
|
||||
Check out the [README](README).
|
||||
Check out the [[README]].
|
||||
|
||||
Or [create a new page](test).
|
||||
|
||||
97
index_test.go
Normal file
97
index_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIndex relies on README.md being indexed
|
||||
func TestIndex(t *testing.T) {
|
||||
index.load()
|
||||
q := "Oddµ"
|
||||
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, "Score %d for %s", p.Score, p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHashtag(t *testing.T) {
|
||||
index.load()
|
||||
q := "#like_this"
|
||||
pages, _ := search(q, "", 1, false)
|
||||
assert.NotZero(t, len(pages))
|
||||
}
|
||||
|
||||
func TestIndexUpdates(t *testing.T) {
|
||||
cleanup(t, "testdata/update")
|
||||
name := "testdata/update/test"
|
||||
index.load()
|
||||
p := &Page{Name: name, Body: []byte("#Old Name\nThis is a test.")}
|
||||
p.save()
|
||||
|
||||
// Find the phrase
|
||||
pages, _ := search("This is a test", "", 1, false)
|
||||
found := false
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found)
|
||||
|
||||
// Find the phrase, case insensitive
|
||||
pages, _ = search("this is a test", "", 1, false)
|
||||
found = false
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found)
|
||||
|
||||
// Find some words
|
||||
pages, _ = search("this test", "", 1, false)
|
||||
found = false
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found)
|
||||
|
||||
// Update the page and no longer find it with the old phrase
|
||||
p = &Page{Name: name, Body: []byte("# New page\nGuvf vf n grfg.")}
|
||||
p.save()
|
||||
pages, _ = search("This is a test", "", 1, false)
|
||||
found = false
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.False(t, found)
|
||||
|
||||
// Find page using a new word
|
||||
pages, _ = search("Guvf", "", 1, false)
|
||||
found = false
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found)
|
||||
|
||||
// Make sure the title was updated
|
||||
index.RLock()
|
||||
defer index.RUnlock()
|
||||
assert.Equal(t, index.titles[name], "New page")
|
||||
}
|
||||
58
languages.go
Normal file
58
languages.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/pemistahl/lingua-go"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// getLangauges returns the environment variable ODDMU_LANGUAGES or
|
||||
// all languages.
|
||||
func getLanguages() ([]lingua.Language, error) {
|
||||
v := os.Getenv("ODDMU_LANGUAGES")
|
||||
if v == "" {
|
||||
return lingua.AllLanguages(), nil
|
||||
}
|
||||
codes := strings.Split(v, ",")
|
||||
if len(codes) == 1 {
|
||||
return nil, errors.New("detection unnecessary")
|
||||
}
|
||||
|
||||
var langs []lingua.Language
|
||||
for _, lang := range codes {
|
||||
langs = append(langs, lingua.GetLanguageFromIsoCode639_1(lingua.GetIsoCode639_1FromValue(lang)))
|
||||
}
|
||||
return langs, nil
|
||||
}
|
||||
|
||||
// detector is the LanguageDetector initialized at startup by loadLanguages.
|
||||
var detector lingua.LanguageDetector
|
||||
|
||||
// loadLanguages initializes the detector using the languages returned
|
||||
// by getLanguages and returns the number of languages loaded.
|
||||
func loadLanguages() int {
|
||||
langs, err := getLanguages()
|
||||
if err == nil {
|
||||
detector = lingua.NewLanguageDetectorBuilder().
|
||||
FromLanguages(langs...).
|
||||
WithPreloadedLanguageModels().
|
||||
WithLowAccuracyMode().
|
||||
Build()
|
||||
} else {
|
||||
detector = nil
|
||||
}
|
||||
return len(langs)
|
||||
}
|
||||
|
||||
// language returns the language used for a string, as a lower case
|
||||
// ISO 639-1 string, e.g. "en" or "de".
|
||||
func language(s string) string {
|
||||
if detector == nil {
|
||||
return os.Getenv("ODDMU_LANGUAGES")
|
||||
}
|
||||
if language, ok := detector.DetectLanguageOf(s); ok {
|
||||
return strings.ToLower(language.IsoCode639_1().String())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
50
languages_test.go
Normal file
50
languages_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAllLanguage(t *testing.T) {
|
||||
os.Unsetenv("ODDMU_LANGUAGES")
|
||||
loadLanguages()
|
||||
l := language(`
|
||||
My back hurts at night
|
||||
My shoulders won't budge today
|
||||
Winter bones I say`)
|
||||
assert.Equal(t, "en", l)
|
||||
}
|
||||
|
||||
func TestSomeLanguages(t *testing.T) {
|
||||
os.Setenv("ODDMU_LANGUAGES", "en,de")
|
||||
loadLanguages()
|
||||
l := language(`
|
||||
Kühle Morgenluft
|
||||
Keine Amsel singt heute
|
||||
Mensch im Dämmerlicht
|
||||
`)
|
||||
assert.Equal(t, "de", l)
|
||||
}
|
||||
|
||||
func TestOneLanguages(t *testing.T) {
|
||||
os.Setenv("ODDMU_LANGUAGES", "en")
|
||||
loadLanguages()
|
||||
l := language(`
|
||||
Schwer wiegt die Luft hier
|
||||
Atme ein, ermahn' ich mich
|
||||
Erinnerungen
|
||||
`)
|
||||
assert.Equal(t, "en", l)
|
||||
}
|
||||
|
||||
func TestWrongLanguages(t *testing.T) {
|
||||
os.Setenv("ODDMU_LANGUAGES", "de,fr")
|
||||
loadLanguages()
|
||||
l := language(`
|
||||
Something drifts down there
|
||||
Head submerged oh god a man
|
||||
Drowning as we stare
|
||||
`)
|
||||
assert.NotEqual(t, "en", l)
|
||||
}
|
||||
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,17 +12,16 @@ MemoryHigh=120M
|
||||
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
|
||||
UMask=0077
|
||||
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
|
||||
@@ -38,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
|
||||
|
||||
169
page.go
169
page.go
@@ -1,14 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"bytes"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"html/template"
|
||||
"strings"
|
||||
"bytes"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Page is a struct containing information about a single page. Title
|
||||
@@ -18,24 +21,79 @@ import (
|
||||
// page and Html is the rendered HTML for that Markdown. Score is a
|
||||
// number indicating how well the page matched for a search query.
|
||||
type Page struct {
|
||||
Title string
|
||||
Name string
|
||||
Body []byte
|
||||
Html template.HTML
|
||||
Score int
|
||||
Title string
|
||||
Name string
|
||||
Language string
|
||||
Body []byte
|
||||
Html template.HTML
|
||||
Score int
|
||||
Hashtags []string
|
||||
}
|
||||
|
||||
var blogRe = regexp.MustCompile(`^\d\d\d\d-\d\d-\d\d`)
|
||||
|
||||
// santizeStrict uses bluemonday to sanitize the HTML away. No elements are allowed except for the b tag because this is
|
||||
// used for snippets.
|
||||
func sanitizeStrict(s string) template.HTML {
|
||||
policy := bluemonday.StrictPolicy()
|
||||
policy.AllowElements("b")
|
||||
return template.HTML(policy.Sanitize(s))
|
||||
}
|
||||
|
||||
// unsafeBytes does not use bluemonday to sanitize the HTML used for pages. This is where you make changes if you want
|
||||
// to be more lenient. If you look at the git repository, there are older versions containing the function sanitizeBytes
|
||||
// which would do elaborate checking.
|
||||
func unsafeBytes(bytes []byte) template.HTML {
|
||||
return template.HTML(bytes)
|
||||
}
|
||||
|
||||
// nameEscape returns the page name safe for use in URLs. That is,
|
||||
// percent escaping is used except for the slashes.
|
||||
func nameEscape(s string) string {
|
||||
parts := strings.Split(s, "/")
|
||||
for i, part := range parts {
|
||||
parts[i] = url.PathEscape(part)
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
// save saves a Page. The filename is based on the Page.Name and gets
|
||||
// the ".md" extension. Page.Body is saved, without any carriage
|
||||
// return characters ("\r"). The file permissions used are readable
|
||||
// and writeable for the current user, i.e. u+rw or 0600. Page.Title
|
||||
// and Page.Html are not saved no caching. There is no caching.
|
||||
// return characters ("\r"). Page.Title and Page.Html are not saved.
|
||||
// There is no caching. Before removing or writing a file, the old
|
||||
// copy is renamed to a backup, appending "~". There is no error
|
||||
// checking for this.
|
||||
func (p *Page) save() error {
|
||||
filename := p.Name + ".md"
|
||||
s := bytes.ReplaceAll(p.Body, []byte{'\r'}, []byte{})
|
||||
if len(s) == 0 {
|
||||
p.removeFromIndex()
|
||||
return os.Rename(filename, filename+"~")
|
||||
}
|
||||
p.Body = s
|
||||
p.updateIndex()
|
||||
return os.WriteFile(filename, s, 0600)
|
||||
d := filepath.Dir(filename)
|
||||
if d != "." {
|
||||
err := os.MkdirAll(d, 0755)
|
||||
if err != nil {
|
||||
log.Printf("Creating directory %s failed: %s", d, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -49,13 +107,13 @@ func loadPage(name string) (*Page, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Page{Title: name, Name: name, Body: body}, nil
|
||||
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.
|
||||
func (p* Page) handleTitle(replace bool) {
|
||||
// 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)
|
||||
if m != nil {
|
||||
@@ -66,46 +124,37 @@ func (p* Page) handleTitle(replace bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// renderHtml renders the Page.Body to HTML and sets Page.Html.
|
||||
func (p* Page) renderHtml() {
|
||||
maybeUnsafeHTML := markdown.ToHTML(p.Body, nil, nil)
|
||||
html := bluemonday.UGCPolicy().SanitizeBytes(maybeUnsafeHTML)
|
||||
p.Html = template.HTML(html);
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
s, c := snippets(q, p.plainText())
|
||||
p.Score = c
|
||||
extract := []byte(s)
|
||||
html := bluemonday.UGCPolicy().SanitizeBytes(extract)
|
||||
p.Html = template.HTML(html)
|
||||
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.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 + "/"
|
||||
}
|
||||
|
||||
// Today returns the date, as a string, for use in templates.
|
||||
func (p *Page) Today() string {
|
||||
return time.Now().Format(time.DateOnly)
|
||||
}
|
||||
|
||||
67
page_test.go
67
page_test.go
@@ -1,59 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPageTitle (t *testing.T) {
|
||||
func TestPageTitle(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Ache
|
||||
My back aches for you
|
||||
I sit, stare and type for hours
|
||||
But yearn for blue sky`)}
|
||||
p.handleTitle(false)
|
||||
if p.Title != "Ache" {
|
||||
t.Logf("The page title was not extracted correctly: %s", p.Title)
|
||||
t.Fail()
|
||||
}
|
||||
if !strings.HasPrefix(string(p.Body), "# Ache") {
|
||||
t.Logf("The page title was removed: %s", p.Body)
|
||||
t.Fail()
|
||||
}
|
||||
assert.Equal(t, "Ache", p.Title)
|
||||
assert.Regexp(t, regexp.MustCompile("^# Ache"), string(p.Body))
|
||||
p.handleTitle(true)
|
||||
if !strings.HasPrefix(string(p.Body), "My back") {
|
||||
t.Logf("The page title was not removed: %s", p.Body)
|
||||
t.Fail()
|
||||
}
|
||||
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`)}
|
||||
s := p.plainText()
|
||||
r := "Water The air will not come To inhale is an effort The summer heat kills"
|
||||
if s != r {
|
||||
t.Logf("The plain text version is wrong: %s", s)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
func TestPageDir(t *testing.T) {
|
||||
cleanup(t, "testdata/dir")
|
||||
index.load()
|
||||
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()
|
||||
|
||||
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()
|
||||
s := string(p.Html)
|
||||
r := `<h1>Sun</h1>
|
||||
o, err := loadPage("testdata/dir/moon")
|
||||
assert.NoError(t, err, "load page")
|
||||
assert.Equal(t, p.Body, o.Body)
|
||||
assert.FileExists(t, "testdata/dir/moon.md")
|
||||
|
||||
<p>Silver leaves shine bright
|
||||
They droop, boneless, weak and sad
|
||||
A cruel sun stares down</p>
|
||||
`
|
||||
if s != r {
|
||||
t.Logf("The HTML is wrong: %s", s)
|
||||
t.Fail()
|
||||
}
|
||||
// Saving an empty page deletes it.
|
||||
p = &Page{Name: "testdata/dir/moon", Body: []byte("")}
|
||||
p.save()
|
||||
assert.NoFileExists(t, "testdata/dir/moon.md")
|
||||
|
||||
// But the backup still exists.
|
||||
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())
|
||||
}
|
||||
43
score.go
Normal file
43
score.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// score splits the query string q into terms and scores the text
|
||||
// based on those terms. This assumes that q already has all its meta
|
||||
// characters quoted.
|
||||
func score(q string, s string) int {
|
||||
score := 0
|
||||
re, err := regexp.Compile("(?i)" + regexp.QuoteMeta(q))
|
||||
if err == nil {
|
||||
m := re.FindAllString(s, -1)
|
||||
if m != nil {
|
||||
// Score increases for each full match of q.
|
||||
score += len(m)
|
||||
}
|
||||
}
|
||||
for _, token := range highlightTokens(q) {
|
||||
re, err := regexp.Compile(`(?is)(\pL?)(` + regexp.QuoteMeta(token) + `)(\pL?)`)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, m := range re.FindAllStringSubmatch(s, -1) {
|
||||
// Term matched increases the score.
|
||||
score++
|
||||
// Terms matching at the beginning and
|
||||
// end of words and matching entire
|
||||
// words increase the score further.
|
||||
if len(m[1]) == 0 {
|
||||
score++
|
||||
}
|
||||
if len(m[3]) == 0 {
|
||||
score++
|
||||
}
|
||||
if len(m[1]) == 0 && len(m[3]) == 0 {
|
||||
score++
|
||||
}
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
104
score_test.go
Normal file
104
score_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScore(t *testing.T) {
|
||||
|
||||
s := `The windows opens
|
||||
A wave of car noise hits me
|
||||
No birds to be heard.`
|
||||
|
||||
q := "window"
|
||||
// Score:
|
||||
// - q itself
|
||||
// - the single token
|
||||
// - the beginning of a word
|
||||
c := score(q, s)
|
||||
if c != 3 {
|
||||
t.Logf("%s score is %d", q, c)
|
||||
t.Fail()
|
||||
}
|
||||
q = "windows"
|
||||
c = score(q, s)
|
||||
// Score:
|
||||
// - q itself
|
||||
// - the single token
|
||||
// - the beginning of a word
|
||||
// - the end of a word
|
||||
// - the whole word
|
||||
if c != 5 {
|
||||
t.Logf("%s score is %d", q, c)
|
||||
t.Fail()
|
||||
}
|
||||
q = "car noise"
|
||||
c = score(q, s)
|
||||
// Score:
|
||||
// - car noise (+1)
|
||||
// - car, with beginning, end, whole word (+4)
|
||||
// - noise, with beginning, end, whole word (+4)
|
||||
if c != 9 {
|
||||
t.Logf("%s score is %d", q, c)
|
||||
t.Fail()
|
||||
}
|
||||
q = "noise car"
|
||||
c = score(q, s)
|
||||
// Score:
|
||||
// - the car token
|
||||
// - the noise token
|
||||
// - each with beginning, end and whole token (3 each)
|
||||
if c != 8 {
|
||||
t.Logf("%s score is %d", q, c)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreLong(t *testing.T) {
|
||||
s := `We are immersed in a sea of dead people. All the dead that have gone before us, silent now, just staring, gaping. As we move and talk and fret, never once stopping to ask ourselves – or them! – what it was all about. Instead we drown ourselves in noise. Incessantly we babble, surrounded by false friends claiming that all is well. And look at us! Yes, we are well. Patting our backs and expecting a pat – and we do! – we smugly do enjoy.`
|
||||
q := "all is well"
|
||||
c := score(q, s)
|
||||
// Score:
|
||||
// - all is well (1)
|
||||
// - all, beginning, end, whole word (+4 × 3 = 12)
|
||||
// - is, beginning, end, whole word (+4 × 1 = 4), and as a substring (1)
|
||||
// - well, beginning, end, whole word (+4 × 2 = 8)
|
||||
if c != 26 {
|
||||
t.Logf("%s score is %d", q, c)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreSubstring(t *testing.T) {
|
||||
s := `The loneliness of space means that receiving messages means knowledge that other people are out there. Not satellites pinging forever. Not bots searching and probing. Instead, humans. People who care. Curious and cautious.`
|
||||
q := "search probe"
|
||||
c := score(q, s)
|
||||
// Score:
|
||||
// - search, beginning (2)
|
||||
// - probe (0)
|
||||
if c != 2 {
|
||||
t.Logf("%s score is %d", q, c)
|
||||
t.Fail()
|
||||
}
|
||||
q = "ear"
|
||||
c = score(q, s)
|
||||
// Score:
|
||||
// - ear, all (2)
|
||||
if c != 2 {
|
||||
t.Logf("%s score is %d", q, c)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
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.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 {
|
||||
t.Logf("%s score is %d", q, p.Score)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
296
search.go
296
search.go
@@ -1,12 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
trigram "github.com/dgryski/go-trigram"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"io/fs"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Search is a struct containing the result of a search. Query is the
|
||||
@@ -14,97 +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
|
||||
}
|
||||
|
||||
// index is a struct containing the trigram index for search. It is
|
||||
// generated at startup and updated after every page edit.
|
||||
var index trigram.Index
|
||||
|
||||
// documents is a map, mapping document ids of the index to page
|
||||
// names.
|
||||
var documents map[trigram.DocID]string
|
||||
|
||||
func indexAdd(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
// 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
|
||||
}
|
||||
return 0
|
||||
}
|
||||
filename := path
|
||||
if info.IsDir() || strings.HasPrefix(filename, ".") || !strings.HasSuffix(filename, ".md") {
|
||||
return nil
|
||||
}
|
||||
name := strings.TrimSuffix(filename, ".md")
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := index.Add(string(p.Body))
|
||||
documents[id] = p.Name
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadIndex() error {
|
||||
index = make(trigram.Index)
|
||||
documents = make(map[trigram.DocID]string)
|
||||
err := filepath.Walk(".", indexAdd)
|
||||
if err != nil {
|
||||
fmt.Println("Indexing failed")
|
||||
index = nil
|
||||
documents = nil
|
||||
// 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 err
|
||||
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
|
||||
}
|
||||
|
||||
func (p *Page) updateIndex() {
|
||||
var id trigram.DocID
|
||||
for docId, name := range documents {
|
||||
if name == p.Name {
|
||||
id = docId
|
||||
break
|
||||
// filterPrefix filters the names by prefix. A prefix of "." means
|
||||
// that all the names are returned, since this is what path.Dir
|
||||
// returns for "no directory".
|
||||
func filterPrefix(names []string, prefix string) []string {
|
||||
if prefix == "." {
|
||||
return names
|
||||
}
|
||||
r := make([]string, 0)
|
||||
for _, name := range names {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
r = append(r, name)
|
||||
}
|
||||
}
|
||||
if id == 0 {
|
||||
id = index.Add(string(p.Body))
|
||||
documents[id] = p.Name
|
||||
} else {
|
||||
o, err := loadPage(p.Name)
|
||||
if err == nil {
|
||||
index.Delete(string(o.Body), id)
|
||||
}
|
||||
index.Insert(string(p.Body), id)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// 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 {
|
||||
ids := index.Query(q)
|
||||
items := make([]Page, len(ids))
|
||||
for i, id := range ids {
|
||||
name := documents[id]
|
||||
// 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 {
|
||||
fmt.Printf("Error loading %s\n", name)
|
||||
} else {
|
||||
p.summarize(q)
|
||||
items[i] = *p
|
||||
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
|
||||
}
|
||||
}
|
||||
fn := func(a, b Page) int {
|
||||
if a.Score < b.Score {
|
||||
return 1
|
||||
} else if a.Score > b.Score {
|
||||
return -1
|
||||
} else if a.Title < b.Title {
|
||||
return -1
|
||||
} else if a.Title > b.Title {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
slices.SortFunc(items, fn)
|
||||
return items
|
||||
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.
|
||||
// Search is limited to a directory and its subdirectories.
|
||||
func searchHandler(w http.ResponseWriter, r *http.Request, dir string) {
|
||||
q := r.FormValue("q")
|
||||
page, err := strconv.Atoi(r.FormValue("page"))
|
||||
if err != nil {
|
||||
page = 1
|
||||
}
|
||||
items, more := search(q, dir, page, false)
|
||||
s := &Search{Query: q, Dir: dir, Items: items, Previous: page - 1, Page: page, Next: page + 1,
|
||||
Results: len(items) > 0, More: more}
|
||||
renderTemplate(w, "search", s)
|
||||
}
|
||||
|
||||
46
search.html
46
search.html
@@ -1,28 +1,56 @@
|
||||
<!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: 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; }
|
||||
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>
|
||||
<h1>Search for {{.Query}}</h1>
|
||||
<div>
|
||||
<header>
|
||||
<a href="#main">Skip navigation</a>
|
||||
<a href="/view/index">Home</a>
|
||||
<form role="search" action="/search/{{.Dir}}" method="GET">
|
||||
<label for="search">Search:</label>
|
||||
<input id="search" type="text" value="{{.Query}}" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
|
||||
<button>Go</button>
|
||||
</form>
|
||||
</header>
|
||||
<main id="main">
|
||||
<h1>Search for {{.Query}}</h1>
|
||||
{{if .Results}}
|
||||
{{range .Items}}
|
||||
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a> <span class="score">{{.Score}}</span></p>
|
||||
<blockquote>{{.Html}}</blockquote>
|
||||
{{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}}
|
||||
{{range .Items}}
|
||||
<article lang="{{.Language}}">
|
||||
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
|
||||
<span class="score">{{.Score}}</span></p>
|
||||
<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}}
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
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())
|
||||
}
|
||||
248
search_test.go
248
search_test.go
@@ -1,71 +1,193 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/url"
|
||||
"slices"
|
||||
"testing"
|
||||
"strings"
|
||||
"os"
|
||||
)
|
||||
|
||||
var name string = "test"
|
||||
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")
|
||||
|
||||
// TestIndex relies on README.md being indexed
|
||||
func TestIndex (t *testing.T) {
|
||||
_ = os.Remove(name + ".md")
|
||||
loadIndex()
|
||||
q := "Oddµ"
|
||||
pages := search(q)
|
||||
if len(pages) == 0 {
|
||||
t.Log("Search found no result")
|
||||
t.Fail()
|
||||
}
|
||||
for _, p := range pages {
|
||||
if !strings.Contains(string(p.Body), q) {
|
||||
t.Logf("Page %s does not contain %s", p.Name, q)
|
||||
t.Fail()
|
||||
}
|
||||
if p.Score == 0 {
|
||||
t.Logf("Page %s has no score", p.Name)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
p := &Page{Name: name, Body: []byte("This is a test.")}
|
||||
p.save()
|
||||
pages = search("This is a test")
|
||||
found := false
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Logf("Page '%s' was not found", name)
|
||||
t.Fail()
|
||||
}
|
||||
p = &Page{Name: name, Body: []byte("Guvf vf n grfg.")}
|
||||
p.save()
|
||||
pages = search("This is a test")
|
||||
found = false
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
t.Logf("Page '%s' was still found using the old content: %s", name, p.Body)
|
||||
t.Fail()
|
||||
}
|
||||
pages = search("Guvf")
|
||||
found = false
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Logf("Page '%s' not found using the new content: %s", name, p.Body)
|
||||
t.Fail()
|
||||
}
|
||||
names := []string{"Berta", "Chris", "Alex"}
|
||||
slices.SortFunc(names, sortNames(terms))
|
||||
assert.True(t, slices.IsSorted(names), fmt.Sprintf("Sorted: %v", names))
|
||||
}
|
||||
|
||||
func TestPrependMatches(t *testing.T) {
|
||||
index.Lock()
|
||||
for _, s := range []string{"Alex", "Berta", "Chris"} {
|
||||
index.titles[s] = s
|
||||
}
|
||||
index.Unlock()
|
||||
r := []string{"Berta", "Chris"} // does not prepend
|
||||
u := []string{"Alex", "Berta", "Chris"} // does prepend
|
||||
v, _ := prependQueryPage(r, "", "Alex")
|
||||
assert.Equal(t, u, v, "prepend q")
|
||||
v, _ = prependQueryPage(r, "", "lex")
|
||||
assert.Equal(t, r, v, "exact matches only")
|
||||
v, _ = prependQueryPage(r, "", "#Alex")
|
||||
assert.Equal(t, u, v, "prepend hashtag")
|
||||
v, _ = prependQueryPage(r, "", "#Alex #Berta")
|
||||
assert.Equal(t, r, v, "do not prepend two hashtags")
|
||||
v, _ = prependQueryPage(r, "", "#alex")
|
||||
assert.Equal(t, r, v, "do not ignore case")
|
||||
v, _ = prependQueryPage(u, "", "Alex")
|
||||
assert.Equal(t, u, v, "do not prepend q twice")
|
||||
v, _ = prependQueryPage([]string{"Berta", "Alex", "Chris"}, "", "Alex")
|
||||
assert.Equal(t, u, v, "sort q to the front")
|
||||
v, _ = prependQueryPage([]string{"Berta", "Chris", "Alex"}, "", "Alex")
|
||||
assert.Equal(t, u, v, "sort q to the front")
|
||||
}
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
data := url.Values{}
|
||||
data.Set("q", "oddµ")
|
||||
|
||||
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
|
||||
assert.Contains(t, body, "Welcome")
|
||||
assert.Contains(t, body, `<span class="score">5</span>`)
|
||||
|
||||
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata", data)
|
||||
assert.NotContains(t, body, "Welcome")
|
||||
}
|
||||
|
||||
func TestSearchDir(t *testing.T) {
|
||||
cleanup(t, "testdata/dir")
|
||||
p := &Page{Name: "testdata/dir/dice", Body: []byte(`# Dice
|
||||
|
||||
A tiny drum roll
|
||||
Dice rolling bouncing stopping
|
||||
Where is lady luck?`)}
|
||||
p.save()
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("q", "luck")
|
||||
|
||||
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
|
||||
assert.Contains(t, body, "luck")
|
||||
|
||||
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata", data)
|
||||
assert.Contains(t, body, "luck")
|
||||
|
||||
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/dir", data)
|
||||
assert.Contains(t, body, "luck")
|
||||
|
||||
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/other", data)
|
||||
assert.Contains(t, body, "No results")
|
||||
}
|
||||
|
||||
func TestTitleSearch(t *testing.T) {
|
||||
items, more := search("title:readme", "", 1, false)
|
||||
assert.Equal(t, 0, len(items), "no page found")
|
||||
assert.False(t, more)
|
||||
|
||||
items, more = search("title:wel", "", 1, false) // README also contains "wel"
|
||||
assert.Equal(t, 1, len(items), "one page found")
|
||||
assert.Equal(t, "index", items[0].Name, "Welcome to Oddµ")
|
||||
assert.Greater(t, items[0].Score, 0, "matches result in a score")
|
||||
assert.False(t, more)
|
||||
|
||||
items, more = search("wel", "", 1, false)
|
||||
assert.Greater(t, len(items), 1, "two pages found")
|
||||
assert.False(t, more)
|
||||
}
|
||||
|
||||
func TestBlogSearch(t *testing.T) {
|
||||
cleanup(t, "testdata/grep")
|
||||
p := &Page{Name: "testdata/grep/2023-09-25", Body: []byte(`# Back then
|
||||
|
||||
I check the git log
|
||||
Was it 2015
|
||||
We met in the park?`)}
|
||||
p.save()
|
||||
|
||||
items, _ := search("blog:false", "", 1, false)
|
||||
for _, item := range items {
|
||||
assert.NotEqual(t, "Back then", item.Title, item.Name)
|
||||
}
|
||||
|
||||
items, _ = search("blog:true", "", 1, false)
|
||||
assert.Equal(t, 1, len(items), "one blog page found")
|
||||
assert.Equal(t, "Back then", items[0].Title, items[0].Name)
|
||||
}
|
||||
|
||||
func TestHashtagSearch(t *testing.T) {
|
||||
cleanup(t, "testdata/hashtag")
|
||||
|
||||
p := &Page{Name: "testdata/hashtag/Haiku", Body: []byte("# Haikus\n")}
|
||||
p.save()
|
||||
|
||||
p = &Page{Name: "testdata/hashtag/2023-10-28", Body: []byte(`# Tea
|
||||
|
||||
My tongue is on fire
|
||||
It looked so calm and peaceful
|
||||
A quick sip too quick
|
||||
|
||||
#Haiku`)}
|
||||
p.save()
|
||||
|
||||
items, _ := search("#Haiku", "testdata/hashtag", 1, false)
|
||||
assert.Equal(t, 2, len(items), "two pages found")
|
||||
assert.Equal(t, "Haikus", items[0].Title, items[0].Name)
|
||||
assert.Equal(t, "Tea", items[1].Title, items[1].Name)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
55
snippets.go
55
snippets.go
@@ -1,22 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func snippets (q string, s string) (string, int) {
|
||||
// re returns a regular expression matching any word in q.
|
||||
func re(q string) (*regexp.Regexp, error) {
|
||||
fields := highlightTokens(q)
|
||||
quoted := make([]string, len(fields))
|
||||
for i, w := range fields {
|
||||
quoted[i] = regexp.QuoteMeta(w)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func snippets(q string, s string) string {
|
||||
// Look for Snippets
|
||||
snippetlen := 100
|
||||
maxsnippets := 4
|
||||
// Compile the query as a regular expression
|
||||
re, err := regexp.Compile("(?i)(" + strings.Join(strings.Split(q, " "), "|") + ")")
|
||||
// If the compilation didn't work, truncate
|
||||
if err != nil || len(s) <= snippetlen {
|
||||
re, err := re(q)
|
||||
// If the compilation didn't work, truncate and return
|
||||
if err != nil {
|
||||
if len(s) > 400 {
|
||||
s = s[0:400]
|
||||
s = s[0:400] + " …"
|
||||
}
|
||||
return highlight(q, s)
|
||||
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], " ")
|
||||
@@ -26,9 +45,9 @@ func snippets (q string, s string) (string, int) {
|
||||
if j == -1 {
|
||||
// Or just truncate the body.
|
||||
if len(s) > 400 {
|
||||
s = s[0:400]
|
||||
s = s[0:400] + " …"
|
||||
}
|
||||
return highlight(q, s)
|
||||
return highlight(q, re, s)
|
||||
}
|
||||
}
|
||||
t := s[0:j]
|
||||
@@ -42,10 +61,11 @@ func snippets (q string, s string) (string, int) {
|
||||
}
|
||||
jsnippet++
|
||||
j = strings.Index(s, m[1])
|
||||
wl := len(m[1])
|
||||
if j > -1 {
|
||||
// get the substring containing the start of
|
||||
// the match, ending on word boundaries
|
||||
from := j - snippetlen / 2
|
||||
from := j - snippetlen/2
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
@@ -55,12 +75,12 @@ func snippets (q string, s string) (string, int) {
|
||||
} else {
|
||||
start += from
|
||||
}
|
||||
to := j + snippetlen / 2
|
||||
to := j + wl + snippetlen/2
|
||||
if to > len(s) {
|
||||
to = len(s)
|
||||
}
|
||||
end := strings.LastIndex(s[:to], " ")
|
||||
if end == -1 {
|
||||
if end == -1 || end <= j+wl {
|
||||
// OK, look for a longer word
|
||||
end = strings.Index(s[to:], " ")
|
||||
if end == -1 {
|
||||
@@ -69,11 +89,14 @@ func snippets (q string, s string) (string, int) {
|
||||
end += to
|
||||
}
|
||||
}
|
||||
t = s[start : end];
|
||||
res = res + t + " …";
|
||||
t = s[start:end]
|
||||
res = res + t
|
||||
if len(s) > end {
|
||||
res = res + " …"
|
||||
}
|
||||
// truncate text to avoid rematching the same string.
|
||||
s = s[end:]
|
||||
}
|
||||
}
|
||||
return highlight(q, res)
|
||||
return highlight(q, re, res)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -9,19 +10,36 @@ 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"
|
||||
r, c := snippets(q, s)
|
||||
if r != h {
|
||||
t.Logf("The snippets are wrong in 「%s」", r)
|
||||
t.Fail()
|
||||
}
|
||||
// Score 12:
|
||||
// - all is well (1)
|
||||
// - all, beginning, end, whole word (+4 × 3 = 12)
|
||||
// - is, beginning, end, whole word (+4 × 1 = 4), and as a substring (1)
|
||||
// - well, beginning, end, whole word (+4 × 2 = 8)
|
||||
if c != 26 {
|
||||
t.Logf("%s score is %d", q, c)
|
||||
t.Fail()
|
||||
}
|
||||
q := "title:all is well"
|
||||
r := snippets(q, s)
|
||||
assert.Equal(t, h, r)
|
||||
}
|
||||
|
||||
func TestSnippetsLong(t *testing.T) {
|
||||
s := `VWwXetig mty8fORN UNia4NFm SQsfyFHk BLDdgVnc AcvKP2fs q8KxPH1A IaCzFj96 J0S2fqca jp3ElV9f ULIZ1aMX GSUO1pLI p7vuJie8 kPfc0ONq EthfIUjm u74guCZ8 IiJYxlR6 5j5LlapY TGO98fOQ fO2RUb1g W8zaPa0v ps0haNzW OOeFwf1h 1N3td7zk 0OoMX8Ek aTd3Ciea 2T1aK9WH QbYfUojs nP59gqvR tqoEK3vJ zJ7JmRby qKReayLo 9BIwFgID 4Q4Tk3HH 1VLdDzSx q0hKUOKm vWkUXz9S 684uXanc gIaJNRFc gabtBO9A EhIh4VtT gJ3p9LYL jPVFqc65 QmMu8FUT vV0iphek 9Vvye5xS q7rJJyxa yHiIEMHA Ce8KLI1B FdbpdvWY qLk23poI aRoZ5LTu fWNL8rcj RpZyI052 HTxj28Q0 GiOjJ1UN iW7zrxBD QPpkiBVE nvOAkh7p c2prdKB8 9DAYvYo5 BPSN8wmO Q2oNZouQ zfEjm5aC lLMDotic hi585ip4 c7LYN3LZ xGmpN32s lcF83ipK 0IwvvEe1 tQxKHCCa u51OKNIE kdEsXUHG tTpUtwbG T6E4hMYv nVpbxCPH 0aACMPtu Oq945xMi wlPQHJ1e bROJU0e7 wdBjAYPt gjIaTuLu bicVsgYN L3a5NLwf 30zu9OHL qtDs1PJM OmTsSOZc v4eM7s8f MQlppFcY 6HTWrZPZ Raj94J30 kcSQPdTQ zsOhnhCQ sQDQkA3a uBP00Du8 qoq7syqj urFj9bqQ TV1EDcpC 4jKGRY27 vb3KgZQy EJillDeB UN4YYoLI hWgf1kqn o1B5s6Wm 98fQL4W0 PXaQeRc2 E45QBYtr od4CfqUo YsPizANv WFJj0nhM h7maM5WQ HuDYldsX qy1NLYCZ ZkvkuCxI hcD6Hyod sDiFWy4n tElzo9YK NNdt31gx NaeEtqmR MGwCCYWu y80zQlGX OAYoTGVY wYs20iOY j4eZDalG HDcd6eWZ Wvxqh0RI jykQ3bNt qRjxSxt6 4HjBIMK1 AIX5UEPr 1HQKp2ZH Fie3kxjb tzwmAigF QntpzTJO 9jQiDIDE LD0OlrSk 8PfSKmt4 MQBr2cK0 FLUQLq2h JfmjaCYv DqkdKyr8 ZtGnI5rj iqhACPMu UsY6ZIpT NjjgMBPV RW4YRcnZ Gyr9nest 9tIXI0km plugRQRv AlFpi0PJ DLcM8Zoq Auk5RBWs tMpfMMlU p6jGYq3Z rTIBTHVM zGFwFwQi j4O1AY21 BJnaiScY`
|
||||
// match at the very beginning: the first 100 characters or less
|
||||
assert.Equal(t,
|
||||
"<b>VWwXetig</b> mty8fORN UNia4NFm SQsfyFHk BLDdgVnc AcvKP2fs q8KxPH1A IaCzFj96 J0S2fqca jp3ElV9f ULIZ1aMX …",
|
||||
snippets("VWwXetig", s))
|
||||
// the first 100 … the match, at most 50 (50 from the start of the match)
|
||||
assert.Equal(t,
|
||||
"VWwXetig mty8fORN UNia4NFm SQsfyFHk BLDdgVnc AcvKP2fs q8KxPH1A IaCzFj96 J0S2fqca jp3ElV9f ULIZ1aMX … <b>GSUO1pLI</b> p7vuJie8 kPfc0ONq EthfIUjm u74guCZ8 IiJYxlR6 …",
|
||||
snippets("GSUO1pLI", s))
|
||||
// the first 100 … less than 50, the match, at most 50
|
||||
assert.Equal(t,
|
||||
"VWwXetig mty8fORN UNia4NFm SQsfyFHk BLDdgVnc AcvKP2fs q8KxPH1A IaCzFj96 J0S2fqca jp3ElV9f ULIZ1aMX … GSUO1pLI p7vuJie8 <b>kPfc0ONq</b> EthfIUjm u74guCZ8 IiJYxlR6 5j5LlapY TGO98fOQ …",
|
||||
snippets("kPfc0ONq", s))
|
||||
// the first 100 … 50, the match, at most 50
|
||||
assert.Equal(t,
|
||||
"VWwXetig mty8fORN UNia4NFm SQsfyFHk BLDdgVnc AcvKP2fs q8KxPH1A IaCzFj96 J0S2fqca jp3ElV9f ULIZ1aMX … u74guCZ8 IiJYxlR6 5j5LlapY TGO98fOQ fO2RUb1g <b>W8zaPa0v</b> ps0haNzW OOeFwf1h 1N3td7zk 0OoMX8Ek aTd3Ciea …",
|
||||
snippets("W8zaPa0v", s))
|
||||
// match at the very end
|
||||
assert.Equal(t,
|
||||
"VWwXetig mty8fORN UNia4NFm SQsfyFHk BLDdgVnc AcvKP2fs q8KxPH1A IaCzFj96 J0S2fqca jp3ElV9f ULIZ1aMX … tMpfMMlU p6jGYq3Z rTIBTHVM zGFwFwQi j4O1AY21 <b>BJnaiScY</b>",
|
||||
snippets("BJnaiScY", s))
|
||||
// match near the end
|
||||
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)
|
||||
}
|
||||
49
upload.html
Normal file
49
upload.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>Upload File</title>
|
||||
<style>
|
||||
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 lang="en">
|
||||
<h1>Upload File</h1>
|
||||
{{if ne .Last ""}}
|
||||
<p>Previous upload: <a href="/view/{{.Last}}">{{.Last}}</a></p>
|
||||
{{if .Image}}
|
||||
<p><img class="last" src="/view/{{.Last}}"></p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<form action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
163
upload_drop.go
Normal file
163
upload_drop.go
Normal file
@@ -0,0 +1,163 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type Upload struct {
|
||||
Dir string
|
||||
Name string
|
||||
Last string
|
||||
Image bool
|
||||
MaxWidth string
|
||||
Quality string
|
||||
}
|
||||
|
||||
var lastRe = regexp.MustCompile(`^(.*)([0-9]+)(.*)$`)
|
||||
|
||||
// uploadHandler uses the "upload.html" template to enable uploads. The file is saved using the 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
|
||||
fi, err := os.Stat(d)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
http.Error(w, "file exists", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := url.Values{}
|
||||
name := r.FormValue("name")
|
||||
data.Set("last", name)
|
||||
filename := filepath.Base(name)
|
||||
if filename == "." || filepath.Dir(name) != "." {
|
||||
http.Error(w, "no filename", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
backup(filename)
|
||||
// create the new file
|
||||
path := d + "/" + filename
|
||||
dst, err := os.Create(path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
// 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)
|
||||
}
|
||||
156
upload_drop_test.go
Normal file
156
upload_drop_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpload(t *testing.T) {
|
||||
cleanup(t, "testdata/files")
|
||||
// for uploads, the directory is not created automatically
|
||||
os.MkdirAll("testdata/files", 0755)
|
||||
assert.HTTPStatusCode(t, makeHandler(uploadHandler, false), "GET", "/upload/testdata/files/", nil, 200)
|
||||
form := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(form)
|
||||
field, err := writer.CreateFormField("name")
|
||||
assert.NoError(t, err)
|
||||
_, err = field.Write([]byte("ok.txt"))
|
||||
assert.NoError(t, err)
|
||||
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/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"`)
|
||||
}
|
||||
89
view.go
Normal file
89
view.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rootHandler just redirects to /view/index.
|
||||
func rootHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/view/index", http.StatusFound)
|
||||
}
|
||||
|
||||
// viewHandler serves pages. If the requested URL maps to an existing file, it is served. If the requested URL maps to a
|
||||
// directory, the browser is redirected to the index page. If the requested URL ends in ".rss" and the corresponding
|
||||
// file ending with ".md" exists, a feed is generated and the "feed.html" template is used (it is used to generate a RSS
|
||||
// 2.0 feed, no matter what the template's extension is). If the requested URL maps to a page name, the corresponding
|
||||
// file (ending in ".md") is loaded and served using the "view.html" template. If none of the above, the browser is
|
||||
// redirected to an edit page.
|
||||
//
|
||||
// Caching: a 304 NOT MODIFIED is returned if the request has an If-Modified-Since header that matches the file's
|
||||
// modification time, truncated to one second. Truncation is required because the file's modtime has sub-second
|
||||
// precision and the HTTP timestamp for the Last-Modified header has not.
|
||||
func viewHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
file := true
|
||||
rss := false
|
||||
if name == "" {
|
||||
name = "."
|
||||
}
|
||||
fn := name
|
||||
fi, err := os.Stat(fn)
|
||||
if err != nil {
|
||||
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 {
|
||||
http.Redirect(w, r, "/edit/"+name, http.StatusFound)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
44
view.html
44
view.html
@@ -1,27 +1,45 @@
|
||||
<!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: 70ch; padding: 2ch; margin: auto; color: #111; background: #ffe; }
|
||||
form { display: inline-block; padding-left: 1em; }
|
||||
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>
|
||||
<h1>{{.Title}}</h1>
|
||||
<div>
|
||||
<a href="/edit/{{.Name}}">Edit this page</a>
|
||||
<form role="search" action="/search" method="GET">
|
||||
<input type="text" spellcheck="false" name="q" required>
|
||||
<button>Search</button>
|
||||
<header>
|
||||
<a href="#main">Skip navigation</a>
|
||||
<a href="/view/index">Home</a>
|
||||
<a href="/edit/{{.Name}}" accesskey="e">Edit</a>
|
||||
<a href="/add/{{.Name}}" accesskey="a">Add</a>
|
||||
<a href="/diff/{{.Name}}" accesskey="d">Diff</a>
|
||||
<a href="/upload/{{.Dir}}" accesskey="u">Upload</a>
|
||||
<form role="search" action="/search/{{.Dir}}" method="GET">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
{{.Html}}
|
||||
</div>
|
||||
</header>
|
||||
<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>
|
||||
|
||||
115
view_test.go
Normal file
115
view_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRootHandler(t *testing.T) {
|
||||
HTTPRedirectTo(t, rootHandler, "GET", "/", nil, "/view/index")
|
||||
}
|
||||
|
||||
// relies on index.md in the current directory!
|
||||
func TestViewHandler(t *testing.T) {
|
||||
assert.Regexp(t, regexp.MustCompile("Welcome to Oddµ"),
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/index", nil))
|
||||
}
|
||||
|
||||
func TestViewHandlerDir(t *testing.T) {
|
||||
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/", nil, "/view/index")
|
||||
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/man", nil, "/view/man/index")
|
||||
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/man/", nil, "/view/man/index")
|
||||
}
|
||||
|
||||
// relies on index.md in the current directory!
|
||||
func TestViewHandlerWithId(t *testing.T) {
|
||||
data := make(url.Values)
|
||||
data.Set("id", "index")
|
||||
assert.Regexp(t, regexp.MustCompile("Welcome to Oddµ"),
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/", data))
|
||||
}
|
||||
|
||||
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/amp/Rock%20%26%20Roll", nil))
|
||||
|
||||
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/amp/Rock%20%26%20Roll", nil))
|
||||
}
|
||||
|
||||
func TestPageTitleWithQuestionMark(t *testing.T) {
|
||||
cleanup(t, "testdata/q")
|
||||
|
||||
p := &Page{Name: "testdata/q/How about no?", Body: []byte("No means no")}
|
||||
p.save()
|
||||
|
||||
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/q/How%20about%20no%3F", nil)
|
||||
assert.Contains(t, body, "No means no")
|
||||
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))
|
||||
}
|
||||
255
wiki.go
255
wiki.go
@@ -1,122 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"regexp"
|
||||
"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", "view.html", "search.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).
|
||||
var validPath = regexp.MustCompile("^/([^/]+)/(.*)$")
|
||||
|
||||
// validPath is a regular expression where the second group matches a
|
||||
// page, so when the handler for "/edit/" 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)
|
||||
}
|
||||
}
|
||||
|
||||
// rootHandler just redirects to /view/index.
|
||||
func rootHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/view/index", http.StatusFound)
|
||||
}
|
||||
|
||||
// viewHandler renders a text file, if the name ends in ".txt" and
|
||||
// such a file exists. Otherwise, it loads the page. If this didn't
|
||||
// work, the browser is redirected to an edit page. Otherwise, the
|
||||
// "view.html" template is used to show the rendered HTML.
|
||||
func viewHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
// Short cut for text files
|
||||
if (strings.HasSuffix(name, ".txt")) {
|
||||
body, err := os.ReadFile(name)
|
||||
if err == nil {
|
||||
w.Write(body)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Attempt to load Markdown page; edit it if this fails
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/edit/"+name, http.StatusFound)
|
||||
return
|
||||
}
|
||||
p.handleTitle(true)
|
||||
p.renderHtml()
|
||||
renderTemplate(w, "view", p)
|
||||
}
|
||||
|
||||
// editHandler uses the "edit.html" template to present an edit page.
|
||||
// When editing, the page title is not overriden by a title in the
|
||||
// text. Instead, the page name is used.
|
||||
func editHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
p = &Page{Title: name, Name: name}
|
||||
} else {
|
||||
p.handleTitle(false)
|
||||
}
|
||||
renderTemplate(w, "edit", p)
|
||||
}
|
||||
|
||||
// saveHandler takes the "body" form parameter and saves it. The
|
||||
// browser is redirected to the page view.
|
||||
func saveHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
body := r.FormValue("body")
|
||||
p := &Page{Name: name, Body: []byte(body)}
|
||||
err := p.save()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/view/"+name, http.StatusFound)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func makeHandler(fn func (http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
q := r.FormValue("q")
|
||||
items := search(q)
|
||||
s := &Search{Query: q, Items: items, Results: len(items) > 0}
|
||||
renderTemplate(w, "search", s)
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
@@ -125,14 +69,117 @@ func getPort() string {
|
||||
return port
|
||||
}
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("/", rootHandler)
|
||||
http.HandleFunc("/view/", makeHandler(viewHandler))
|
||||
http.HandleFunc("/edit/", makeHandler(editHandler))
|
||||
http.HandleFunc("/save/", makeHandler(saveHandler))
|
||||
http.HandleFunc("/search", searchHandler)
|
||||
loadIndex()
|
||||
// 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()
|
||||
fmt.Printf("Serving a wiki on port %s\n", port)
|
||||
http.ListenAndServe(":" + port, nil)
|
||||
|
||||
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() {
|
||||
log.Print("Indexing pages")
|
||||
n, err := index.load()
|
||||
if err == nil {
|
||||
log.Printf("Indexed %d pages", n)
|
||||
} else {
|
||||
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.
|
||||
func scheduleLoadLanguages() {
|
||||
log.Print("Loading languages")
|
||||
n := loadLanguages()
|
||||
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/", makeHandler(searchHandler, false))
|
||||
go scheduleLoadIndex()
|
||||
go scheduleLoadLanguages()
|
||||
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() {
|
||||
if len(os.Args) == 1 {
|
||||
serve()
|
||||
} else {
|
||||
commands()
|
||||
}
|
||||
}
|
||||
|
||||
113
wiki_test.go
Normal file
113
wiki_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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
|
||||
// nil if building a new request fails.
|
||||
func HTTPHeaders(handler http.HandlerFunc, method, url string, values url.Values, header string) []string {
|
||||
w := httptest.NewRecorder()
|
||||
req, err := http.NewRequest(method, url+"?"+values.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
handler(w, req)
|
||||
return w.Result().Header[header]
|
||||
}
|
||||
|
||||
// HTTPRedirectTo 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. Note: This method assumes
|
||||
// that POST requests ignore the query part of the URL.
|
||||
func HTTPRedirectTo(t *testing.T, handler http.HandlerFunc, method, url string, values url.Values, destination string) bool {
|
||||
w := httptest.NewRecorder()
|
||||
var req *http.Request
|
||||
var err error
|
||||
if method == http.MethodPost {
|
||||
body := strings.NewReader(values.Encode())
|
||||
req, err = http.NewRequest(method, url, body)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
} else {
|
||||
req, err = http.NewRequest(method, url+"?"+values.Encode(), nil)
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
handler(w, req)
|
||||
code := w.Code
|
||||
isRedirectCode := code >= http.StatusMultipleChoices && code <= http.StatusTemporaryRedirect
|
||||
assert.True(t, isRedirectCode, "Expected HTTP redirect status code for %q but received %d", url+"?"+values.Encode(), 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+"?"+values.Encode(), headers)
|
||||
return isRedirectCode
|
||||
}
|
||||
|
||||
// HTTPUploadLocation returns the location header after an upload.
|
||||
func HTTPUploadLocation(t *testing.T, handler http.HandlerFunc, url, contentType string, body *bytes.Buffer) string {
|
||||
w := httptest.NewRecorder()
|
||||
req, err := http.NewRequest("POST", url, body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
assert.NoError(t, err)
|
||||
handler(w, req)
|
||||
code := w.Code
|
||||
isRedirectCode := code >= http.StatusMultipleChoices && code <= http.StatusTemporaryRedirect
|
||||
assert.True(t, isRedirectCode, "Expected HTTP redirect status code for %q but received %d", url, code)
|
||||
headers := w.Result().Header["Location"]
|
||||
assert.True(t, len(headers) == 1, "Expected a single redirect header but got %d locations", len(headers))
|
||||
return headers[0]
|
||||
}
|
||||
|
||||
// HTTPUploadAndRedirectTo checks that the request results in a redirect and it
|
||||
// checks the destination of the redirect. It returns whether the
|
||||
// request did in fact result in a redirect.
|
||||
func HTTPUploadAndRedirectTo(t *testing.T, handler http.HandlerFunc, url, contentType string, body *bytes.Buffer, destination string) {
|
||||
location := HTTPUploadLocation(t, handler, url, contentType, body)
|
||||
assert.Equal(t, destination, location,
|
||||
"Expected HTTP redirect location %s for %q but received %s", destination, url, location)
|
||||
}
|
||||
|
||||
// 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