forked from mirror/oddmu
Compare commits
436 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
380692d616 | ||
|
|
7ad04e561c | ||
|
|
0e0c9f3bb5 | ||
|
|
7d4530383e | ||
|
|
df9439c356 | ||
|
|
b4ee637600 | ||
|
|
2cd5a38885 | ||
|
|
149f4cf7a4 | ||
|
|
d26e3479c9 | ||
|
|
2e94abfabb | ||
|
|
9116095f69 | ||
|
|
a82bbba62b | ||
|
|
3066574167 | ||
|
|
e065888279 | ||
|
|
4e81401b8c | ||
|
|
acac745e1f | ||
|
|
3f542388db | ||
|
|
8a275c103a | ||
|
|
2934471ed3 | ||
|
|
86cd3baa89 | ||
|
|
95b0c6951d | ||
|
|
f935237fcb | ||
|
|
50d603781e | ||
|
|
0a37ac34d5 | ||
|
|
14f9f783eb | ||
|
|
def0534771 | ||
|
|
8c70bd5c7b | ||
|
|
81f2dc3ace | ||
|
|
07aa4f0064 | ||
|
|
926c4faf06 | ||
|
|
4636b10cce | ||
|
|
114e2b8790 | ||
|
|
1fa26ab6f9 | ||
|
|
69d2f452d1 | ||
|
|
c3b3bbb1c4 | ||
|
|
07d395ab1e | ||
|
|
6ed4e17767 | ||
|
|
10bbda4a6e | ||
|
|
006b8211f4 | ||
|
|
1885fdaaad | ||
|
|
cd6fb187b4 | ||
|
|
4f5b49a065 | ||
|
|
7fbdac3f9a | ||
|
|
38f13dc8f8 | ||
|
|
e3ceaf031e | ||
|
|
5feb5f9b21 | ||
|
|
086e65c304 | ||
|
|
ffaae10f94 | ||
|
|
6c6d326c59 | ||
|
|
f41d6b8e9e | ||
|
|
8e35336cb3 | ||
|
|
2a44c2a74f | ||
|
|
fe9a621f1e | ||
|
|
be663eed32 | ||
|
|
86ef305e9c | ||
|
|
1fd97ae717 | ||
|
|
d0fdf8c3c6 | ||
|
|
1786050e72 | ||
|
|
f12252e148 | ||
|
|
f5f997261e | ||
|
|
43408707c5 | ||
|
|
50ce79d60d | ||
|
|
745500f09f | ||
|
|
f02491dda0 | ||
|
|
0001583044 | ||
|
|
4a5b7d52cd | ||
|
|
c65f3ea386 | ||
|
|
d2adffed6e | ||
|
|
d8e1d79127 | ||
|
|
d839219f96 | ||
|
|
103d1f4609 | ||
|
|
28a63e7479 | ||
|
|
3cfe2d71b4 | ||
|
|
30b0b64129 | ||
|
|
b352930651 | ||
|
|
9e5f83d36e | ||
|
|
2b298d84c3 | ||
|
|
ebd7b920ca | ||
|
|
9f97ed8d04 | ||
|
|
4837d6477c | ||
|
|
9eb9a977f1 | ||
|
|
ed05d028e8 | ||
|
|
0a0aa59f7e | ||
|
|
73fb124c74 | ||
|
|
e454b02011 | ||
|
|
ffc4a515fc | ||
|
|
a26c7d046b | ||
|
|
3e68ecd388 | ||
|
|
1abde4c884 | ||
|
|
24e950931b | ||
|
|
a08df076fa | ||
|
|
a5a7549bec | ||
|
|
883f3fee47 | ||
|
|
6a4d1e5ca9 | ||
|
|
8698c64dda | ||
|
|
dade693451 | ||
|
|
b5272803f5 | ||
|
|
3f586a51f1 | ||
|
|
72aad37979 | ||
|
|
93de7b2517 | ||
|
|
afda27be76 | ||
|
|
9457d38cc1 | ||
|
|
cd92bea269 | ||
|
|
fdaf1303c8 | ||
|
|
d5286c9633 | ||
|
|
a1469a7c19 | ||
|
|
31065ea95e | ||
|
|
785b861ba0 | ||
|
|
2ecc0861f0 | ||
|
|
e7be104718 | ||
|
|
6bec3500c3 | ||
|
|
9b3dfd31d0 | ||
|
|
3f876c6326 | ||
|
|
74b192a609 | ||
|
|
c39ad26901 | ||
|
|
293e5bc1c5 | ||
|
|
1b3dc89f28 | ||
|
|
7374114bbd | ||
|
|
0802091da1 | ||
|
|
148aff3d74 | ||
|
|
bcaa51506c | ||
|
|
2d12168612 | ||
|
|
c8197cd811 | ||
|
|
791b95e80d | ||
|
|
f0517a1d30 | ||
|
|
6cf1a2dc9e | ||
|
|
c236adf0d6 | ||
|
|
6f48140958 | ||
|
|
85b3b63af8 | ||
|
|
739787f582 | ||
|
|
ffe39953a4 | ||
|
|
e0fa8756ea | ||
|
|
0cb4291394 | ||
|
|
fd6bce6418 | ||
|
|
7e5d8b768c | ||
|
|
ff04c08537 | ||
|
|
57763c661b | ||
|
|
67d9a2a178 | ||
|
|
5083787f7c | ||
|
|
3ffe58d4b9 | ||
|
|
d8ee0ff963 | ||
|
|
c829725f91 | ||
|
|
0fb599bf8b | ||
|
|
eec2a477ab | ||
|
|
0b0315802c | ||
|
|
efa9175c71 | ||
|
|
3b4211bc61 | ||
|
|
0283be53d6 | ||
|
|
5b1558cc57 | ||
|
|
1cd68929ff | ||
|
|
d7711832c6 | ||
|
|
304b803114 | ||
|
|
eef226f9d2 | ||
|
|
e72a4418fd | ||
|
|
912957c990 | ||
|
|
b64d56a648 | ||
|
|
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 | ||
|
|
6f61dde12a | ||
|
|
5b29e6433a | ||
|
|
2bd20432e2 | ||
|
|
49d62a7979 | ||
|
|
071e807886 | ||
|
|
df1fdf4373 | ||
|
|
08b63ae84b | ||
|
|
645a87e5c8 | ||
|
|
c54e41da28 | ||
|
|
6a4e014d1c | ||
|
|
2142144b0c | ||
|
|
504dc1e52a | ||
|
|
919ad20e99 | ||
|
|
50174a2c65 | ||
|
|
f349bd8f4b | ||
|
|
c19fadf662 | ||
|
|
01adab1910 | ||
|
|
2962efc59f | ||
|
|
cb0ce25d76 | ||
|
|
1ab8f905e0 | ||
|
|
01e5b4247f | ||
|
|
037a429419 | ||
|
|
cf6190f4fc | ||
|
|
18060c607d | ||
|
|
e9304cd3d4 | ||
|
|
e68eda4f27 | ||
|
|
73eb4837e9 | ||
|
|
b661655d4a | ||
|
|
8cfb622937 | ||
|
|
f359ba5c76 | ||
|
|
51fc0fbedb | ||
|
|
3cc39054bb | ||
|
|
d45c583f6e | ||
|
|
d7257ffa38 | ||
|
|
f3743d8e60 | ||
|
|
e57002b40c |
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/oddmu
|
||||
test.md
|
||||
/testdata/
|
||||
688
LICENSE
688
LICENSE
@@ -1,27 +1,667 @@
|
||||
Copyright (c) 2012 The Go Authors. All rights reserved.
|
||||
This software is Copyright (c) 2015–2024 by Alex Schroeder.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
This is free software, licensed under:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
The GNU Affero General Public License, Version 3, November 2007
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
59
Makefile
Normal file
59
Makefile
Normal file
@@ -0,0 +1,59 @@
|
||||
SHELL=/bin/bash
|
||||
PREFIX=${HOME}/.local
|
||||
|
||||
.PHONY: help build test run upload docs install missing
|
||||
|
||||
help:
|
||||
@echo Help for Oddmu
|
||||
@echo =====================
|
||||
@echo
|
||||
@echo make run
|
||||
@echo " runs program, offline"
|
||||
@echo
|
||||
@echo make test
|
||||
@echo " runs the tests without log output"
|
||||
@echo
|
||||
@echo make docs
|
||||
@echo " create man pages from text files"
|
||||
@echo
|
||||
@echo make 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"
|
||||
|
||||
build: oddmu
|
||||
|
||||
oddmu: *.go
|
||||
go build
|
||||
|
||||
test:
|
||||
go test -shuffle on .
|
||||
|
||||
run:
|
||||
go run .
|
||||
|
||||
upload: build
|
||||
rsync --itemize-changes --archive oddmu sibirocobombus.root:/home/oddmu/
|
||||
ssh sibirocobombus.root "systemctl restart oddmu; systemctl restart alex; systemctl restart claudia; systemctl restart campaignwiki"
|
||||
@echo Changes to the template files need careful consideration
|
||||
|
||||
docs:
|
||||
cd man; make
|
||||
|
||||
install:
|
||||
for n in 1 5 7; do install -D -t ${PREFIX}/share/man/man$$n man/*.$$n; done
|
||||
install -D -t ${PREFIX}/.local/bin oddmu
|
||||
|
||||
# More could be added, of course!
|
||||
dist: oddmu-linux-amd64.tar.gz
|
||||
|
||||
oddmu-linux-amd64: *.go
|
||||
GOOS=linux GOARCH=amd64 go build -o $@
|
||||
|
||||
%.tar.gz: %
|
||||
tar czf $@ --transform='s/^$</oddmu/' --transform='s/^/oddmu\//' --exclude='*~' \
|
||||
$< Makefile *.socket *.service *.md man/Makefile man/*.1 man/*.5 man/*.7 themes/
|
||||
353
README.md
353
README.md
@@ -1,22 +1,345 @@
|
||||
# Oddµ: A Small Wiki Written in Go
|
||||
# Oddµ: A minimal wiki
|
||||
|
||||
This is a minimal wiki in the spirit of [Shortest Wiki Contest](https://wiki.c2.com/?ShortestWikiContest)
|
||||
and [Wiki Principles](https://wiki.c2.com/?WikiPrinciples).
|
||||
The article [Writing Web Applications](https://golang.org/doc/articles/wiki/)
|
||||
provided the initial code. It's how I learned Go.
|
||||
This program helps you run a minimal wiki, blog, digital garden, memex
|
||||
or Zettelkasten. There is no version history.
|
||||
|
||||
To get started, use `go run .` to compile and run it; then use your browser to visit
|
||||
`http://localhost:8080/edit/index`.
|
||||
It's well suited as a self-hosted, single-user web application, when
|
||||
there is no need for collaboration on the site itself. Links and email
|
||||
connect you to the rest of the net. The wiki can be public or private.
|
||||
Perhaps it just runs on your local machine, unreachable from the
|
||||
Internet.
|
||||
|
||||
## Some context
|
||||
It's well suited as a secondary medium for a close-knit group:
|
||||
collaboration and conversation happens elsewhere, in chat, on social
|
||||
media. The wiki serves as the text repository that results from these
|
||||
discussions. As there are no logins and no version histories, it is
|
||||
not possible to undo vandalism and spam. Only allow people you trust
|
||||
write-access to the site.
|
||||
|
||||
Initially, this wiki used [Cajun](https://github.com/m4tty/cajun) to render Wiki Creole
|
||||
but I switched it to [Markdown](https://github.com/gomarkdown/markdown).
|
||||
It's well suited as a simple static site generator. There are no
|
||||
plugins.
|
||||
|
||||
At the time, I planned to use [git2go](https://github.com/libgit2/git2go) to implement
|
||||
history pages, recent changes, reverting to old versions. None of that ever came about,
|
||||
however.
|
||||
When Oddµ runs as a web server, it serves all the Markdown files
|
||||
(ending in `.md`) as web pages. These pages can be edited via the web.
|
||||
|
||||
Many years later I did use it as the basis for [Oddμ](https://alexschroeder.ch/view/oddmu/index),
|
||||
however.
|
||||
Oddmu adds the following extensions to Markdown: local links `[[like
|
||||
this]]`, hashtags `#Like_This` and fediverse account links like
|
||||
`@alex@alexschroeder.ch`.
|
||||
|
||||
If your pages don't provide their own title (`# title`), the file name
|
||||
(without `.md`) is used as the title. Subdirectories are created as
|
||||
necessary.
|
||||
|
||||
Other files can be uploaded and images (ending in `.jpg`, `.jpeg`,
|
||||
`.png`, `.heic` or `webp`) can be resized when they are uploaded
|
||||
(resulting in `.jpg` or `.png` files).
|
||||
|
||||
## Documentation
|
||||
|
||||
This project uses man(1) pages. They are generated from text files
|
||||
using [scdoc](https://git.sr.ht/~sircmpwn/scdoc). These are the files
|
||||
available:
|
||||
|
||||
[oddmu(1)](/oddmu.git/blob/main/man/oddmu.1.txt): This man page has a
|
||||
short introduction to Oddmu, its configuration via templates and
|
||||
environment variables, plus points to the other man pages.
|
||||
|
||||
[oddmu(5)](/oddmu.git/blob/main/man/oddmu.5.txt): This man page talks
|
||||
about the Markdown and includes some examples for the non-standard
|
||||
features such as table markup. It also talks about the Oddmu
|
||||
extensions to Markdown: wiki links, hashtags and fediverse account
|
||||
links. Local links must use percent encoding for page names so there
|
||||
is a section about percent encoding. The man page also explains how
|
||||
feeds are generated.
|
||||
|
||||
[oddmu-releases(7)](/oddmu.git/blob/main/man/oddmu-releases.7.txt):
|
||||
This man page lists all the Oddmu versions and their user-visible
|
||||
changes.
|
||||
|
||||
[oddmu-releases(7)](/oddmu.git/blob/main/man/oddmu-releases.7.txt):
|
||||
This man page lists all the Oddmu versions and their user-visible
|
||||
changes.
|
||||
|
||||
[oddmu-version(1)](/oddmu.git/blob/main/man/oddmu-version.1.txt): This
|
||||
man page documents the "version" subcommand which you can use to get
|
||||
installed Oddmu version.
|
||||
|
||||
[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.
|
||||
|
||||
[oddmu-search(1)](/oddmu.git/blob/main/man/oddmu-search.1.txt): This
|
||||
man page documents the "search" subcommand which you can use to build
|
||||
indexes – lists of page links. These are important for feeds.
|
||||
|
||||
[oddmu-search(7)](/oddmu.git/blob/main/man/oddmu-search.7.txt): This
|
||||
man page documents how search and scoring work.
|
||||
|
||||
[oddmu-filter(7)](/oddmu.git/blob/main/man/oddmu-filter.7.txt): This
|
||||
man page documents how to exclude subdirectories from search and
|
||||
archiving.
|
||||
|
||||
[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 Apache web server for various
|
||||
common tasks such as using logins to limit what visitors can edit.
|
||||
|
||||
[oddmu-nginx(5)](/oddmu.git/blob/main/man/oddmu-nginx.5.txt): This man
|
||||
page documents how to set up the freenginx 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
|
||||
|
||||
To build the binary:
|
||||
|
||||
```sh
|
||||
go build
|
||||
```
|
||||
|
||||
The man pages are already built. If you want to rebuild them, you need
|
||||
to have [scdoc](https://git.sr.ht/~sircmpwn/scdoc) installed.
|
||||
|
||||
```sh
|
||||
make docs
|
||||
```
|
||||
|
||||
The `Makefile` in the `man` directory has targets to create Markdown
|
||||
and HTML files.
|
||||
|
||||
The HEIC library uses C code and prevents cross-compilation.
|
||||
|
||||
As the repository changed URLs a few times (from GitHub, to
|
||||
self-hosted using `cgit` to self-hosted using `legit`), there is no
|
||||
way to install it using `go install`. You need to `git clone` the
|
||||
repository and build it locally.
|
||||
|
||||
## 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.
|
||||
|
||||
Here's how to build and run straight from the source directory:
|
||||
|
||||
```sh
|
||||
go run .
|
||||
```
|
||||
|
||||
The program serves the local directory as a wiki on port 8080. Point
|
||||
your browser to http://localhost:8080/ to use it.
|
||||
|
||||
Once the `oddmu` binary is built, you can run it instead:
|
||||
|
||||
```sh
|
||||
./oddmu
|
||||
```
|
||||
|
||||
To read the main man page witihout installing Oddmu:
|
||||
|
||||
```sh
|
||||
man -l man/oddmu.1
|
||||
```
|
||||
|
||||
## Installing
|
||||
|
||||
This installs `oddmu` into `$HOME/.local/bin` and the manual pages
|
||||
into `$HOME/.local/share/man/`.
|
||||
|
||||
```sh
|
||||
make install
|
||||
```
|
||||
|
||||
To install it elsewhere, here's an example using [GNU
|
||||
Stow](https://www.gnu.org/software/stow/) to install it into
|
||||
`/usr/local/stow` in a way that allows you to uninstall it later:
|
||||
|
||||
```sh
|
||||
sudo mkdir /usr/local/stow/oddmu
|
||||
sudo make install PREFIX=/usr/local/stow/oddmu/
|
||||
cd /usr/local/stow
|
||||
sudo stow oddmu
|
||||
```
|
||||
|
||||
## Hacking
|
||||
|
||||
If you're interested in making changes to the code, here's a
|
||||
high-level introduction to the various source files.
|
||||
|
||||
- `*_test.go` are the test files; a few library functions are defined
|
||||
in `wiki_test.go`.
|
||||
- `*_cmd.go` are the files implementing the various subcommands with
|
||||
matching names
|
||||
- `accounts.go` implements the webfinger code to fetch fediverse
|
||||
account link destinations with the URI provided by webfinger
|
||||
- `add_append.go` implements the `/add` and `/append` handlers
|
||||
- `archive.go` implements the `/archive` handler
|
||||
- `changes.go` implements the "notifications": the automatic addition
|
||||
of links to index, changes and hashtag files when pages are edited
|
||||
- `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
|
||||
- `templates.go` implements template loading and reloading
|
||||
- `tokenizer.go` implements the various tokenizers used
|
||||
- `upload_drop.go` implements the `/upload` and `/drop` handlers
|
||||
- `view.go` implements the `/view` handler
|
||||
- `watch.go` implements the filesystem notification watch
|
||||
- `wiki.go` implements the main function
|
||||
|
||||
The code of this package is licensed to you under the
|
||||
AGPL-3.0-or-later license. If you do make changes and your site is
|
||||
public, be aware of section 13:
|
||||
|
||||
> … if you modify the Program, your modified version must prominently
|
||||
> offer all users interacting with it remotely through a computer
|
||||
> network (if your version supports such interaction) an opportunity
|
||||
> to receive the Corresponding Source of your version by providing
|
||||
> access to the Corresponding Source from a network server at no
|
||||
> charge, through some standard or customary means of facilitating
|
||||
> copying of software.
|
||||
|
||||
### Changing the markup rules
|
||||
|
||||
If you want to change the markup rules, your starting point should be
|
||||
`parser.go`. Make sure you read the documentation of [Go
|
||||
Markdown](https://github.com/gomarkdown/markdown) and note that it
|
||||
offers MathJax support (needs a change to the `view.html` template so
|
||||
that the MathJax Javascript gets loaded) and
|
||||
[MMark](https://mmark.miek.nl/post/syntax/) support, and it shows how
|
||||
extensions can be added.
|
||||
|
||||
### Filenames and URL path
|
||||
|
||||
One of the sad parts of the code is the distinction between path and
|
||||
filepath. On a Linux system, this doesn't matter. I suspect that it
|
||||
also doesn't matter on MacOS and Windows because the file systems
|
||||
handle forward slashes just fine. The code still tries to do the right
|
||||
thing. A path that is derived from a URL is a path with slashes.
|
||||
Before accessing a file, it has to be turned into a filepath using
|
||||
`filepath.FromSlashes` and in the rare case where the inverse happens,
|
||||
use `filepath.ToSlashes`. Any path received via the URL path uses
|
||||
slashes and needs to be converted to a filepath before passing it to
|
||||
any `os` function. Any path received within a `path/filepath.WalkFunc`
|
||||
is a filepath and needs to be converted to use slashes when used in
|
||||
HTML output.
|
||||
|
||||
If you need to access the page name in code that is used from a
|
||||
template, you have to decode the path. See the code in `diff.go` for
|
||||
an example.
|
||||
|
||||
### HTTP handlers
|
||||
|
||||
The URL paths all have the form `/action/directory/pagename` (with
|
||||
directory being optional and pagename sometimes being optional). If
|
||||
you need to limit access in Apache or nginx or some other web server
|
||||
acting as a [reverse
|
||||
proxy](https://en.wikipedia.org/wiki/Reverse_proxy), you can do that.
|
||||
See `man oddmu-apache` and `man oddmu-nginx` for some configuration
|
||||
examples.
|
||||
|
||||
This is how you can prevent some actions by simply not passing them on
|
||||
to Oddmu, or you can require authentication for certain actions.
|
||||
Furthermore, you can do the same for directories, allowing you to use
|
||||
subdirectories as separate sites, each with their own editors.
|
||||
|
||||
## Dependencies
|
||||
|
||||
This section lists the non-standard libraries Oddmu uses and their
|
||||
respective licenses.
|
||||
|
||||
[github.com/gomarkdown/markdown](https://github.com/gomarkdown/markdown)
|
||||
is used to generate the web pages from Markdown. BSD-2-Clause.
|
||||
|
||||
[github.com/microcosm-cc/bluemonday](https://github.com/microcosm-cc/bluemonday)
|
||||
is used to strip rendered search results of all HTML except for the
|
||||
bold tag. Regular HTML generated from pages is *not* sanitized. Don't
|
||||
give people you don't trust access to your wiki. BSD-3-Clause.
|
||||
|
||||
[github.com/pemistahl/lingua-go](https://github.com/pemistahl/lingua-go)
|
||||
detects languages in order to set the language tag in templates. This
|
||||
in turn can be used by browsers to get hyphenation right. Apache-2.0.
|
||||
|
||||
[github.com/gabriel-vasile/mimetype](https://github.com/gabriel-vasile/mimetype)
|
||||
is used to sniff the MIME type of files with unknown filename
|
||||
extensions. MIT.
|
||||
|
||||
[github.com/bashdrew/goheif](https://github.com/bashdrew/goheif) is
|
||||
used to decode HEIC files (the new default file format for photos on
|
||||
iPhones). LGPL-3.0-only.
|
||||
|
||||
[github.com/disintegration/imaging](https://github.com/disintegration/imaging)
|
||||
is used to resize images. MIT.
|
||||
|
||||
[github.com/edwvee/exiffix](https://github.com/edwvee/exiffix) is used
|
||||
to rotate images before resizing them if the EXIF data says the image
|
||||
wasn't taken with the default orientation of the camera. This is
|
||||
necessary because after resizing, the EXIF data is gone. MIT.
|
||||
|
||||
[github.com/google/subcommands](https://github.com/google/subcommands)
|
||||
is used for the parsing and documenting of subcommands. Apache-2.0.
|
||||
|
||||
[github.com/muesli/reflow/wordwrap](https://github.com/muesli/reflow/wordwrap)
|
||||
is used to wrap the search subcommand output. MIT.
|
||||
|
||||
[github.com/hexops/gotextdiff](https://github.com/hexops/gotextdiff)
|
||||
is used to show a compact unified diff on the command line before
|
||||
doing any replacements. BSD-3-Clause.
|
||||
|
||||
[github.com/sergi/go-diff/diffmatchpatch](https://github.com/sergi/go-diff/diffmatchpatch)
|
||||
is used to show the page diffs on the web. MIT.
|
||||
|
||||
[github.com/fsnotify/fsnotify](https://github.com/fsnotify/fsnotify)
|
||||
is used to watch the filesystem for changes. BSD-3-Clause.
|
||||
|
||||
[golang.org/x/exp/constraints](https://golang.org/x/exp/constraints)
|
||||
for the computation of the intersection between two sets of pages.
|
||||
BSD-3-Clause.
|
||||
|
||||
[github.com/stretchr/testify/assert](https://github.com/stretchr/testify/assert)
|
||||
is used for testing. MIT.
|
||||
|
||||
## Bugs
|
||||
|
||||
If you spot any, [contact](https://alexschroeder.ch/wiki/Contact) me.
|
||||
|
||||
## References
|
||||
|
||||
[Writing Web Applications](https://golang.org/doc/articles/wiki/)
|
||||
provided the initial code for this wiki.
|
||||
|
||||
14
RELEASE
Normal file
14
RELEASE
Normal file
@@ -0,0 +1,14 @@
|
||||
When preparing a new release
|
||||
----------------------------
|
||||
|
||||
1. Run tests
|
||||
|
||||
2. Update man/oddmu-releases.7.txt
|
||||
|
||||
3. make docs
|
||||
|
||||
4. Make sure all files are checked in
|
||||
|
||||
5. Tag the release and push the tag to all remotes
|
||||
|
||||
6. cd man && make upload
|
||||
153
accounts.go
Normal file
153
accounts.go
Normal file
@@ -0,0 +1,153 @@
|
||||
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
|
||||
|
||||
// accountStore controlls access to the usernames. Make sure to lock and unlock as appropriate.
|
||||
type accountStore 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 accountStore
|
||||
|
||||
// This is called once at startup and therefore does not need to be locked. On every restart, this map starts empty and
|
||||
// is slowly repopulated as pages are visited.
|
||||
func init() {
|
||||
if os.Getenv("ODDMU_WEBFINGER") == "1" {
|
||||
accounts.uris = make(map[string]string)
|
||||
useWebfinger = true
|
||||
}
|
||||
}
|
||||
|
||||
// accountLink links a social media accountLink like @accountLink@domain to a profile page like https://domain/user/accountLink. Any
|
||||
// accountLink 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 accountLink(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
|
||||
}
|
||||
35
accounts_test.go
Normal file
35
accounts_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
22
add.html
Normal file
22
add.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!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: #111; background-color: #ffe; }
|
||||
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="{{.Language}}" autofocus required></textarea>
|
||||
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
|
||||
<p><input type="submit" value="Add">
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
63
add_append.go
Normal file
63
add_append.go
Normal file
@@ -0,0 +1,63 @@
|
||||
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, p.Dir(), "add", p)
|
||||
}
|
||||
|
||||
// appendHandler takes the "body" form parameter and appends it. The browser is redirected to the page view. This is
|
||||
// similar to the saveHandler.
|
||||
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
|
||||
}
|
||||
username, _, ok := r.BasicAuth()
|
||||
if ok {
|
||||
log.Println("Save", name, "by", username)
|
||||
} else {
|
||||
log.Println("Save", name)
|
||||
}
|
||||
if r.FormValue("notify") == "on" {
|
||||
err = p.notify()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
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...)
|
||||
}
|
||||
72
add_append_test.go
Normal file
72
add_append_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
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, false),
|
||||
"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, false),
|
||||
"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")
|
||||
}
|
||||
69
archive.go
Normal file
69
archive.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// archiveHandler serves a zip file. Directories starting with a period are skipped. Filenames starting with a period
|
||||
// are skipped. If the environment variable ODDMU_FILTER is a regular expression that matches the starting directory,
|
||||
// this is a "separate site"; if the regular expression does not match, this is the "main site" and page names must also
|
||||
// not match the regular expression.
|
||||
func archiveHandler(w http.ResponseWriter, r *http.Request, path string) {
|
||||
filter := os.Getenv("ODDMU_FILTER")
|
||||
re, err := regexp.Compile(filter)
|
||||
if err != nil {
|
||||
log.Println("ODDMU_FILTER does not compile:", filter, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
matches := re.MatchString(path)
|
||||
dir := filepath.Dir(filepath.FromSlash(path))
|
||||
z := zip.NewWriter(w)
|
||||
err = filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
} else if !strings.HasPrefix(filepath.Base(path), ".") &&
|
||||
(matches || !re.MatchString(path)) {
|
||||
zf, err := z.Create(path)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(zf, file)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = z.Close()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
27
archive_test.go
Normal file
27
archive_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArchive(t *testing.T) {
|
||||
cleanup(t, "testdata/archive")
|
||||
assert.NoError(t, os.MkdirAll("testdata/archive/public", 0755))
|
||||
assert.NoError(t, os.MkdirAll("testdata/archive/secret", 0755))
|
||||
assert.NoError(t, os.WriteFile("testdata/archive/public/index.md", []byte("# Public\nChurch tower bells ringing\nA cold wind biting my ears\nWalk across the square"), 0644))
|
||||
assert.NoError(t, os.WriteFile("testdata/archive/secret/index.md", []byte("# Secret\nMany years ago I danced\nSpending nights in clubs and bars\nIt is my secret"), 0644))
|
||||
os.Setenv("ODDMU_FILTER", "^testdata/archive/secret/")
|
||||
body := assert.HTTPBody(makeHandler(archiveHandler, true), "GET", "/archive/testdata/data.zip", nil)
|
||||
r, err := zip.NewReader(strings.NewReader(body), int64(len(body)))
|
||||
assert.NoError(t, err, "Unzip")
|
||||
names := []string{}
|
||||
for _, file := range r.File {
|
||||
names = append(names, file.Name)
|
||||
}
|
||||
assert.Contains(t, names, "testdata/archive/public/index.md")
|
||||
assert.NotContains(t, names, "testdata/archive/secret/index.md")
|
||||
}
|
||||
187
changes.go
Normal file
187
changes.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"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. Errors are logged before being returned because the
|
||||
// error messages are confusing from the point of view of the saveHandler.
|
||||
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)
|
||||
err := addLinkWithDate(path.Join(dir, "changes"), link, re)
|
||||
if err != nil {
|
||||
log.Printf("Updating changes in %s failed: %s", dir, err)
|
||||
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 := ""
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
// create a new page
|
||||
p = &Page{Name: name, Body: []byte("# Changes\n\n## " + date + "\n" + link)}
|
||||
} else {
|
||||
org = string(p.Body)
|
||||
// remove the old match, if one exists
|
||||
loc := re.FindIndex(p.Body)
|
||||
if loc != nil {
|
||||
r := p.Body[:loc[0]]
|
||||
if loc[1] < len(p.Body) {
|
||||
r = append(r, p.Body[loc[1]:]...)
|
||||
}
|
||||
p.Body = r
|
||||
if loc[0] >= 14 && len(p.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(p.Body[loc[0]-14 : loc[0]+15]) {
|
||||
p.Body = append(p.Body[0:loc[0]-14], p.Body[loc[0]+1:]...)
|
||||
}
|
||||
} else if len(p.Body) == loc[0] {
|
||||
// remove a trailing date
|
||||
re := regexp.MustCompile(`## (\d\d\d\d-\d\d-\d\d)\n`)
|
||||
if re.Match(p.Body[loc[0]-14 : loc[0]]) {
|
||||
p.Body = p.Body[0 : loc[0]-14]
|
||||
}
|
||||
}
|
||||
}
|
||||
// locate the beginning of the list to insert the line
|
||||
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n`)
|
||||
loc = re.FindIndex(p.Body)
|
||||
if loc == nil {
|
||||
// if no list was found, use the end of the page
|
||||
loc = []int{len(p.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(p.Body[loc[0]-14 : loc[0]])
|
||||
if m == nil {
|
||||
// not a date: insert date, don't move insertion point
|
||||
} else if string(p.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, p.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(p.Body) > loc[0] && p.Body[loc[0]] != '*' {
|
||||
r = append(r, '\n')
|
||||
}
|
||||
// append the rest
|
||||
r = append(r, p.Body[loc[0]:]...)
|
||||
p.Body = r
|
||||
}
|
||||
// only save if something changed
|
||||
if string(p.Body) != org {
|
||||
return p.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 {
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
if mandatory {
|
||||
p = &Page{Name: name, Body: []byte(link)}
|
||||
return p.save()
|
||||
} else {
|
||||
// Skip non-existing files: no error
|
||||
return nil
|
||||
}
|
||||
}
|
||||
org := string(p.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(p.Body)
|
||||
// if no link exists, find a good place to insert it
|
||||
if loc == nil {
|
||||
// locate the beginning of the list to insert the line
|
||||
re = regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n`)
|
||||
loc = re.FindIndex(p.Body)
|
||||
if loc == nil {
|
||||
// if no list was found, use the end of the page
|
||||
m := len(p.Body)
|
||||
loc = []int{m, m}
|
||||
} else {
|
||||
// if a list item was found, use just the beginning as insertion point
|
||||
loc[1] = loc[0]
|
||||
}
|
||||
}
|
||||
// start with new page content
|
||||
r := []byte("")
|
||||
// append up to the insertion point
|
||||
r = append(r, p.Body[:loc[0]]...)
|
||||
// append link
|
||||
r = append(r, []byte(link)...)
|
||||
// append the rest
|
||||
r = append(r, p.Body[loc[1]:]...)
|
||||
p.Body = r
|
||||
// only save if something changed
|
||||
if string(p.Body) != org {
|
||||
return p.save()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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~")
|
||||
}
|
||||
68
diff.go
Normal file
68
diff.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
"html"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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, p.Dir(), "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 {
|
||||
path, err := url.PathUnescape(p.Name)
|
||||
if err != nil {
|
||||
return template.HTML("Cannot unescape " + p.Name)
|
||||
}
|
||||
fp := filepath.FromSlash(path)
|
||||
a := fp + ".md~"
|
||||
t1, err := os.ReadFile(a)
|
||||
if err != nil {
|
||||
return template.HTML("Cannot read " + a + ", so the page is new.")
|
||||
}
|
||||
b := fp + ".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()
|
||||
}
|
||||
28
diff.html
Normal file
28
diff.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{.Language}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
|
||||
body { hyphens: auto; }
|
||||
del { background-color: #fab }
|
||||
ins { background-color: #af8 }
|
||||
pre { white-space: normal; background-color: white; border: 1px solid #eee; padding: 1ch }
|
||||
</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>`)
|
||||
}
|
||||
30
edit.html
30
edit.html
@@ -1,10 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Editing {{.Title}}</h1>
|
||||
<form action="/save/{{.Title}}" method="POST">
|
||||
<div><textarea name="body" rows="20" cols="80">{{printf "%s" .Body}}</textarea></div>
|
||||
<div><input type="submit" value="Save"></div>
|
||||
</form>
|
||||
</body>
|
||||
<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-color: #ffe; }
|
||||
form, textarea { width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Editing {{.Title}}</h1>
|
||||
<form action="/save/{{.Name}}" method="POST">
|
||||
<textarea name="body" rows="20" cols="80" placeholder="# Title
|
||||
|
||||
Text" lang="{{.Language}}" autofocus>{{printf "%s" .Body}}</textarea>
|
||||
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
|
||||
<p><input type="submit" value="Save">
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
45
edit_save.go
Normal file
45
edit_save.go
Normal file
@@ -0,0 +1,45 @@
|
||||
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, p.Dir(), "edit", p)
|
||||
}
|
||||
|
||||
// saveHandler takes the "body" form parameter and saves it. The browser is redirected to the page view. This is similar
|
||||
// to the appendHandler.
|
||||
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 {
|
||||
log.Println(err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
username, _, ok := r.BasicAuth()
|
||||
if ok {
|
||||
log.Println("Save", name, "by", username)
|
||||
} else {
|
||||
log.Println("Save", name)
|
||||
}
|
||||
if r.FormValue("notify") == "on" {
|
||||
err = p.notify() // errors have already been logged, so no logging here
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/view/"+name, http.StatusFound)
|
||||
}
|
||||
87
edit_save_test.go
Normal file
87
edit_save_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"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, false),
|
||||
"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, false),
|
||||
"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, false),
|
||||
"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")
|
||||
}
|
||||
|
||||
// Test the following view.html:
|
||||
// <form action="/edit/" method="GET">
|
||||
//
|
||||
// <label for="id">New page:</label>
|
||||
// <input id="id" type="text" spellcheck="false" name="id" accesskey="g" value="{{.Dir}}/{{.Today}}" required>
|
||||
// <button>Edit</button>
|
||||
//
|
||||
// </form>
|
||||
func TestEditId(t *testing.T) {
|
||||
cleanup(t, "testdata/id")
|
||||
data := url.Values{}
|
||||
data.Set("id", "testdata/id/alex")
|
||||
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
|
||||
"GET", "/edit/", data, http.StatusBadRequest,
|
||||
"No slashes in id")
|
||||
data.Set("id", ".alex")
|
||||
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
|
||||
"GET", "/edit/", data, http.StatusForbidden,
|
||||
"No hidden files")
|
||||
data.Set("id", "alex")
|
||||
assert.Contains(t, assert.HTTPBody(makeHandler(editHandler, true),
|
||||
"GET", "/edit/testdata/id/", data),
|
||||
"Editing testdata/id/alex")
|
||||
}
|
||||
90
feed.go
Normal file
90
feed.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"html/template"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Item is a Page plus a Date.
|
||||
type Item struct {
|
||||
|
||||
// Page is the page being used as the feed item.
|
||||
Page
|
||||
|
||||
// Date is the last modification date of the file storing the page. As the pages used by Oddmu are plain
|
||||
// Markdown files, they don't contain any metadata. Instead, the last modification date of the file is used.
|
||||
// This makes it work well with changes made to the files outside of Oddmu.
|
||||
Date string
|
||||
}
|
||||
|
||||
// Feed is an Item used for the feed itself, plus an array of items based on the linked pages.
|
||||
type Feed struct {
|
||||
|
||||
// Item is the page containing the list of links. It's title is used for the feed and it's last modified time is
|
||||
// used for the publication date. Thus, if linked pages change but the page with the links doesn't change, the
|
||||
// publication date remains unchanged.
|
||||
Item
|
||||
|
||||
// Items are based on the pages linked in list items starting with an asterisk ("*"). Links in
|
||||
// list items starting with a minus ("-") are ignored!
|
||||
Items []Item
|
||||
}
|
||||
|
||||
// feed returns a RSS 2.0 feed for any page. The feed items it contains are the pages linked from in list items starting
|
||||
// with an asterisk ("*").
|
||||
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(filepath.FromSlash(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>you@example.org (Your Name)</managingEditor>
|
||||
<webMaster>you@example.org (Your Name)</webMaster>
|
||||
<atom:link href="https://example.org/view/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
|
||||
<description>This is the digital garden of Your Name.</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>
|
||||
55
feed_test.go
Normal file
55
feed_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFeed(t *testing.T) {
|
||||
assert.Contains(t,
|
||||
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index.rss", nil),
|
||||
"Welcome to Oddµ")
|
||||
}
|
||||
|
||||
func TestNoFeed(t *testing.T) {
|
||||
assert.HTTPStatusCode(t,
|
||||
makeHandler(viewHandler, false), "GET", "/view/no-feed.rss", nil, http.StatusNotFound)
|
||||
}
|
||||
|
||||
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, false), "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 id="cactus">Cactus</h1>")
|
||||
assert.Contains(t, body, "<h1 id="dragon">Dragon</h1>")
|
||||
assert.Contains(t, body, "<category>Succulent</category>")
|
||||
assert.Contains(t, body, "<category>Palmtree</category>")
|
||||
}
|
||||
35
go.mod
35
go.mod
@@ -1,5 +1,36 @@
|
||||
module alexschroeder.ch/cgit/oddmu
|
||||
|
||||
go 1.22.3
|
||||
go 1.21.0
|
||||
|
||||
require github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b // indirect
|
||||
require (
|
||||
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.3
|
||||
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47
|
||||
github.com/google/subcommands v1.2.0
|
||||
github.com/hexops/gotextdiff v1.0.3
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/muesli/reflow v0.3.0
|
||||
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-20240119083558-1b970713d09a
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.6 // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/shopspring/decimal v1.3.1 // indirect
|
||||
golang.org/x/image v0.15.0 // indirect
|
||||
golang.org/x/net v0.20.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
google.golang.org/protobuf v1.32.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
78
go.sum
78
go.sum
@@ -1,4 +1,74 @@
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/m4tty/cajun v0.0.0-20150303030909-35de273cc87b h1:aY3LtSBlkQahoWaPTytHcIFsDbeXFYMc4noRQ/N5Q+A=
|
||||
github.com/m4tty/cajun v0.0.0-20150303030909-35de273cc87b/go.mod h1:zFXkL7I5vIwKg4dxEA9025SLdIHu9qFX/cYTdUcusHc=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd h1:SxkQeH4jjXT0zMgiRgkiIQjIvWfe9vXuTAmE3cfcQrU=
|
||||
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd/go.mod h1:p1sbxRy+MY71fEWHcfRmerC8WUYXDFCExF9A7aXwp98=
|
||||
github.com/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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
|
||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k=
|
||||
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
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/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/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
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.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
|
||||
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
|
||||
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
|
||||
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
||||
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
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=
|
||||
|
||||
13
highlight.go
Normal file
13
highlight.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// highlight splits the query string q into terms and highlights them
|
||||
// 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
|
||||
}
|
||||
43
highlight_test.go
Normal file
43
highlight_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHighlight(t *testing.T) {
|
||||
|
||||
s := `The windows opens
|
||||
A wave of car noise hits me
|
||||
No birds to be heard.`
|
||||
|
||||
h := `The <b>window</b>s opens
|
||||
A wave of car noise hits me
|
||||
No birds to be heard.`
|
||||
|
||||
q := "window"
|
||||
re, _ := re(q)
|
||||
r := highlight(q, re, s)
|
||||
if r != h {
|
||||
t.Logf("The highlighting is wrong in 「%s」", r)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
56
html_cmd.go
Normal file
56
html_cmd.go
Normal file
@@ -0,0 +1,56 @@
|
||||
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 one or more pages 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
|
||||
}
|
||||
if useTemplate {
|
||||
p.handleTitle(true)
|
||||
p.renderHtml()
|
||||
t := "view.html"
|
||||
loadTemplates()
|
||||
err := templates.template[t].Execute(w, 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 id="welcome-to-oddµ">Welcome to Oddµ</h1>
|
||||
|
||||
<p>Hello! 🙃</p>
|
||||
|
||||
<p>Check out the <a href="README">README</a> and <a href="themes">themes</a>.</p>
|
||||
|
||||
<p>Or <a href="test">create a new page</a>.</p>
|
||||
|
||||
`
|
||||
assert.Equal(t, b.String(), r)
|
||||
}
|
||||
233
index.go
Normal file
233
index.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// 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
|
||||
|
||||
// indexStore controls access to the maps used for search. Make sure to lock and unlock as appropriate.
|
||||
type indexStore 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 indexStore
|
||||
|
||||
func init() {
|
||||
index.reset()
|
||||
}
|
||||
|
||||
// reset the index. This assumes that the index is locked. It's useful for tests.
|
||||
func (idx *indexStore) reset() {
|
||||
idx.next_id = 0
|
||||
idx.token = make(map[string][]docid)
|
||||
idx.documents = make(map[docid]string)
|
||||
idx.titles = make(map[string]string)
|
||||
}
|
||||
|
||||
// addDocument adds the text as a new document. This assumes that the index is locked!
|
||||
func (idx *indexStore) 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 len(ids) > 0 && ids[len(ids)-1] == id {
|
||||
continue
|
||||
}
|
||||
idx.token[token] = append(ids, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// deleteDocument deletes all references to the id. The id can no longer be used. This assumes that the index is locked.
|
||||
func (idx *indexStore) deleteDocument(id docid) {
|
||||
// Looping through all tokens makes sense if there are few tokens (like hashtags). It doesn't make sense if the
|
||||
// number of tokens is large (like for full-text search or a trigram index).
|
||||
for token, ids := range idx.token {
|
||||
// If the token appears only in this document, remove the whole entry.
|
||||
if len(ids) == 1 && ids[0] == id {
|
||||
delete(idx.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:])
|
||||
idx.token[token] = ids[:len(ids)-1]
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deletePageName determines the document id based on the page name and calls deleteDocument to delete all references.
|
||||
// This assumes that the index is unlocked.
|
||||
func (idx *indexStore) deletePageName(name string) {
|
||||
idx.Lock()
|
||||
defer idx.Unlock()
|
||||
var id docid
|
||||
// Reverse lookup! At least it's in memory.
|
||||
for key, value := range idx.documents {
|
||||
if value == name {
|
||||
id = key
|
||||
break
|
||||
}
|
||||
}
|
||||
if id != 0 {
|
||||
idx.deleteDocument(id)
|
||||
delete(idx.documents, id)
|
||||
}
|
||||
delete(idx.titles, name)
|
||||
}
|
||||
|
||||
// remove the page from the index. Do this when deleting a page. This assumes that the index is unlocked.
|
||||
func (idx *indexStore) remove(p *Page) {
|
||||
idx.deletePageName(p.Name)
|
||||
}
|
||||
|
||||
// load loads all the pages and indexes them. This takes a while. It returns the number of pages indexed.
|
||||
func (idx *indexStore) load() (int, error) {
|
||||
idx.Lock()
|
||||
defer idx.Unlock()
|
||||
err := filepath.Walk(".", idx.walk)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := len(idx.documents)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// walk reads a file and adds it to the index. This assumes that the index is locked.
|
||||
func (idx *indexStore) walk(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// skip hidden directories and files
|
||||
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// skipp all but page files
|
||||
if !strings.HasSuffix(path, ".md") {
|
||||
return nil
|
||||
}
|
||||
p, err := loadPage(strings.TrimSuffix(path, ".md"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.handleTitle(false)
|
||||
idx.addPage(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addPage adds a page to the index. This assumes that the index is locked.
|
||||
func (idx *indexStore) addPage(p *Page) {
|
||||
id := idx.addDocument(p.Body)
|
||||
idx.documents[id] = p.Name
|
||||
p.handleTitle(false)
|
||||
idx.titles[p.Name] = p.Title
|
||||
}
|
||||
|
||||
// add a page to the index. This assumes that the index is unlocked.
|
||||
func (idx *indexStore) add(p *Page) {
|
||||
idx.Lock()
|
||||
defer idx.Unlock()
|
||||
idx.addPage(p)
|
||||
}
|
||||
|
||||
// dump prints the index to the log for debugging.
|
||||
func (idx *indexStore) dump() {
|
||||
idx.RLock()
|
||||
defer idx.RUnlock()
|
||||
for token, ids := range idx.token {
|
||||
log.Printf("%s: %v", token, ids)
|
||||
}
|
||||
}
|
||||
|
||||
// updateIndex updates the index for a single page.
|
||||
func (idx *indexStore) update(p *Page) {
|
||||
idx.remove(p)
|
||||
idx.add(p)
|
||||
}
|
||||
|
||||
// search searches the index for a query string and returns page
|
||||
// names.
|
||||
func (idx *indexStore) search(q string) []string {
|
||||
idx.RLock()
|
||||
defer idx.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
|
||||
}
|
||||
7
index.md
Normal file
7
index.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Welcome to Oddµ
|
||||
|
||||
Hello! 🙃
|
||||
|
||||
Check out the [[README]] and [[themes]].
|
||||
|
||||
Or [create a new page](test).
|
||||
109
index_test.go
Normal file
109
index_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIndexAdd(t *testing.T) {
|
||||
idx := &indexStore{}
|
||||
idx.reset()
|
||||
idx.Lock()
|
||||
defer idx.Unlock()
|
||||
tag := "#hello"
|
||||
id := idx.addDocument([]byte("oh hi " + tag))
|
||||
assert.Contains(t, idx.token, tag)
|
||||
idx.deleteDocument(id)
|
||||
assert.NotContains(t, idx.token, tag)
|
||||
}
|
||||
|
||||
// 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, "New page", index.titles[name])
|
||||
}
|
||||
64
languages.go
Normal file
64
languages.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/pemistahl/lingua-go"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// getLanguages 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. If this is skipped, no language detection happens and the templates cannot use {{.Language}} to use
|
||||
// this. Usually this is used for correct hyphenation by the browser.
|
||||
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 ""
|
||||
}
|
||||
|
||||
// Language returns the language used for the page, as a lower case
|
||||
// ISO 639-1 string, e.g. "en" or "de".
|
||||
func (p *Page) Language() string {
|
||||
return language(p.plainText())
|
||||
}
|
||||
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 suiteable
|
||||
// for substring matching of page names.
|
||||
func checkDir(dir string) (string, error) {
|
||||
if dir != "" {
|
||||
fi, err := os.Stat(filepath.FromSlash(dir))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return "", err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
fmt.Println("This is not a sub-directory:", dir)
|
||||
return "", err
|
||||
}
|
||||
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")
|
||||
}
|
||||
1
man/.gitignore
vendored
Normal file
1
man/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.md
|
||||
43
man/Makefile
Normal file
43
man/Makefile
Normal file
@@ -0,0 +1,43 @@
|
||||
TEXT=$(wildcard *.txt)
|
||||
MAN=$(patsubst %.txt,%,${TEXT})
|
||||
HTML=$(patsubst %.txt,%.html,${TEXT})
|
||||
MD=$(patsubst %.txt,%.md,${TEXT})
|
||||
|
||||
man: ${MAN}
|
||||
|
||||
%: %.txt
|
||||
scdoc < $< > $@
|
||||
|
||||
html: ${HTML}
|
||||
|
||||
%.html: %.md
|
||||
echo '<!DOCTYPE html>' > $@
|
||||
oddmu html $(basename $<) | sed --regexp-extended \
|
||||
-e 's/<a href="(oddmu[a-z.-]*.[1-9])">([^<>]*)<\/a>/<a href="\1.html">\2<\/a>/g' >> $@
|
||||
|
||||
md: ${MD}
|
||||
|
||||
%.md: %.txt
|
||||
sed --regexp-extended \
|
||||
-e 's/\*([^*]+)\*/**\1**/g' \
|
||||
-e 's/_(oddmu[a-z.-]*)_\(([1-9])\)/[\1(\2)](\1.\2)/g' \
|
||||
-e 's/\b_([^_]+)_\b/*\1*/g' \
|
||||
-e 's/^# /## /' \
|
||||
-e 's/#([^ #])/\\#\1/' \
|
||||
-e 's/^([A-Z.-]*\([1-9]\))( ".*")?$$/# \1/' \
|
||||
< $< > $@
|
||||
|
||||
README.md: ../README.md
|
||||
sed --regexp-extended \
|
||||
-e 's/\]\(.*\/(.*)\.txt\)/](\1)/' \
|
||||
< $< > $@
|
||||
|
||||
upload: ${MD} README.md
|
||||
rsync --itemize-changes --archive *.md sibirocobombus:alexschroeder.ch/wiki/oddmu/
|
||||
make clean
|
||||
|
||||
clean:
|
||||
rm --force ${HTML} ${MD} README.md
|
||||
|
||||
realclean: clean
|
||||
rm --force ${MAN}
|
||||
415
man/oddmu-apache.5
Normal file
415
man/oddmu-apache.5
Normal file
@@ -0,0 +1,415 @@
|
||||
.\" Generated by scdoc 1.11.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-APACHE" "5" "2024-02-19"
|
||||
.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
|
||||
The examples below use the domain "transjovian.\&org" and the Apache installation
|
||||
is the one that comes with Debian.\&
|
||||
.PP
|
||||
The site itself is configured in a file called
|
||||
"/etc/apache2/sites-available/transjovian.\&conf" and a link points there from
|
||||
"/etc/apache2/sites-enabled".\& Create this link using \fIa2ensite\fR(1).\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
MDomain transjovian\&.org
|
||||
MDCertificateAgreement accepted
|
||||
ServerAdmin alex@alexschroeder\&.ch
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName transjovian\&.org
|
||||
Redirect "/" "https://transjovian\&.org/"
|
||||
</VirtualHost>
|
||||
<VirtualHost *:443>
|
||||
ServerName transjovian\&.org
|
||||
SSLEngine on
|
||||
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
|
||||
"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 (see \fBServe static files\fR).\&
|
||||
.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
|
||||
In a situation where Apache acts as a reverse proxy, you can prevent some
|
||||
actions from being proxied.\& If you don'\&t want to allow strangers to make
|
||||
changes, search or archive the site, use a limited setup like the following:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
MDomain transjovian\&.org
|
||||
MDCertificateAgreement accepted
|
||||
ServerAdmin alex@alexschroeder\&.ch
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName transjovian\&.org
|
||||
Redirect "/" "https://transjovian\&.org/"
|
||||
</VirtualHost>
|
||||
<VirtualHost *:443>
|
||||
ServerName transjovian\&.org
|
||||
SSLEngine on
|
||||
ProxyPassMatch "^/(view/\&.*)?$" "http://localhost:8080/$1"
|
||||
</VirtualHost>
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
You'\&ll need to edit the source pages some other way.\& Edit them locally and
|
||||
upload them using rsync; edit them remotely using an editor that can do this;
|
||||
use SSHFS to mount the remote directory locally for editing; use \fIstunnel\fR(8) to
|
||||
access the remote wiki on the local port 8080 for editing.\& There are probably a
|
||||
lot more such options available.\& All of them have the drawback that they'\&re
|
||||
probably not easy to use when on a mobile phone.\&
|
||||
.PP
|
||||
.SS Allow HTTP for viewing
|
||||
.PP
|
||||
When looking at pages, you might want to allow HTTP since no password is
|
||||
required.\& Therefore, proxy the read-only requests from the virtual host on port
|
||||
80 to the wiki instead of redirecting them to port 443.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
MDomain transjovian\&.org
|
||||
MDCertificateAgreement accepted
|
||||
ServerAdmin alex@alexschroeder\&.ch
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName transjovian\&.org
|
||||
ProxyPassMatch "^/((view|diff|search|archive)/(\&.*))?$"
|
||||
"http://localhost:8080/$1"
|
||||
RedirectMatch "^/((edit|save|add|append|upload|drop)/(\&.*))?$"
|
||||
"https://transjovian\&.org/$1"
|
||||
</VirtualHost>
|
||||
<VirtualHost *:443>
|
||||
ServerName transjovian\&.org
|
||||
SSLEngine on
|
||||
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
|
||||
"http://localhost:8080/$1"
|
||||
</VirtualHost>
|
||||
.fi
|
||||
.RE
|
||||
.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
|
||||
To test just the unix domain socket, use \fIncat(1)\fR:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
echo -e "GET /view/index HTTP/1\&.1rnHost: localhostrnrn"
|
||||
| ncat --unixsock /run/oddmu/oddmu\&.sock
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
On the Apache side, you can proxy to the socket directly.\& This sends all
|
||||
requests to the socket:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
ProxyPass "/" "unix:/run/oddmu/oddmu\&.sock|http://localhost/"
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Now, all traffic between the web server and the wiki goes over the socket at
|
||||
"/run/oddmu/oddmu.\&sock".\&
|
||||
.PP
|
||||
To test it on the command-line, use a tool like \fIcurl(1)\fR.\& Make sure to provide
|
||||
the correct servername!\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
curl http://transjovian\&.org/view/index
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
You probably want to serve some static files as well (see \fBServe static files\fR).\&
|
||||
In that case, you need to use the ProxyPassMatch directive.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
|
||||
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
There'\&s a curious problem with this expression, however.\& If you use \fIcurl(1)\fR to
|
||||
get the root path, Apache hangs:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
curl http://transjovian\&.org/
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
A workaround is to add the redirect manually and drop the question-mark:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
RedirectMatch "^/$" "/view/index"
|
||||
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))$"
|
||||
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
If you know why this is happening, let me know.\&
|
||||
.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 personal 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
|
||||
The way Oddmu handles subdirectories is that all files and directories are
|
||||
visible, except for "hidden" files and directories (whose name starts with a
|
||||
period).\& Specifically, do not rely on Apache to hide locations in subdirectories
|
||||
from public view.\& Search reveals the existence of these pages and produces an
|
||||
extract, even if users cannot follow the links.\& Archive links pack all the
|
||||
subdirectories, including locations you may have hidden from view using Apache.\&
|
||||
.PP
|
||||
If you to treat subdirectories as separate sites, you need to set the
|
||||
environment variable ODDMU_FILTER to a regular expression matching the those
|
||||
directories.\& If search starts in a directory that doesn'\&t match the regular
|
||||
expression, all directories matching the regular expression are excluded.\& See
|
||||
\fIoddmu-filter\fR(7).\&
|
||||
.PP
|
||||
In the following example, ODDMU_FILTER is set to "^secret/".\&
|
||||
.PP
|
||||
http://transjovian.\&org/search/index?\&q=something does not search the "secret/"
|
||||
directory and its subdirectories are excluded.\&
|
||||
.PP
|
||||
http://transjovian.\&org/search/secret/index?\&q=something searches just the
|
||||
"secret" directory and its subdirectories.\&
|
||||
.PP
|
||||
You need to configure the web server to prevent access to the "secret/"
|
||||
directory:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|search|archive)/secret)/">
|
||||
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/",
|
||||
"/search/" or "/archive/".\& For example, create a file called "robots.\&txt"
|
||||
containing the following, telling all robots that they'\&re not welcome.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Your 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
|
||||
The "view.\&html" template would start as follows:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
<!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>
|
||||
<link href="/css/oddmu-2023\&.css" rel="stylesheet" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{\&.Title}}" href="/view/{{\&.Name}}\&.rss" />
|
||||
</head>
|
||||
…
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
In this case, "/css/oddmu-2023.\&css" would be the name of your stylesheet.\& If
|
||||
your document root is "/home/oddmu", then the filename of your stylesheet would
|
||||
have to be "/home/oddmu/css/oddmu-2023.\&css" for this to work.\&
|
||||
.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 location
|
||||
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), \fIoddmu-filter\fR(7), \fIoddmu-nginx\fR(5)
|
||||
.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>.\&
|
||||
362
man/oddmu-apache.5.txt
Normal file
362
man/oddmu-apache.5.txt
Normal file
@@ -0,0 +1,362 @@
|
||||
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.
|
||||
|
||||
The examples below use the domain "transjovian.org" and the Apache installation
|
||||
is the one that comes with Debian.
|
||||
|
||||
The site itself is configured in a file called
|
||||
"/etc/apache2/sites-available/transjovian.conf" and a link points there from
|
||||
"/etc/apache2/sites-enabled". Create this link using _a2ensite_(1).
|
||||
|
||||
```
|
||||
MDomain transjovian.org
|
||||
MDCertificateAgreement accepted
|
||||
ServerAdmin alex@alexschroeder.ch
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName transjovian.org
|
||||
Redirect "/" "https://transjovian.org/"
|
||||
</VirtualHost>
|
||||
<VirtualHost *:443>
|
||||
ServerName transjovian.org
|
||||
SSLEngine on
|
||||
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
|
||||
"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 (see *Serve static files*).
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
In a situation where Apache acts as a reverse proxy, you can prevent some
|
||||
actions from being proxied. If you don't want to allow strangers to make
|
||||
changes, search or archive the site, use a limited setup like the following:
|
||||
|
||||
```
|
||||
MDomain transjovian.org
|
||||
MDCertificateAgreement accepted
|
||||
ServerAdmin alex@alexschroeder.ch
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName transjovian.org
|
||||
Redirect "/" "https://transjovian.org/"
|
||||
</VirtualHost>
|
||||
<VirtualHost *:443>
|
||||
ServerName transjovian.org
|
||||
SSLEngine on
|
||||
ProxyPassMatch "^/(view/.*)?$" "http://localhost:8080/$1"
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
You'll need to edit the source pages some other way. Edit them locally and
|
||||
upload them using rsync; edit them remotely using an editor that can do this;
|
||||
use SSHFS to mount the remote directory locally for editing; use _stunnel_(8) to
|
||||
access the remote wiki on the local port 8080 for editing. There are probably a
|
||||
lot more such options available. All of them have the drawback that they're
|
||||
probably not easy to use when on a mobile phone.
|
||||
|
||||
## Allow HTTP for viewing
|
||||
|
||||
When looking at pages, you might want to allow HTTP since no password is
|
||||
required. Therefore, proxy the read-only requests from the virtual host on port
|
||||
80 to the wiki instead of redirecting them to port 443.
|
||||
|
||||
```
|
||||
MDomain transjovian.org
|
||||
MDCertificateAgreement accepted
|
||||
ServerAdmin alex@alexschroeder.ch
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName transjovian.org
|
||||
ProxyPassMatch "^/((view|diff|search|archive)/(.*))?$" \
|
||||
"http://localhost:8080/$1"
|
||||
RedirectMatch "^/((edit|save|add|append|upload|drop)/(.*))?$" \
|
||||
"https://transjovian.org/$1"
|
||||
</VirtualHost>
|
||||
<VirtualHost *:443>
|
||||
ServerName transjovian.org
|
||||
SSLEngine on
|
||||
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
|
||||
"http://localhost:8080/$1"
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
## 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)_.
|
||||
|
||||
To test just the unix domain socket, use _ncat(1)_:
|
||||
|
||||
```
|
||||
echo -e "GET /view/index HTTP/1.1\r\nHost: localhost\r\n\r\n" \
|
||||
| ncat --unixsock /run/oddmu/oddmu.sock
|
||||
```
|
||||
|
||||
On the Apache side, you can proxy to the socket directly. This sends all
|
||||
requests to the socket:
|
||||
|
||||
```
|
||||
ProxyPass "/" "unix:/run/oddmu/oddmu.sock|http://localhost/"
|
||||
```
|
||||
|
||||
Now, all traffic between the web server and the wiki goes over the socket at
|
||||
"/run/oddmu/oddmu.sock".
|
||||
|
||||
To test it on the command-line, use a tool like _curl(1)_. Make sure to provide
|
||||
the correct servername!
|
||||
|
||||
```
|
||||
curl http://transjovian.org/view/index
|
||||
```
|
||||
|
||||
You probably want to serve some static files as well (see *Serve static files*).
|
||||
In that case, you need to use the ProxyPassMatch directive.
|
||||
|
||||
```
|
||||
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
|
||||
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
|
||||
```
|
||||
|
||||
There's a curious problem with this expression, however. If you use _curl(1)_ to
|
||||
get the root path, Apache hangs:
|
||||
|
||||
```
|
||||
curl http://transjovian.org/
|
||||
```
|
||||
|
||||
A workaround is to add the redirect manually and drop the question-mark:
|
||||
|
||||
```
|
||||
RedirectMatch "^/$" "/view/index"
|
||||
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(.*))$" \
|
||||
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
|
||||
```
|
||||
|
||||
If you know why this is happening, let me know.
|
||||
|
||||
## 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 personal 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>
|
||||
```
|
||||
|
||||
The way Oddmu handles subdirectories is that all files and directories are
|
||||
visible, except for "hidden" files and directories (whose name starts with a
|
||||
period). Specifically, do not rely on Apache to hide locations in subdirectories
|
||||
from public view. Search reveals the existence of these pages and produces an
|
||||
extract, even if users cannot follow the links. Archive links pack all the
|
||||
subdirectories, including locations you may have hidden from view using Apache.
|
||||
|
||||
If you to treat subdirectories as separate sites, you need to set the
|
||||
environment variable ODDMU_FILTER to a regular expression matching the those
|
||||
directories. If search starts in a directory that doesn't match the regular
|
||||
expression, all directories matching the regular expression are excluded. See
|
||||
_oddmu-filter_(7).
|
||||
|
||||
In the following example, ODDMU_FILTER is set to "^secret/".
|
||||
|
||||
http://transjovian.org/search/index?q=something does not search the "secret/"
|
||||
directory and its subdirectories are excluded.
|
||||
|
||||
http://transjovian.org/search/secret/index?q=something searches just the
|
||||
"secret" directory and its subdirectories.
|
||||
|
||||
You need to configure the web server to prevent access to the "secret/"
|
||||
directory:
|
||||
|
||||
```
|
||||
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|search|archive)/secret)/">
|
||||
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/",
|
||||
"/search/" or "/archive/". For example, create a file called "robots.txt"
|
||||
containing the following, telling all robots that they're not welcome.
|
||||
|
||||
```
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
```
|
||||
|
||||
Your site now serves "/robots.txt" without interfering with the wiki, and
|
||||
without needing a wiki page.
|
||||
|
||||
Another option would be to create a CSS file and use it with a <link> element in
|
||||
all the templates instead of relying on the <style> element.
|
||||
|
||||
The "view.html" template would start as follows:
|
||||
|
||||
```
|
||||
<!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>
|
||||
<link href="/css/oddmu-2023.css" rel="stylesheet" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{.Title}}" href="/view/{{.Name}}.rss" />
|
||||
</head>
|
||||
…
|
||||
```
|
||||
|
||||
In this case, "/css/oddmu-2023.css" would be the name of your stylesheet. If
|
||||
your document root is "/home/oddmu", then the filename of your stylesheet would
|
||||
have to be "/home/oddmu/css/oddmu-2023.css" for this to work.
|
||||
|
||||
## 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 location
|
||||
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), _oddmu-filter_(7), _oddmu-nginx_(5)
|
||||
|
||||
"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>.
|
||||
63
man/oddmu-filter.7
Normal file
63
man/oddmu-filter.7
Normal file
@@ -0,0 +1,63 @@
|
||||
.\" Generated by scdoc 1.11.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-FILTER" "7" "2024-02-19"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-filter - keeping subdirectories separate
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
There are actions such as searching and archiving that act on multiple pages,
|
||||
not just a single page.\& These actions walk the directory tree, including all
|
||||
subdirectories.\& In some cases, this is not desirable.\&
|
||||
.PP
|
||||
Sometimes, subdirectories are separate sites, like the sites of other projects
|
||||
or different people.\& Essentially, the subdirectory acts as a different site.\&
|
||||
Depending on how you think about it, you might not want to include those "sites"
|
||||
in searches or archives of the whole site.\&
|
||||
.PP
|
||||
What'\&s important in this situation is whether the visitor is looking at the
|
||||
"main site" (a page further up in the directory tree) or at a particular page in
|
||||
a "separate site".\&
|
||||
.PP
|
||||
Since directory tree actions always start in the directory the visitor is
|
||||
currenly looking at, directory tree actions starting in a "separate site"
|
||||
automatically act as expected.\& The action is limited to that subdirectory tree.\&
|
||||
.PP
|
||||
When visitors look at a page in the "main site", however, directory tree actions
|
||||
must skip any sub directories that are part of a "separate site".\&
|
||||
.PP
|
||||
The way to identify separate sates is via the environment variable ODDMU_FILTER.\&
|
||||
It'\&s value is a regular expression matching separate sites.\&
|
||||
.PP
|
||||
.SH EXAMPLES
|
||||
.PP
|
||||
"ODDMU_FILTER=^project/" means that a directory tree action outside the
|
||||
"project/" directory does not include pages in the "project/" directory.\&
|
||||
.PP
|
||||
In other words, http://localhost:8080/search/?\&q=oddmu skips any pages in
|
||||
"project/".\&
|
||||
.PP
|
||||
At the same time, http://localhost:8080/search/project/?\&q=oddmu works like it
|
||||
always does: search is limited to "project/" and its subdirectories.\&
|
||||
.PP
|
||||
.SH SECURITY
|
||||
.PP
|
||||
If the subdirectory is a private site, then you need to use ODDMU_FILTER to
|
||||
exclude it from directory tree actions in the main site, and you need to
|
||||
configure your web server such that it doesn'\&t allow visitors access to the
|
||||
directory tree without authentication.\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1), \fIoddmu-search\fR(7), \fIoddmu-apache\fR(5), \fIoddmu-nginx\fR(5)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
56
man/oddmu-filter.7.txt
Normal file
56
man/oddmu-filter.7.txt
Normal file
@@ -0,0 +1,56 @@
|
||||
ODDMU-FILTER(7)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-filter - keeping subdirectories separate
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
There are actions such as searching and archiving that act on multiple pages,
|
||||
not just a single page. These actions walk the directory tree, including all
|
||||
subdirectories. In some cases, this is not desirable.
|
||||
|
||||
Sometimes, subdirectories are separate sites, like the sites of other projects
|
||||
or different people. Essentially, the subdirectory acts as a different site.
|
||||
Depending on how you think about it, you might not want to include those "sites"
|
||||
in searches or archives of the whole site.
|
||||
|
||||
What's important in this situation is whether the visitor is looking at the
|
||||
"main site" (a page further up in the directory tree) or at a particular page in
|
||||
a "separate site".
|
||||
|
||||
Since directory tree actions always start in the directory the visitor is
|
||||
currenly looking at, directory tree actions starting in a "separate site"
|
||||
automatically act as expected. The action is limited to that subdirectory tree.
|
||||
|
||||
When visitors look at a page in the "main site", however, directory tree actions
|
||||
must skip any sub directories that are part of a "separate site".
|
||||
|
||||
The way to identify separate sates is via the environment variable ODDMU_FILTER.
|
||||
It's value is a regular expression matching separate sites.
|
||||
|
||||
# EXAMPLES
|
||||
|
||||
"ODDMU_FILTER=^project/" means that a directory tree action outside the
|
||||
"project/" directory does not include pages in the "project/" directory.
|
||||
|
||||
In other words, http://localhost:8080/search/?q=oddmu skips any pages in
|
||||
"project/".
|
||||
|
||||
At the same time, http://localhost:8080/search/project/?q=oddmu works like it
|
||||
always does: search is limited to "project/" and its subdirectories.
|
||||
|
||||
# SECURITY
|
||||
|
||||
If the subdirectory is a private site, then you need to use ODDMU_FILTER to
|
||||
exclude it from directory tree actions in the main site, and you need to
|
||||
configure your web server such that it doesn't allow visitors access to the
|
||||
directory tree without authentication.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1), _oddmu-search_(7), _oddmu-apache_(5), _oddmu-nginx_(5)
|
||||
|
||||
# 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.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-HTML" "1" "2024-02-26"
|
||||
.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.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-LIST" "1" "2024-02-24"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-list - list page names and titles from the command-line
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu list\fR [-dir \fIstring\fR]
|
||||
.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.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-MISSING" "1" "2024-02-17"
|
||||
.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>.
|
||||
91
man/oddmu-nginx.5
Normal file
91
man/oddmu-nginx.5
Normal file
@@ -0,0 +1,91 @@
|
||||
.\" Generated by scdoc 1.11.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-NGINX" "5" "2024-02-19"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-nginx - how to setup Nginx 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.\&
|
||||
.PP
|
||||
This page explains how to setup NGINX on Debian to act as a reverse proxy for
|
||||
Oddmu.\& Once this is done, you can use NGINX to provide HTTPS, request users to
|
||||
authenticate themselves, and so on.\&
|
||||
.PP
|
||||
.SS CONFIGURATION
|
||||
.PP
|
||||
The site is defined in "/etc/nginx/sites-available/default", in the \fIserver\fR
|
||||
section.\& Add a new \fIlocation\fR section after the existing \fIlocation\fR section:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
location ~ ^/(view|diff|edit|save|add|append|upload|drop|search|archive)/ {
|
||||
proxy_pass http://localhost:8080;
|
||||
}
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
If you remove an action from the regular expression, those requests no longer
|
||||
get passed on to Oddmu.\& They are essentially disabled.\& Somebody on the same
|
||||
machine pointing their browser at http://localhost:8080/ directly would still
|
||||
have access to all the actions, of course.\&
|
||||
.PP
|
||||
To restrict access to some actions, use two different \fIlocation\fR sections:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
# public
|
||||
location ~ ^/(view|diff|search)/ {
|
||||
proxy_pass http://localhost:8080;
|
||||
}
|
||||
# password required
|
||||
location ~ ^/(edit|save|add|append|upload|drop|archive)/ {
|
||||
auth_basic "Oddmu author";
|
||||
auth_basic_user_file /etc/nginx/conf\&.d/htpasswd;
|
||||
proxy_pass http://localhost:8080;
|
||||
}
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
The passwords in "/etc/nginx/conf.\&d/htpasswd" are generated using \fIopenssl\fR(1).\&
|
||||
Assuming the password is "CPTk&qO[Y@?\&M~L>qKOkd", this is how you encrypt it:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
openssl passwd \&'CPTk&qO[Y@?M~L>qKOkd\&'
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
The output gets used in "/etc/nginx/conf.\&d/htpasswd".\& Here'\&s the user "alex"
|
||||
using this password:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
alex:$1$DOwphABk$W4VmR9p8t2\&.htxF6ctXHX\&.
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1), \fIoddmu-apache\fR(5)
|
||||
.PP
|
||||
"freenginx"
|
||||
http://freenginx.\&org/
|
||||
.PP
|
||||
"freenginx ngx_http_proxy_module", proxy_pass
|
||||
http://freenginx.\&org/en/docs/http/ngx_http_proxy_module.\&html#proxy_pass
|
||||
.PP
|
||||
"freenginx ngx_http_auth_basic_module"
|
||||
http://freenginx.\&org/en/docs/http/ngx_http_auth_basic_module.\&html
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
76
man/oddmu-nginx.5.txt
Normal file
76
man/oddmu-nginx.5.txt
Normal file
@@ -0,0 +1,76 @@
|
||||
ODDMU-NGINX(5)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-nginx - how to setup Nginx as a reverse proxy for Oddmu
|
||||
|
||||
## DESCRIPTION
|
||||
|
||||
The oddmu program serves the current working directory as a wiki on port 8080.
|
||||
This is an unpriviledged port so an ordinary user account can do this.
|
||||
|
||||
This page explains how to setup NGINX on Debian to act as a reverse proxy for
|
||||
Oddmu. Once this is done, you can use NGINX to provide HTTPS, request users to
|
||||
authenticate themselves, and so on.
|
||||
|
||||
## CONFIGURATION
|
||||
|
||||
The site is defined in "/etc/nginx/sites-available/default", in the _server_
|
||||
section. Add a new _location_ section after the existing _location_ section:
|
||||
|
||||
```
|
||||
location ~ ^/(view|diff|edit|save|add|append|upload|drop|search|archive)/ {
|
||||
proxy_pass http://localhost:8080;
|
||||
}
|
||||
```
|
||||
|
||||
If you remove an action from the regular expression, those requests no longer
|
||||
get passed on to Oddmu. They are essentially disabled. Somebody on the same
|
||||
machine pointing their browser at http://localhost:8080/ directly would still
|
||||
have access to all the actions, of course.
|
||||
|
||||
To restrict access to some actions, use two different _location_ sections:
|
||||
|
||||
```
|
||||
# public
|
||||
location ~ ^/(view|diff|search)/ {
|
||||
proxy_pass http://localhost:8080;
|
||||
}
|
||||
# password required
|
||||
location ~ ^/(edit|save|add|append|upload|drop|archive)/ {
|
||||
auth_basic "Oddmu author";
|
||||
auth_basic_user_file /etc/nginx/conf.d/htpasswd;
|
||||
proxy_pass http://localhost:8080;
|
||||
}
|
||||
```
|
||||
|
||||
The passwords in "/etc/nginx/conf.d/htpasswd" are generated using _openssl_(1).
|
||||
Assuming the password is "CPTk&qO[Y@?M~L>qKOkd", this is how you encrypt it:
|
||||
|
||||
```
|
||||
openssl passwd 'CPTk&qO[Y@?M~L>qKOkd'
|
||||
```
|
||||
|
||||
The output gets used in "/etc/nginx/conf.d/htpasswd". Here's the user "alex"
|
||||
using this password:
|
||||
|
||||
```
|
||||
alex:$1$DOwphABk$W4VmR9p8t2.htxF6ctXHX.
|
||||
```
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1), _oddmu-apache_(5)
|
||||
|
||||
"freenginx"
|
||||
http://freenginx.org/
|
||||
|
||||
"freenginx ngx_http_proxy_module", proxy_pass
|
||||
http://freenginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_pass
|
||||
|
||||
"freenginx ngx_http_auth_basic_module"
|
||||
http://freenginx.org/en/docs/http/ngx_http_auth_basic_module.html
|
||||
|
||||
# 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.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-NOTIFY" "1" "2024-02-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>.
|
||||
213
man/oddmu-releases.7
Normal file
213
man/oddmu-releases.7
Normal file
@@ -0,0 +1,213 @@
|
||||
.\" Generated by scdoc 1.11.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-RELEASES" "7" "2024-03-10"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-releases - what'\&s new in this releases?\&
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
This page lists user-visible features and template changes to consider.\&
|
||||
.PP
|
||||
.SS 1.9 (2024)
|
||||
.PP
|
||||
There is a change to make to copies of \fIupload.\&html\fR if subdirectories are being
|
||||
used.\& The \fILast\fR property no longer contains the directory.\& It has to be added
|
||||
to the template as follows:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
{{if ne \&.Last ""}}
|
||||
<p>Previous upload: <a href="/view/{{\&.Dir}}{{\&.Last}}">{{\&.Last}}</a></p>
|
||||
{{if \&.Image}}
|
||||
<p><img class="last" src="/view/{{\&.Dir}}{{\&.Last}}"></p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
You can use the \fILast\fR property without a directory to suggest the markup to
|
||||
use, for example:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
<p>Use the following for <a href="/view/{{\&.Dir}}{{\&.Today}}">{{\&.Today}}</a>:
|
||||
<pre></a></pre>
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
The upload template can use the \fIToday\fR property.\&
|
||||
.PP
|
||||
The upload template comes with JavaScript that allows users to paste images or
|
||||
drag and drop files.\&
|
||||
.PP
|
||||
The upload template changed the id for the filename field from `text` to `name`.\&
|
||||
.PP
|
||||
The source repository now comes with example templates.\&
|
||||
.PP
|
||||
.SS 1.8 (2024)
|
||||
.PP
|
||||
No user-visible changes.\& Documentation and code comments got better.\&
|
||||
.PP
|
||||
.SS 1.7 (2024)
|
||||
.PP
|
||||
Allow upload of multiple files.\& This requires an update to the \fIupload.\&html\fR
|
||||
template: Add the \fImultiple\fR attribute to the file input element and change the
|
||||
label from "file" to "files".\&
|
||||
.PP
|
||||
Fix orientation of uploaded images.\& JPG and HEIC images have EXIF data telling a
|
||||
viewer how to orient the image.\& Oddmu now uses this information to rotate the
|
||||
image correctly before stripping it.\&
|
||||
.PP
|
||||
The version command now displays much less information unless given the -full
|
||||
argument.\&
|
||||
.PP
|
||||
.SS 1.6 (2024)
|
||||
.PP
|
||||
Add \fIarchive\fR action to serve a zip file.\&
|
||||
.PP
|
||||
.SS 1.5 (2024)
|
||||
.PP
|
||||
Filtering separate sites in subdirectories via the ODDMU_FILTER environment
|
||||
variable in order to exclude them from the \fIsearch\fR action.\&
|
||||
.PP
|
||||
Add \fIversion\fR subcommand.\&
|
||||
.PP
|
||||
Add filesystem watchers to automatically reindex changed pages and reload
|
||||
changed templates.\&
|
||||
.PP
|
||||
When rendering a page, use templates in the same directory, if available.\&
|
||||
.PP
|
||||
Delete uploaded files by uploading a file with zero bytes.\&
|
||||
.PP
|
||||
.SS 1.4 (2024)
|
||||
.PP
|
||||
If stdin is a Unix-domain socket, use that to serve the site.\& Otherwise, allow
|
||||
specifying a listen address via the ODDMU_ADDRESS environment variable.\&
|
||||
.PP
|
||||
.SS 1.3 (2024)
|
||||
.PP
|
||||
Add support for resizing HEIC images (and saving them as JPG files).\&
|
||||
.PP
|
||||
.SS 1.2 (2023)
|
||||
.PP
|
||||
Add \fIlist\fR subcommand.\&
|
||||
.PP
|
||||
.SS 1.1 (2023)
|
||||
.PP
|
||||
Rewrote most of the README into man pages.\&
|
||||
.PP
|
||||
Add fediverse account rendering if ODDMU_WEBFINGER is set.\&
|
||||
.PP
|
||||
Add notifications when saving files: adding links to \fIindex\fR, \fIchanges\fR and
|
||||
\fIhashtag\fR pages.\&
|
||||
.PP
|
||||
Add \fIreplace\fR subcommand.\& Add \fImissing\fR subcommand.\& Add \fInotify\fR command.\& Add
|
||||
\fIstatic\fR command.\&
|
||||
.PP
|
||||
Add \fIdiff\fR action.\&
|
||||
.PP
|
||||
Add feed generation based on the local links from a page.\&
|
||||
.PP
|
||||
Add caching support by considering the If-Modified-Since header in requests and
|
||||
providing a Last-Modified header in responses.\&
|
||||
.PP
|
||||
Handle HEAD requests.\&
|
||||
.PP
|
||||
Remove HTML sanitization.\&
|
||||
.PP
|
||||
Remove MathJax support from the wiki parser.\& The templates never included the
|
||||
necessary MathJax JavaScript anyway so the special handling of $ was just an
|
||||
annoyance.\&
|
||||
.PP
|
||||
Drop trigram index and just search all the files.\& This takes much less RAM and
|
||||
doesn'\&t take too much time even with a few thousand pages.\&
|
||||
.PP
|
||||
Add "blog:true" and "blog:false" predicates to search.\&
|
||||
.PP
|
||||
Limit search to the current directory tree.\&
|
||||
.PP
|
||||
Do not overwrite fresh backups: there must be a 1h break before the backup is
|
||||
overwritten.\&
|
||||
.PP
|
||||
.SS 1.0 (2023)
|
||||
.PP
|
||||
Paginate search results and no longer sort search results by score.\&
|
||||
.PP
|
||||
.SS 0.9 (2023)
|
||||
.PP
|
||||
Add image resizing.\&
|
||||
.PP
|
||||
Add wiki links in double square brackets to the parser.\&
|
||||
.PP
|
||||
.SS 0.8 (2023)
|
||||
.PP
|
||||
Rename files to backups before saving.\&
|
||||
.PP
|
||||
Rename the \fIsaveUpload\fR action to \fIdrop\fR.\&
|
||||
.PP
|
||||
Add the \fIsearch\fR subcommand.\&
|
||||
.PP
|
||||
.SS 0.7 (2023)
|
||||
.PP
|
||||
Add \fIupload\fR and \fIsaveUpload\fR action so that one can upload files.\&
|
||||
.PP
|
||||
Add \fIhtml\fR subcommand.\&
|
||||
.PP
|
||||
.SS 0.6 (2003)
|
||||
.PP
|
||||
Add \fIadd\fR and \fIappend\fR action so that one can add to an existing page.\& This is
|
||||
important for me as editing pages on the phone can be cumbersome but leaving
|
||||
comments on my own site has always been easy to do.\&
|
||||
.PP
|
||||
Serve all existing files, not just text files.\&
|
||||
.PP
|
||||
Save an empty page to delete it.\&
|
||||
.PP
|
||||
Changed default permissions from 600 to 644 for files and from 700 to 755 for
|
||||
directories.\&
|
||||
.PP
|
||||
Make language detection configurable using an environment variable.\&
|
||||
.PP
|
||||
.SS 0.5 (2023)
|
||||
.PP
|
||||
Add hyphenation to templates using Peter M.\& Stahl'\&s Lingua library.\&
|
||||
.PP
|
||||
.SS 0.4 (2023)
|
||||
.PP
|
||||
Create subdirectories as necessary.\&
|
||||
.PP
|
||||
.SS 0.3 (2023)
|
||||
.PP
|
||||
Add \fIsearch\fR action using Damian Gryski'\&s trigram indexing, with scoring,
|
||||
highlighting and snippet extraction.\&
|
||||
.PP
|
||||
.SS 0.2 (2023)
|
||||
.PP
|
||||
Switch to Krzysztof Kowalczyk'\&s Go Markdown fork of Blackfriday to render
|
||||
Markdown.\& Use Dee'\&s Bluemonday to sanitize HTML.\&
|
||||
.PP
|
||||
Switch to GNU Affero GPL 3 license.\&
|
||||
.PP
|
||||
Serve text files (.\&txt).\&
|
||||
.PP
|
||||
Support serving on any port via the environment variable ODDMU_PORT.\&
|
||||
.PP
|
||||
.SS 0.1 (2015)
|
||||
.PP
|
||||
A web server that allows editing files in Wiki Creole Matt Self'\&s Cajun library.\&
|
||||
Supported actions are \fIedit\fR, \fIsave\fR, and \fIview\fR.\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
202
man/oddmu-releases.7.txt
Normal file
202
man/oddmu-releases.7.txt
Normal file
@@ -0,0 +1,202 @@
|
||||
ODDMU-RELEASES(7)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-releases - what's new in this releases?
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
This page lists user-visible features and template changes to consider.
|
||||
|
||||
## 1.9 (2024)
|
||||
|
||||
There is a change to make to copies of _upload.html_ if subdirectories are being
|
||||
used. The _Last_ property no longer contains the directory. It has to be added
|
||||
to the template as follows:
|
||||
|
||||
```
|
||||
{{if ne .Last ""}}
|
||||
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a></p>
|
||||
{{if .Image}}
|
||||
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"></p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
```
|
||||
|
||||
You can use the _Last_ property without a directory to suggest the markup to
|
||||
use, for example:
|
||||
|
||||
```
|
||||
<p>Use the following for <a href="/view/{{.Dir}}{{.Today}}">{{.Today}}</a>:
|
||||
<pre></a></pre>
|
||||
```
|
||||
|
||||
The upload template can use the _Today_ property.
|
||||
|
||||
The upload template comes with JavaScript that allows users to paste images or
|
||||
drag and drop files.
|
||||
|
||||
The upload template changed the id for the filename field from `text` to `name`.
|
||||
|
||||
The source repository now comes with example templates.
|
||||
|
||||
## 1.8 (2024)
|
||||
|
||||
No user-visible changes. Documentation and code comments got better.
|
||||
|
||||
## 1.7 (2024)
|
||||
|
||||
Allow upload of multiple files. This requires an update to the _upload.html_
|
||||
template: Add the _multiple_ attribute to the file input element and change the
|
||||
label from "file" to "files".
|
||||
|
||||
Fix orientation of uploaded images. JPG and HEIC images have EXIF data telling a
|
||||
viewer how to orient the image. Oddmu now uses this information to rotate the
|
||||
image correctly before stripping it.
|
||||
|
||||
The version command now displays much less information unless given the -full
|
||||
argument.
|
||||
|
||||
## 1.6 (2024)
|
||||
|
||||
Add _archive_ action to serve a zip file.
|
||||
|
||||
## 1.5 (2024)
|
||||
|
||||
Filtering separate sites in subdirectories via the ODDMU_FILTER environment
|
||||
variable in order to exclude them from the _search_ action.
|
||||
|
||||
Add _version_ subcommand.
|
||||
|
||||
Add filesystem watchers to automatically reindex changed pages and reload
|
||||
changed templates.
|
||||
|
||||
When rendering a page, use templates in the same directory, if available.
|
||||
|
||||
Delete uploaded files by uploading a file with zero bytes.
|
||||
|
||||
## 1.4 (2024)
|
||||
|
||||
If stdin is a Unix-domain socket, use that to serve the site. Otherwise, allow
|
||||
specifying a listen address via the ODDMU_ADDRESS environment variable.
|
||||
|
||||
## 1.3 (2024)
|
||||
|
||||
Add support for resizing HEIC images (and saving them as JPG files).
|
||||
|
||||
## 1.2 (2023)
|
||||
|
||||
Add _list_ subcommand.
|
||||
|
||||
## 1.1 (2023)
|
||||
|
||||
Rewrote most of the README into man pages.
|
||||
|
||||
Add fediverse account rendering if ODDMU_WEBFINGER is set.
|
||||
|
||||
Add notifications when saving files: adding links to _index_, _changes_ and
|
||||
_hashtag_ pages.
|
||||
|
||||
Add _replace_ subcommand. Add _missing_ subcommand. Add _notify_ command. Add
|
||||
_static_ command.
|
||||
|
||||
Add _diff_ action.
|
||||
|
||||
Add feed generation based on the local links from a page.
|
||||
|
||||
Add caching support by considering the If-Modified-Since header in requests and
|
||||
providing a Last-Modified header in responses.
|
||||
|
||||
Handle HEAD requests.
|
||||
|
||||
Remove HTML sanitization.
|
||||
|
||||
Remove MathJax support from the wiki parser. The templates never included the
|
||||
necessary MathJax JavaScript anyway so the special handling of $ was just an
|
||||
annoyance.
|
||||
|
||||
Drop trigram index and just search all the files. This takes much less RAM and
|
||||
doesn't take too much time even with a few thousand pages.
|
||||
|
||||
Add "blog:true" and "blog:false" predicates to search.
|
||||
|
||||
Limit search to the current directory tree.
|
||||
|
||||
Do not overwrite fresh backups: there must be a 1h break before the backup is
|
||||
overwritten.
|
||||
|
||||
## 1.0 (2023)
|
||||
|
||||
Paginate search results and no longer sort search results by score.
|
||||
|
||||
## 0.9 (2023)
|
||||
|
||||
Add image resizing.
|
||||
|
||||
Add wiki links in double square brackets to the parser.
|
||||
|
||||
## 0.8 (2023)
|
||||
|
||||
Rename files to backups before saving.
|
||||
|
||||
Rename the _saveUpload_ action to _drop_.
|
||||
|
||||
Add the _search_ subcommand.
|
||||
|
||||
## 0.7 (2023)
|
||||
|
||||
Add _upload_ and _saveUpload_ action so that one can upload files.
|
||||
|
||||
Add _html_ subcommand.
|
||||
|
||||
## 0.6 (2003)
|
||||
|
||||
Add _add_ and _append_ action so that one can add to an existing page. This is
|
||||
important for me as editing pages on the phone can be cumbersome but leaving
|
||||
comments on my own site has always been easy to do.
|
||||
|
||||
Serve all existing files, not just text files.
|
||||
|
||||
Save an empty page to delete it.
|
||||
|
||||
Changed default permissions from 600 to 644 for files and from 700 to 755 for
|
||||
directories.
|
||||
|
||||
Make language detection configurable using an environment variable.
|
||||
|
||||
## 0.5 (2023)
|
||||
|
||||
Add hyphenation to templates using Peter M. Stahl's Lingua library.
|
||||
|
||||
## 0.4 (2023)
|
||||
|
||||
Create subdirectories as necessary.
|
||||
|
||||
## 0.3 (2023)
|
||||
|
||||
Add _search_ action using Damian Gryski's trigram indexing, with scoring,
|
||||
highlighting and snippet extraction.
|
||||
|
||||
## 0.2 (2023)
|
||||
|
||||
Switch to Krzysztof Kowalczyk's Go Markdown fork of Blackfriday to render
|
||||
Markdown. Use Dee's Bluemonday to sanitize HTML.
|
||||
|
||||
Switch to GNU Affero GPL 3 license.
|
||||
|
||||
Serve text files (.txt).
|
||||
|
||||
Support serving on any port via the environment variable ODDMU_PORT.
|
||||
|
||||
## 0.1 (2015)
|
||||
|
||||
A web server that allows editing files in Wiki Creole Matt Self's Cajun library.
|
||||
Supported actions are _edit_, _save_, and _view_.
|
||||
|
||||
# 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.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-REPLACE" "1" "2024-02-17"
|
||||
.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.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-SEARCH" "1" "2024-02-17"
|
||||
.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>.
|
||||
107
man/oddmu-search.7
Normal file
107
man/oddmu-search.7
Normal file
@@ -0,0 +1,107 @@
|
||||
.\" Generated by scdoc 1.11.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-SEARCH" "7" "2024-02-19"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-search - understanding the Oddmu search engine
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The wiki keeps an index of all the hash tags and page titles in memory.\& Using
|
||||
hashtags and predicates in your queries speeds them up because fewer files are
|
||||
opened.\&
|
||||
.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 ENVIRONMENT
|
||||
.PP
|
||||
To exclude subdirectories from searches, use the ODDMU_FILTER environment
|
||||
variable.\& Set it to a regular expression matching sub-directories such as
|
||||
"^projects/".\& If search starts in a directory matching the regular expression,
|
||||
it is limited to the directory tree, as always.\& However, if search starts in a
|
||||
directory that doesn'\&t match, subdirectories that do match are skipped.\& See
|
||||
\fIoddmu-filter\fR(7).\&
|
||||
.PP
|
||||
To prevent access to a private directory tree, you must configure the web server
|
||||
in addition to setting the ODDMU_FILTER environment variable.\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1), \fIoddmu-search\fR(1), \fIoddmu-filter\fR(7), \fIoddmu-apache\fR(5),
|
||||
\fIoddmu-nginx\fR(5)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
87
man/oddmu-search.7.txt
Normal file
87
man/oddmu-search.7.txt
Normal file
@@ -0,0 +1,87 @@
|
||||
ODDMU-SEARCH(7)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-search - understanding the Oddmu search engine
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The wiki keeps an index of all the hash tags and page titles in memory. Using
|
||||
hashtags and predicates in your queries speeds them up because fewer files are
|
||||
opened.
|
||||
|
||||
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.
|
||||
|
||||
# ENVIRONMENT
|
||||
|
||||
To exclude subdirectories from searches, use the ODDMU_FILTER environment
|
||||
variable. Set it to a regular expression matching sub-directories such as
|
||||
"^projects/". If search starts in a directory matching the regular expression,
|
||||
it is limited to the directory tree, as always. However, if search starts in a
|
||||
directory that doesn't match, subdirectories that do match are skipped. See
|
||||
_oddmu-filter_(7).
|
||||
|
||||
To prevent access to a private directory tree, you must configure the web server
|
||||
in addition to setting the ODDMU_FILTER environment variable.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1), _oddmu-search_(1), _oddmu-filter_(7), _oddmu-apache_(5),
|
||||
_oddmu-nginx_(5)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
96
man/oddmu-static.1
Normal file
96
man/oddmu-static.1
Normal file
@@ -0,0 +1,96 @@
|
||||
.\" Generated by scdoc 1.11.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-STATIC" "1" "2024-03-12"
|
||||
.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.\& Existing files are
|
||||
only overwritten if they are older than the source file.\&
|
||||
.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
|
||||
If a page has a name case-insensitively matching a hashtag, a feed file is
|
||||
generated (ending with ".\&rss") if any suitable links are found.\& A suitable link
|
||||
for a feed item must appear in a bullet list item using an asterisk ("*").\& If
|
||||
no feed items are found, no feed is written.\&
|
||||
.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 files besides Markdown files.\&
|
||||
.PP
|
||||
Furthermore, in-place editing changes the file for all names.\& Avoid editing the
|
||||
hard-linked files (anything that'\&s not a HTML file) in the destination
|
||||
directory, just to be on the safe side.\& Usually you should be fine, as an editor
|
||||
moves the file that'\&s being edited to a backup file and creates a new file.\& But
|
||||
then again, who knows.\& A SQLite file, for example, would change in-place, and
|
||||
therefore making changes to it in the destination directory would change the
|
||||
original, too.\&
|
||||
.PP
|
||||
.SH EXAMPLE
|
||||
.PP
|
||||
Generate a static copy of the site, but only loading language detection for
|
||||
German and English, significantly reducing the time it takes to generate the
|
||||
static site:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
env ODDMU_LANGUAGES=de,en oddmu static \&.\&./archive
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH LIMITATIONS
|
||||
.PP
|
||||
There can be nameclashes with generated HTML and RSS files and existing files
|
||||
ending in ".\&html" and ".\&rss".\& Instead of overwriting existing files in these
|
||||
cases, a warning is printed.\&
|
||||
.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>.\&
|
||||
87
man/oddmu-static.1.txt
Normal file
87
man/oddmu-static.1.txt
Normal file
@@ -0,0 +1,87 @@
|
||||
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. Existing files are
|
||||
only overwritten if they are older than the source file.
|
||||
|
||||
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.
|
||||
|
||||
If a page has a name case-insensitively matching a hashtag, a feed file is
|
||||
generated (ending with ".rss") if any suitable links are found. A suitable link
|
||||
for a feed item must appear in a bullet list item using an asterisk ("\*"). If
|
||||
no feed items are found, no feed is written.
|
||||
|
||||
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 files besides Markdown files.
|
||||
|
||||
Furthermore, in-place editing changes the file for all names. Avoid editing the
|
||||
hard-linked files (anything that's not a HTML file) in the destination
|
||||
directory, just to be on the safe side. Usually you should be fine, as an editor
|
||||
moves the file that's being edited to a backup file and creates a new file. But
|
||||
then again, who knows. A SQLite file, for example, would change in-place, and
|
||||
therefore making changes to it in the destination directory would change the
|
||||
original, too.
|
||||
|
||||
# EXAMPLE
|
||||
|
||||
Generate a static copy of the site, but only loading language detection for
|
||||
German and English, significantly reducing the time it takes to generate the
|
||||
static site:
|
||||
|
||||
```
|
||||
env ODDMU_LANGUAGES=de,en oddmu static ../archive
|
||||
```
|
||||
|
||||
# LIMITATIONS
|
||||
|
||||
There can be nameclashes with generated HTML and RSS files and existing files
|
||||
ending in ".html" and ".rss". Instead of overwriting existing files in these
|
||||
cases, a warning is printed.
|
||||
|
||||
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>.
|
||||
249
man/oddmu-templates.5
Normal file
249
man/oddmu-templates.5
Normal file
@@ -0,0 +1,249 @@
|
||||
.\" Generated by scdoc 1.11.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-TEMPLATES" "5" "2024-03-14" "File Formats Manual"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-templates - how to write the templates
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
These files act as HTML templates: \fIadd.\&html\fR, \fIdiff.\&html\fR, \fIedit.\&html\fR,
|
||||
\fIfeed.\&html\fR, \fIsearch.\&html\fR, \fIstatic.\&html\fR, \fIupload.\&html\fR and \fIview.\&html\fR.\& They
|
||||
contain special placeholders in double bracers {{like this}}.\&
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
Each template receives an object and uses the object'\&s properties to replace the
|
||||
placeholders.\&
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
\fIadd.\&html\fR uses a \fIpage\fR
|
||||
.IP \(bu 4
|
||||
\fIdiff.\&html\fR uses a \fIpage\fR
|
||||
.IP \(bu 4
|
||||
\fIedit.\&html\fR uses a \fIpage\fR
|
||||
.IP \(bu 4
|
||||
\fIfeed.\&html\fR uses a \fIfeed\fR
|
||||
.IP \(bu 4
|
||||
\fIsearch.\&html\fR uses a \fIsearch\fR
|
||||
.IP \(bu 4
|
||||
\fIstatic.\&html\fR uses a \fIpage\fR
|
||||
.IP \(bu 4
|
||||
\fIupload.\&html\fR uses an \fIupload\fR
|
||||
.IP \(bu 4
|
||||
\fIview.\&html\fR uses a \fIpage\fR
|
||||
.PD
|
||||
.PP
|
||||
.SS Page
|
||||
.PP
|
||||
A page has the following properties:
|
||||
.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{{.\&Base}}\fR is the basename of the current file (without the directory and
|
||||
without the \fI.\&md\fR extension), escaped for use in URLs.\&
|
||||
.PP
|
||||
\fI{{.\&Language}}\fR is the suspected language of the page.\& This is used to set the
|
||||
language on the \fIview.\&html\fR template.\& See "Non-English hyphenation" below.\&
|
||||
.PP
|
||||
\fI{{.\&Body}}\fR is the raw byte content of the page.\& Use \fI{{printf "%s" .\&Body}}\fR to
|
||||
get the Markdown, as a string.\& This is used for the text area of the \fIedit.\&html\fR
|
||||
template.\&
|
||||
.PP
|
||||
\fI{{.\&Hashtags}}\fR is an array of strings.\&
|
||||
.PP
|
||||
\fI{{.\&Html}}\fR contains some sort of HTML that depends on the template used.\&
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
For \fIview.\&html\fR, it is the rendered Markdown, as HTML.\&
|
||||
.IP \(bu 4
|
||||
For \fIsearch.\&html\fR, it is a page summary, with bold matches, as HTML.\&
|
||||
.IP \(bu 4
|
||||
For \fIfeed.\&html\fR, it is the escaped (!\&) HTML of the feed item.\&
|
||||
.PD
|
||||
.PP
|
||||
\fI{{.\&Score}}\fR is a numerical score.\& It is only computed for \fIsearch.\&html\fR.\&
|
||||
.PP
|
||||
\fI{{.\&IsBlog}}\fR says whether the current page has a name starting with an ISO
|
||||
date.\&
|
||||
.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
|
||||
\fI{{.\&Diff}}\fR is the page diff for \fIdiff.\&html\fR.\& It 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
|
||||
.SS Feed
|
||||
.PP
|
||||
The feed contains an item for the head of the feed and an array of items.\&
|
||||
.PP
|
||||
\fI{{.\&Items}}\fR is the array of feed items.\& To refer to them, you need to use a
|
||||
\fI{{range .\&Items}}\fR … \fI{{end}}\fR construct.\&
|
||||
.PP
|
||||
If page A links to pages B and C, the head of the feed is based on page A and
|
||||
the list of items contains B and C.\&
|
||||
.PP
|
||||
An item is a page plus a date.\& All the properties of a page can be used (see
|
||||
\fBPage\fR above).\&
|
||||
.PP
|
||||
\fI{{.\&Date}}\fR is the date of the last update to the page, in RFC 822 format.\&
|
||||
.PP
|
||||
.SS Search
|
||||
.PP
|
||||
\fI{{.\&Query}}\fR is the query string.\&
|
||||
.PP
|
||||
\fI{{.\&Dir}}\fR is the directory in which the search starts, percent-escaped except
|
||||
for the slashes.\&
|
||||
.PP
|
||||
\fI{{.\&Items}}\fR is an array of pages (see \fBPage\fR above).\& To refer to them, you need
|
||||
to use a \fI{{range .\&Items}}\fR … \fI{{end}}\fR construct.\&
|
||||
.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 is not
|
||||
available.\&
|
||||
.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
|
||||
.SS Upload
|
||||
.PP
|
||||
\fI{{.\&Dir}}\fR is the directory where the uploaded file ends up, based on the URL
|
||||
path, percent-escaped except for the slashes.\&
|
||||
.PP
|
||||
\fI{{.\&Name}}\fR is the \fIfilename\fR query parameter.\&
|
||||
.PP
|
||||
\fI{{.\&Last}}\fR is the filename of the last image uploaded.\&
|
||||
.PP
|
||||
\fI{{.\&Image}}\fR is a boolean to indicate whether the last file uploaded has a file
|
||||
name indicating an image or not (such as ending in \fI.\&jpg\fR).\& If so, a thumbnail
|
||||
can be shown by the template, for example.\&
|
||||
.PP
|
||||
\fI{{.\&MaxWidth}}\fR is the \fImaxwidth\fR query parameter, i.\&e.\& the value used for the
|
||||
previous image uploaded.\&
|
||||
.PP
|
||||
\fI{{.\&Quality}}\fR is the \fIquality\fR query parameter, i.\&e.\& the value used for the
|
||||
previous image uploaded.\&
|
||||
.PP
|
||||
\fI{{.\&Today}}\fR is the current date, in ISO format.\&
|
||||
.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
|
||||
The following puts the current date into the text area if and only if the page
|
||||
itself is a blog page.\& Useful for \fIadd.\&html\fR:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
<textarea name="body" rows="20" cols="80" placeholder="Text" lang=""
|
||||
autofocus required>{{- if \&.IsBlog}}**{{\&.Today}}**\&. {{end}}</textarea>
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH NOTES
|
||||
.PP
|
||||
The templates 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.\& In
|
||||
this case, a visitor looking at "/view/projects/wiki" following a link to
|
||||
"index" ends up on "/view/projects/index", not on "/view/index".\&
|
||||
.PP
|
||||
It'\&s up to you to decide what'\&s best for your site, of course.\&
|
||||
.PP
|
||||
If you want a link on \fIupload.\&html\fR to point to the current directory'\&s "index"
|
||||
page, you need to use "/view/{{.\&Dir}}index" because if you link to "index" the
|
||||
result points to "/upload/{{.\&Dir}}index".\&
|
||||
.PP
|
||||
Templates can be changed by uploading new copies of the template files.\&
|
||||
.PP
|
||||
Subdirectories can have their own copies of template files.\& One example use for
|
||||
this is that they can point to a different CSS file.\&
|
||||
.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>.\&
|
||||
221
man/oddmu-templates.5.txt
Normal file
221
man/oddmu-templates.5.txt
Normal file
@@ -0,0 +1,221 @@
|
||||
ODDMU-TEMPLATES(5) "File Formats Manual"
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-templates - how to write the templates
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
These files act as HTML templates: _add.html_, _diff.html_, _edit.html_,
|
||||
_feed.html_, _search.html_, _static.html_, _upload.html_ and _view.html_. They
|
||||
contain special placeholders in double bracers {{like this}}.
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
Each template receives an object and uses the object's properties to replace the
|
||||
placeholders.
|
||||
|
||||
- _add.html_ uses a _page_
|
||||
- _diff.html_ uses a _page_
|
||||
- _edit.html_ uses a _page_
|
||||
- _feed.html_ uses a _feed_
|
||||
- _search.html_ uses a _search_
|
||||
- _static.html_ uses a _page_
|
||||
- _upload.html_ uses an _upload_
|
||||
- _view.html_ uses a _page_
|
||||
|
||||
## Page
|
||||
|
||||
A page has the following properties:
|
||||
|
||||
_{{.Title}}_ is the page title. If the page doesn't provide its own title, the
|
||||
page name is used.
|
||||
|
||||
_{{.Name}}_ is the page name, escaped for use in URLs. More specifically, it is
|
||||
percent-escaped except for the slashes. The page name doesn't include the _.md_
|
||||
extension.
|
||||
|
||||
_{{.Dir}}_ is the page directory, percent-escaped except for the slashes.
|
||||
|
||||
_{{.Base}}_ is the basename of the current file (without the directory and
|
||||
without the _.md_ extension), escaped for use in URLs.
|
||||
|
||||
_{{.Language}}_ is the suspected language of the page. This is used to set the
|
||||
language on the _view.html_ template. See "Non-English hyphenation" below.
|
||||
|
||||
_{{.Body}}_ is the raw byte content of the page. Use _{{printf "%s" .Body}}_ to
|
||||
get the Markdown, as a string. This is used for the text area of the _edit.html_
|
||||
template.
|
||||
|
||||
_{{.Hashtags}}_ is an array of strings.
|
||||
|
||||
_{{.Html}}_ contains some sort of HTML that depends on the template used.
|
||||
|
||||
- For _view.html_, it is the rendered Markdown, as HTML.
|
||||
- For _search.html_, it is a page summary, with bold matches, as HTML.
|
||||
- For _feed.html_, it is the escaped (!) HTML of the feed item.
|
||||
|
||||
_{{.Score}}_ is a numerical score. It is only computed for _search.html_.
|
||||
|
||||
_{{.IsBlog}}_ says whether the current page has a name starting with an ISO
|
||||
date.
|
||||
|
||||
_{{.Today}}_ is the current date, in ISO format. This is useful for "new page"
|
||||
like links or forms (see *EXAMPLE* below).
|
||||
|
||||
_{{.Diff}}_ is the page diff for _diff.html_. It 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.
|
||||
|
||||
## Feed
|
||||
|
||||
The feed contains an item for the head of the feed and an array of items.
|
||||
|
||||
_{{.Items}}_ is the array of feed items. To refer to them, you need to use a
|
||||
_{{range .Items}}_ … _{{end}}_ construct.
|
||||
|
||||
If page A links to pages B and C, the head of the feed is based on page A and
|
||||
the list of items contains B and C.
|
||||
|
||||
An item is a page plus a date. All the properties of a page can be used (see
|
||||
*Page* above).
|
||||
|
||||
_{{.Date}}_ is the date of the last update to the page, in RFC 822 format.
|
||||
|
||||
## Search
|
||||
|
||||
_{{.Query}}_ is the query string.
|
||||
|
||||
_{{.Dir}}_ is the directory in which the search starts, percent-escaped except
|
||||
for the slashes.
|
||||
|
||||
_{{.Items}}_ is an array of pages (see *Page* above). To refer to them, you need
|
||||
to use a _{{range .Items}}_ … _{{end}}_ construct.
|
||||
|
||||
_{{.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 is not
|
||||
available.
|
||||
|
||||
_{{.More}}_ indicates if there are any more search results.
|
||||
|
||||
_{{.Results}}_ indicates if there were any search results at all.
|
||||
|
||||
## Upload
|
||||
|
||||
_{{.Dir}}_ is the directory where the uploaded file ends up, based on the URL
|
||||
path, percent-escaped except for the slashes.
|
||||
|
||||
_{{.Name}}_ is the _filename_ query parameter.
|
||||
|
||||
_{{.Last}}_ is the filename of the last image uploaded.
|
||||
|
||||
_{{.Image}}_ is a boolean to indicate whether the last file uploaded has a file
|
||||
name indicating an image or not (such as ending in _.jpg_). If so, a thumbnail
|
||||
can be shown by the template, for example.
|
||||
|
||||
_{{.MaxWidth}}_ is the _maxwidth_ query parameter, i.e. the value used for the
|
||||
previous image uploaded.
|
||||
|
||||
_{{.Quality}}_ is the _quality_ query parameter, i.e. the value used for the
|
||||
previous image uploaded.
|
||||
|
||||
_{{.Today}}_ is the current date, in ISO format.
|
||||
|
||||
## 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>
|
||||
```
|
||||
|
||||
The following puts the current date into the text area if and only if the page
|
||||
itself is a blog page. Useful for _add.html_:
|
||||
|
||||
```
|
||||
<textarea name="body" rows="20" cols="80" placeholder="Text" lang=""
|
||||
autofocus required>{{- if .IsBlog}}**{{.Today}}**. {{end}}</textarea>
|
||||
```
|
||||
|
||||
# NOTES
|
||||
|
||||
The templates 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. In
|
||||
this case, a visitor looking at "/view/projects/wiki" following a link to
|
||||
"index" ends up on "/view/projects/index", not on "/view/index".
|
||||
|
||||
It's up to you to decide what's best for your site, of course.
|
||||
|
||||
If you want a link on _upload.html_ to point to the current directory's "index"
|
||||
page, you need to use "/view/{{.Dir}}index" because if you link to "index" the
|
||||
result points to "/upload/{{.Dir}}index".
|
||||
|
||||
Templates can be changed by uploading new copies of the template files.
|
||||
|
||||
Subdirectories can have their own copies of template files. One example use for
|
||||
this is that they can point to a different CSS file.
|
||||
|
||||
# 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>.
|
||||
38
man/oddmu-version.1
Normal file
38
man/oddmu-version.1
Normal file
@@ -0,0 +1,38 @@
|
||||
.\" Generated by scdoc 1.11.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-VERSION" "1" "2024-02-23"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-version - print build info on the command-line
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu version\fR [-full]
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The "version" subcommand prints information related to the version control
|
||||
system state when it was built: what remote was used, what commit was checked
|
||||
out, whether there were any local changes were made.\&
|
||||
.PP
|
||||
.SH OPTIONS
|
||||
.PP
|
||||
\fB-full\fR
|
||||
.RS 4
|
||||
Print a lot more information, including the versions of dependencies
|
||||
used.\& It'\&s the equivalent of running "go version -m oddmu".\&
|
||||
.PP
|
||||
.RE
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
29
man/oddmu-version.1.txt
Normal file
29
man/oddmu-version.1.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
ODDMU-VERSION(1)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-version - print build info on the command-line
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu version* [-full]
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The "version" subcommand prints information related to the version control
|
||||
system state when it was built: what remote was used, what commit was checked
|
||||
out, whether there were any local changes were made.
|
||||
|
||||
# OPTIONS
|
||||
|
||||
*-full*
|
||||
Print a lot more information, including the versions of dependencies
|
||||
used. It's the equivalent of running "go version -m oddmu".
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
409
man/oddmu.1
Normal file
409
man/oddmu.1
Normal file
@@ -0,0 +1,409 @@
|
||||
.\" Generated by scdoc 1.11.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU" "1" "2024-03-07"
|
||||
.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
|
||||
Oddmu can be used as a static site generator, turning Markdown files into HTML
|
||||
files, or it can be used as a public or a private wiki server.\& If it runs as a
|
||||
public wiki server, a regular webserver should be used as reverse proxy.\&
|
||||
.PP
|
||||
Run Oddmu without any arguments to serve the current working directory as a wiki
|
||||
on port 8080.\& Point your browser to http://localhost:8080/ to use it.\& This
|
||||
redirects you to http://localhost:8080/view/index – the first page you'\&ll
|
||||
create, most likely.\&
|
||||
.PP
|
||||
See \fIoddmu\fR(5) for details about the page formatting.\&
|
||||
.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
|
||||
The wiki knows the following actions for a given page name and (optional)
|
||||
directory:
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
\fI/\fR redirects to /view/index
|
||||
.IP \(bu 4
|
||||
\fI/view/dir/\fR redirects to /view/dir/index
|
||||
.IP \(bu 4
|
||||
\fI/view/dir/name\fR shows a page
|
||||
.IP \(bu 4
|
||||
\fI/view/dir/name.\&md\fR shows the source text of a page
|
||||
.IP \(bu 4
|
||||
\fI/view/dir/name.\&rss\fR shows the RSS feed for the pages linked
|
||||
.IP \(bu 4
|
||||
\fI/diff/dir/name\fR shows the last change to a page
|
||||
.IP \(bu 4
|
||||
\fI/edit/dir/name\fR shows a form to edit a page
|
||||
.IP \(bu 4
|
||||
\fI/save/dir/name\fR saves an edit
|
||||
.IP \(bu 4
|
||||
\fI/add/dir/name\fR shows a form to add to a page
|
||||
.IP \(bu 4
|
||||
\fI/append/dir/name\fR appends an addition to a page
|
||||
.IP \(bu 4
|
||||
\fI/upload/dir/name\fR shows a form to upload a file
|
||||
.IP \(bu 4
|
||||
\fI/drop/dir/name\fR saves an upload
|
||||
.IP \(bu 4
|
||||
\fI/search/dir/?\&q=term\fR to search for a term
|
||||
.IP \(bu 4
|
||||
\fI/archive/dir/name.\&zip\fR to download a zip file of a directory
|
||||
.PD
|
||||
.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 \fIdrop\fR action, the query parameters used are \fIname\fR for the
|
||||
target filename, \fIfile\fR for the file to upload.\& If the query parameter
|
||||
\fImaxwidth\fR is set, an attempt is made to decode and resize the image.\& JPG, PNG,
|
||||
WEBP and HEIC files can be decoded.\& Only JPG and PNG files can be encoded,
|
||||
however.\& If the target name ends in \fI.\&jpg\fR, the \fIquality\fR query parameter is
|
||||
also taken into account.\& To upload some thumbnails:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
for f in *\&.jpg; do
|
||||
curl --form name="$f" --form file=@"$f" --form maxwidth=100
|
||||
http://localhost:8080/drop/
|
||||
done
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
When calling the \fIsearch\fR action, the search terms are taken from the query
|
||||
parameter \fIq\fR.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
curl \&'http://localhost:8080/search/?q=towel\&'
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
The page name to act upon is optionally taken from the query parameter \fIid\fR.\& In
|
||||
this case, the directory must also be part of the query parameter and not of the
|
||||
URL path.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
curl \&'http://localhost:8080/view/?id=man/oddmu\&.1\&.txt\&'
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
The base name for the \fIarchive\fR action is used by the browser to save the
|
||||
downloaded file.\& For Oddmu, only the directory is important.\& The following zips
|
||||
the \fIman\fR directory and saves it as \fIman.\&zip\fR.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
curl --remote-name \&'http://localhost:8080/archive/man/man\&.zip
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH CONFIGURATION
|
||||
.PP
|
||||
The template files are the HTML files in the working directory:
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
\fIview.\&html\fR shows a page
|
||||
.IP \(bu 4
|
||||
\fIdiff.\&html\fR shows the last change to a page
|
||||
.IP \(bu 4
|
||||
\fIedit.\&html\fR shows a form to edit a page
|
||||
.IP \(bu 4
|
||||
\fIadd.\&html\fR shows a form to add to a page
|
||||
.IP \(bu 4
|
||||
\fIupload.\&html\fR shows a form to upload a file
|
||||
.IP \(bu 4
|
||||
\fIsearch.\&html\fR shows the search results
|
||||
.IP \(bu 4
|
||||
\fIstatic.\&html\fR is used to generate a static site
|
||||
.IP \(bu 4
|
||||
\fIfeed.\&html\fR is used to generate a RSS feed
|
||||
.PD
|
||||
.PP
|
||||
Please change the templates!\&
|
||||
.PP
|
||||
The first change you should make is to replace the name and email address in the
|
||||
footer of \fIview.\&html\fR.\& 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".\&
|
||||
.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
|
||||
.nf
|
||||
.RS 4
|
||||
ODDMU_ADDRESS=127\&.0\&.0\&.1 # The loopback IPv4 address\&.
|
||||
ODDMU_ADDRESS=2001:db8::3:1 # An IPv6 address\&.
|
||||
.fi
|
||||
.RE
|
||||
.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
|
||||
If you use secret subdirectories, you cannot rely on the web server to hide
|
||||
those pages because some actions such as searching and archiving include
|
||||
subdirectories.\& They act upon a whole tree of pages, not just a single page.\& The
|
||||
ODDMU_FILTER can be used to exclude subdirectories from such tree actions.\& See
|
||||
\fIoddmu-filter\fR(7) and \fIoddmu-apache\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
|
||||
Oddmu 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
|
||||
To compute the space used by your setup, use regular tools:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
du --exclude=\&'*/.*\&' --exclude \&'*~\&' --block-size=M
|
||||
.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.\&
|
||||
.PP
|
||||
Oddmu watches the working directory and any subdirectories for changes made
|
||||
directly.\& Thus, in theory, it'\&s not necessary to restart it after making such
|
||||
changes.\&
|
||||
.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
|
||||
In order to delete uploaded files via the web, create an empty file and upload
|
||||
it.\& In order to delete a wiki page, save an empty page.\&
|
||||
.PP
|
||||
Note that some HTML file names are special: they act as templates.\& See
|
||||
\fIoddmu-templates\fR(5) for their names and their use.\&
|
||||
.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-releases\fR(7), on what features are part of the latest release
|
||||
.IP \(bu 4
|
||||
\fIoddmu-filter\fR(7), on how to treat subdirectories as separate sites
|
||||
.IP \(bu 4
|
||||
\fIoddmu-search\fR(7), on how search works
|
||||
.IP \(bu 4
|
||||
\fIoddmu-templates\fR(5), on how to write the HTML templates
|
||||
.PD
|
||||
.PP
|
||||
If you run Oddmu as a web server:
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
\fIoddmu-apache\fR(5), on how to set up Apache as a reverse proxy
|
||||
.IP \(bu 4
|
||||
\fIoddmu-nginx\fR(5), on how to set up freenginx as a reverse proxy
|
||||
.IP \(bu 4
|
||||
\fIoddmu.\&service\fR(5), on how to run the service under systemd
|
||||
.PD
|
||||
.PP
|
||||
If you run Oddmu as a static site generator or pages offline and sync them with
|
||||
Oddmu running as a webserver:
|
||||
.PP
|
||||
.PD 0
|
||||
.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-notify\fR(1), on updating index, changes and hashtag pages 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-static\fR(1), on generating a static site from the command-line
|
||||
.IP \(bu 4
|
||||
\fIoddmu-version\fR(1), on how to get all the build information from the binary
|
||||
.PD
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
330
man/oddmu.1.txt
Normal file
330
man/oddmu.1.txt
Normal file
@@ -0,0 +1,330 @@
|
||||
ODDMU(1)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu - a wiki server
|
||||
|
||||
Oddmu is sometimes written Oddµ because µ is the letter mu.
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu*
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
Oddmu can be used as a static site generator, turning Markdown files into HTML
|
||||
files, or it can be used as a public or a private wiki server. If it runs as a
|
||||
public wiki server, a regular webserver should be used as reverse proxy.
|
||||
|
||||
Run Oddmu without any arguments to serve the current working directory as a wiki
|
||||
on port 8080. Point your browser to http://localhost:8080/ to use it. This
|
||||
redirects you to http://localhost:8080/view/index – the first page you'll
|
||||
create, most likely.
|
||||
|
||||
See _oddmu_(5) for details about the page formatting.
|
||||
|
||||
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.
|
||||
|
||||
The wiki knows the following actions for a given page name and (optional)
|
||||
directory:
|
||||
|
||||
- _/_ redirects to /view/index
|
||||
- _/view/dir/_ redirects to /view/dir/index
|
||||
- _/view/dir/name_ shows a page
|
||||
- _/view/dir/name.md_ shows the source text of a page
|
||||
- _/view/dir/name.rss_ shows the RSS feed for the pages linked
|
||||
- _/diff/dir/name_ shows the last change to a page
|
||||
- _/edit/dir/name_ shows a form to edit a page
|
||||
- _/save/dir/name_ saves an edit
|
||||
- _/add/dir/name_ shows a form to add to a page
|
||||
- _/append/dir/name_ appends an addition to a page
|
||||
- _/upload/dir/name_ shows a form to upload a file
|
||||
- _/drop/dir/name_ saves an upload
|
||||
- _/search/dir/?q=term_ to search for a term
|
||||
- _/archive/dir/name.zip_ to download a zip file of a directory
|
||||
|
||||
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 _drop_ action, the query parameters used are _name_ for the
|
||||
target filename, _file_ for the file to upload. If the query parameter
|
||||
_maxwidth_ is set, an attempt is made to decode and resize the image. JPG, PNG,
|
||||
WEBP and HEIC files can be decoded. Only JPG and PNG files can be encoded,
|
||||
however. If the target name ends in _.jpg_, the _quality_ query parameter is
|
||||
also taken into account. To upload some thumbnails:
|
||||
|
||||
```
|
||||
for f in *.jpg; do
|
||||
curl --form name="$f" --form file=@"$f" --form maxwidth=100 \
|
||||
http://localhost:8080/drop/
|
||||
done
|
||||
```
|
||||
|
||||
When calling the _search_ action, the search terms are taken from the query
|
||||
parameter _q_.
|
||||
|
||||
```
|
||||
curl 'http://localhost:8080/search/?q=towel'
|
||||
```
|
||||
|
||||
The page name to act upon is optionally taken from the query parameter _id_. In
|
||||
this case, the directory must also be part of the query parameter and not of the
|
||||
URL path.
|
||||
|
||||
```
|
||||
curl 'http://localhost:8080/view/?id=man/oddmu.1.txt'
|
||||
```
|
||||
|
||||
The base name for the _archive_ action is used by the browser to save the
|
||||
downloaded file. For Oddmu, only the directory is important. The following zips
|
||||
the _man_ directory and saves it as _man.zip_.
|
||||
|
||||
```
|
||||
curl --remote-name 'http://localhost:8080/archive/man/man.zip
|
||||
```
|
||||
|
||||
# CONFIGURATION
|
||||
|
||||
The template files are the HTML files in the working directory:
|
||||
|
||||
- _view.html_ shows a page
|
||||
- _diff.html_ shows the last change to a page
|
||||
- _edit.html_ shows a form to edit a page
|
||||
- _add.html_ shows a form to add to a page
|
||||
- _upload.html_ shows a form to upload a file
|
||||
- _search.html_ shows the search results
|
||||
- _static.html_ is used to generate a static site
|
||||
- _feed.html_ is used to generate a RSS feed
|
||||
|
||||
Please change the templates!
|
||||
|
||||
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".
|
||||
|
||||
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).
|
||||
|
||||
If you use secret subdirectories, you cannot rely on the web server to hide
|
||||
those pages because some actions such as searching and archiving include
|
||||
subdirectories. They act upon a whole tree of pages, not just a single page. The
|
||||
ODDMU_FILTER can be used to exclude subdirectories from such tree actions. See
|
||||
_oddmu-filter_(7) and _oddmu-apache_(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
|
||||
|
||||
Oddmu can be run on the command-line using various subcommands.
|
||||
|
||||
- to generate the HTML for a single page, see _oddmu-html_(1)
|
||||
- to generate the HTML for the entire site, using Oddmu as a static site
|
||||
generator, see _oddmu-static_(1)
|
||||
- to search a regular expression and replace it across all files, see
|
||||
_oddmu-replace_(1)
|
||||
- to 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
|
||||
```
|
||||
|
||||
To compute the space used by your setup, use regular tools:
|
||||
|
||||
```
|
||||
du --exclude='*/\.*' --exclude '*~' --block-size=M
|
||||
```
|
||||
|
||||
# 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.
|
||||
|
||||
Oddmu watches the working directory and any subdirectories for changes made
|
||||
directly. Thus, in theory, it's not necessary to restart it after making such
|
||||
changes.
|
||||
|
||||
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.
|
||||
|
||||
In order to delete uploaded files via the web, create an empty file and upload
|
||||
it. In order to delete a wiki page, save an empty page.
|
||||
|
||||
Note that some HTML file names are special: they act as templates. See
|
||||
_oddmu-templates_(5) for their names and their use.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
- _oddmu_(5), about the markup syntax and how feeds are generated based on link
|
||||
lists
|
||||
- _oddmu-releases_(7), on what features are part of the latest release
|
||||
- _oddmu-filter_(7), on how to treat subdirectories as separate sites
|
||||
- _oddmu-search_(7), on how search works
|
||||
- _oddmu-templates_(5), on how to write the HTML templates
|
||||
|
||||
If you run Oddmu as a web server:
|
||||
|
||||
- _oddmu-apache_(5), on how to set up Apache as a reverse proxy
|
||||
- _oddmu-nginx_(5), on how to set up freenginx as a reverse proxy
|
||||
- _oddmu.service_(5), on how to run the service under systemd
|
||||
|
||||
If you run Oddmu as a static site generator or pages offline and sync them with
|
||||
Oddmu running as a webserver:
|
||||
|
||||
- _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-notify_(1), on updating index, changes and hashtag pages 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-static_(1), on generating a static site from the command-line
|
||||
- _oddmu-version_(1), on how to get all the build information from the binary
|
||||
|
||||
# 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.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU" "5" "2024-02-17" "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.3
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU.SERVICE" "5" "2024-02-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>.
|
||||
97
man_test.go
Normal file
97
man_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManPages(t *testing.T) {
|
||||
b, err := os.ReadFile("man/oddmu.1.txt")
|
||||
main := string(b)
|
||||
assert.NoError(t, err)
|
||||
filepath.Walk("man", func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(path, ".txt") &&
|
||||
path != "man/oddmu.1.txt" {
|
||||
s := strings.TrimPrefix(path, "man/")
|
||||
s = strings.TrimSuffix(s, ".txt")
|
||||
i := strings.LastIndex(s, ".")
|
||||
ref := "_" + s[:i] + "_(" + s[i+1:] + ")"
|
||||
assert.Contains(t, main, ref, ref)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadme(t *testing.T) {
|
||||
b, err := os.ReadFile("README.md")
|
||||
main := string(b)
|
||||
assert.NoError(t, err)
|
||||
filepath.Walk("man", func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(path, ".txt") {
|
||||
s := strings.TrimPrefix(path, "man/")
|
||||
s = strings.TrimSuffix(s, ".txt")
|
||||
i := strings.LastIndex(s, ".")
|
||||
ref := "[" + s[:i] + "(" + s[i+1:] + ")]"
|
||||
assert.Contains(t, main, ref, ref)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(path, ".go") &&
|
||||
!strings.HasSuffix(path, "_test.go") &&
|
||||
!strings.HasSuffix(path, "_cmd.go") {
|
||||
s := strings.TrimPrefix(path, "./")
|
||||
ref := "`" + s + "`"
|
||||
assert.Contains(t, main, ref, ref)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestDocumentDependencies(t *testing.T) {
|
||||
b, err := os.ReadFile("README.md")
|
||||
readme := string(b)
|
||||
assert.NoError(t, err)
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, ".", nil, parser.ImportsOnly)
|
||||
assert.NoError(t, err)
|
||||
imports := []string{}
|
||||
for _, pkg := range pkgs {
|
||||
for _, file := range pkg.Files {
|
||||
for _, imp := range file.Imports {
|
||||
name := imp.Path.Value[1 : len(imp.Path.Value)-1]
|
||||
if strings.Contains(name, ".") && !slices.Contains(imports, name) {
|
||||
imports = append(imports, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(imports, func(i, j int) bool { return len(imports[i]) < len(imports[j]) })
|
||||
IMPORT:
|
||||
for _, name := range imports {
|
||||
for _, other := range imports {
|
||||
if strings.HasPrefix(name, other) && name != other {
|
||||
continue IMPORT
|
||||
}
|
||||
}
|
||||
ok := strings.Contains(readme, name)
|
||||
assert.True(t, ok, name)
|
||||
}
|
||||
}
|
||||
134
missing_cmd.go
Normal file
134
missing_cmd.go
Normal file
@@ -0,0 +1,134 @@
|
||||
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"
|
||||
"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. This command does not
|
||||
understand links to directories being redirected to index pages.
|
||||
A link such as [up](..) is reported as a link to a missing page.
|
||||
Rewrite it as [up](../index) for it to work as intended.
|
||||
`
|
||||
}
|
||||
|
||||
func (cmd *missingCmd) SetFlags(f *flag.FlagSet) {
|
||||
}
|
||||
|
||||
func (cmd *missingCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
return missingCli(os.Stdout)
|
||||
}
|
||||
|
||||
func missingCli(w io.Writer) subcommands.ExitStatus {
|
||||
names, err := existingPages()
|
||||
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", p.Name, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
for _, link := range p.links() {
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, p.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", p.Name, link)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
fmt.Fprintln(w, "No missing pages found.")
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
func existingPages() (map[string]bool, error) {
|
||||
names := make(map[string]bool)
|
||||
err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// skip hidden directories and files
|
||||
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(path, ".md") {
|
||||
name := filepath.ToSlash(strings.TrimSuffix(path, ".md"))
|
||||
names[name] = true
|
||||
} else {
|
||||
names[path] = false
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return names, err
|
||||
}
|
||||
|
||||
// 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:
|
||||
link := string(v.Destination)
|
||||
dir := p.Dir()
|
||||
links = append(links, path.Join(dir, link))
|
||||
}
|
||||
}
|
||||
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)
|
||||
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(w, "Loading %s: %s\n", name, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
err = p.notify()
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "%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
|
||||
49
oddmu.service
Normal file
49
oddmu.service
Normal file
@@ -0,0 +1,49 @@
|
||||
[Unit]
|
||||
Description=Oddmu
|
||||
After=network.target
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
DynamicUser=true
|
||||
MemoryMax=100M
|
||||
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
|
||||
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
|
||||
30
oddmu.svg
Normal file
30
oddmu.svg
Normal file
@@ -0,0 +1,30 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
heigh="320" width="320"
|
||||
viewBox="0 0 320 320">
|
||||
<g>
|
||||
<circle id="hand1" cx="60" cy="190" r="15" fill="white" stroke="black" stroke-width="2"/>
|
||||
<path id="stick" d="M40,40 L60,300 H66 L46,40 Z" fill="white" stroke="black" stroke-width="2"/>
|
||||
<ellipse id="fingers" cx="50" cy="195" rx="5" ry="15" fill="white" stroke="black" stroke-width="2"/>
|
||||
<path id="thumb" d="M60,180 Q40,180 60,190 Z" fill="white" stroke="black" stroke-width="2"/>
|
||||
</g>
|
||||
<g id="hand2">
|
||||
<circle cx="240" cy="200" r="15" fill="white" stroke="black" stroke-width="2"/>
|
||||
</g>
|
||||
<g id="body">
|
||||
<path d="M60,170 H170 L240,180 V230 L190,220
|
||||
L200,222 L220,290 H120 V215
|
||||
L125,215 H60 V170" fill="white" stroke="black" stroke-width="2"/>
|
||||
</g>
|
||||
<g id="face">
|
||||
<circle cx="150" cy="150" r="30" fill="white" stroke="black" stroke-width="2"/>
|
||||
<circle cx="138" cy="145" r="2" fill="black" stroke="black"/>
|
||||
<circle cx="158" cy="145" r="2" fill="black" stroke="black"/>
|
||||
<path d="M132,158 Q145,175 170,155 " fill="none" stroke="black" stroke-width="2"/>
|
||||
</g>
|
||||
<g id="foot1">
|
||||
<path d="M120,300 C100,270 180,270 160,300 Z" fill="white" stroke="black" stroke-width="2"/>
|
||||
</g>
|
||||
<g id="foot1">
|
||||
<path d="M180,300 V290 H190 C210,270 250,280 240,300 Z" fill="white" stroke="black" stroke-width="2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
170
page.go
Normal file
170
page.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Page is a struct containing information about a single page. Title
|
||||
// is the title extracted from the page content using titleRegexp.
|
||||
// Name is the path without extension (so a path of "foo.md"
|
||||
// results in the Name "foo"). Body is the Markdown content of the
|
||||
// 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
|
||||
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 path is based on the Page.Name and gets the ".md" extension. Page.Body is saved, without any
|
||||
// carriage return characters ("\r"). Page.Title and Page.Html are not saved. There is no caching. Before removing or
|
||||
// writing a file, the old copy is renamed to a backup, appending "~". Errors are not logged but returned.
|
||||
func (p *Page) save() error {
|
||||
fp := filepath.FromSlash(p.Name + ".md")
|
||||
watches.ignore(fp)
|
||||
s := bytes.ReplaceAll(p.Body, []byte{'\r'}, []byte{})
|
||||
if len(s) == 0 {
|
||||
log.Println("Delete", p.Name)
|
||||
index.remove(p)
|
||||
return os.Rename(fp, fp+"~")
|
||||
}
|
||||
p.Body = s
|
||||
index.update(p)
|
||||
d := filepath.Dir(fp)
|
||||
if d != "." {
|
||||
err := os.MkdirAll(d, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err := backup(fp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(fp, s, 0644)
|
||||
}
|
||||
|
||||
// backup a file by renaming (!) it unless the existing backup is less than an hour old. A backup gets a tilde appended
|
||||
// to it ("~"). This is true even if the file refers to a binary file like "image.png" and most applications don't know
|
||||
// what to do with a file called "image.png~". This expects a file path. Use filepath.FromSlash(path) if necessary.
|
||||
func backup(fp string) error {
|
||||
_, err := os.Stat(fp)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
bp := fp + "~"
|
||||
fi, err := os.Stat(bp)
|
||||
if err != nil || time.Since(fi.ModTime()).Minutes() >= 60 {
|
||||
return os.Rename(fp, bp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadPage loads a Page given a name. The path loaded is that Page.Name with the ".md" extension. The Page.Title is set
|
||||
// to the Page.Name (and possibly changed, later). The Page.Body is set to the file content. The Page.Html remains
|
||||
// undefined (there is no caching).
|
||||
func loadPage(path string) (*Page, error) {
|
||||
path = strings.TrimPrefix(path, "./") // result of a filepath.TreeWalk starting with "."
|
||||
body, err := os.ReadFile(filepath.FromSlash(path + ".md"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Page{Title: path, Name: path, Body: body}, nil
|
||||
}
|
||||
|
||||
// handleTitle extracts the title from a Page and sets Page.Title, if any. If replace is true, the page title is also
|
||||
// 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 {
|
||||
p.Title = m[1]
|
||||
if replace {
|
||||
p.Body = []byte(strings.Replace(s, m[0], "", 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// score sets Page.Title and computes Page.Score.
|
||||
func (p *Page) score(q string) {
|
||||
p.handleTitle(true)
|
||||
p.Score = score(q, string(p.Body)) + score(q, p.Title)
|
||||
}
|
||||
|
||||
// summarize sets Page.Html to an extract.
|
||||
func (p *Page) summarize(q string) {
|
||||
t := p.plainText()
|
||||
p.Name = nameEscape(p.Name)
|
||||
p.Html = sanitizeStrict(snippets(q, 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 + "/"
|
||||
}
|
||||
|
||||
// Base returns the basename of the page name: no directory and no extension. This is used to create the upload link
|
||||
// in "view.html", for example.
|
||||
func (p *Page) Base() string {
|
||||
n := filepath.Base(p.Name)
|
||||
if n == "." {
|
||||
return ""
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Today returns the date, as a string, for use in templates.
|
||||
func (p *Page) Today() string {
|
||||
return time.Now().Format(time.DateOnly)
|
||||
}
|
||||
42
page_test.go
Normal file
42
page_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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)
|
||||
assert.Equal(t, "Ache", p.Title)
|
||||
assert.Regexp(t, regexp.MustCompile("^# Ache"), string(p.Body))
|
||||
p.handleTitle(true)
|
||||
assert.Regexp(t, regexp.MustCompile("^My back"), string(p.Body))
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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")
|
||||
|
||||
// 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~")
|
||||
}
|
||||
121
parser.go
Normal file
121
parser.go
Normal file
@@ -0,0 +1,121 @@
|
||||
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.AutoHeadingIDs | 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('@', accountLink)
|
||||
}
|
||||
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.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 id="sun">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 id="comet">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 id="photos-and-books">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 id="no-dollar-can-buy-this">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")
|
||||
}
|
||||
117
replace_cmd.go
Normal file
117
replace_cmd.go
Normal file
@@ -0,0 +1,117 @@
|
||||
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
|
||||
}
|
||||
// skip hidden directories and files
|
||||
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// skipp all but page files
|
||||
if !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()
|
||||
}
|
||||
}
|
||||
258
search.go
Normal file
258
search.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Search is a struct containing the result of a search. Query is the
|
||||
// query string and Items is the array of pages with the result.
|
||||
// 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
|
||||
Dir string
|
||||
Items []*Page
|
||||
Previous int
|
||||
Page int
|
||||
Next int
|
||||
More bool
|
||||
Results bool
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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, dir, filter string, page int, all bool) ([]*Page, bool) {
|
||||
if len(q) == 0 {
|
||||
return make([]*Page, 0), false
|
||||
}
|
||||
names := index.search(q) // hashtags or all names
|
||||
names = filterPath(names, dir, filter)
|
||||
predicates, terms := predicatesAndTokens(q)
|
||||
names = filterNames(names, predicates)
|
||||
index.RLock()
|
||||
slices.SortFunc(names, sortNames(terms))
|
||||
index.RUnlock()
|
||||
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
|
||||
}
|
||||
|
||||
// filterPath filters the names by prefix and by a regular expression. A prefix of "." means that all the names are
|
||||
// returned, since this is what path.Dir returns for "no directory".
|
||||
//
|
||||
// The regular expression can be used to ensure that search does not descend into subdirectories unless the search
|
||||
// already starts there. Given the pages a, public/b and secret/c and ODDMU_FILTER=^secret/ then if search starts in the
|
||||
// root directory /, search does not enter secret/, but if search starts in secret/, search does search the pages in
|
||||
// secret/ – it us up to the web server to ensure access to secret/ is limited. More specifically: the page names must
|
||||
// match the prefix, always; if prefix also matches the filter, this means the page names are all part of a "separate
|
||||
// site"; if the prefix does not match the filter, then the page names must also not match the filter since only the
|
||||
// "main site" is shown. If the filter is empty, all prefixes and all page names match, so no problem.
|
||||
func filterPath(names []string, prefix, filter string) []string {
|
||||
re, err := regexp.Compile(filter)
|
||||
if err != nil {
|
||||
log.Println("ODDMU_FILTER does not compile:", filter, err)
|
||||
return []string{}
|
||||
}
|
||||
matches := re.MatchString(prefix)
|
||||
r := make([]string, 0)
|
||||
for _, name := range names {
|
||||
if strings.HasPrefix(name, prefix) &&
|
||||
(matches || !re.MatchString(name)) {
|
||||
r = append(r, name)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// filterNames filters the names by all the predicats such as
|
||||
// "title:foo" or "blog:true".
|
||||
func filterNames(names, predicates []string) []string {
|
||||
if len(predicates) == 0 {
|
||||
return names
|
||||
}
|
||||
// the intersection requires sorted lists
|
||||
slices.Sort(names)
|
||||
index.RLock()
|
||||
defer index.RUnlock()
|
||||
for _, predicate := range predicates {
|
||||
r := make([]string, 0)
|
||||
if strings.HasPrefix(predicate, "title:") {
|
||||
token := predicate[6:]
|
||||
for _, name := range names {
|
||||
if strings.Contains(strings.ToLower(index.titles[name]), token) {
|
||||
r = append(r, name)
|
||||
}
|
||||
}
|
||||
} else if predicate == "blog:true" || predicate == "blog:false" {
|
||||
blog := predicate == "blog:true"
|
||||
re := regexp.MustCompile(`(^|/)\d\d\d\d-\d\d-\d\d`)
|
||||
for _, name := range names {
|
||||
match := re.MatchString(name)
|
||||
if blog && match || !blog && !match {
|
||||
r = append(r, name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("Unsupported predicate: %s", predicate)
|
||||
}
|
||||
names = intersection(names, r)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// grep searches the files for matches to all the tokens. It returns just a single page of results based [from:to-1] and
|
||||
// returns if there are more results. The all parameter ignores pagination (the from and to parameters). The keepFirst
|
||||
// parameter keeps the first page in the list, even if there is no match. This is used for hashtag pages.
|
||||
func grep(tokens, names []string, from, to int, all, keepFirst bool) ([]*Page, bool) {
|
||||
pages := make([]*Page, 0)
|
||||
i := 0
|
||||
NameLoop:
|
||||
for n, name := range names {
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
log.Printf("grep: cannot load %s: %s", name, err)
|
||||
continue NameLoop
|
||||
}
|
||||
if n != 0 || !keepFirst {
|
||||
body := strings.ToLower(string(p.Body))
|
||||
for _, token := range tokens {
|
||||
if !strings.Contains(body, token) {
|
||||
continue NameLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
i++
|
||||
if all || i > from {
|
||||
pages = append(pages, p)
|
||||
}
|
||||
if !all && i > to {
|
||||
return pages, true
|
||||
}
|
||||
}
|
||||
return pages, false
|
||||
}
|
||||
|
||||
// prependQueryPage prepends the query itself, if a matching page name exists. This helps if people remember the name
|
||||
// exactly, or if searching for a hashtag. This function assumes that q is not the empty string. Return wether a page
|
||||
// was prepended or not.
|
||||
func prependQueryPage(names []string, dir, q string) ([]string, bool) {
|
||||
index.RLock()
|
||||
defer index.RUnlock()
|
||||
if q[0] == '#' && !strings.Contains(q[1:], "#") {
|
||||
q = q[1:]
|
||||
}
|
||||
q = path.Join(dir, q)
|
||||
// if q exists in names, move it to the front
|
||||
i := slices.Index(names, q)
|
||||
if i == 0 {
|
||||
return names, false
|
||||
} else if i != -1 {
|
||||
r := []string{q}
|
||||
r = append(r, names[0:i]...)
|
||||
r = append(r, names[i+1:]...)
|
||||
return r, false
|
||||
}
|
||||
// otherwise, if q is a known page name, prepend it
|
||||
_, ok := index.titles[q]
|
||||
if ok {
|
||||
return append([]string{q}, names...), true
|
||||
}
|
||||
return names, false
|
||||
}
|
||||
|
||||
// searchHandler presents a search result. It uses the query string in the form parameter "q" and the template
|
||||
// "search.html". For each page found, the HTML is just an extract of the actual body. Search is limited to a directory
|
||||
// and its subdirectories.
|
||||
//
|
||||
// A filter can be defined using the environment variable ODDMU_FILTER. It is passed on to search.
|
||||
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
|
||||
}
|
||||
filter := os.Getenv("ODDMU_FILTER")
|
||||
items, more := search(q, dir, filter, 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, dir, "search", s)
|
||||
}
|
||||
55
search.html
Normal file
55
search.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!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>Search for {{.Query}}</title>
|
||||
<style>
|
||||
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
|
||||
body { hyphens: auto; }
|
||||
header a { margin-right: 1ch; }
|
||||
form { display: inline-block; }
|
||||
input#search { width: 20ch; }
|
||||
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
|
||||
img { max-width: 20%; }
|
||||
.result { font-size: larger }
|
||||
.score { font-size: smaller; opacity: 0.8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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}}
|
||||
<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}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
99
search_cmd.go
Normal file
99
search_cmd.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/muesli/reflow/wordwrap"
|
||||
"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.reset()
|
||||
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 := func (s string) string { return "\x1b[1;4m" + s + "\x1b[0m" } // bold + underline
|
||||
match := func (s string) string { return "\x1b[1m" + s + "\x1b[0m" } // bold
|
||||
re := regexp.MustCompile(`<b>(.*?)</b>`)
|
||||
for _, p := range items {
|
||||
s := re.ReplaceAllString(string(p.Html), match(`$1`))
|
||||
fmt.Fprintln(w, heading(p.Title))
|
||||
if p.Name != p.Title {
|
||||
fmt.Fprintln(w, p.Name)
|
||||
}
|
||||
for _, s := range strings.Split(wordwrap.String(s, 72), "\n") {
|
||||
fmt.Fprintln(w, " ", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
34
search_cmd_test.go
Normal file
34
search_cmd_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
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)
|
||||
* [Themes](themes/index)
|
||||
* [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())
|
||||
}
|
||||
272
search_test.go
Normal file
272
search_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/url"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSortNames(t *testing.T) {
|
||||
index.Lock()
|
||||
defer index.Unlock()
|
||||
for _, s := range []string{"Alex", "Berta", "Chris", "2015-06-14", "2023-09-26"} {
|
||||
index.titles[s] = s
|
||||
}
|
||||
terms := []string{"Z"}
|
||||
fn := sortNames(terms)
|
||||
assert.Equal(t, 1, fn("Berta", "Alex"), "B is after A")
|
||||
assert.Equal(t, -1, fn("Alex", "Berta"), "A is before B")
|
||||
assert.Equal(t, 0, fn("Berta", "Berta"), "B and B are equal")
|
||||
assert.Equal(t, -1, fn("2023-09-26", "Alex"), "numbers before letters")
|
||||
assert.Equal(t, 1, fn("Alex", "2023-09-26"), "numbers after letters")
|
||||
assert.Equal(t, -1, fn("2023-09-26", "2015-06-14"), "higher numbers before lower numbers")
|
||||
assert.Equal(t, 1, fn("2015-06-14", "2023-09-26"), "lower numbers after higher numbers")
|
||||
|
||||
names := []string{"Berta", "Chris", "Alex"}
|
||||
slices.SortFunc(names, sortNames(terms))
|
||||
assert.True(t, slices.IsSorted(names), fmt.Sprintf("Sorted: %v", names))
|
||||
}
|
||||
|
||||
func TestPrependMatches(t *testing.T) {
|
||||
index.Lock()
|
||||
for _, s := range []string{"Alex", "Berta", "Chris"} {
|
||||
index.titles[s] = s
|
||||
}
|
||||
index.Unlock()
|
||||
r := []string{"Berta", "Chris"} // does not prepend
|
||||
u := []string{"Alex", "Berta", "Chris"} // does prepend
|
||||
v, _ := prependQueryPage(r, "", "Alex")
|
||||
assert.Equal(t, u, v, "prepend q")
|
||||
v, _ = prependQueryPage(r, "", "lex")
|
||||
assert.Equal(t, r, v, "exact matches only")
|
||||
v, _ = prependQueryPage(r, "", "#Alex")
|
||||
assert.Equal(t, u, v, "prepend hashtag")
|
||||
v, _ = prependQueryPage(r, "", "#Alex #Berta")
|
||||
assert.Equal(t, r, v, "do not prepend two hashtags")
|
||||
v, _ = prependQueryPage(r, "", "#alex")
|
||||
assert.Equal(t, r, v, "do not ignore case")
|
||||
v, _ = prependQueryPage(u, "", "Alex")
|
||||
assert.Equal(t, u, v, "do not prepend q twice")
|
||||
v, _ = prependQueryPage([]string{"Berta", "Alex", "Chris"}, "", "Alex")
|
||||
assert.Equal(t, u, v, "sort q to the front")
|
||||
v, _ = prependQueryPage([]string{"Berta", "Chris", "Alex"}, "", "Alex")
|
||||
assert.Equal(t, u, v, "sort q to the front")
|
||||
}
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
// working in the main directory
|
||||
index.reset()
|
||||
index.load()
|
||||
|
||||
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 TestSearchFilter(t *testing.T) {
|
||||
names := []string{"a", "public/b", "secret/c"}
|
||||
|
||||
f := filterPath(names, "", "")
|
||||
assert.Equal(t, names, f)
|
||||
|
||||
f = filterPath(names, "public/", "")
|
||||
assert.Equal(t, []string{"public/b"}, f)
|
||||
|
||||
f = filterPath(names, "secret/", "")
|
||||
assert.Equal(t, []string{"secret/c"}, f)
|
||||
|
||||
// critically, this no longer returns c
|
||||
f = filterPath(names, "", "^secret/")
|
||||
assert.Equal(t, []string{"a", "public/b"}, f)
|
||||
|
||||
// unchanged
|
||||
f = filterPath(names, "public/", "^secret/")
|
||||
assert.Equal(t, []string{"public/b"}, f)
|
||||
|
||||
// unchanged
|
||||
f = filterPath(names, "secret/", "^secret/")
|
||||
assert.Equal(t, []string{"secret/c"}, f)
|
||||
|
||||
}
|
||||
|
||||
func TestSearchFilterLong(t *testing.T) {
|
||||
cleanup(t, "testdata/filter")
|
||||
p := &Page{Name: "testdata/filter/one", Body: []byte(`# One
|
||||
|
||||
One day, I heard you say
|
||||
Just one more day and I'd know
|
||||
But that was last spring`)}
|
||||
p.save()
|
||||
p = &Page{Name: "testdata/filter/public/two", Body: []byte(`# Two
|
||||
Oh, the two of us
|
||||
Have often seen this forest
|
||||
But this bird is new`)}
|
||||
p.save()
|
||||
p = &Page{Name: "testdata/filter/secret/three", Body: []byte(`# Three
|
||||
Three years have gone by
|
||||
And we're good, we live, we breathe
|
||||
But we don't say it`)}
|
||||
p.save()
|
||||
|
||||
// normal search works
|
||||
items, _ := search("spring", "testdata/", "", 1, false)
|
||||
assert.Equal(t, len(items), 1)
|
||||
assert.Equal(t, "One", items[0].Title)
|
||||
|
||||
// not found because it's in /secret and we start at /
|
||||
items, _ = search("year", "testdata/", "^testdata/filter/secret/", 1, false)
|
||||
assert.Equal(t, 0, len(items))
|
||||
|
||||
// only found two because the third one is in /secret and we start at /
|
||||
items, _ = search("but", "testdata/", "^testdata/filter/secret/", 1, false)
|
||||
assert.Equal(t, 2, len(items))
|
||||
assert.Equal(t, "One", items[0].Title)
|
||||
assert.Equal(t, "Two", items[1].Title)
|
||||
|
||||
// starting in the public/ directory, we find only one page
|
||||
items, _ = search("but", "testdata/filter/public/", "^testdata/filter/secret/", 1, false)
|
||||
assert.Equal(t, 1, len(items))
|
||||
assert.Equal(t, "Two", items[0].Title)
|
||||
|
||||
// starting in the secret/ directory, we find only one page
|
||||
items, _ = search("but", "testdata/filter/secret/", "^testdata/filter/secret/", 1, false)
|
||||
assert.Equal(t, 1, len(items))
|
||||
assert.Contains(t, "Three", items[0].Title)
|
||||
}
|
||||
|
||||
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) {
|
||||
// working in the main directory
|
||||
index.reset()
|
||||
index.load()
|
||||
|
||||
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)
|
||||
}
|
||||
102
snippets.go
Normal file
102
snippets.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
re, err := re(q)
|
||||
// If the compilation didn't work, truncate and return
|
||||
if err != nil {
|
||||
if len(s) > 400 {
|
||||
s = s[0:400] + " …"
|
||||
}
|
||||
return s
|
||||
}
|
||||
// Short cut for short pages
|
||||
if len(s) <= snippetlen {
|
||||
return highlight(q, re, s)
|
||||
}
|
||||
// show a snippet from the beginning of the document
|
||||
j := strings.LastIndex(s[:snippetlen], " ")
|
||||
if j == -1 {
|
||||
// OK, look for a longer word
|
||||
j = strings.Index(s, " ")
|
||||
if j == -1 {
|
||||
// Or just truncate the body.
|
||||
if len(s) > 400 {
|
||||
s = s[0:400] + " …"
|
||||
}
|
||||
return highlight(q, re, s)
|
||||
}
|
||||
}
|
||||
t := s[0:j]
|
||||
res := t + " …"
|
||||
s = s[j:] // avoid rematching
|
||||
jsnippet := 0
|
||||
for jsnippet < maxsnippets {
|
||||
m := re.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
break
|
||||
}
|
||||
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
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
start := strings.Index(s[from:], " ")
|
||||
if start == -1 {
|
||||
start = 0
|
||||
} else {
|
||||
start += from
|
||||
}
|
||||
to := j + wl + snippetlen/2
|
||||
if to > len(s) {
|
||||
to = len(s)
|
||||
}
|
||||
end := strings.LastIndex(s[:to], " ")
|
||||
if end == -1 || end <= j+wl {
|
||||
// OK, look for a longer word
|
||||
end = strings.Index(s[to:], " ")
|
||||
if end == -1 {
|
||||
end = len(s)
|
||||
} else {
|
||||
end += to
|
||||
}
|
||||
}
|
||||
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, re, res)
|
||||
}
|
||||
45
snippets_test.go
Normal file
45
snippets_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSnippets(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.`
|
||||
|
||||
h := `We are immersed in a sea of dead people. <b>All</b> the dead that have gone before us, silent now, just … to ask ourselves – or them! – what it was <b>all</b> about. Instead we drown ourselves in no<b>is</b>e. … surrounded by false friends claiming that <b>all</b> <b>is</b> <b>well</b>. And look at us! Yes, we are <b>well</b>. …`
|
||||
|
||||
q := "title:all is well"
|
||||
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))
|
||||
|
||||
}
|
||||
30
static.html
Normal file
30
static.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{.Language}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
|
||||
body { hyphens: auto; }
|
||||
header a { margin-right: 1ch; }
|
||||
form { display: inline-block; }
|
||||
input#search { width: 12ch; }
|
||||
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
|
||||
footer { border-top: 1px solid #888 }
|
||||
img { max-width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="main">
|
||||
<h1>{{.Title}}</h1>
|
||||
{{.Html}}
|
||||
</main>
|
||||
<footer>
|
||||
<address>
|
||||
Comments? Send mail to Your Name <<a href="mailto:you@example.org">you@example.org</a>>
|
||||
</address>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
268
static_cmd.go
Normal file
268
static_cmd.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/google/subcommands"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type staticCmd struct {
|
||||
jobs int
|
||||
}
|
||||
|
||||
func (cmd *staticCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.IntVar(&cmd.jobs, "jobs", 2, "how many jobs to use")
|
||||
}
|
||||
|
||||
func (*staticCmd) Name() string { return "static" }
|
||||
func (*staticCmd) Synopsis() string { return "generate static HTML files for all pages" }
|
||||
func (*staticCmd) Usage() string {
|
||||
return `static [-jobs n] <dir name>:
|
||||
Create static copies in the given directory. Per default, two jobs
|
||||
are used to read and write files, but more can be assigned.
|
||||
`
|
||||
}
|
||||
|
||||
func (cmd *staticCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
args := f.Args()
|
||||
if len(args) != 1 {
|
||||
fmt.Fprintln(os.Stderr, "Exactly one target directory is required")
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
dir := filepath.Clean(args[0])
|
||||
return staticCli(dir, cmd.jobs, false)
|
||||
}
|
||||
|
||||
type args struct {
|
||||
source, target string
|
||||
info fs.FileInfo
|
||||
}
|
||||
|
||||
// staticCli generates a static site in the designated directory. The quiet flag is used to suppress output when running
|
||||
// tests.
|
||||
func staticCli(dir string, jobs int, quiet bool) subcommands.ExitStatus {
|
||||
index.load()
|
||||
index.RLock()
|
||||
defer index.RUnlock()
|
||||
loadLanguages()
|
||||
loadTemplates()
|
||||
tasks := make(chan args)
|
||||
results := make(chan error)
|
||||
done := make(chan bool)
|
||||
stop := make(chan error)
|
||||
for i := 0; i < jobs; i++ {
|
||||
go staticWorker(tasks, results, done)
|
||||
}
|
||||
go staticWalk(dir, tasks, stop)
|
||||
go staticWatch(jobs, results, done)
|
||||
n, err := staticProgressIndicator(results, stop, quiet)
|
||||
if !quiet {
|
||||
fmt.Printf("\r%d files processed\n", n)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
// staticWalk walks the directory tree. Any directory it finds, it recreates in the destination directory. Any file it
|
||||
// finds, it puts into the tasks channel for the staticWorker. When the directory walk is finished, the tasks channel is
|
||||
// closed. If there's an error on the stop channel, the walk returns that error.
|
||||
func staticWalk (dir string, tasks chan(args), stop chan(error)) {
|
||||
// The error returned here is what's in the stop channel but at the very end, a worker might return an error
|
||||
// even though the walk is already done. This is why we cannot rely on the return value of the walk.
|
||||
filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case err := <- stop:
|
||||
return err
|
||||
default:
|
||||
base := filepath.Base(path)
|
||||
// skip hidden directories and files
|
||||
if path != "." && strings.HasPrefix(base, ".") {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// skip backup files, avoid recursion
|
||||
if strings.HasSuffix(path, "~") || strings.HasPrefix(path, dir) {
|
||||
return nil
|
||||
}
|
||||
// recreate subdirectories
|
||||
target := filepath.Join(dir, path)
|
||||
if info.IsDir() {
|
||||
return os.Mkdir(target, 0755)
|
||||
}
|
||||
// do the task if the target file doesn't exist or if the source file is newer
|
||||
other, err := os.Stat(target)
|
||||
if err != nil || info.ModTime().After(other.ModTime()) {
|
||||
tasks <- args{ source: path, target: target, info: info }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
})
|
||||
close(tasks)
|
||||
}
|
||||
|
||||
// staticWatch counts the values coming out of the done channel. When the count matches the number of jobs started, we
|
||||
// know that all the tasks have been processed and the results channel is closed.
|
||||
func staticWatch(jobs int, results chan(error), done chan(bool)) {
|
||||
for i := 0; i < jobs; i++ {
|
||||
<- done
|
||||
}
|
||||
close(results)
|
||||
}
|
||||
|
||||
// staticWorker takes arguments off the tasks channel (the file to process) and put results in the results channel (any
|
||||
// errors encountered); when they're done they send true on the done channel.
|
||||
func staticWorker(tasks chan(args), results chan(error), done chan(bool)) {
|
||||
task, ok := <- tasks
|
||||
for ok {
|
||||
results <- staticFile(task.source, task.target, task.info)
|
||||
task, ok = <- tasks
|
||||
}
|
||||
done <- true
|
||||
}
|
||||
|
||||
// staticProgressIndicator watches the results channel and does a countdown. If the result channel reports an error,
|
||||
// that is put into the stop channel so that staticWalk stops adding to the tasks channel.
|
||||
func staticProgressIndicator(results chan(error), stop chan(error), quiet bool) (int, error) {
|
||||
n := 0
|
||||
t := time.Now()
|
||||
var err error
|
||||
for result := range results {
|
||||
if result != nil {
|
||||
err := result
|
||||
// this stops the walker from adding more tasks
|
||||
stop <- err
|
||||
} else {
|
||||
n++
|
||||
if !quiet && n % 13 == 0 {
|
||||
if time.Since(t) > time.Second {
|
||||
fmt.Printf("\r%d", n)
|
||||
t = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// 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(source, target string, info fs.FileInfo) error {
|
||||
// render pages
|
||||
if strings.HasSuffix(source, ".md") {
|
||||
p, err := staticPage(source[:len(source)-3], target[:len(target)-3] + ".html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return staticFeed(source[:len(source)-3], target[:len(target)-3] + ".rss", p, info.ModTime())
|
||||
}
|
||||
// remaining files are linked unless this is a template
|
||||
if slices.Contains(templateFiles, filepath.Base(source)) {
|
||||
return nil
|
||||
}
|
||||
return os.Link(source, target)
|
||||
}
|
||||
|
||||
// staticPage takes the filename of a page (ending in ".md") and generates a static HTML page.
|
||||
func staticPage(source, target string) (*Page, error) {
|
||||
p, err := loadPage(filepath.ToSlash(source))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", source, err)
|
||||
return nil, 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.Hashtags = *hashtags
|
||||
return p, write(p, target, "", "static.html")
|
||||
}
|
||||
|
||||
// staticFeed writes a .rss file for a page, but only if it's an index page or a page that might be used as a hashtag
|
||||
func staticFeed(source, target string, p *Page, ti time.Time) error {
|
||||
// render feed, maybe
|
||||
base := filepath.Base(source)
|
||||
_, ok := index.token["#"+strings.ToLower(base)]
|
||||
if base == "index" || ok {
|
||||
f := feed(p, ti)
|
||||
if len(f.Items) > 0 {
|
||||
return write(f, target, `<?xml version="1.0" encoding="UTF-8"?>`, "feed.html")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// write a page or feed with an appropriate template to a specific destination, overwriting it.
|
||||
func write(data any, path, prefix, templateFile string) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot create %s: %s\n", path, err)
|
||||
return err
|
||||
}
|
||||
_, err = file.Write([]byte(prefix))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot write prefix %s: %s\n", path, err)
|
||||
return err
|
||||
}
|
||||
templates.RLock()
|
||||
defer templates.RUnlock()
|
||||
err = templates.template[templateFile].Execute(file, data)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", templateFile, path, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
48
static_cmd_test.go
Normal file
48
static_cmd_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/google/subcommands"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStaticCmd(t *testing.T) {
|
||||
cleanup(t, "testdata/static")
|
||||
s := staticCli("testdata/static", 2, 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")
|
||||
}
|
||||
|
||||
func TestFeedStaticCmd(t *testing.T) {
|
||||
cleanup(t, "testdata/static-feed")
|
||||
cleanup(t, "testdata/static-feed-out")
|
||||
p := &Page{Name: "testdata/static-feed/Haiku", Body: []byte("# Haiku\n")}
|
||||
p.save()
|
||||
h := &Page{Name: "testdata/static-feed/2024-03-07-poem",
|
||||
Body: []byte(`# Rain
|
||||
I cannot hear you
|
||||
The birds outside are singing
|
||||
And the cars so loud
|
||||
|
||||
#Haiku
|
||||
`)}
|
||||
h.save()
|
||||
h.notify()
|
||||
wd, err := os.Getwd()
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, os.Chdir("testdata/static-feed"))
|
||||
s := staticCli("../static-feed-out/", 2, true)
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
assert.NoError(t, os.Chdir(wd))
|
||||
assert.FileExists(t, "testdata/static-feed-out/2024-03-07-poem.html")
|
||||
assert.FileExists(t, "testdata/static-feed-out/Haiku.html")
|
||||
b, err := os.ReadFile("testdata/static-feed-out/Haiku.rss")
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, string(b), "<channel>")
|
||||
}
|
||||
114
templates.go
Normal file
114
templates.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// templateFiles are the various HTML template files used. These files must exist in the root directory for Oddmu to be
|
||||
// able to generate HTML output. This always requires a template.
|
||||
var templateFiles = []string{"edit.html", "add.html", "view.html",
|
||||
"diff.html", "search.html", "static.html", "upload.html", "feed.html"}
|
||||
|
||||
// templateStore controls access to map of parsed HTML templates. Make sure to lock and unlock as appropriate. See
|
||||
// renderTemplate and loadTemplates.
|
||||
type templateStore struct {
|
||||
sync.RWMutex
|
||||
|
||||
// template is a map of parsed HTML templates. The key is their path name. By default, the map only contains
|
||||
// top-level templates like "view.html". Subdirectories may contain their own templates which override the
|
||||
// templates in the root directory. If so, they are paths like "dir/view.html", not filepaths. Use
|
||||
// filepath.ToSlash() if necessary.
|
||||
template map[string]*template.Template
|
||||
}
|
||||
|
||||
var templates templateStore
|
||||
|
||||
// loadTemplates loads the templates. If templates have already been loaded, return immediately.
|
||||
func loadTemplates() {
|
||||
if templates.template != nil {
|
||||
return
|
||||
}
|
||||
templates.Lock()
|
||||
defer templates.Unlock()
|
||||
// walk the directory, load templates and add directories
|
||||
templates.template = make(map[string]*template.Template)
|
||||
filepath.Walk(".", loadTemplate)
|
||||
log.Println(len(templates.template), "templates loaded")
|
||||
}
|
||||
|
||||
// loadTemplate is used to walk the directory. It loads all the template files it finds, including the ones in
|
||||
// subdirectories. This is called with templates already locked.
|
||||
func loadTemplate(fp string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(fp, ".html") &&
|
||||
slices.Contains(templateFiles, filepath.Base(fp)) {
|
||||
t, err := template.ParseFiles(fp)
|
||||
if err != nil {
|
||||
log.Println("Cannot parse template:", fp, err)
|
||||
// ignore error
|
||||
} else {
|
||||
// log.Println("Parse template:", path)
|
||||
templates.template[filepath.ToSlash(fp)] = t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateTemplate checks whether this is a valid template file and if so, reloads it.
|
||||
func updateTemplate(fp string) {
|
||||
if strings.HasSuffix(fp, ".html") &&
|
||||
slices.Contains(templateFiles, filepath.Base(fp)) {
|
||||
t, err := template.ParseFiles(fp)
|
||||
if err != nil {
|
||||
log.Println("Template:", fp, err)
|
||||
} else {
|
||||
templates.Lock()
|
||||
defer templates.Unlock()
|
||||
templates.template[filepath.ToSlash(fp)] = t
|
||||
log.Println("Parse template:", fp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removeTemplate removes a template unless it's a root template because that would result in the site being unusable.
|
||||
func removeTemplate(fp string) {
|
||||
if slices.Contains(templateFiles, filepath.Base(fp)) &&
|
||||
filepath.Dir(fp) != "." {
|
||||
templates.Lock()
|
||||
defer templates.Unlock()
|
||||
delete(templates.template, filepath.ToSlash(fp))
|
||||
log.Println("Discard template:", fp)
|
||||
}
|
||||
}
|
||||
|
||||
// renderTemplate is the helper that is used to render the templates with data.
|
||||
// A template in the same directory is preferred, if it exists.
|
||||
func renderTemplate(w http.ResponseWriter, dir, tmpl string, data any) {
|
||||
loadTemplates()
|
||||
base := tmpl + ".html"
|
||||
templates.RLock()
|
||||
defer templates.RUnlock()
|
||||
t := templates.template[path.Join(dir, base)]
|
||||
if t == nil {
|
||||
t = templates.template[base]
|
||||
}
|
||||
if t == nil {
|
||||
log.Println("Template not found:", base)
|
||||
http.Error(w, "Template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err := t.Execute(w, data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
50
templates_test.go
Normal file
50
templates_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"mime/multipart"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTemplates(t *testing.T) {
|
||||
cleanup(t, "testdata/templates")
|
||||
// save a file to create the directory
|
||||
p := &Page{Name: "testdata/templates/snow", Body: []byte(`# Snow
|
||||
|
||||
A blob on the grass
|
||||
Covered in needles and dust
|
||||
Memories of cold
|
||||
`)}
|
||||
p.save()
|
||||
assert.Contains(t,
|
||||
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/snow", nil),
|
||||
"Skip navigation")
|
||||
// save a new view handler
|
||||
html := "<body><h1>{{.Title}}</h1>{{.Html}}"
|
||||
form := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(form)
|
||||
field, err := writer.CreateFormField("name")
|
||||
assert.NoError(t, err)
|
||||
field.Write([]byte("view.html"))
|
||||
file, err := writer.CreateFormFile("file", "test.html")
|
||||
assert.NoError(t, err)
|
||||
n, err := file.Write([]byte(html))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, len(html), n)
|
||||
writer.Close()
|
||||
HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/testdata/templates/", writer.FormDataContentType(), form)
|
||||
assert.FileExists(t, "view.html", "original view.html still exists")
|
||||
assert.FileExists(t, "testdata/templates/view.html", "new view.html also exists")
|
||||
assert.Contains(t,
|
||||
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/view.html", nil),
|
||||
html)
|
||||
// verify that it works
|
||||
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/snow", nil)
|
||||
assert.Contains(t, body, "<h1>Snow</h1>")
|
||||
assert.NotContains(t, body, "Skip")
|
||||
// verify that the top level still uses the old template
|
||||
assert.Contains(t,
|
||||
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index", nil),
|
||||
"Skip navigation")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user