Compare commits
272 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3313e17efa | ||
|
|
f8bba997bf | ||
|
|
4cf4d1695f | ||
|
|
1b5abe4de1 | ||
|
|
5dfbbdb775 | ||
|
|
7e1948c93f | ||
|
|
3718f6ec7c | ||
|
|
ce108bc07d | ||
|
|
da84a76e79 | ||
|
|
d679eb4661 | ||
|
|
0f7525a23c | ||
|
|
727280147a | ||
|
|
a3e8654c5b | ||
|
|
a4cc67cd74 | ||
|
|
4c5f385afd | ||
|
|
d70d8aa990 | ||
|
|
380a8c321a | ||
|
|
41733c50bd | ||
|
|
a9ee700aad | ||
|
|
ea7d60dd72 | ||
|
|
00bd7e1f78 | ||
|
|
719de9b530 | ||
|
|
7d5636486d | ||
|
|
922181ee93 | ||
|
|
522640f8df | ||
|
|
4bfbb6a2d7 | ||
|
|
8f9c5d3677 | ||
|
|
f1d4310ec1 | ||
|
|
33a477cf36 | ||
|
|
a0ec4f5fbf | ||
|
|
9ef08fb42d | ||
|
|
0b132d33fb | ||
|
|
326ace8e13 | ||
|
|
e0a0385656 | ||
|
|
d85c12bae5 | ||
|
|
d1bf1f76eb | ||
|
|
2702b4da63 | ||
|
|
640dd3d972 | ||
|
|
a0945b1210 | ||
|
|
96820305f2 | ||
|
|
5547cb153d | ||
|
|
b691ea04ce | ||
|
|
b43f2836c1 | ||
|
|
afe2f0c9e2 | ||
|
|
210615efa2 | ||
|
|
7ad8a06f66 | ||
|
|
5f592acc55 | ||
|
|
4629f39e99 | ||
|
|
5ed9e6d9ef | ||
|
|
b41acf1f57 | ||
|
|
6c2a3c9745 | ||
|
|
13a1019c9d | ||
|
|
1a77fbbb8c | ||
|
|
103a3a0e7d | ||
|
|
5d8eaef6d7 | ||
|
|
d0be765935 | ||
|
|
fe4fd09cee | ||
|
|
c2619a6b82 | ||
|
|
353d462bbe | ||
|
|
eae42c310d | ||
|
|
6b8d9addc5 | ||
|
|
b540b94477 | ||
|
|
2dbcb0dc27 | ||
|
|
de94ca4967 | ||
|
|
dcf823522a | ||
|
|
b7be7b0b3a | ||
|
|
96d7401f5f | ||
|
|
9a458a78a6 | ||
|
|
3c32e40e59 | ||
|
|
7b0aec0a2e | ||
|
|
a9eeccb9b9 | ||
|
|
e03d17f9a1 | ||
|
|
dda0cf70c5 | ||
|
|
cb2431115c | ||
|
|
a61d9c673c | ||
|
|
dc1d9f1ffb | ||
|
|
19bc10464d | ||
|
|
7140c79b63 | ||
|
|
bdb5bfc6ec | ||
|
|
fed4472ae3 | ||
|
|
b29042a52c | ||
|
|
2dab26dafa | ||
|
|
62c568414e | ||
|
|
40dbbf5376 | ||
|
|
4e6adec81a | ||
|
|
9b4b225525 | ||
|
|
0df2399c5b | ||
|
|
b286c16ac2 | ||
|
|
474c69d6d2 | ||
|
|
dea9d644c5 | ||
|
|
9551db6719 | ||
|
|
d6f2599453 | ||
|
|
292b03908b | ||
|
|
409da8b84e | ||
|
|
2e3a1dac85 | ||
|
|
fd6889cea3 | ||
|
|
97df77d158 | ||
|
|
ab848247c9 | ||
|
|
8850b50624 | ||
|
|
ebe618ed1f | ||
|
|
59b5da086d | ||
|
|
56985c1093 | ||
|
|
7de805c00f | ||
|
|
95a802cdf0 | ||
|
|
d2891da3e1 | ||
|
|
ade626a488 | ||
|
|
33642140ba | ||
|
|
c89376bad8 | ||
|
|
b18abeba92 | ||
|
|
1cd63103b6 | ||
|
|
7bd47d0195 | ||
|
|
1d005442f5 | ||
|
|
6c25ef9085 | ||
|
|
5f751cca07 | ||
|
|
2381b6abfe | ||
|
|
16c43371d9 | ||
|
|
738948f752 | ||
|
|
4831e4c7af | ||
|
|
945cdc934c | ||
|
|
55879806a3 | ||
|
|
18ec5b669e | ||
|
|
aaff38a61c | ||
|
|
0b9dc5c2a6 | ||
|
|
482a81975c | ||
|
|
8b3fd9b240 | ||
|
|
4ede2783c5 | ||
|
|
caa1bcf8bb | ||
|
|
9bcae9f2b2 | ||
|
|
0638446cd3 | ||
|
|
f1342c0d75 | ||
|
|
2c5b609319 | ||
|
|
9275c9d3f3 | ||
|
|
c1946d8849 | ||
|
|
e0f2868d34 | ||
|
|
f3437d93c8 | ||
|
|
7fc48f21fc | ||
|
|
dbaf87404b | ||
|
|
d10518bbf6 | ||
|
|
0e5fd60b9d | ||
|
|
b1cdb1e279 | ||
|
|
4a9cbfd1eb | ||
|
|
0c3e46e51b | ||
|
|
ac4c4d665c | ||
|
|
e577b79d67 | ||
|
|
2c8e21e6cf | ||
|
|
9f8a6299cc | ||
|
|
ba4d7b9d26 | ||
|
|
39ef4f9a2a | ||
|
|
3ff57d1d03 | ||
|
|
827c9a0005 | ||
|
|
d6946b9e31 | ||
|
|
06caee907f | ||
|
|
25fe9a870d | ||
|
|
b7b8e26e3f | ||
|
|
81e67b419a | ||
|
|
a4c9edd0ca | ||
|
|
28001f034e | ||
|
|
dde770e3e6 | ||
|
|
cff7dafcea | ||
|
|
5b829f1d82 | ||
|
|
d44c4484de | ||
|
|
a17a0fea83 | ||
|
|
e38daba7ad | ||
|
|
df1f94eae4 | ||
|
|
684c53aa8c | ||
|
|
66c3c1570d | ||
|
|
2b6ee9c597 | ||
|
|
322b0603fb | ||
|
|
ba54369096 | ||
|
|
6168b4acf1 | ||
|
|
fba6ed4141 | ||
|
|
7a98d29c74 | ||
|
|
9713c18b6b | ||
|
|
c0495fbfcc | ||
|
|
3177760f2b | ||
|
|
ee8bc742a8 | ||
|
|
56c1a23f51 | ||
|
|
8c52e1efee | ||
|
|
ee9602c745 | ||
|
|
5e2c20c559 | ||
|
|
2b14fa8de1 | ||
|
|
afba9b597a | ||
|
|
e4d0543afc | ||
|
|
901ceed65c | ||
|
|
ccc7703836 | ||
|
|
787882cb80 | ||
|
|
4b9038c00b | ||
|
|
79e79c6efd | ||
|
|
b5093b0b76 | ||
|
|
88353c8ad6 | ||
|
|
4dd96e445f | ||
|
|
913f85b440 | ||
|
|
9e99e8da11 | ||
|
|
cbf7ae50d3 | ||
|
|
b7d524880f | ||
|
|
38ab54b8c3 | ||
|
|
ac1391e64a | ||
|
|
feb53ec52b | ||
|
|
3cdea90f39 | ||
|
|
33c8192bb3 | ||
|
|
4035d4253a | ||
|
|
afb5c22c40 | ||
|
|
a16304b26f | ||
|
|
e922af77c1 | ||
|
|
e1f4b7465e | ||
|
|
d9e0fa7f7f | ||
|
|
3e8d1fd161 | ||
|
|
2b8ffc69bd | ||
|
|
fe7c1c482f | ||
|
|
a0cd3bd621 | ||
|
|
5bc704b404 | ||
|
|
9a540ba022 | ||
|
|
1a98beccb4 | ||
|
|
fdba598c57 | ||
|
|
2d81e54f14 | ||
|
|
2e59f75647 | ||
|
|
c1ac0bbd16 | ||
|
|
2a1e6409c8 | ||
|
|
95e6589d2e | ||
|
|
dc36a177a9 | ||
|
|
b2e504ec06 | ||
|
|
ce6447fea4 | ||
|
|
72a3e20ee7 | ||
|
|
758f8e876f | ||
|
|
797293203f | ||
|
|
ba91d3e2f7 | ||
|
|
9136622ffc | ||
|
|
b581221445 | ||
|
|
57efc3e848 | ||
|
|
49b94074d1 | ||
|
|
79519f0a7d | ||
|
|
4f50bd54fe | ||
|
|
59deabd0d1 | ||
|
|
d7b4ea9002 | ||
|
|
9200739bf8 | ||
|
|
bbee985cd4 | ||
|
|
fe360f582d | ||
|
|
cfe3ae4ff6 | ||
|
|
03fbf2f97f | ||
|
|
9c0cfccf0e | ||
|
|
b677f4137a | ||
|
|
8ffe4a44b9 | ||
|
|
6cf59f5e2d | ||
|
|
8752278f29 | ||
|
|
4713c1e569 | ||
|
|
b90b36a5fa | ||
|
|
a11c69027d | ||
|
|
e2b570615e | ||
|
|
8702660484 | ||
|
|
a15eeca117 | ||
|
|
17e006322f | ||
|
|
23bb12d573 | ||
|
|
00a1cbb696 | ||
|
|
674fdf8ac3 | ||
|
|
2dcb1a5fe7 | ||
|
|
75ded17a03 | ||
|
|
cc01a3551d | ||
|
|
5bf3808039 | ||
|
|
9eb92cfa6e | ||
|
|
f8fbc23202 | ||
|
|
f9857768b7 | ||
|
|
0ab4bb5846 | ||
|
|
9d971871eb | ||
|
|
e69c1dc2f9 | ||
|
|
686c329615 | ||
|
|
cbf416d7bb | ||
|
|
ca3da43e53 | ||
|
|
c3fafb735f | ||
|
|
3dad3a3a4f | ||
|
|
78bae127b3 | ||
|
|
2769a096e5 | ||
|
|
a8b3e3d43a |
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
.git
|
||||||
30
.github/workflows/release.yaml
vendored
30
.github/workflows/release.yaml
vendored
@ -1,30 +0,0 @@
|
|||||||
on:
|
|
||||||
release:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
releases-matrix:
|
|
||||||
name: Release Go Binary
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
# build and publish in parallel a lot of binaries
|
|
||||||
# https://golang.org/doc/install/source#environment See supported Go OS/Arch pairs here
|
|
||||||
goos: [linux, darwin, freebsd, openbsd, windows]
|
|
||||||
goarch: ["386", amd64, arm64]
|
|
||||||
exclude:
|
|
||||||
- goarch: "386"
|
|
||||||
goos: darwin
|
|
||||||
- goarch: arm64
|
|
||||||
goos: freebsd
|
|
||||||
- goarch: arm64
|
|
||||||
goos: windows
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- uses: wangyoucao577/go-release-action@v1.17
|
|
||||||
with:
|
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
goos: ${{ matrix.goos }}
|
|
||||||
goarch: ${{ matrix.goarch }}
|
|
||||||
binary_name: "mycorrhiza"
|
|
||||||
extra_files: LICENSE README.md
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,2 +1,2 @@
|
|||||||
mycorrhiza
|
mycorrhiza
|
||||||
config.mk
|
.idea
|
||||||
85
Boilerplate.md
Normal file
85
Boilerplate.md
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
# Boilerplate in Mycorrhiza codebase
|
||||||
|
|
||||||
|
Being programmed by Go, mostly by Bouncepaw, the codebase contains a lot of boilerplate. This document is an attempt to describe how it is done.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
Mycorrhiza is arranged in quite many packages. They are thematic. For example, package `backlinks` has all things backlinks, including the storage, the views, and exported functions (not many, ideally). Such packages can be called modules, if you want.
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
Views are the biggest source of similar code. Before the transition from QTPL to Go's standard templates, this boilerplate energy was split differently, but was not instantly obvious. The current approach does not really introduce new boilerplate energy, but it does focus it, resulting in actual boilerplate. I hope you get the idea.
|
||||||
|
|
||||||
|
All related views are part of one module.
|
||||||
|
|
||||||
|
Views come in multiple parts.
|
||||||
|
|
||||||
|
The first part is the template itself. Call template files like that: `view_user_list.html`, prefixed with `view_`. The boilerplate is as follows.
|
||||||
|
|
||||||
|
```html
|
||||||
|
{{define "title"}}{{end}}
|
||||||
|
{{define "body"}}
|
||||||
|
{{end}}
|
||||||
|
```
|
||||||
|
|
||||||
|
More often than not, you will want to make template `title` a different template in the same file. See existing files for inspiration.
|
||||||
|
|
||||||
|
The code that makes those templates runnable lies in one file. This is the second part. It contains the following:.
|
||||||
|
|
||||||
|
The Russian translation is a `string` variable called `ruTranslation`; we currently have no other translations, but they are to be called like `frTranslation`, `eoTranslation`, et cetera.
|
||||||
|
|
||||||
|
|
||||||
|
```go
|
||||||
|
var (
|
||||||
|
ruTranslation = `
|
||||||
|
{{define "one thing"}}...{{end}}
|
||||||
|
{{define "other thing"}}...{{end}}
|
||||||
|
`
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Chains are collection of different language variants of the same template. Declare them and then assign them in a function, which you call somewhere (not just `init`!).
|
||||||
|
|
||||||
|
```go
|
||||||
|
var (
|
||||||
|
ruTranslation = `...`
|
||||||
|
chainStuff, chainAddStuff viewutil.Chain
|
||||||
|
)
|
||||||
|
|
||||||
|
func initViews() {
|
||||||
|
chainStuff = viewutil.CopyEnRuWith(fs, "view_stuff.html", ruTranslation)
|
||||||
|
chainAddStuff = viewutil.CopyEnRuWith(fs, "view_add_stuff.html", ruTranslation)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then every view has a runner and its own datatype.
|
||||||
|
|
||||||
|
```go
|
||||||
|
//...
|
||||||
|
|
||||||
|
type dataStuff struct {
|
||||||
|
*viewutil.BaseData
|
||||||
|
StuffName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewStuff(meta viewutil.Meta, stuffName string) {
|
||||||
|
viewutil.ExecutePage(meta, chainStuff, dataUserList{
|
||||||
|
BaseData: &viewutil.BaseData{},
|
||||||
|
StuffName: stuffName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sometimes, two datatypes of different views are the same, it is ok to just share one, but name it so that it mentions both.
|
||||||
|
|
||||||
|
Avoid any logic in those runners. Keep them as boilerplate as they are. You rarely need to fill the `BaseData` field. Do it if you need to. If you don't, `viewutil.ExecutePage` will do its best to guess. We name the fields with capital letters.
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
* Declared the chains?
|
||||||
|
* Assigned the chains?
|
||||||
|
* Assigned the chains, sure?
|
||||||
|
* Used the correct data type?
|
||||||
|
* `*viewutil.BaseData` is there?
|
||||||
|
* Used the correct chain?
|
||||||
@ -7,6 +7,9 @@ RUN go build -o /out/mycorrhiza .
|
|||||||
FROM alpine/git as app
|
FROM alpine/git as app
|
||||||
EXPOSE 1737
|
EXPOSE 1737
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
HEALTHCHECK CMD curl -Ns localhost:1737 || exit 1
|
||||||
|
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
RUN mkdir wiki
|
RUN mkdir wiki
|
||||||
COPY --from=build /out/mycorrhiza /usr/bin
|
COPY --from=build /out/mycorrhiza /usr/bin
|
||||||
|
|||||||
47
README.md
47
README.md
@ -2,52 +2,49 @@
|
|||||||
|
|
||||||
**Mycorrhiza Wiki** is a lightweight file-system wiki engine that uses Git for keeping history. [Main wiki](https://mycorrhiza.wiki)
|
**Mycorrhiza Wiki** is a lightweight file-system wiki engine that uses Git for keeping history. [Main wiki](https://mycorrhiza.wiki)
|
||||||
|
|
||||||
<img src="https://mycorrhiza.wiki/binary/release/1.9/screenshot" alt="A screenshot of mycorrhiza.wiki's home page in the Safari browser" width="700">
|
<img src="https://mycorrhiza.wiki/binary/release/1.15.1/screenshot" alt="A screenshot of mycorrhiza.wiki's home page in the Safari browser" width="700">
|
||||||
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* **No database required.** Everything is stored in plain files. It makes installation super easy, and you can modify the content directly by yourself.
|
* **No database used.** Everything is stored in plain files. It makes installation super easy, and you can modify the content directly by yourself.
|
||||||
* **Everything is hyphae.** A [hypha] is a unit of content such as a picture, video or a text article. Hyphae can [transclude][transclusion] and link each other resulting in a tight network of hypertext pages.
|
* **Everything is hyphae.** A hypha is a unit of content such as a picture, video or a text article. Hyphae can [transclude] and link each other, forming a tight network of hypertext pages.
|
||||||
* **Hyphae are authored in [Mycomarkup],** a markup language that's designed to be unambigious yet easy to use.
|
* **Hyphae are authored in [Mycomarkup],** a markup language that's designed to be unambiguous yet easy to use.
|
||||||
* **Categories.**
|
* **Categories** let you organize hyphae without any hierarchy restrictions, with all the benefits of a category system.
|
||||||
* **Nesting of hyphae** is also supported.
|
* **Nesting of hyphae** is also supported if you like hierarchies.
|
||||||
* **History of changes** for textual parts of hyphae. Every change is safely stored in [Git]. Web feeds for recent changes included!
|
* **History of changes.** Every change is safely stored in [Git]. Web feeds (RSS, Atom, JSON Feed) for recent changes included.
|
||||||
* **Keyboard-driven navigation.** Press `?` to see the list of shortcuts.
|
* **Keyboard-driven navigation.** Press `?` to see the list of shortcuts.
|
||||||
* **Support for [authorization].**
|
* **Support for [authorization].** Both plain username-password pairs and [Telegram]'s login widget are supported.
|
||||||
* **[Open Graph] support.**
|
* **[Open Graph] support.** The most relevant info about a hypha is made available through OG meta tags for consumption by other software.
|
||||||
* **Optional [Telegram] authentication.**
|
* **Interwiki support.**
|
||||||
|
|
||||||
[hypha]: https://mycorrhiza.wiki/hypha/feature/hypha
|
[transclude]: https://mycorrhiza.wiki/hypha/feature/transclusion
|
||||||
[transclusion]: https://mycorrhiza.wiki/hypha/feature/transclusion
|
|
||||||
[authorization]: https://mycorrhiza.wiki/hypha/feature/authorization
|
|
||||||
[Mycomarkup]: https://mycorrhiza.wiki/help/en/mycomarkup
|
[Mycomarkup]: https://mycorrhiza.wiki/help/en/mycomarkup
|
||||||
[Git]: https://mycorrhiza.wiki/hypha/integration/git
|
[Git]: https://mycorrhiza.wiki/hypha/integration/git
|
||||||
[Open Graph]: https://mycorrhiza.wiki/hypha/standard/opengraph
|
[authorization]: https://mycorrhiza.wiki/hypha/authorization
|
||||||
[Telegram]: https://mycorrhiza.wiki/help/en/telegram
|
[Telegram]: https://mycorrhiza.wiki/help/en/telegram
|
||||||
|
[Open Graph]: https://mycorrhiza.wiki/hypha/opengraph
|
||||||
|
|
||||||
Compare Mycorrhiza Wiki with other engines on [WikiMatrix](https://www.wikimatrix.org/show/mycorrhiza).
|
Compare Mycorrhiza Wiki with other engines on [WikiMatrix](https://www.wikimatrix.org/show/mycorrhiza).
|
||||||
|
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
See [the deployment guide](https://mycorrhiza.wiki/hypha/guide/deployment) on the wiki.
|
See [the deployment guide](https://mycorrhiza.wiki/hypha/deployment) on the wiki. Also, Mycorrhiza might be available in your repositories. See [Repology](https://repology.org/project/mycorrhiza/versions).
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing and community
|
||||||
|
|
||||||
* [GitHub](https://github.com/bouncepaw/mycorrhiza)
|
* [GitHub](https://github.com/bouncepaw/mycorrhiza)
|
||||||
* [SourceHut](https://sr.ht/~bouncepaw/mycorrhiza)
|
* [Fediverse @mycorrhiza@floss.social](https://floss.social/@mycorrhiza)
|
||||||
* [#mycorrhiza on irc.libera.chat](irc://irc.libera.chat/#mycorrhiza)
|
* Mirrors:
|
||||||
|
* [SourceHut](https://sr.ht/~bouncepaw/mycorrhiza)
|
||||||
|
* [Codeberg](https://codeberg.org/bouncepaw/mycorrhiza)
|
||||||
* [@mycorrhizadev (Russian) in Telegram](https://t.me/mycorrhizadev)
|
* [@mycorrhizadev (Russian) in Telegram](https://t.me/mycorrhizadev)
|
||||||
|
|
||||||
If you want to contribute with code, open a pull request on GitHub or send a
|
If you want to contribute with code, open a pull request on GitHub or send a patch to the [mailing list](https://lists.sr.ht/~bouncepaw/mycorrhiza-devel).
|
||||||
patch to the [mailing list]. If you want to report an issue, open an issue on
|
If you want to report an issue, open an issue on GitHub or contact us directly.
|
||||||
GitHub or contact us directly.
|
|
||||||
|
|
||||||
Consider supporting the development on [Boosty](https://boosty.to/bouncepaw).
|
Consider supporting the development on [Boosty](https://boosty.to/bouncepaw).
|
||||||
|
|
||||||
You can view the list of planned features on the [roadmap page].
|
Check out [Betula](https://betula.mycorrhiza.wiki) as well.
|
||||||
|
|
||||||
[mailing list]: https://lists.sr.ht/~bouncepaw/mycorrhiza-devel
|
|
||||||
[roadmap page]: https://mycorrhiza.wiki/hypha/release/roadmap
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
package cfg
|
|
||||||
|
|
||||||
// See https://mycorrhiza.wiki/hypha/configuration/header
|
|
||||||
import (
|
|
||||||
"github.com/bouncepaw/mycomarkup/v3"
|
|
||||||
"github.com/bouncepaw/mycomarkup/v3/blocks"
|
|
||||||
"github.com/bouncepaw/mycomarkup/v3/mycocontext"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HeaderLinks is a list off current header links. Feel free to iterate it directly but do not modify it by yourself. Call ParseHeaderLinks if you need to set new header links.
|
|
||||||
var HeaderLinks []HeaderLink
|
|
||||||
|
|
||||||
// SetDefaultHeaderLinks sets the header links to the default list of: home hypha, recent changes, hyphae list, random hypha.
|
|
||||||
func SetDefaultHeaderLinks() {
|
|
||||||
HeaderLinks = []HeaderLink{
|
|
||||||
{"/recent-changes", "Recent changes"},
|
|
||||||
{"/list", "All hyphae"},
|
|
||||||
{"/random", "Random"},
|
|
||||||
{"/help", "Help"},
|
|
||||||
{"/category", "Categories"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseHeaderLinks extracts all rocketlinks from the given text and saves them as header links.
|
|
||||||
func ParseHeaderLinks(text string) {
|
|
||||||
HeaderLinks = []HeaderLink{}
|
|
||||||
ctx, _ := mycocontext.ContextFromStringInput("", text)
|
|
||||||
// We call for side-effects
|
|
||||||
_ = mycomarkup.BlockTree(ctx, func(block blocks.Block) {
|
|
||||||
switch launchpad := block.(type) {
|
|
||||||
case blocks.LaunchPad:
|
|
||||||
for _, rocket := range launchpad.Rockets {
|
|
||||||
HeaderLinks = append(HeaderLinks, HeaderLink{
|
|
||||||
Href: rocket.Href(),
|
|
||||||
Display: rocket.Display(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// HeaderLink represents a header link. Header links are the links shown in the top gray bar.
|
|
||||||
type HeaderLink struct {
|
|
||||||
// Href is the URL of the link. It goes <a href="here">...</a>.
|
|
||||||
Href string
|
|
||||||
// Display is what is shown when the link is rendered. It goes <a href="...">here</a>.
|
|
||||||
Display string
|
|
||||||
}
|
|
||||||
104
flag.go
104
flag.go
@ -1,18 +1,21 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/cfg"
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
"github.com/bouncepaw/mycorrhiza/files"
|
"github.com/bouncepaw/mycorrhiza/internal/files"
|
||||||
"github.com/bouncepaw/mycorrhiza/user"
|
"github.com/bouncepaw/mycorrhiza/internal/user"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/version"
|
||||||
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -20,7 +23,7 @@ import (
|
|||||||
|
|
||||||
// printHelp prints the help message.
|
// printHelp prints the help message.
|
||||||
func printHelp() {
|
func printHelp() {
|
||||||
fmt.Fprintf(
|
_, _ = fmt.Fprintf(
|
||||||
flag.CommandLine.Output(),
|
flag.CommandLine.Output(),
|
||||||
"Usage: %s WIKI_PATH\n",
|
"Usage: %s WIKI_PATH\n",
|
||||||
os.Args[0],
|
os.Args[0],
|
||||||
@ -29,65 +32,94 @@ func printHelp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseCliArgs parses CLI options and sets several important global variables. Call it early.
|
// parseCliArgs parses CLI options and sets several important global variables. Call it early.
|
||||||
func parseCliArgs() {
|
func parseCliArgs() error {
|
||||||
var createAdminName string
|
var createAdminName string
|
||||||
|
var versionFlag bool
|
||||||
|
|
||||||
flag.StringVar(&cfg.ListenAddr, "listen-addr", "", "Address to listen on. For example, 127.0.0.1:1737 or /run/mycorrhiza.sock.")
|
flag.StringVar(&cfg.ListenAddr, "listen-addr", "", "Address to listen on. For example, 127.0.0.1:1737 or /run/mycorrhiza.sock.")
|
||||||
flag.StringVar(&createAdminName, "create-admin", "", "Create a new admin. The password will be prompted in the terminal.")
|
flag.StringVar(&createAdminName, "create-admin", "", "Create a new admin. The password will be prompted in the terminal.")
|
||||||
|
flag.BoolVar(&versionFlag, "version", false, "Print version information and exit.")
|
||||||
flag.Usage = printHelp
|
flag.Usage = printHelp
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
if versionFlag {
|
||||||
|
slog.Info("Running Mycorrhiza Wiki", "version", version.Long)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
args := flag.Args()
|
args := flag.Args()
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
log.Fatal("error: pass a wiki directory")
|
slog.Error("Pass a wiki directory")
|
||||||
|
return errors.New("wiki directory not passed")
|
||||||
}
|
}
|
||||||
|
|
||||||
wikiDir, err := filepath.Abs(args[0])
|
wikiDir, err := filepath.Abs(args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
slog.Error("Failed to take absolute filepath of wiki directory",
|
||||||
|
"path", args[0], "err", err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.WikiDir = wikiDir
|
cfg.WikiDir = wikiDir
|
||||||
|
|
||||||
if createAdminName != "" {
|
if createAdminName != "" {
|
||||||
createAdminCommand(createAdminName)
|
if err := createAdminCommand(createAdminName); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createAdminCommand(name string) {
|
func createAdminCommand(name string) error {
|
||||||
wr := log.Writer()
|
|
||||||
log.SetFlags(0)
|
|
||||||
|
|
||||||
if err := files.PrepareWikiRoot(); err != nil {
|
if err := files.PrepareWikiRoot(); err != nil {
|
||||||
log.Fatal("error: ", err)
|
slog.Error("Failed to prepare wiki root", "err", err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
cfg.UseAuth = true
|
cfg.UseAuth = true
|
||||||
cfg.AllowRegistration = true
|
cfg.AllowRegistration = true
|
||||||
|
|
||||||
log.SetOutput(io.Discard)
|
|
||||||
user.InitUserDatabase()
|
user.InitUserDatabase()
|
||||||
log.SetOutput(wr)
|
|
||||||
|
|
||||||
handle /*rug*/ := syscall.Stdin
|
password, err := askPass("Password")
|
||||||
if !term.IsTerminal(handle) {
|
|
||||||
log.Fatal("error: not a terminal")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Print("Password: ")
|
|
||||||
passwordBytes, err := term.ReadPassword(handle)
|
|
||||||
fmt.Print("\n")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("error: ", err)
|
slog.Error("Failed to prompt password", "err", err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
if err := user.Register(name, password, "admin", "local", true); err != nil {
|
||||||
password := string(passwordBytes)
|
slog.Error("Failed to register admin", "err", err)
|
||||||
|
return err
|
||||||
log.SetOutput(io.Discard)
|
|
||||||
err = user.Register(name, password, "admin", "local", true)
|
|
||||||
log.SetOutput(wr)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("error: ", err)
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func askPass(prompt string) (string, error) {
|
||||||
|
var password []byte
|
||||||
|
var err error
|
||||||
|
fd := int(os.Stdin.Fd())
|
||||||
|
|
||||||
|
if term.IsTerminal(fd) {
|
||||||
|
fmt.Printf("%s: ", prompt)
|
||||||
|
password, err = term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: Reading password from stdin.\n")
|
||||||
|
// TODO: the buffering messes up repeated calls to readPassword
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
if !scanner.Scan() {
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "", io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
password = scanner.Bytes()
|
||||||
|
|
||||||
|
if len(password) == 0 {
|
||||||
|
return "", fmt.Errorf("zero length password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(password), nil
|
||||||
}
|
}
|
||||||
|
|||||||
27
go.mod
27
go.mod
@ -1,29 +1,22 @@
|
|||||||
module github.com/bouncepaw/mycorrhiza
|
module github.com/bouncepaw/mycorrhiza
|
||||||
|
|
||||||
go 1.18
|
go 1.21
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bouncepaw/mycomarkup/v3 v3.6.3
|
git.sr.ht/~bouncepaw/mycomarkup/v5 v5.6.0
|
||||||
github.com/go-ini/ini v1.63.2
|
github.com/go-ini/ini v1.67.0
|
||||||
github.com/gorilla/feeds v1.1.1
|
github.com/gorilla/feeds v1.2.0
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/valyala/quicktemplate v1.7.0
|
golang.org/x/crypto v0.31.0
|
||||||
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa
|
golang.org/x/term v0.27.0
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
|
golang.org/x/text v0.21.0
|
||||||
golang.org/x/text v0.3.7
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/kr/pretty v0.2.1 // indirect
|
|
||||||
github.com/stretchr/testify v1.7.0 // indirect
|
github.com/stretchr/testify v1.7.0 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
golang.org/x/sys v0.0.0-20211109184856-51b60fd695b3 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Use this trick to test local Mycomarkup changes, replace the path with yours,
|
// Use this trick to test local Mycomarkup changes, replace the path with yours,
|
||||||
// but do not commit the change to the path:
|
// but do not commit the change to the path:
|
||||||
// replace github.com/bouncepaw/mycomarkup/v3 v3.6.3 => "/Users/bouncepaw/GolandProjects/mycomarkup"
|
// replace git.sr.ht/~bouncepaw/mycomarkup/v5 v5.6.0 => "/Users/bouncepaw/src/mycomarkup"
|
||||||
|
|
||||||
// Use this utility every time Mycomarkup gets a major update:
|
|
||||||
// https://github.com/marwan-at-work/mod
|
|
||||||
// Or maybe just ⌘⇧R every time, the utility is kinda weird.
|
|
||||||
|
|||||||
64
go.sum
64
go.sum
@ -1,52 +1,32 @@
|
|||||||
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
git.sr.ht/~bouncepaw/mycomarkup/v5 v5.6.0 h1:zAZwMF+6x8U/nunpqPRVYoDiqVUMBHI04PG8GsDrFOk=
|
||||||
github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
git.sr.ht/~bouncepaw/mycomarkup/v5 v5.6.0/go.mod h1:TCzFBqW11En4EjLfcQtJu8C/Ro7FIFR8vZ+nM9f6Q28=
|
||||||
github.com/bouncepaw/mycomarkup/v3 v3.6.3 h1:FQzzCxrHAEFBjPKFF/7R9gamyeU/8Cn+cFZEgngYtjE=
|
|
||||||
github.com/bouncepaw/mycomarkup/v3 v3.6.3/go.mod h1:BpiGUVsYCgRZCDxF0iIdc08LJokm/Ab36S/Hif0J6D0=
|
|
||||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-ini/ini v1.63.2 h1:kwN3umicd2HF3Tgvap4um1ZG52/WyKT9GGdPx0CJk6Y=
|
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||||
github.com/go-ini/ini v1.63.2/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc=
|
||||||
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
|
github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
|
||||||
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
|
||||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
github.com/valyala/fasthttp v1.30.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus=
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
github.com/valyala/quicktemplate v1.7.0 h1:LUPTJmlVcb46OOUY3IeD9DojFpAVbsG+5WFTcjMJzCM=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
github.com/valyala/quicktemplate v1.7.0/go.mod h1:sqKJnoaOF88V07vkO+9FL8fb9uZg/VPSJnLYn+LmLk8=
|
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa h1:idItI2DDfCokpg0N51B2VtiLdJ4vAuXC9fnCb2gACo4=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
||||||
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20211109184856-51b60fd695b3 h1:T6tyxxvHMj2L1R2kZg0uNMpS8ZhB9lRa9XRGTCSA65w=
|
|
||||||
golang.org/x/sys v0.0.0-20211109184856-51b60fd695b3/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
|
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
11
help/en.myco
11
help/en.myco
@ -1,11 +1,8 @@
|
|||||||
= Help
|
= Help
|
||||||
|
|
||||||
This is documentation for **Mycorrhiza Wiki** 1.9.
|
This is documentation for Mycorrhiza Wiki 1.15.1. Choose a topic from the list.
|
||||||
|
|
||||||
//Choose a topic from the list.//
|
The documentation is incomplete. If you want to contribute to the documentation, open a pull request or an issue on [[https://github.com/bouncepaw/mycorrhiza | GitHub]] or [[https://lists.sr.ht/~bouncepaw/mycorrhiza-devel | send a patch]].
|
||||||
|
|
||||||
Currently, the documentation does not cover everything there is to cover, but it will probably change in the future.
|
=> https://mycorrhiza.wiki | Official wiki
|
||||||
|
=> https://floss.social/@mycorrhiza | Fediverse
|
||||||
If you want to contribute to the documentation, open a pull request or an issue on [[https://github.com/bouncepaw/mycorrhiza | GitHub]].
|
|
||||||
|
|
||||||
=> https://mycorrhiza.wiki | Official wiki
|
|
||||||
|
|||||||
@ -7,3 +7,5 @@ Category names follow the same rules as hypha names: case-insensitive, spaces an
|
|||||||
|
|
||||||
To see the list of all categories, see:
|
To see the list of all categories, see:
|
||||||
=> /category
|
=> /category
|
||||||
|
|
||||||
|
Only trusted editors (and higher) can modify categories.
|
||||||
@ -7,10 +7,10 @@ The file is generated automatically when you create a new wiki:
|
|||||||
|
|
||||||
```
|
```
|
||||||
# Generate a new wiki
|
# Generate a new wiki
|
||||||
$ mycorrhiza bestWiki
|
$ mycorrhiza best-wiki
|
||||||
...
|
...
|
||||||
# See what's inside
|
# See what's inside
|
||||||
$ ls bestWiki
|
$ ls best-wiki
|
||||||
cache config.ini static wiki.git
|
cache config.ini static wiki.git
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ The file is written in the [[https://en.wikipedia.org/wiki/INI_file | .ini forma
|
|||||||
== Example configuration
|
== Example configuration
|
||||||
The auto-generated file is the best example (it has comments too).
|
The auto-generated file is the best example (it has comments too).
|
||||||
|
|
||||||
Here's another example:
|
Here's an example of a configuration file adapted from the default:
|
||||||
```ini
|
```ini
|
||||||
WikiName = My wiki
|
WikiName = My wiki
|
||||||
NaviTitleIcon = 🐑
|
NaviTitleIcon = 🐑
|
||||||
@ -30,12 +30,12 @@ UserHypha = u
|
|||||||
HeaderLinksHypha = header-links
|
HeaderLinksHypha = header-links
|
||||||
|
|
||||||
[Network]
|
[Network]
|
||||||
HTTPPort = 8080
|
ListenAddr = 0.0.0.0:8080
|
||||||
URL = https://wiki
|
URL = https://wiki
|
||||||
GeminiCertificatePath = /home/wiki/gemcerts
|
|
||||||
|
|
||||||
[Authorization]
|
[Authorization]
|
||||||
UseRegistration = true
|
UseAuth = true
|
||||||
|
AllowRegistration = true
|
||||||
```
|
```
|
||||||
|
|
||||||
== Fields
|
== Fields
|
||||||
@ -46,18 +46,27 @@ UseRegistration = true
|
|||||||
=== [Hyphae]
|
=== [Hyphae]
|
||||||
* `HomeHypha`: //string//. The name your home hypha has. **Default:** `home`.
|
* `HomeHypha`: //string//. The name your home hypha has. **Default:** `home`.
|
||||||
* `UserHypha`: //string//. The name of the hypha that is parent of all user hyphae. **Default:** `u`.
|
* `UserHypha`: //string//. The name of the hypha that is parent of all user hyphae. **Default:** `u`.
|
||||||
* `HeaderLinkHypha`: //string//. The name of the hypha where you can configure the header. There is no default.
|
* `HeaderLinkHypha`: //string//. The name of the hypha where you can configure the header. See [[/help/en/top_bar]]. There is no default.
|
||||||
|
|
||||||
=== [Network]
|
=== [Network]
|
||||||
* `HTTPPort`: //number//. What port is used for serving the web interface of Mycorrhiza. **Default:** `1737`.
|
* `ListenAddr`: //number//. What port is used for serving the web interface of Mycorrhiza. **Default:** `1737`.
|
||||||
* `URL`: //url//. What URL is used for Opengraph and Web feed in the web interface. There is no default and you really should set it to something.
|
* `URL`: //url//. What URL is used for Opengraph and Web feed in the web interface. There is no default and you really should set it to something.
|
||||||
|
|
||||||
=== [Authorization]
|
=== [Authorization]
|
||||||
* `UseRegistration`: //boolean//. Whether you want unregistered visitors to be able to register themselves using the web form. **Default:** `false`.
|
* `UseAuth`: //boolean//. Whether to enable authorization system. **Default:** `false`.
|
||||||
* `LimitRegistration`: //number//. There cannot be more registered users than this number. If the number is zero, there is no limit. Makes sense only when `UseRegistration` is `true`. **Default:** `0`.
|
* `AllowRegistration`: //boolean//. Whether you want unregistered visitors to be able to register themselves using the web form. **Default:** `false`.
|
||||||
|
* `RegistrationLimit`: //number//. There cannot be more registered users than this number. If the number is zero, there is no limit. Makes sense only when `UseRegistration` is `true`. **Default:** `0`.
|
||||||
|
* `Locked`: //boolean//. Whether the users have to authorize first to access the wiki. **Default:** `false`.
|
||||||
|
* `UseWhiteList`: //boolean//. Whether to use a whitelist to allow specific users in. **Default:** `false`.
|
||||||
|
* `WhiteList`: //list of strings//. Usernames of people to allow in, if `UseWhiteList` is turned on. **Default:** `[]`.
|
||||||
|
|
||||||
=== [CustomScripts]
|
=== [CustomScripts]
|
||||||
You can specify URLs of JavaScript files you want to load.
|
You can specify URLs of JavaScript files you want to load.
|
||||||
* `CommonScripts`: //list of url//. Comma-separated list of unquoted URLs to JS files to load on //all// pages.
|
* `CommonScripts`: //list of url//. Comma-separated list of unquoted URLs to JS files to load on //all// pages.
|
||||||
* `ViewScripts`: //list of urls//. Comma-separated list of unquoted URLs to JS files to load on //view// pages: `/hypha`, `/rev`.
|
* `ViewScripts`: //list of urls//. Comma-separated list of unquoted URLs to JS files to load on //view// pages: `/hypha`, `/rev`.
|
||||||
* `EditScripts`: //list of urls//. Comma-separated list of unquoted URLs to JS files to load on the `/edit` page.
|
* `EditScripts`: //list of urls//. Comma-separated list of unquoted URLs to JS files to load on the `/edit` page.
|
||||||
|
|
||||||
|
=== [Telegram]
|
||||||
|
You can set up Telegram-based authorization. You have to define both parameters.
|
||||||
|
* `TelegramBotToken`: //string// Token of your bot. There is no default.
|
||||||
|
* `TelegramBotName`: //string// Username of your bot, sans @. There is no default.
|
||||||
|
|||||||
20
help/en/file_structure.myco
Normal file
20
help/en/file_structure.myco
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
= File structure
|
||||||
|
//This article is intended for wiki administrators.//
|
||||||
|
|
||||||
|
Every Mycorrhiza wiki is stored in one directory. This document explaints the structure of this directory, what files can be there, and what you should do with them.
|
||||||
|
|
||||||
|
You can edit all of the files manually, if you want, just do your best to not break anything.
|
||||||
|
|
||||||
|
* `config.ini` is the [[/help/en/config_file | configuration file]]. It has comments in it, feel free to edit it.
|
||||||
|
* `wiki.git/` is the Git repository of the wiki, it has all hyphae in it. You can edit it directly, but do not forget to make Git commits with your changes and [[/reindex]] you wiki afterwards.
|
||||||
|
* `static` holds static data. You can access data there from your wiki with addresses like `/static/image.png`.
|
||||||
|
** `static/favicon.ico` is your wiki's favicon, accessed at [[/favicon.ico]] by browsers.
|
||||||
|
** `static/default.css` redefines the engine's default style, if exists. You probably don't need to use it.
|
||||||
|
** `static/custom.css` is loaded after the main style. If you want to make visual changes to your wiki, this is probably where you should do that.
|
||||||
|
** `static/robots.txt` redefines default `robots.txt` file.
|
||||||
|
* `categories.json` contains the information about all categories in your wiki.
|
||||||
|
* `users.json` stores users' information. The passwords are not stored, only their hashes are, this is safe. Their tokens are stored in `cache/tokens.json`.
|
||||||
|
* `interwiki.json` holds the interwiki configuration.
|
||||||
|
* `cache/` holds cached data. If you back up your wiki, you can omit this directory.
|
||||||
|
** `cache/tokens.json` holds users' tokens. By deleting specific tokens, you can log out users remotely.
|
||||||
|
* Mycomarkup migration markers are hidden files prefixed with `.mycomarkup-`. You should probably not touch them.
|
||||||
66
help/en/interwiki.myco
Normal file
66
help/en/interwiki.myco
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
= Interwiki
|
||||||
|
**Interwiki** is a means of bringing wikis together, making a federation of them. In Mycorrhiza, one aspect of interwiki is supported: the interwiki links, or the **interlinks**. Most other wiki systems support them too. Interwiki links are shown in green.
|
||||||
|
|
||||||
|
In Mycomarkup, you can address a different wiki by prefixing the link target with a name and a `>` character. For example:
|
||||||
|
```myco
|
||||||
|
[[Wikipedia>Wiki]]
|
||||||
|
=> Mycorrhiza>deployment
|
||||||
|
```
|
||||||
|
|
||||||
|
The interwiki prefixes obey the same naming rules as hyphae. In particular, they are case-insensitive, among other things. Every interwiki entry has one main name and it might have any number of aliases, which can be used interchangeably.
|
||||||
|
|
||||||
|
See [[/interwiki]] for the list of configured interwiki entries. Unlike the WWW, there is no distributed list of wikis similar to DNS, so each wiki administrator has to maintain their own list.
|
||||||
|
|
||||||
|
== Mycorrhiza interwiki
|
||||||
|
Intermycorrhizal interwiki works the best, due to the nature of hyphae. Unlike with some other systems, you can address images from the other wikis reliably.
|
||||||
|
|
||||||
|
```myco
|
||||||
|
img {
|
||||||
|
melanocarpa>quadrat 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
== Setting the intermap
|
||||||
|
//This section is meant for wiki administrators.//
|
||||||
|
**Intermap**, or interwiki map, is the collection of all configured interwiki entries. To configure it, an administrator has to visit [[/interwiki]] and change the existing entries or add a new one.
|
||||||
|
|
||||||
|
Entries have the following fields:
|
||||||
|
*. {
|
||||||
|
**Name.** This is the canonical name of the wiki and one of the prefixes you can use for interlinks.
|
||||||
|
}
|
||||||
|
*. {
|
||||||
|
**Aliases.** They are separated with commas. You don't have to set them up.
|
||||||
|
|
||||||
|
A good idea is to have the full name in the //name// field (such as `wikipedia`), and shorter names in //aliases// (such as `pedia` and `wp`).
|
||||||
|
}
|
||||||
|
*. {
|
||||||
|
**URL.** The URL of the index/home/main page of the wiki. It should not end on trailing slash.
|
||||||
|
}
|
||||||
|
*. {
|
||||||
|
**Engine.** This is the software the target wiki runs.
|
||||||
|
|
||||||
|
There are two engines supported explicitly:
|
||||||
|
* Mycorrhiza
|
||||||
|
* [[https://anagora.org | Agora]]
|
||||||
|
|
||||||
|
Choose the //Generic// option for sites running different software.
|
||||||
|
}
|
||||||
|
*. {
|
||||||
|
**Formats.** Because interlinks are supported for two cases (hyperlinks and images), there has to be a way to convert a resource name to an URL of the resource. Format strings are a way to do that.
|
||||||
|
|
||||||
|
There are two format strings: one for hyperlinks, one for images. They directly correspond to HTML's `href` and `src` attributes of the `a` and `img` tags.
|
||||||
|
|
||||||
|
For Mycorrhiza wikis, you don't have to set them, they are set automatically to the following values (replace `https\:/\/example.org` with the URL of the target wiki):
|
||||||
|
* Link: `https\:/\/example.org/hypha/{NAME}`
|
||||||
|
* Image: `https\:/\/example.org/binary/{NAME}`
|
||||||
|
|
||||||
|
For Agora, they are set to:
|
||||||
|
* Link: `https\:/\/example.org/node/{NAME}`
|
||||||
|
* Image: `https\:/\/example.org/{NAME}`, which doesn't make a lot of sense
|
||||||
|
|
||||||
|
For generic sites, you will have to think of something by yourself. If you do not set it, it will default to `https\:/\/example.org/{NAME}`.
|
||||||
|
|
||||||
|
`{NAME}` is substituted. For example, from link `[\[Melanocarpa\>uxn]]`, `{NAME}` is replaced with `uxn`.
|
||||||
|
}
|
||||||
|
|
||||||
|
You can also change `interwiki.json` directly. Reload the wiki after editing it.
|
||||||
@ -23,5 +23,8 @@ https://mycorrhiza.wiki/binary/release/1.3/lock_screenshot
|
|||||||
|
|
||||||
If they log in, they will be able to navigate the wiki. They are not able to register, though. [[/help/en/telegram | Telegram]] authorization is supported.
|
If they log in, they will be able to navigate the wiki. They are not able to register, though. [[/help/en/telegram | Telegram]] authorization is supported.
|
||||||
|
|
||||||
|
== Readers
|
||||||
|
You might want to add some users to the `reader` group. Readers can log in even to locked wikis, but they cannot edit.
|
||||||
|
|
||||||
== See also
|
== See also
|
||||||
=> /help/en/whitelist | Whitelist
|
=> /help/en/whitelist | Whitelist
|
||||||
@ -6,7 +6,7 @@ You can upload any media file, but only those listed below will be displayed on
|
|||||||
|
|
||||||
* **Images:** jpg, gif, png, webp, svg, ico
|
* **Images:** jpg, gif, png, webp, svg, ico
|
||||||
* **Video:** ogg, webm, mp4
|
* **Video:** ogg, webm, mp4
|
||||||
* **Audio:** ogg, webm, mp3
|
* **Audio:** ogg, webm, mp3, flac, wav
|
||||||
|
|
||||||
== How to upload media?
|
== How to upload media?
|
||||||
For non-existent hyphae, upload a file in the //Upload media// section.
|
For non-existent hyphae, upload a file in the //Upload media// section.
|
||||||
|
|||||||
@ -11,7 +11,7 @@ A Mycomarkup document (which is most often a hypha's text part) consists of //bl
|
|||||||
=> /help/en/mycomarkup#Rocket_link | Rocket link
|
=> /help/en/mycomarkup#Rocket_link | Rocket link
|
||||||
=> /help/en/mycomarkup#Heading | Heading
|
=> /help/en/mycomarkup#Heading | Heading
|
||||||
=> /help/en/mycomarkup#Codeblock | Codeblock
|
=> /help/en/mycomarkup#Codeblock | Codeblock
|
||||||
=> /help/en/mycomarkup#Horizontal_line | Horizontal line
|
=> /help/en/mycomarkup#Thematic_break | Thematic break
|
||||||
=> /help/en/mycomarkup#Image_gallery | Image gallery
|
=> /help/en/mycomarkup#Image_gallery | Image gallery
|
||||||
=> /help/en/mycomarkup#List | List
|
=> /help/en/mycomarkup#List | List
|
||||||
=> /help/en/mycomarkup#Quote | Quote
|
=> /help/en/mycomarkup#Quote | Quote
|
||||||
@ -86,7 +86,7 @@ Links to [[hypha | a Hypha]] and [[https://example.org | some website]].
|
|||||||
```}
|
```}
|
||||||
* Links to [[hypha | a Hypha]] and [[https://example.org | some website]].
|
* Links to [[hypha | a Hypha]] and [[https://example.org | some website]].
|
||||||
|
|
||||||
Since hypha names are case-insensitive, these links are basically the same: `[[hypha]]`, `[[Hypha]]`, `[[HYPHA]]`.
|
Since hypha names are case-insensitive, these links are basically the same: `[\[hypha]]`, `[\[Hypha]]`, `[\[HYPHA]]`.
|
||||||
|
|
||||||
=== Rocket link
|
=== Rocket link
|
||||||
**Rocket links** are special links. They take up a whole line. They are not consistent with usual inline links. They were taken from [[https://gemini.circumlunar.space/docs/gemtext.gmi | gemtext]].
|
**Rocket links** are special links. They take up a whole line. They are not consistent with usual inline links. They were taken from [[https://gemini.circumlunar.space/docs/gemtext.gmi | gemtext]].
|
||||||
@ -132,8 +132,6 @@ There are four levels of **headings**. They consist of some equal signs followed
|
|||||||
|
|
||||||
There is an invisible link that shows the § sign near every heading right after the heading text. You can reveal it with a mouse. If you click it, the URL in the browser will change to the URL leading to that very heading. Try that on headings in this article.
|
There is an invisible link that shows the § sign near every heading right after the heading text. You can reveal it with a mouse. If you click it, the URL in the browser will change to the URL leading to that very heading. Try that on headings in this article.
|
||||||
|
|
||||||
**NB.** There is the legacy syntax for headings: `# ` to `###### `, similar to Markdown. You should not use them.
|
|
||||||
|
|
||||||
== Codeblock
|
== Codeblock
|
||||||
Use **codeblocks** to show code or any other preformatted text. Codeblocks start with triple backticks on column 1 and end similarly. You can write any text after the backticks, it is ignored. Put the preformatted text between them.
|
Use **codeblocks** to show code or any other preformatted text. Codeblocks start with triple backticks on column 1 and end similarly. You can write any text after the backticks, it is ignored. Put the preformatted text between them.
|
||||||
|
|
||||||
@ -150,8 +148,8 @@ this is preformatted
|
|||||||
see
|
see
|
||||||
```
|
```
|
||||||
|
|
||||||
== Horizontal line
|
== Thematic break
|
||||||
Write four hyphens to insert a **horizontal line**.
|
Write four hyphens to insert a **thematic break**, represented by a horizontal line. Use it to break a theme.
|
||||||
|
|
||||||
* {```
|
* {```
|
||||||
----
|
----
|
||||||
@ -165,22 +163,22 @@ You can write a description for the image and specify its size.
|
|||||||
|
|
||||||
* {```
|
* {```
|
||||||
img {
|
img {
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg
|
https://bouncepaw.com/mushroom.jpg
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg {
|
https://bouncepaw.com/mushroom.jpg {
|
||||||
Description //here//
|
Description //here//
|
||||||
}
|
}
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg | 100 { Size }
|
https://bouncepaw.com/mushroom.jpg | 100 { Size }
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg | 50*50
|
https://bouncepaw.com/mushroom.jpg | 50*50
|
||||||
}
|
}
|
||||||
```}
|
```}
|
||||||
* {
|
* {
|
||||||
img {
|
img {
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg
|
https://bouncepaw.com/mushroom.jpg
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg {
|
https://bouncepaw.com/mushroom.jpg {
|
||||||
Description //here//
|
Description //here//
|
||||||
}
|
}
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg | 100 { Size }
|
https://bouncepaw.com/mushroom.jpg | 100 { Size }
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg | 50*50 { Square }
|
https://bouncepaw.com/mushroom.jpg | 50*50 { Square }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,25 +205,25 @@ Specify the layout after `img` and before `{`. If you do not write any of them,
|
|||||||
|
|
||||||
```
|
```
|
||||||
img grid {
|
img grid {
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg
|
https://bouncepaw.com/mushroom.jpg
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg
|
https://bouncepaw.com/mushroom.jpg
|
||||||
}
|
}
|
||||||
|
|
||||||
img side {
|
img side {
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg | 200
|
https://bouncepaw.com/mushroom.jpg | 200
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg | 200
|
https://bouncepaw.com/mushroom.jpg | 200
|
||||||
}
|
}
|
||||||
|
|
||||||
This text is wrapped.
|
This text is wrapped.
|
||||||
```
|
```
|
||||||
img grid {
|
img grid {
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg
|
https://bouncepaw.com/mushroom.jpg
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg
|
https://bouncepaw.com/mushroom.jpg
|
||||||
}
|
}
|
||||||
|
|
||||||
img side {
|
img side {
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg | 200
|
https://bouncepaw.com/mushroom.jpg | 200
|
||||||
https://upload.wikimedia.org/wikipedia/commons/4/48/Timbre_ciuperci_otravitoare.jpg | 200
|
https://bouncepaw.com/mushroom.jpg | 200
|
||||||
}
|
}
|
||||||
|
|
||||||
This text is wrapped.
|
This text is wrapped.
|
||||||
@ -427,4 +425,4 @@ This is an actual transclusion of a hypha below. It will fail if your wiki does
|
|||||||
Recursive transclusion is also supported but it is limited to three iterations.
|
Recursive transclusion is also supported but it is limited to three iterations.
|
||||||
|
|
||||||
== See also
|
== See also
|
||||||
=> https://mycorrhiza.wiki/hypha/essay/why_mycomarkup | Why it was created
|
=> https://mycorrhiza.wiki/hypha/why_mycomarkup | Why it was created
|
||||||
|
|||||||
4
help/en/orphans.myco
Normal file
4
help/en/orphans.myco
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
= Orphaned hyphae
|
||||||
|
Page [[/orphans]] lists **orphaned hyphae**, or orphans for short. An orphan is a hypha with no backlinks, i/e not linked anywhere. Being part of a [[/help/en/category | category]] does not de-orphan the hypha.
|
||||||
|
|
||||||
|
This page can be used in some workflows. For example, you may create several hyphae, and get back to linking them later. Locating them in this list might help you.
|
||||||
21
help/en/rename.myco
Normal file
21
help/en/rename.myco
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
= Renaming
|
||||||
|
You can **rename** existing hyphae. On the bottom of the hyphae, there is a //Rename// link. Follow it to open the renaming dialog.
|
||||||
|
|
||||||
|
> You can also access the dialog by pressing `r` or visiting URL `/rename/<hypha name>`.
|
||||||
|
|
||||||
|
Set the new name there. If you don't change it, submitting the form does nothing. Two options are set by default.
|
||||||
|
|
||||||
|
*. **Rename subhyphae too.** If on, subhyphae will remain subhyphae after the renaming. {
|
||||||
|
For example, if you had hyphae //Apple// and //Apple/red// and renamed //Apple// to //Malum//, //Apple/red// would also be renamed to //Malum/red//.
|
||||||
|
}
|
||||||
|
*. **Leave redirections.** If on, the old name will not remain empty. Rather than that, a hypha with a link to the new name is left. This hypha is called a **redirection hypha**. This option also combines with the other option, i/e subhyphae will receive their corresponding redirection hyphae.
|
||||||
|
|
||||||
|
== Redirection hyphae category
|
||||||
|
All redirection hyphae are added to a specific category. By default, the category is [[/category/redirection | Redirection]]. This way, you can find all redirections and modify them.
|
||||||
|
|
||||||
|
=== How to change the category
|
||||||
|
//This section is for wiki administrators only.//
|
||||||
|
|
||||||
|
*. Edit `config.ini`
|
||||||
|
*. Change the `RedirectionCategory` value in the `[Hyphae]` section.
|
||||||
|
*. Save the file, restart the wiki.
|
||||||
@ -1,13 +0,0 @@
|
|||||||
= Sibling hyphae section
|
|
||||||
On the right (or below on smaller devices) of hypha pages there is a special section that lists **sibling hyphae**.
|
|
||||||
|
|
||||||
> **Sibling hyphae** are hyphae that are subhyphae of the same hypha. For example, //Fruit/Apple// and //Fruit/Pear// are sibling hyphae to each other.
|
|
||||||
|
|
||||||
The sibling hyphae are listed alphabetically. The name of the hypha you are currently viewing is also part of the list. The rest are links that lead you to the hyphae.
|
|
||||||
|
|
||||||
Sometimes, there are numbers beside the links:
|
|
||||||
* **No number.** The hypha has no subhyphae.
|
|
||||||
* **One number.** The number indicates how many direct subhyphae it has.
|
|
||||||
* **Two numbers.** The first number is the number of direct subhyphae. The second number in parentheses is the number of indirect subhyphae.
|
|
||||||
|
|
||||||
For hypha //Fruit//, hyphae //Fruit/Apple// and //Fruit/Pear// would be direct subhyphae, and hyphae //Fruit/Apple/Red// and //Fruit/Apple/Green// would be indirect subhyphae.
|
|
||||||
4
help/en/today.myco
Normal file
4
help/en/today.myco
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
= Today links
|
||||||
|
Some people use Mycorrhiza to keep their diaries. They put each day's notes into separate hyphae, usually named in the ISO format, for example, `2024-06-01` for the 1st of June, 2024. This date format is especially handy because it aligns with the way Mycorrhiza sorts hyphae, so the days are in order.
|
||||||
|
|
||||||
|
Links [[/today]] and [[/edit-today]] are special links that redirect to or edit the “today” hypha. Put them on your home hypha or on [[/help/en/top_bar | the top bar]].
|
||||||
@ -15,6 +15,7 @@ On big screens, the top bar is spread onto two lines.
|
|||||||
** All hyphae
|
** All hyphae
|
||||||
** Random
|
** Random
|
||||||
** Help
|
** Help
|
||||||
|
** Categories
|
||||||
|
|
||||||
On small screens, the authorization section and the most-used-links section are hidden behind a menu. Click the button to see them. If your browser does not support JavaScript, they are always shown.
|
On small screens, the authorization section and the most-used-links section are hidden behind a menu. Click the button to see them. If your browser does not support JavaScript, they are always shown.
|
||||||
|
|
||||||
@ -40,9 +41,11 @@ Reload the wiki.
|
|||||||
|
|
||||||
----
|
----
|
||||||
|
|
||||||
Edit the hypha. You can put any markup there. Rocket links will be used for generating the top bar:
|
Edit the hypha. You can put any markup there. Only rocket links will be used for generating the top bar:
|
||||||
|
|
||||||
```myco
|
```myco
|
||||||
|
This paragraph is unused.
|
||||||
|
|
||||||
=> /recent-changes | Recent changes
|
=> /recent-changes | Recent changes
|
||||||
=> Highlights
|
=> Highlights
|
||||||
=> Philosophy | Our views on life
|
=> Philosophy | Our views on life
|
||||||
|
|||||||
@ -5,11 +5,12 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed en en.myco
|
//go:embed en en.myco *.html
|
||||||
var fs embed.FS
|
var fs embed.FS
|
||||||
|
|
||||||
// Get determines what help text you need and returns it. The path is a substring from URL, it follows this form:
|
// Get determines what help text you need and returns it. The path is a substring from URL, it follows this form:
|
||||||
// <language>/<topic>
|
//
|
||||||
|
// <language>/<topic>
|
||||||
func Get(path string) ([]byte, error) {
|
func Get(path string) ([]byte, error) {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return Get("en")
|
return Get("en")
|
||||||
|
|||||||
52
help/view_help.html
Normal file
52
help/view_help.html
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
{{define "title"}}Help{{end}}
|
||||||
|
{{define "body"}}
|
||||||
|
<main class="main-width help">
|
||||||
|
<article>
|
||||||
|
{{if .ContentsHTML}}
|
||||||
|
{{.ContentsHTML}}
|
||||||
|
{{else}}
|
||||||
|
<h1>{{block "entry not found" .}}Entry not found{{end}}</h1>
|
||||||
|
<p>{{block "entry not found invitation" .}}If you want to write this entry by yourself, consider <a class="wikilink wikilink_external wikilink_https" href="https://github.com/bouncepaw/mycorrhiza">contributing</a> it directly.{{end}}</p>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
<aside class="help-topics layout-card">
|
||||||
|
<h2 class="layout-card__title">Help topics</h2>
|
||||||
|
<ul class="help-topics__list">
|
||||||
|
<li><a href="/help/en">Main</a></li>
|
||||||
|
<li><a href="/help/en/hypha">Hypha</a>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/help/en/media">Media</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><a href="/help/en/mycomarkup">Mycomarkup</a></li>
|
||||||
|
<li><a href="/help/en/category">Categories</a></li>
|
||||||
|
<li><a href="/help/en/rename">Renaming</a></li>
|
||||||
|
<li>Interface
|
||||||
|
<ul>
|
||||||
|
<li><a href="/help/en/prevnext">Previous/next</a></li>
|
||||||
|
<li><a href="/help/en/top_bar">Top bar</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Special pages
|
||||||
|
<ul>
|
||||||
|
<li><a href="/help/en/recent_changes">Recent changes</a></li>
|
||||||
|
<li><a href="/help/en/feeds">Feeds</a></li>
|
||||||
|
<li><a href="/help/en/orphans">Orphaned hyphae</a></li>
|
||||||
|
<li><a href="/help/en/today">Today links</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Configuration (for administrators)
|
||||||
|
<ul>
|
||||||
|
<li><a href="/help/en/config_file">Configuration file</a></li>
|
||||||
|
<li><a href="/help/en/lock">Lock</a></li>
|
||||||
|
<li><a href="/help/en/whitelist">Whitelist</a></li>
|
||||||
|
<li><a href="/help/en/telegram">Telegram authentication</a></li>
|
||||||
|
<li><a href="/help/en/interwiki">Interwiki</a></li>
|
||||||
|
<li><a href="/help/en/file_structure">File structure</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
106
help/web.go
Normal file
106
help/web.go
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
package help
|
||||||
|
|
||||||
|
// stuff.go is used for meta stuff about the wiki or all hyphae at once.
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/mycoopts"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/web/viewutil"
|
||||||
|
|
||||||
|
"git.sr.ht/~bouncepaw/mycomarkup/v5"
|
||||||
|
"git.sr.ht/~bouncepaw/mycomarkup/v5/mycocontext"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
chain viewutil.Chain
|
||||||
|
ruTranslation = `
|
||||||
|
{{define "title"}}Справка{{end}}
|
||||||
|
{{define "entry not found"}}Статья не найдена{{end}}
|
||||||
|
{{define "entry not found invitation"}}Если вы хотите написать эту статью сами, то будем рады вашим правкам <a class="wikilink wikilink_external wikilink_https" href="https://github.com/bouncepaw/mycorrhiza">в репозитории Микоризы</a>.{{end}}
|
||||||
|
|
||||||
|
{{define "topics"}}Темы справки{{end}}
|
||||||
|
{{define "main"}}Введение{{end}}
|
||||||
|
{{define "hypha"}}Гифа{{end}}
|
||||||
|
{{define "media"}}Медиа{{end}}
|
||||||
|
{{define "mycomarkup"}}Микоразметка{{end}}
|
||||||
|
{{define "category"}}Категории{{end}}
|
||||||
|
{{define "interface"}}Интерфейс{{end}}
|
||||||
|
{{define "prevnext"}}Пред/след{{end}}
|
||||||
|
{{define "top_bar"}}Верхняя панель{{end}}
|
||||||
|
{{define "rename"}}Переименовывание{{end}}
|
||||||
|
{{define "special pages"}}Специальные страницы{{end}}
|
||||||
|
{{define "recent_changes"}}Свежие правки{{end}}
|
||||||
|
{{define "feeds"}}Ленты{{end}}
|
||||||
|
{{define "orphans"}}Гифы-сироты{{end}}
|
||||||
|
{{define "configuration"}}Конфигурация (для администраторов){{end}}
|
||||||
|
{{define "config_file"}}Файл конфигурации{{end}}
|
||||||
|
{{define "lock"}}Замок{{end}}
|
||||||
|
{{define "whitelist"}}Белый список{{end}}
|
||||||
|
{{define "telegram"}}Вход через Телеграм{{end}}
|
||||||
|
{{define "interwiki"}}Интервики{{end}}
|
||||||
|
{{define "file structure"}}Файловая структура{{end}}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitHandlers(r *mux.Router) {
|
||||||
|
r.PathPrefix("/help").HandlerFunc(handlerHelp)
|
||||||
|
chain = viewutil.CopyEnRuWith(fs, "view_help.html", ruTranslation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlerHelp gets the appropriate documentation or tells you where you (personally) have failed.
|
||||||
|
func handlerHelp(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
// See the history of this file to resurrect the old algorithm that supported multiple languages
|
||||||
|
var (
|
||||||
|
meta = viewutil.MetaFrom(w, rq)
|
||||||
|
articlePath = strings.TrimPrefix(strings.TrimPrefix(rq.URL.Path, "/help/"), "/help")
|
||||||
|
lang = "en"
|
||||||
|
)
|
||||||
|
if articlePath == "" {
|
||||||
|
articlePath = "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(articlePath, "en") {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = io.WriteString(w, "404 Not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := Get(articlePath)
|
||||||
|
if err != nil && strings.HasPrefix(err.Error(), "open") {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
viewHelp(meta, lang, "", articlePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
viewHelp(meta, lang, err.Error(), articlePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: change for the function that uses byte array when there is such function in mycomarkup.
|
||||||
|
ctx, _ := mycocontext.ContextFromStringInput(string(content), mycoopts.MarkupOptions(articlePath))
|
||||||
|
ast := mycomarkup.BlockTree(ctx)
|
||||||
|
result := mycomarkup.BlocksToHTML(ctx, ast)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
viewHelp(meta, lang, result, articlePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
type helpData struct {
|
||||||
|
*viewutil.BaseData
|
||||||
|
ContentsHTML string
|
||||||
|
Lang string
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewHelp(meta viewutil.Meta, lang, contentsHTML, articlePath string) {
|
||||||
|
viewutil.ExecutePage(meta, chain, helpData{
|
||||||
|
BaseData: &viewutil.BaseData{
|
||||||
|
Addr: "/help/" + articlePath,
|
||||||
|
},
|
||||||
|
ContentsHTML: contentsHTML,
|
||||||
|
Lang: lang,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/cfg"
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
|
|
||||||
"github.com/gorilla/feeds"
|
"github.com/gorilla/feeds"
|
||||||
)
|
)
|
||||||
@ -121,6 +121,7 @@ func (grp revisionGroup) feedItem(opts FeedOptions) feeds.Item {
|
|||||||
Created: grp[len(grp)-1].Time, // earliest revision
|
Created: grp[len(grp)-1].Time, // earliest revision
|
||||||
Updated: grp[0].Time, // latest revision
|
Updated: grp[0].Time, // latest revision
|
||||||
Link: &feeds.Link{Href: cfg.URL + grp[0].bestLink()},
|
Link: &feeds.Link{Href: cfg.URL + grp[0].bestLink()},
|
||||||
|
Content: grp.descriptionForFeed(opts.order),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,12 +4,12 @@ package history
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/files"
|
"github.com/bouncepaw/mycorrhiza/internal/files"
|
||||||
"github.com/bouncepaw/mycorrhiza/util"
|
"github.com/bouncepaw/mycorrhiza/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -21,12 +21,14 @@ var renameMsgPattern = regexp.MustCompile(`^Rename ‘(.*)’ to ‘.*’`)
|
|||||||
var gitEnv = []string{"GIT_COMMITTER_NAME=wikimind", "GIT_COMMITTER_EMAIL=wikimind@mycorrhiza"}
|
var gitEnv = []string{"GIT_COMMITTER_NAME=wikimind", "GIT_COMMITTER_EMAIL=wikimind@mycorrhiza"}
|
||||||
|
|
||||||
// Start finds git and initializes git credentials.
|
// Start finds git and initializes git credentials.
|
||||||
func Start() {
|
func Start() error {
|
||||||
path, err := exec.LookPath("git")
|
path, err := exec.LookPath("git")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Could not find the git executable. Check your $PATH.")
|
slog.Error("Could not find the Git executable. Check your $PATH.")
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
gitpath = path
|
gitpath = path
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitGitRepo checks a Git repository and initializes it if necessary.
|
// InitGitRepo checks a Git repository and initializes it if necessary.
|
||||||
@ -44,7 +46,7 @@ func InitGitRepo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !isGitRepo {
|
if !isGitRepo {
|
||||||
log.Println("Initializing Git repo at", files.HyphaeDir())
|
slog.Info("Initializing Git repo", "path", files.HyphaeDir())
|
||||||
gitsh("init")
|
gitsh("init")
|
||||||
gitsh("config", "core.quotePath", "false")
|
gitsh("config", "core.quotePath", "false")
|
||||||
}
|
}
|
||||||
@ -56,11 +58,11 @@ func gitsh(args ...string) (out bytes.Buffer, err error) {
|
|||||||
fmt.Printf("$ %v\n", args)
|
fmt.Printf("$ %v\n", args)
|
||||||
cmd := exec.Command(gitpath, args...)
|
cmd := exec.Command(gitpath, args...)
|
||||||
cmd.Dir = files.HyphaeDir()
|
cmd.Dir = files.HyphaeDir()
|
||||||
cmd.Env = gitEnv
|
cmd.Env = append(cmd.Environ(), gitEnv...)
|
||||||
|
|
||||||
b, err := cmd.CombinedOutput()
|
b, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("gitsh:", err)
|
slog.Info("Git command failed", "err", err, "output", string(b))
|
||||||
}
|
}
|
||||||
return *bytes.NewBuffer(b), err
|
return *bytes.NewBuffer(b), err
|
||||||
}
|
}
|
||||||
@ -69,7 +71,7 @@ func gitsh(args ...string) (out bytes.Buffer, err error) {
|
|||||||
func silentGitsh(args ...string) (out bytes.Buffer, err error) {
|
func silentGitsh(args ...string) (out bytes.Buffer, err error) {
|
||||||
cmd := exec.Command(gitpath, args...)
|
cmd := exec.Command(gitpath, args...)
|
||||||
cmd.Dir = files.HyphaeDir()
|
cmd.Dir = files.HyphaeDir()
|
||||||
cmd.Env = gitEnv
|
cmd.Env = append(cmd.Environ(), gitEnv...)
|
||||||
|
|
||||||
b, err := cmd.CombinedOutput()
|
b, err := cmd.CombinedOutput()
|
||||||
return *bytes.NewBuffer(b), err
|
return *bytes.NewBuffer(b), err
|
||||||
@ -77,7 +79,9 @@ func silentGitsh(args ...string) (out bytes.Buffer, err error) {
|
|||||||
|
|
||||||
// Rename renames from `from` to `to` using `git mv`.
|
// Rename renames from `from` to `to` using `git mv`.
|
||||||
func Rename(from, to string) error {
|
func Rename(from, to string) error {
|
||||||
log.Println(util.ShorterPath(from), util.ShorterPath(to))
|
slog.Info("Renaming file with git mv",
|
||||||
|
"from", util.ShorterPath(from),
|
||||||
|
"to", util.ShorterPath(to))
|
||||||
_, err := gitsh("mv", "--force", from, to)
|
_, err := gitsh("mv", "--force", from, to)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
227
history/histweb/histview.go
Normal file
227
history/histweb/histview.go
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
// Package histweb provides web stuff for history
|
||||||
|
package histweb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/history"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/files"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/util"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/web/viewutil"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitHandlers(rtr *mux.Router) {
|
||||||
|
rtr.PathPrefix("/primitive-diff/").HandlerFunc(handlerPrimitiveDiff)
|
||||||
|
rtr.HandleFunc("/recent-changes/{count:[0-9]+}", handlerRecentChanges)
|
||||||
|
rtr.HandleFunc("/recent-changes/", func(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
http.Redirect(w, rq, "/recent-changes/20", http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
rtr.PathPrefix("/history/").HandlerFunc(handlerHistory)
|
||||||
|
rtr.HandleFunc("/recent-changes-rss", handlerRecentChangesRSS)
|
||||||
|
rtr.HandleFunc("/recent-changes-atom", handlerRecentChangesAtom)
|
||||||
|
rtr.HandleFunc("/recent-changes-json", handlerRecentChangesJSON)
|
||||||
|
|
||||||
|
chainPrimitiveDiff = viewutil.CopyEnRuWith(fs, "view_primitive_diff.html", ruTranslation)
|
||||||
|
chainRecentChanges = viewutil.CopyEnRuWith(fs, "view_recent_changes.html", ruTranslation)
|
||||||
|
chainHistory = viewutil.CopyEnRuWith(fs, "view_history.html", ruTranslation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlerPrimitiveDiff(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
util.PrepareRq(rq)
|
||||||
|
shorterURL := strings.TrimPrefix(rq.URL.Path, "/primitive-diff/")
|
||||||
|
revHash, slug, found := strings.Cut(shorterURL, "/")
|
||||||
|
if !found || !util.IsRevHash(revHash) || len(slug) < 1 {
|
||||||
|
http.Error(w, "403 bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
mycoFilePath string
|
||||||
|
h = hyphae.ByName(util.CanonicalName(slug))
|
||||||
|
)
|
||||||
|
switch h := h.(type) {
|
||||||
|
case hyphae.ExistingHypha:
|
||||||
|
mycoFilePath = h.TextFilePath()
|
||||||
|
case *hyphae.EmptyHypha:
|
||||||
|
mycoFilePath = filepath.Join(files.HyphaeDir(), h.CanonicalName()+".myco")
|
||||||
|
}
|
||||||
|
text, err := history.PrimitiveDiffAtRevision(mycoFilePath, revHash)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
primitiveDiff(viewutil.MetaFrom(w, rq), h, revHash, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlerRecentChanges displays the /recent-changes/ page.
|
||||||
|
func handlerRecentChanges(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
// Error ignored: filtered by regex
|
||||||
|
editCount, _ := strconv.Atoi(mux.Vars(rq)["count"])
|
||||||
|
if editCount > 100 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recentChanges(viewutil.MetaFrom(w, rq), editCount, history.RecentChanges(editCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlerHistory lists all revisions of a hypha.
|
||||||
|
func handlerHistory(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
hyphaName := util.HyphaNameFromRq(rq, "history")
|
||||||
|
var list string
|
||||||
|
|
||||||
|
// History can be found for files that do not exist anymore.
|
||||||
|
revs, err := history.Revisions(hyphaName)
|
||||||
|
if err == nil {
|
||||||
|
list = history.WithRevisions(hyphaName, revs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: extra log, not needed?
|
||||||
|
slog.Info("Found revisions", "hyphaName", hyphaName, "n", len(revs), "err", err)
|
||||||
|
|
||||||
|
historyView(viewutil.MetaFrom(w, rq), hyphaName, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// genericHandlerOfFeeds is a helper function for the web feed handlers.
|
||||||
|
func genericHandlerOfFeeds(w http.ResponseWriter, rq *http.Request, f func(history.FeedOptions) (string, error), name string, contentType string) {
|
||||||
|
opts, err := history.ParseFeedOptions(rq.URL.Query())
|
||||||
|
var content string
|
||||||
|
if err == nil {
|
||||||
|
content, err = f(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "text/plain;charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprint(w, "An error while generating "+name+": "+err.Error())
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Content-Type", fmt.Sprintf("%s;charset=utf-8", contentType))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlerRecentChangesRSS(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
genericHandlerOfFeeds(w, rq, history.RecentChangesRSS, "RSS", "application/rss+xml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlerRecentChangesAtom(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
genericHandlerOfFeeds(w, rq, history.RecentChangesAtom, "Atom", "application/atom+xml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlerRecentChangesJSON(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
genericHandlerOfFeeds(w, rq, history.RecentChangesJSON, "JSON feed", "application/feed+json")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed *.html
|
||||||
|
fs embed.FS
|
||||||
|
ruTranslation = `
|
||||||
|
{{define "history of title"}}История «{{.}}»{{end}}
|
||||||
|
{{define "history of heading"}}История <a href="/hypha/{{.}}">{{beautifulName .}}</a>{{end}}
|
||||||
|
|
||||||
|
{{define "diff for at title"}}Разница для {{beautifulName .HyphaName}} для {{.Hash}}{{end}}
|
||||||
|
{{define "diff for at heading"}}Разница для <a href="/hypha/{{.HyphaName}}">{{beautifulName .HyphaName}}</a> для {{.Hash}}{{end}}
|
||||||
|
{{define "no text diff available"}}Нет текстовой разницы.{{end}}
|
||||||
|
|
||||||
|
{{define "count pre"}}Отобразить{{end}}
|
||||||
|
{{define "count post"}}свежих правок.{{end}}
|
||||||
|
{{define "subscribe via"}}Подписаться через <a href="/recent-changes-rss">RSS</a>, <a href="/recent-changes-atom">Atom</a> или <a href="/recent-changes-json">JSON-ленту</a>.{{end}}
|
||||||
|
{{define "recent changes"}}Свежие правки{{end}}
|
||||||
|
{{define "n recent changes"}}{{.}} свеж{{if eq . 1}}ая правка{{else if le . 4}}их правок{{else}}их правок{{end}}{{end}}
|
||||||
|
{{define "recent empty"}}Правки не найдены.{{end}}
|
||||||
|
`
|
||||||
|
chainPrimitiveDiff, chainRecentChanges, chainHistory viewutil.Chain
|
||||||
|
)
|
||||||
|
|
||||||
|
type recentChangesData struct {
|
||||||
|
*viewutil.BaseData
|
||||||
|
EditCount int
|
||||||
|
Changes []history.Revision
|
||||||
|
UserHypha string
|
||||||
|
Stops []int
|
||||||
|
}
|
||||||
|
|
||||||
|
func recentChanges(meta viewutil.Meta, editCount int, changes []history.Revision) {
|
||||||
|
viewutil.ExecutePage(meta, chainRecentChanges, recentChangesData{
|
||||||
|
BaseData: &viewutil.BaseData{},
|
||||||
|
EditCount: editCount,
|
||||||
|
Changes: changes,
|
||||||
|
UserHypha: cfg.UserHypha,
|
||||||
|
Stops: []int{20, 50, 100},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type primitiveDiffData struct {
|
||||||
|
*viewutil.BaseData
|
||||||
|
HyphaName string
|
||||||
|
Hash string
|
||||||
|
Text template.HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
func primitiveDiff(meta viewutil.Meta, h hyphae.Hypha, hash, text string) {
|
||||||
|
hunks := history.SplitPrimitiveDiff(text)
|
||||||
|
if len(hunks) > 0 {
|
||||||
|
var buf strings.Builder
|
||||||
|
for _, hunk := range hunks {
|
||||||
|
lines := strings.Split(hunk, "\n")
|
||||||
|
buf.WriteString(`<pre class="codeblock">`)
|
||||||
|
for i, line := range lines {
|
||||||
|
line = strings.Trim(line, "\r")
|
||||||
|
var class string
|
||||||
|
if len(line) > 0 {
|
||||||
|
switch line[0] {
|
||||||
|
case '+':
|
||||||
|
class = "primitive-diff__addition"
|
||||||
|
case '-':
|
||||||
|
class = "primitive-diff__deletion"
|
||||||
|
case '@':
|
||||||
|
class = "primitive-diff__context"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i > 0 {
|
||||||
|
buf.WriteString("\n")
|
||||||
|
}
|
||||||
|
line = template.HTMLEscapeString(line)
|
||||||
|
fmt.Fprintf(&buf, `<code class="%s">%s</code>`,
|
||||||
|
class, line)
|
||||||
|
}
|
||||||
|
buf.WriteString(`</pre>`)
|
||||||
|
}
|
||||||
|
text = buf.String()
|
||||||
|
} else if text != "" {
|
||||||
|
text = template.HTMLEscapeString(text)
|
||||||
|
text = fmt.Sprintf(
|
||||||
|
`<pre class="codeblock"><code>%s</code></pre>`, text)
|
||||||
|
}
|
||||||
|
viewutil.ExecutePage(meta, chainPrimitiveDiff, primitiveDiffData{
|
||||||
|
BaseData: &viewutil.BaseData{},
|
||||||
|
HyphaName: h.CanonicalName(),
|
||||||
|
Hash: hash,
|
||||||
|
Text: template.HTML(text),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type historyData struct {
|
||||||
|
*viewutil.BaseData
|
||||||
|
HyphaName string
|
||||||
|
Contents string
|
||||||
|
}
|
||||||
|
|
||||||
|
func historyView(meta viewutil.Meta, hyphaName, contents string) {
|
||||||
|
viewutil.ExecutePage(meta, chainHistory, historyData{
|
||||||
|
BaseData: &viewutil.BaseData{
|
||||||
|
Addr: "/history/" + util.CanonicalName(hyphaName),
|
||||||
|
},
|
||||||
|
HyphaName: hyphaName,
|
||||||
|
Contents: contents,
|
||||||
|
})
|
||||||
|
}
|
||||||
10
history/histweb/view_history.html
Normal file
10
history/histweb/view_history.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{{define "history of title"}}History of {{.}}{{end}}
|
||||||
|
{{define "title"}}{{template "history of title" .HyphaName}}{{end}}
|
||||||
|
{{define "body"}}
|
||||||
|
<main class="main-width">
|
||||||
|
<article class="history">
|
||||||
|
<h1>{{block "history of heading" .HyphaName}}History of <a href="/hypha/{{.}}">{{beautifulName .}}</a>{{end}}</h1>
|
||||||
|
{{.Contents}}
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
{{end}}
|
||||||
12
history/histweb/view_primitive_diff.html
Normal file
12
history/histweb/view_primitive_diff.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{{define "diff for at title"}}Diff of {{beautifulName .HyphaName}} at {{.Hash}}{{end}}
|
||||||
|
{{define "diff for at heading"}}Diff of <a href="/hypha/{{.HyphaName}}">{{beautifulName .HyphaName}}</a> at {{.Hash}}{{end}}
|
||||||
|
{{define "no text diff available"}}No text diff available.{{end}}
|
||||||
|
{{define "title"}}{{template "diff for at title" .}}{{end}}
|
||||||
|
{{define "body"}}
|
||||||
|
<main class="main-width">
|
||||||
|
<article>
|
||||||
|
<h1>{{template "diff for at heading" .}}</h1>
|
||||||
|
{{if .Text}}{{.Text}}{{else}}{{template "no text diff available" .}}{{end}}
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
{{end}}
|
||||||
71
history/histweb/view_recent_changes.html
Normal file
71
history/histweb/view_recent_changes.html
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
{{define "recent changes"}}Recent changes{{end}}
|
||||||
|
{{define "n recent changes"}}{{.}} recent change{{if ne . 1}}s{{end}}{{end}}
|
||||||
|
{{define "title"}}{{template "n recent changes" .EditCount}}{{end}}
|
||||||
|
|
||||||
|
{{define "body"}}
|
||||||
|
<main class="main-width recent-changes">
|
||||||
|
<h1>{{template "recent changes"}}</h1>
|
||||||
|
|
||||||
|
{{$userHypha := .UserHypha}}
|
||||||
|
{{$year := 0}}{{$month := 0}}{{$day := 0}}
|
||||||
|
<section class="recent-changes__list" role="feed">
|
||||||
|
{{range $i, $entry := .Changes}}
|
||||||
|
{{$time := $entry.Time.UTC}}
|
||||||
|
{{$y := $time.Year}}{{$m := $time.Month}}{{$d := $time.Day}}
|
||||||
|
{{if or (ne $d $day) (ne $m $month) (ne $y $year)}}
|
||||||
|
<h2 class="recent-changes__heading">
|
||||||
|
{{printf "%04d-%02d-%02d" $y $m $d}}
|
||||||
|
</h2>
|
||||||
|
{{$year = $y}}{{$month = $m}}{{$day = $d}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="recent-changes__entry">
|
||||||
|
<div>
|
||||||
|
<time class="recent-changes__entry__time">
|
||||||
|
{{ $time.Format "15:04 UTC" }}
|
||||||
|
</time>
|
||||||
|
<span class="recent-changes__entry__message">
|
||||||
|
{{$entry.HyphaeDiffsHTML}}
|
||||||
|
</span>
|
||||||
|
{{ if $entry.Username | ne "anon" }}
|
||||||
|
<span class="recent-changes__entry__author">
|
||||||
|
— <a href="/hypha/{{$userHypha}}/{{$entry.Username}}" rel="author">{{$entry.Username}}</a>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="recent-changes__entry__links">
|
||||||
|
{{$entry.HyphaeLinksHTML}}
|
||||||
|
</span>
|
||||||
|
<span class="recent-changes__entry__message">
|
||||||
|
{{$entry.Message}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p>{{block "recent empty" .}}No recent changes found.{{end}}</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="recent-changes__count">
|
||||||
|
{{block "count pre" .}}See{{end}}
|
||||||
|
{{ $editCount := .EditCount }}
|
||||||
|
{{range $i, $m := .Stops }}
|
||||||
|
{{if gt $i 0}}
|
||||||
|
<span aria-hidden="true">|</span>
|
||||||
|
{{end}}
|
||||||
|
{{if $m | eq $editCount}}
|
||||||
|
<b>{{$m}}</b>
|
||||||
|
{{else}}
|
||||||
|
<a href="/recent-changes/{{$m}}">{{$m}}</a>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{block "count post" .}}recent changes{{end}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<img class="icon" width="20" height="20" src="/static/icon/feed.svg" aria-hidden="true" alt="RSS icon">
|
||||||
|
{{block "subscribe via" .}}Subscribe via <a href="/recent-changes-rss">RSS</a>, <a href="/recent-changes-atom">Atom</a> or <a href="/recent-changes-json">JSON feed</a>.{{end}}
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
{{end}}
|
||||||
@ -8,7 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/user"
|
"github.com/bouncepaw/mycorrhiza/internal/user"
|
||||||
"github.com/bouncepaw/mycorrhiza/util"
|
"github.com/bouncepaw/mycorrhiza/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -118,6 +118,7 @@ func (hop *Op) Apply() *Op {
|
|||||||
"commit",
|
"commit",
|
||||||
"--author='"+hop.name+" <"+hop.email+">'",
|
"--author='"+hop.name+" <"+hop.email+">'",
|
||||||
"--message="+hop.userMsg,
|
"--message="+hop.userMsg,
|
||||||
|
"--no-gpg-sign",
|
||||||
)
|
)
|
||||||
gitMutex.Unlock()
|
gitMutex.Unlock()
|
||||||
return hop
|
return hop
|
||||||
|
|||||||
@ -2,18 +2,72 @@ package history
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"html"
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/files"
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/files"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Revision represents a revision, duh. Hash is usually short. Username is extracted from email.
|
// WithRevisions returns an HTML representation of `revs` that is meant to be inserted in a history page.
|
||||||
|
func WithRevisions(hyphaName string, revs []Revision) string {
|
||||||
|
var buf strings.Builder
|
||||||
|
|
||||||
|
for _, grp := range groupRevisionsByMonth(revs) {
|
||||||
|
currentYear := grp[0].Time.Year()
|
||||||
|
currentMonth := grp[0].Time.Month()
|
||||||
|
sectionId := fmt.Sprintf("%04d-%02d", currentYear, currentMonth)
|
||||||
|
|
||||||
|
buf.WriteString(fmt.Sprintf(
|
||||||
|
`<section class="history__month">
|
||||||
|
<a href="#%s" class="history__month-anchor">
|
||||||
|
<h2 id="%s" class="history__month-title">%d %s</h2>
|
||||||
|
</a>
|
||||||
|
<ul class="history__entries">`,
|
||||||
|
sectionId, sectionId, currentYear, currentMonth.String(),
|
||||||
|
))
|
||||||
|
|
||||||
|
for _, rev := range grp {
|
||||||
|
buf.WriteString(fmt.Sprintf(
|
||||||
|
`<li class="history__entry">
|
||||||
|
<a class="history-entry" href="/rev/%s/%s">
|
||||||
|
<time class="history-entry__time">%s</time>
|
||||||
|
</a>
|
||||||
|
<span class="history-entry__hash"><a href="/primitive-diff/%s/%s">%s</a></span>
|
||||||
|
<span class="history-entry__msg">%s</span>`,
|
||||||
|
rev.Hash, hyphaName,
|
||||||
|
rev.timeToDisplay(),
|
||||||
|
rev.Hash, hyphaName, rev.Hash,
|
||||||
|
html.EscapeString(rev.Message),
|
||||||
|
))
|
||||||
|
|
||||||
|
if rev.Username != "anon" {
|
||||||
|
buf.WriteString(fmt.Sprintf(
|
||||||
|
`<span class="history-entry__author">by <a href="/hypha/%s/%s" rel="author">%s</a></span>`,
|
||||||
|
cfg.UserHypha, rev.Username, rev.Username,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.WriteString("</li>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.WriteString(`</ul></section>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revision represents a revision of a hypha.
|
||||||
type Revision struct {
|
type Revision struct {
|
||||||
Hash string
|
// Hash is usually short.
|
||||||
|
Hash string
|
||||||
|
// Username is extracted from email.
|
||||||
Username string
|
Username string
|
||||||
Time time.Time
|
Time time.Time
|
||||||
Message string
|
Message string
|
||||||
@ -21,13 +75,74 @@ type Revision struct {
|
|||||||
hyphaeAffectedBuf []string
|
hyphaeAffectedBuf []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HyphaeDiffsHTML returns a comma-separated list of diffs links of current revision for every affected file as HTML string.
|
||||||
|
func (rev Revision) HyphaeDiffsHTML() string {
|
||||||
|
entries := rev.hyphaeAffected()
|
||||||
|
if len(entries) == 1 {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`<a href="/primitive-diff/%s/%s">%s</a>`,
|
||||||
|
rev.Hash, entries[0], rev.Hash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
for i, hyphaName := range entries {
|
||||||
|
if i > 0 {
|
||||||
|
buf.WriteString(`<span aria-hidden="true">, </span>`)
|
||||||
|
}
|
||||||
|
buf.WriteString(`<a href="/primitive-diff/`)
|
||||||
|
buf.WriteString(rev.Hash)
|
||||||
|
buf.WriteString(`/`)
|
||||||
|
buf.WriteString(hyphaName)
|
||||||
|
buf.WriteString(`">`)
|
||||||
|
if i == 0 {
|
||||||
|
buf.WriteString(rev.Hash)
|
||||||
|
buf.WriteString(" ")
|
||||||
|
}
|
||||||
|
buf.WriteString(hyphaName)
|
||||||
|
buf.WriteString(`</a>`)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// descriptionForFeed generates a good enough HTML contents for a web feed.
|
||||||
|
func (rev *Revision) descriptionForFeed() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`<p><b>%s</b> (by %s at %s)</p>
|
||||||
|
<p>Hyphae affected: %s</p>
|
||||||
|
<pre><code>%s</code></pre>`,
|
||||||
|
rev.Message, rev.Username, rev.TimeString(),
|
||||||
|
rev.HyphaeLinksHTML(),
|
||||||
|
rev.textDiff(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HyphaeLinksHTML returns a comma-separated list of hyphae that were affected by this revision as HTML string.
|
||||||
|
func (rev Revision) HyphaeLinksHTML() string {
|
||||||
|
var buf strings.Builder
|
||||||
|
for i, hyphaName := range rev.hyphaeAffected() {
|
||||||
|
if i > 0 {
|
||||||
|
buf.WriteString(`<span aria-hidden="true">, <span>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
urlSafeHyphaName := url.PathEscape(hyphaName)
|
||||||
|
buf.WriteString(fmt.Sprintf(`<a href="/hypha/%s">%s</a>`, urlSafeHyphaName, hyphaName))
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
// gitLog calls `git log` and parses the results.
|
// gitLog calls `git log` and parses the results.
|
||||||
func gitLog(args ...string) ([]Revision, error) {
|
func gitLog(args ...string) ([]Revision, error) {
|
||||||
args = append([]string{
|
args = append([]string{
|
||||||
"log", "--abbrev-commit", "--no-merges",
|
"log", "--abbrev-commit", "--no-merges",
|
||||||
"--pretty=format:%h\t%ae\t%at\t%s",
|
"--pretty=format:%h\t%ae\t%at\t%s",
|
||||||
}, args...)
|
}, args...)
|
||||||
|
args = append(args, "--")
|
||||||
out, err := silentGitsh(args...)
|
out, err := silentGitsh(args...)
|
||||||
|
if strings.Contains(out.String(), "bad revision 'HEAD'") {
|
||||||
|
// Then we have no recent changes! It's a hack.
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -63,11 +178,17 @@ func (stream *recentChangesStream) next(n int) []Revision {
|
|||||||
// currHash is the last revision from the last call, so skip it
|
// currHash is the last revision from the last call, so skip it
|
||||||
args = append(args, "--skip=1", stream.currHash)
|
args = append(args, "--skip=1", stream.currHash)
|
||||||
}
|
}
|
||||||
// I don't think this can fail, so ignore the error
|
|
||||||
res, _ := gitLog(args...)
|
res, err := gitLog(args...)
|
||||||
|
if err != nil {
|
||||||
|
// TODO: return error
|
||||||
|
slog.Error("Failed to git log", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
if len(res) != 0 {
|
if len(res) != 0 {
|
||||||
stream.currHash = res[len(res)-1].Hash
|
stream.currHash = res[len(res)-1].Hash
|
||||||
}
|
}
|
||||||
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,14 +215,14 @@ func (stream recentChangesStream) iterator() func() (Revision, bool) {
|
|||||||
func RecentChanges(n int) []Revision {
|
func RecentChanges(n int) []Revision {
|
||||||
stream := newRecentChangesStream()
|
stream := newRecentChangesStream()
|
||||||
revs := stream.next(n)
|
revs := stream.next(n)
|
||||||
log.Printf("Found %d recent changes", len(revs))
|
slog.Info("Found recent changes", "n", len(revs))
|
||||||
return revs
|
return revs
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revisions returns slice of revisions for the given hypha name, ordered most recent first.
|
// Revisions returns slice of revisions for the given hypha name, ordered most recent first.
|
||||||
func Revisions(hyphaName string) ([]Revision, error) {
|
func Revisions(hyphaName string) ([]Revision, error) {
|
||||||
revs, err := gitLog("--", hyphaName+".*")
|
revs, err := gitLog("--", hyphaName+".*")
|
||||||
log.Printf("Found %d revisions for ‘%s’\n", len(revs), hyphaName)
|
slog.Info("Found revisions", "hyphaName", hyphaName, "n", len(revs), "err", err)
|
||||||
return revs, err
|
return revs, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,7 +296,7 @@ func (rev *Revision) hyphaeAffected() (hyphae []string) {
|
|||||||
filesAffected = rev.filesAffected()
|
filesAffected = rev.filesAffected()
|
||||||
)
|
)
|
||||||
for _, filename := range filesAffected {
|
for _, filename := range filesAffected {
|
||||||
if strings.IndexRune(filename, '.') >= 0 {
|
if strings.ContainsRune(filename, '.') {
|
||||||
dotPos := strings.LastIndexByte(filename, '.')
|
dotPos := strings.LastIndexByte(filename, '.')
|
||||||
hyphaName := string([]byte(filename)[0:dotPos]) // is it safe?
|
hyphaName := string([]byte(filename)[0:dotPos]) // is it safe?
|
||||||
if isNewName(hyphaName) {
|
if isNewName(hyphaName) {
|
||||||
@ -252,3 +373,21 @@ func PrimitiveDiffAtRevision(filepath, hash string) (string, error) {
|
|||||||
}
|
}
|
||||||
return out.String(), err
|
return out.String(), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SplitPrimitiveDiff splits a primitive diff of a single file into hunks.
|
||||||
|
func SplitPrimitiveDiff(text string) (result []string) {
|
||||||
|
idx := strings.Index(text, "@@ -")
|
||||||
|
if idx < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text = text[idx:]
|
||||||
|
for {
|
||||||
|
idx = strings.Index(text, "\n@@ -")
|
||||||
|
if idx < 0 {
|
||||||
|
result = append(result, text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result = append(result, text[:idx+1])
|
||||||
|
text = text[idx+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,55 +0,0 @@
|
|||||||
{% import "fmt" %}
|
|
||||||
{% import "github.com/bouncepaw/mycorrhiza/cfg" %}
|
|
||||||
|
|
||||||
HyphaeLinksHTML returns a comma-separated list of hyphae that were affected by this revision as HTML string.
|
|
||||||
{% func (rev Revision) HyphaeLinksHTML() %}
|
|
||||||
{% stripspace %}
|
|
||||||
{% for i, hyphaName := range rev.hyphaeAffected() %}
|
|
||||||
{% if i > 0 %}
|
|
||||||
<span aria-hidden="true">, </span>
|
|
||||||
{% endif %}
|
|
||||||
<a href="/hypha/{%s hyphaName %}">{%s hyphaName %}</a>
|
|
||||||
{% endfor %}
|
|
||||||
{% endstripspace %}
|
|
||||||
{% endfunc %}
|
|
||||||
|
|
||||||
descriptionForFeed generates a good enough HTML contents for a web feed.
|
|
||||||
{% func (rev *Revision) descriptionForFeed() %}
|
|
||||||
<p><b>{%s rev.Message %}</b> (by {%s rev.Username %} at {%s rev.TimeString() %})</p>
|
|
||||||
<p>Hyphae affected: {%= rev.HyphaeLinksHTML() %}</p>
|
|
||||||
<pre><code>{%s rev.textDiff() %}</code></pre>
|
|
||||||
{% endfunc %}
|
|
||||||
|
|
||||||
WithRevisions returns an html representation of `revs` that is meant to be inserted in a history page.
|
|
||||||
{% func WithRevisions(hyphaName string, revs []Revision) %}
|
|
||||||
{% for _, grp := range groupRevisionsByMonth(revs) %}
|
|
||||||
{% code
|
|
||||||
currentYear := grp[0].Time.Year()
|
|
||||||
currentMonth := grp[0].Time.Month()
|
|
||||||
sectionId := fmt.Sprintf("%d-%d", currentYear, currentMonth)
|
|
||||||
%}
|
|
||||||
<section class="history__month">
|
|
||||||
<a href="#{%s sectionId %}" class="history__month-anchor">
|
|
||||||
<h2 id="{%s sectionId %}" class="history__month-title">{%d currentYear %} {%s currentMonth.String() %}</h2>
|
|
||||||
</a>
|
|
||||||
<ul class="history__entries">
|
|
||||||
{% for _, rev := range grp %}
|
|
||||||
{%= rev.asHistoryEntry(hyphaName) %}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{% endfor %}
|
|
||||||
{% endfunc %}
|
|
||||||
|
|
||||||
{% func (rev *Revision) asHistoryEntry(hyphaName string) %}
|
|
||||||
<li class="history__entry">
|
|
||||||
<a class="history-entry" href="/rev/{%s rev.Hash %}/{%s hyphaName %}">
|
|
||||||
<time class="history-entry__time">{%s rev.timeToDisplay() %}</time>
|
|
||||||
</a>
|
|
||||||
<span class="history-entry__hash"><a href="/primitive-diff/{%s rev.Hash %}/{%s hyphaName %}">{%s rev.Hash %}</a></span>
|
|
||||||
<span class="history-entry__msg">{%s rev.Message %}</span>
|
|
||||||
{% if rev.Username != "anon" %}
|
|
||||||
<span class="history-entry__author">by <a href="/hypha/{%s cfg.UserHypha %}/{%s rev.Username %}" rel="author">{%s rev.Username %}</a></span>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfunc %}
|
|
||||||
@ -1,326 +0,0 @@
|
|||||||
// Code generated by qtc from "view.qtpl". DO NOT EDIT.
|
|
||||||
// See https://github.com/valyala/quicktemplate for details.
|
|
||||||
|
|
||||||
//line history/view.qtpl:1
|
|
||||||
package history
|
|
||||||
|
|
||||||
//line history/view.qtpl:1
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
//line history/view.qtpl:2
|
|
||||||
import "github.com/bouncepaw/mycorrhiza/cfg"
|
|
||||||
|
|
||||||
// HyphaeLinksHTML returns a comma-separated list of hyphae that were affected by this revision as HTML string.
|
|
||||||
|
|
||||||
//line history/view.qtpl:5
|
|
||||||
import (
|
|
||||||
qtio422016 "io"
|
|
||||||
|
|
||||||
qt422016 "github.com/valyala/quicktemplate"
|
|
||||||
)
|
|
||||||
|
|
||||||
//line history/view.qtpl:5
|
|
||||||
var (
|
|
||||||
_ = qtio422016.Copy
|
|
||||||
_ = qt422016.AcquireByteBuffer
|
|
||||||
)
|
|
||||||
|
|
||||||
//line history/view.qtpl:5
|
|
||||||
func (rev Revision) StreamHyphaeLinksHTML(qw422016 *qt422016.Writer) {
|
|
||||||
//line history/view.qtpl:5
|
|
||||||
qw422016.N().S(`
|
|
||||||
`)
|
|
||||||
//line history/view.qtpl:7
|
|
||||||
for i, hyphaName := range rev.hyphaeAffected() {
|
|
||||||
//line history/view.qtpl:8
|
|
||||||
if i > 0 {
|
|
||||||
//line history/view.qtpl:8
|
|
||||||
qw422016.N().S(`<span aria-hidden="true">, </span>`)
|
|
||||||
//line history/view.qtpl:10
|
|
||||||
}
|
|
||||||
//line history/view.qtpl:10
|
|
||||||
qw422016.N().S(`<a href="/hypha/`)
|
|
||||||
//line history/view.qtpl:11
|
|
||||||
qw422016.E().S(hyphaName)
|
|
||||||
//line history/view.qtpl:11
|
|
||||||
qw422016.N().S(`">`)
|
|
||||||
//line history/view.qtpl:11
|
|
||||||
qw422016.E().S(hyphaName)
|
|
||||||
//line history/view.qtpl:11
|
|
||||||
qw422016.N().S(`</a>`)
|
|
||||||
//line history/view.qtpl:12
|
|
||||||
}
|
|
||||||
//line history/view.qtpl:13
|
|
||||||
qw422016.N().S(`
|
|
||||||
`)
|
|
||||||
//line history/view.qtpl:14
|
|
||||||
}
|
|
||||||
|
|
||||||
//line history/view.qtpl:14
|
|
||||||
func (rev Revision) WriteHyphaeLinksHTML(qq422016 qtio422016.Writer) {
|
|
||||||
//line history/view.qtpl:14
|
|
||||||
qw422016 := qt422016.AcquireWriter(qq422016)
|
|
||||||
//line history/view.qtpl:14
|
|
||||||
rev.StreamHyphaeLinksHTML(qw422016)
|
|
||||||
//line history/view.qtpl:14
|
|
||||||
qt422016.ReleaseWriter(qw422016)
|
|
||||||
//line history/view.qtpl:14
|
|
||||||
}
|
|
||||||
|
|
||||||
//line history/view.qtpl:14
|
|
||||||
func (rev Revision) HyphaeLinksHTML() string {
|
|
||||||
//line history/view.qtpl:14
|
|
||||||
qb422016 := qt422016.AcquireByteBuffer()
|
|
||||||
//line history/view.qtpl:14
|
|
||||||
rev.WriteHyphaeLinksHTML(qb422016)
|
|
||||||
//line history/view.qtpl:14
|
|
||||||
qs422016 := string(qb422016.B)
|
|
||||||
//line history/view.qtpl:14
|
|
||||||
qt422016.ReleaseByteBuffer(qb422016)
|
|
||||||
//line history/view.qtpl:14
|
|
||||||
return qs422016
|
|
||||||
//line history/view.qtpl:14
|
|
||||||
}
|
|
||||||
|
|
||||||
// descriptionForFeed generates a good enough HTML contents for a web feed.
|
|
||||||
|
|
||||||
//line history/view.qtpl:17
|
|
||||||
func (rev *Revision) streamdescriptionForFeed(qw422016 *qt422016.Writer) {
|
|
||||||
//line history/view.qtpl:17
|
|
||||||
qw422016.N().S(`
|
|
||||||
<p><b>`)
|
|
||||||
//line history/view.qtpl:18
|
|
||||||
qw422016.E().S(rev.Message)
|
|
||||||
//line history/view.qtpl:18
|
|
||||||
qw422016.N().S(`</b> (by `)
|
|
||||||
//line history/view.qtpl:18
|
|
||||||
qw422016.E().S(rev.Username)
|
|
||||||
//line history/view.qtpl:18
|
|
||||||
qw422016.N().S(` at `)
|
|
||||||
//line history/view.qtpl:18
|
|
||||||
qw422016.E().S(rev.TimeString())
|
|
||||||
//line history/view.qtpl:18
|
|
||||||
qw422016.N().S(`)</p>
|
|
||||||
<p>Hyphae affected: `)
|
|
||||||
//line history/view.qtpl:19
|
|
||||||
rev.StreamHyphaeLinksHTML(qw422016)
|
|
||||||
//line history/view.qtpl:19
|
|
||||||
qw422016.N().S(`</p>
|
|
||||||
<pre><code>`)
|
|
||||||
//line history/view.qtpl:20
|
|
||||||
qw422016.E().S(rev.textDiff())
|
|
||||||
//line history/view.qtpl:20
|
|
||||||
qw422016.N().S(`</code></pre>
|
|
||||||
`)
|
|
||||||
//line history/view.qtpl:21
|
|
||||||
}
|
|
||||||
|
|
||||||
//line history/view.qtpl:21
|
|
||||||
func (rev *Revision) writedescriptionForFeed(qq422016 qtio422016.Writer) {
|
|
||||||
//line history/view.qtpl:21
|
|
||||||
qw422016 := qt422016.AcquireWriter(qq422016)
|
|
||||||
//line history/view.qtpl:21
|
|
||||||
rev.streamdescriptionForFeed(qw422016)
|
|
||||||
//line history/view.qtpl:21
|
|
||||||
qt422016.ReleaseWriter(qw422016)
|
|
||||||
//line history/view.qtpl:21
|
|
||||||
}
|
|
||||||
|
|
||||||
//line history/view.qtpl:21
|
|
||||||
func (rev *Revision) descriptionForFeed() string {
|
|
||||||
//line history/view.qtpl:21
|
|
||||||
qb422016 := qt422016.AcquireByteBuffer()
|
|
||||||
//line history/view.qtpl:21
|
|
||||||
rev.writedescriptionForFeed(qb422016)
|
|
||||||
//line history/view.qtpl:21
|
|
||||||
qs422016 := string(qb422016.B)
|
|
||||||
//line history/view.qtpl:21
|
|
||||||
qt422016.ReleaseByteBuffer(qb422016)
|
|
||||||
//line history/view.qtpl:21
|
|
||||||
return qs422016
|
|
||||||
//line history/view.qtpl:21
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithRevisions returns an html representation of `revs` that is meant to be inserted in a history page.
|
|
||||||
|
|
||||||
//line history/view.qtpl:24
|
|
||||||
func StreamWithRevisions(qw422016 *qt422016.Writer, hyphaName string, revs []Revision) {
|
|
||||||
//line history/view.qtpl:24
|
|
||||||
qw422016.N().S(`
|
|
||||||
`)
|
|
||||||
//line history/view.qtpl:25
|
|
||||||
for _, grp := range groupRevisionsByMonth(revs) {
|
|
||||||
//line history/view.qtpl:25
|
|
||||||
qw422016.N().S(`
|
|
||||||
`)
|
|
||||||
//line history/view.qtpl:27
|
|
||||||
currentYear := grp[0].Time.Year()
|
|
||||||
currentMonth := grp[0].Time.Month()
|
|
||||||
sectionId := fmt.Sprintf("%d-%d", currentYear, currentMonth)
|
|
||||||
|
|
||||||
//line history/view.qtpl:30
|
|
||||||
qw422016.N().S(`
|
|
||||||
<section class="history__month">
|
|
||||||
<a href="#`)
|
|
||||||
//line history/view.qtpl:32
|
|
||||||
qw422016.E().S(sectionId)
|
|
||||||
//line history/view.qtpl:32
|
|
||||||
qw422016.N().S(`" class="history__month-anchor">
|
|
||||||
<h2 id="`)
|
|
||||||
//line history/view.qtpl:33
|
|
||||||
qw422016.E().S(sectionId)
|
|
||||||
//line history/view.qtpl:33
|
|
||||||
qw422016.N().S(`" class="history__month-title">`)
|
|
||||||
//line history/view.qtpl:33
|
|
||||||
qw422016.N().D(currentYear)
|
|
||||||
//line history/view.qtpl:33
|
|
||||||
qw422016.N().S(` `)
|
|
||||||
//line history/view.qtpl:33
|
|
||||||
qw422016.E().S(currentMonth.String())
|
|
||||||
//line history/view.qtpl:33
|
|
||||||
qw422016.N().S(`</h2>
|
|
||||||
</a>
|
|
||||||
<ul class="history__entries">
|
|
||||||
`)
|
|
||||||
//line history/view.qtpl:36
|
|
||||||
for _, rev := range grp {
|
|
||||||
//line history/view.qtpl:36
|
|
||||||
qw422016.N().S(`
|
|
||||||
`)
|
|
||||||
//line history/view.qtpl:37
|
|
||||||
rev.streamasHistoryEntry(qw422016, hyphaName)
|
|
||||||
//line history/view.qtpl:37
|
|
||||||
qw422016.N().S(`
|
|
||||||
`)
|
|
||||||
//line history/view.qtpl:38
|
|
||||||
}
|
|
||||||
//line history/view.qtpl:38
|
|
||||||
qw422016.N().S(`
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
`)
|
|
||||||
//line history/view.qtpl:41
|
|
||||||
}
|
|
||||||
//line history/view.qtpl:41
|
|
||||||
qw422016.N().S(`
|
|
||||||
`)
|
|
||||||
//line history/view.qtpl:42
|
|
||||||
}
|
|
||||||
|
|
||||||
//line history/view.qtpl:42
|
|
||||||
func WriteWithRevisions(qq422016 qtio422016.Writer, hyphaName string, revs []Revision) {
|
|
||||||
//line history/view.qtpl:42
|
|
||||||
qw422016 := qt422016.AcquireWriter(qq422016)
|
|
||||||
//line history/view.qtpl:42
|
|
||||||
StreamWithRevisions(qw422016, hyphaName, revs)
|
|
||||||
//line history/view.qtpl:42
|
|
||||||
qt422016.ReleaseWriter(qw422016)
|
|
||||||
//line history/view.qtpl:42
|
|
||||||
}
|
|
||||||
|
|
||||||
//line history/view.qtpl:42
|
|
||||||
func WithRevisions(hyphaName string, revs []Revision) string {
|
|
||||||
//line history/view.qtpl:42
|
|
||||||
qb422016 := qt422016.AcquireByteBuffer()
|
|
||||||
//line history/view.qtpl:42
|
|
||||||
WriteWithRevisions(qb422016, hyphaName, revs)
|
|
||||||
//line history/view.qtpl:42
|
|
||||||
qs422016 := string(qb422016.B)
|
|
||||||
//line history/view.qtpl:42
|
|
||||||
qt422016.ReleaseByteBuffer(qb422016)
|
|
||||||
//line history/view.qtpl:42
|
|
||||||
return qs422016
|
|
||||||
//line history/view.qtpl:42
|
|
||||||
}
|
|
||||||
|
|
||||||
//line history/view.qtpl:44
|
|
||||||
func (rev *Revision) streamasHistoryEntry(qw422016 *qt422016.Writer, hyphaName string) {
|
|
||||||
//line history/view.qtpl:44
|
|
||||||
qw422016.N().S(`
|
|
||||||
<li class="history__entry">
|
|
||||||
<a class="history-entry" href="/rev/`)
|
|
||||||
//line history/view.qtpl:46
|
|
||||||
qw422016.E().S(rev.Hash)
|
|
||||||
//line history/view.qtpl:46
|
|
||||||
qw422016.N().S(`/`)
|
|
||||||
//line history/view.qtpl:46
|
|
||||||
qw422016.E().S(hyphaName)
|
|
||||||
//line history/view.qtpl:46
|
|
||||||
qw422016.N().S(`">
|
|
||||||
<time class="history-entry__time">`)
|
|
||||||
//line history/view.qtpl:47
|
|
||||||
qw422016.E().S(rev.timeToDisplay())
|
|
||||||
//line history/view.qtpl:47
|
|
||||||
qw422016.N().S(`</time>
|
|
||||||
</a>
|
|
||||||
<span class="history-entry__hash"><a href="/primitive-diff/`)
|
|
||||||
//line history/view.qtpl:49
|
|
||||||
qw422016.E().S(rev.Hash)
|
|
||||||
//line history/view.qtpl:49
|
|
||||||
qw422016.N().S(`/`)
|
|
||||||
//line history/view.qtpl:49
|
|
||||||
qw422016.E().S(hyphaName)
|
|
||||||
//line history/view.qtpl:49
|
|
||||||
qw422016.N().S(`">`)
|
|
||||||
//line history/view.qtpl:49
|
|
||||||
qw422016.E().S(rev.Hash)
|
|
||||||
//line history/view.qtpl:49
|
|
||||||
qw422016.N().S(`</a></span>
|
|
||||||
<span class="history-entry__msg">`)
|
|
||||||
//line history/view.qtpl:50
|
|
||||||
qw422016.E().S(rev.Message)
|
|
||||||
//line history/view.qtpl:50
|
|
||||||
qw422016.N().S(`</span>
|
|
||||||
`)
|
|
||||||
//line history/view.qtpl:51
|
|
||||||
if rev.Username != "anon" {
|
|
||||||
//line history/view.qtpl:51
|
|
||||||
qw422016.N().S(`
|
|
||||||
<span class="history-entry__author">by <a href="/hypha/`)
|
|
||||||
//line history/view.qtpl:52
|
|
||||||
qw422016.E().S(cfg.UserHypha)
|
|
||||||
//line history/view.qtpl:52
|
|
||||||
qw422016.N().S(`/`)
|
|
||||||
//line history/view.qtpl:52
|
|
||||||
qw422016.E().S(rev.Username)
|
|
||||||
//line history/view.qtpl:52
|
|
||||||
qw422016.N().S(`" rel="author">`)
|
|
||||||
//line history/view.qtpl:52
|
|
||||||
qw422016.E().S(rev.Username)
|
|
||||||
//line history/view.qtpl:52
|
|
||||||
qw422016.N().S(`</a></span>
|
|
||||||
`)
|
|
||||||
//line history/view.qtpl:53
|
|
||||||
}
|
|
||||||
//line history/view.qtpl:53
|
|
||||||
qw422016.N().S(`
|
|
||||||
</li>
|
|
||||||
`)
|
|
||||||
//line history/view.qtpl:55
|
|
||||||
}
|
|
||||||
|
|
||||||
//line history/view.qtpl:55
|
|
||||||
func (rev *Revision) writeasHistoryEntry(qq422016 qtio422016.Writer, hyphaName string) {
|
|
||||||
//line history/view.qtpl:55
|
|
||||||
qw422016 := qt422016.AcquireWriter(qq422016)
|
|
||||||
//line history/view.qtpl:55
|
|
||||||
rev.streamasHistoryEntry(qw422016, hyphaName)
|
|
||||||
//line history/view.qtpl:55
|
|
||||||
qt422016.ReleaseWriter(qw422016)
|
|
||||||
//line history/view.qtpl:55
|
|
||||||
}
|
|
||||||
|
|
||||||
//line history/view.qtpl:55
|
|
||||||
func (rev *Revision) asHistoryEntry(hyphaName string) string {
|
|
||||||
//line history/view.qtpl:55
|
|
||||||
qb422016 := qt422016.AcquireByteBuffer()
|
|
||||||
//line history/view.qtpl:55
|
|
||||||
rev.writeasHistoryEntry(qb422016, hyphaName)
|
|
||||||
//line history/view.qtpl:55
|
|
||||||
qs422016 := string(qb422016.B)
|
|
||||||
//line history/view.qtpl:55
|
|
||||||
qt422016.ReleaseByteBuffer(qb422016)
|
|
||||||
//line history/view.qtpl:55
|
|
||||||
return qs422016
|
|
||||||
//line history/view.qtpl:55
|
|
||||||
}
|
|
||||||
61
httpd.go
61
httpd.go
@ -1,17 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/cfg"
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func serveHTTP(handler http.Handler) {
|
func serveHTTP(handler http.Handler) (err error) {
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
ReadTimeout: 300 * time.Second,
|
ReadTimeout: 300 * time.Second,
|
||||||
WriteTimeout: 300 * time.Second,
|
WriteTimeout: 300 * time.Second,
|
||||||
@ -20,35 +21,51 @@ func serveHTTP(handler http.Handler) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(cfg.ListenAddr, "/") {
|
if strings.HasPrefix(cfg.ListenAddr, "/") {
|
||||||
startUnixSocketServer(server, cfg.ListenAddr)
|
err = startUnixSocketServer(server, cfg.ListenAddr)
|
||||||
} else {
|
} else {
|
||||||
server.Addr = cfg.ListenAddr
|
server.Addr = cfg.ListenAddr
|
||||||
startHTTPServer(server)
|
err = startHTTPServer(server)
|
||||||
}
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func startUnixSocketServer(server *http.Server, socketFile string) {
|
func startUnixSocketServer(server *http.Server, socketPath string) error {
|
||||||
os.Remove(socketFile)
|
err := os.Remove(socketPath)
|
||||||
|
|
||||||
listener, err := net.Listen("unix", socketFile)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to start a server: %v", err)
|
slog.Warn("Failed to clean up old socket", "err", err)
|
||||||
}
|
|
||||||
defer listener.Close()
|
|
||||||
|
|
||||||
if err := os.Chmod(socketFile, 0666); err != nil {
|
|
||||||
log.Fatalf("Failed to set socket permissions: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Listening on Unix socket %s", cfg.ListenAddr)
|
listener, err := net.Listen("unix", socketPath)
|
||||||
if err := server.Serve(listener); err != http.ErrServerClosed {
|
if err != nil {
|
||||||
log.Fatalf("Failed to start a server: %v", err)
|
slog.Error("Failed to start the server", "err", err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
defer func(listener net.Listener) {
|
||||||
|
_ = listener.Close()
|
||||||
|
}(listener)
|
||||||
|
|
||||||
|
if err := os.Chmod(socketPath, 0666); err != nil {
|
||||||
|
slog.Error("Failed to set socket permissions", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Listening Unix socket", "addr", socketPath)
|
||||||
|
|
||||||
|
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
slog.Error("Failed to start the server", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func startHTTPServer(server *http.Server) {
|
func startHTTPServer(server *http.Server) error {
|
||||||
log.Printf("Listening on %s", server.Addr)
|
slog.Info("Listening over HTTP", "addr", server.Addr)
|
||||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
|
||||||
log.Fatalf("Failed to start a server: %v", err)
|
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
slog.Error("Failed to start the server", "err", err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,89 +0,0 @@
|
|||||||
// Package categories provides category management. All operations in this package are mutexed.
|
|
||||||
//
|
|
||||||
// As per the long pondering, this is how categories (cats for short)
|
|
||||||
// work in Mycorrhiza:
|
|
||||||
//
|
|
||||||
// - Cats are not hyphae. Cats are separate entities. This is not as
|
|
||||||
// vibeful as I would have wanted, but seems to be more practical
|
|
||||||
// due to //the reasons//.
|
|
||||||
// - Cats are stored outside of git. Instead, they are stored in a
|
|
||||||
// JSON file, path to which is determined by files.CategoriesJSON.
|
|
||||||
// - Due to not being stored in git, no cat history is tracked, and
|
|
||||||
// cat operations are not mentioned on the recent changes page.
|
|
||||||
// - For cat A, if there are 0 hyphae in the cat, cat A does not
|
|
||||||
// exist. If there are 1 or more hyphae in the cat, cat A exists.
|
|
||||||
package categories
|
|
||||||
|
|
||||||
import "sync"
|
|
||||||
|
|
||||||
// List returns names of all categories.
|
|
||||||
func List() (categoryList []string) {
|
|
||||||
mutex.RLock()
|
|
||||||
for cat, _ := range categoryToHyphae {
|
|
||||||
categoryList = append(categoryList, cat)
|
|
||||||
}
|
|
||||||
mutex.RUnlock()
|
|
||||||
return categoryList
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithHypha returns what categories have the given hypha. The hypha name must be canonical.
|
|
||||||
func WithHypha(hyphaName string) (categoryList []string) {
|
|
||||||
mutex.RLock()
|
|
||||||
defer mutex.RUnlock()
|
|
||||||
if node, ok := hyphaToCategories[hyphaName]; ok {
|
|
||||||
return node.categoryList
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contents returns what hyphae are in the category. If the returned slice is empty, the category does not exist, and vice versa. The category name must be canonical.
|
|
||||||
func Contents(catName string) (hyphaList []string) {
|
|
||||||
mutex.RLock()
|
|
||||||
defer mutex.RUnlock()
|
|
||||||
if node, ok := categoryToHyphae[catName]; ok {
|
|
||||||
return node.hyphaList
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var mutex sync.RWMutex
|
|
||||||
|
|
||||||
// AddHyphaToCategory adds the hypha to the category and updates the records on the disk. If the hypha is already in the category, nothing happens. Pass canonical names.
|
|
||||||
func AddHyphaToCategory(hyphaName, catName string) {
|
|
||||||
mutex.Lock()
|
|
||||||
if node, ok := hyphaToCategories[hyphaName]; ok {
|
|
||||||
node.storeCategory(catName)
|
|
||||||
} else {
|
|
||||||
hyphaToCategories[hyphaName] = &hyphaNode{categoryList: []string{catName}}
|
|
||||||
}
|
|
||||||
|
|
||||||
if node, ok := categoryToHyphae[catName]; ok {
|
|
||||||
node.storeHypha(hyphaName)
|
|
||||||
} else {
|
|
||||||
categoryToHyphae[catName] = &categoryNode{hyphaList: []string{hyphaName}}
|
|
||||||
}
|
|
||||||
mutex.Unlock()
|
|
||||||
go saveToDisk()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveHyphaFromCategory removes the hypha from the category and updates the records on the disk. If the hypha is not in the category, nothing happens. Pass canonical names.
|
|
||||||
func RemoveHyphaFromCategory(hyphaName, catName string) {
|
|
||||||
mutex.Lock()
|
|
||||||
if node, ok := hyphaToCategories[hyphaName]; ok {
|
|
||||||
node.removeCategory(catName)
|
|
||||||
if len(node.categoryList) == 0 {
|
|
||||||
delete(hyphaToCategories, hyphaName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if node, ok := categoryToHyphae[catName]; ok {
|
|
||||||
node.removeHypha(hyphaName)
|
|
||||||
if len(node.hyphaList) == 0 {
|
|
||||||
delete(categoryToHyphae, catName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mutex.Unlock()
|
|
||||||
go saveToDisk()
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
// Package iteration provides a handy API for making multiple checks on all hyphae in one go.
|
|
||||||
package iteration
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Iteration represents an iteration over all existing hyphae in the storage. Iteration is done on all existing hyphae. The order of hyphae is not specified. For all hyphae, checks are made.
|
|
||||||
type Iteration struct {
|
|
||||||
sync.Mutex
|
|
||||||
checks []func(h hyphae.Hypha) CheckResult
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewIteration constructs an iteration without checks.
|
|
||||||
func NewIteration() *Iteration {
|
|
||||||
return &Iteration{
|
|
||||||
checks: make([]func(h hyphae.Hypha) CheckResult, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddCheck adds the check to the iteration. It is concurrent-safe. Checks are meant to have side-effects.
|
|
||||||
func (i7n *Iteration) AddCheck(check func(h hyphae.Hypha) CheckResult) {
|
|
||||||
i7n.Lock()
|
|
||||||
i7n.checks = append(i7n.checks, check)
|
|
||||||
i7n.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i7n *Iteration) removeCheck(i int) {
|
|
||||||
i7n.checks[i] = i7n.checks[len(i7n.checks)-1]
|
|
||||||
i7n.checks = i7n.checks[:len(i7n.checks)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ignite does the iteration by walking over all hyphae yielded by the iterator used and calling all checks on the hypha. Ignited iterations are not concurrent-safe.
|
|
||||||
//
|
|
||||||
// After ignition, you should not use the same Iteration again.
|
|
||||||
func (i7n *Iteration) Ignite() {
|
|
||||||
for h := range hyphae.YieldExistingHyphae() {
|
|
||||||
for i, check := range i7n.checks {
|
|
||||||
if res := check(h); res == CheckForgetMe {
|
|
||||||
i7n.removeCheck(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckResult is a result of an iteration check.
|
|
||||||
type CheckResult int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// CheckContinue is returned when the check wants to be used next time too.
|
|
||||||
CheckContinue CheckResult = iota
|
|
||||||
// CheckForgetMe is returned when the check wants to be forgotten and not used anymore.
|
|
||||||
CheckForgetMe
|
|
||||||
)
|
|
||||||
87
hypview/hypview.go
Normal file
87
hypview/hypview.go
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
package hypview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/backlinks"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/web/viewutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed *.html
|
||||||
|
fs embed.FS
|
||||||
|
ruTranslation = `
|
||||||
|
{{define "rename hypha?"}}Переименовать {{beautifulName .}}?{{end}}
|
||||||
|
{{define "rename [[hypha]]?"}}Переименовать <a href="/hypha/{{.}}">{{beautifulName .}}</a>?{{end}}
|
||||||
|
{{define "new name"}}Новое название:{{end}}
|
||||||
|
{{define "rename recursively"}}Также переименовать подгифы{{end}}
|
||||||
|
{{define "rename tip"}}Переименовывайте аккуратно. <a href="/help/en/rename">Документация на английском.</a>{{end}}
|
||||||
|
{{define "leave redirection"}}Оставить перенаправление{{end}}
|
||||||
|
|
||||||
|
|
||||||
|
`
|
||||||
|
chainNaviTitle viewutil.Chain
|
||||||
|
chainRenameHypha viewutil.Chain
|
||||||
|
)
|
||||||
|
|
||||||
|
func Init() {
|
||||||
|
chainNaviTitle = viewutil.CopyEnRuWith(fs, "view_navititle.html", "")
|
||||||
|
chainRenameHypha = viewutil.CopyEnRuWith(fs, "view_rename.html", ruTranslation)
|
||||||
|
}
|
||||||
|
|
||||||
|
type renameData struct {
|
||||||
|
*viewutil.BaseData
|
||||||
|
HyphaName string
|
||||||
|
LeaveRedirectionDefault bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenameHypha(meta viewutil.Meta, hyphaName string) {
|
||||||
|
viewutil.ExecutePage(meta, chainRenameHypha, renameData{
|
||||||
|
BaseData: &viewutil.BaseData{
|
||||||
|
Addr: "/rename/" + hyphaName,
|
||||||
|
},
|
||||||
|
HyphaName: hyphaName,
|
||||||
|
LeaveRedirectionDefault: backlinks.BacklinksCount(hyphaName) != 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type naviTitleData struct {
|
||||||
|
HyphaNameParts []string
|
||||||
|
HyphaNamePartsWithParents []string
|
||||||
|
Icon string
|
||||||
|
HomeHypha string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NaviTitle(meta viewutil.Meta, hyphaName string) template.HTML {
|
||||||
|
parts, partsWithParents := naviTitleify(hyphaName)
|
||||||
|
var buf strings.Builder
|
||||||
|
err := chainNaviTitle.Get(meta).ExecuteTemplate(&buf, "navititle", naviTitleData{
|
||||||
|
HyphaNameParts: parts,
|
||||||
|
HyphaNamePartsWithParents: partsWithParents,
|
||||||
|
Icon: cfg.NaviTitleIcon,
|
||||||
|
HomeHypha: cfg.HomeHypha,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to render NaviTitle properly; using nevertheless", "err", err)
|
||||||
|
}
|
||||||
|
return template.HTML(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func naviTitleify(hyphaName string) ([]string, []string) {
|
||||||
|
var (
|
||||||
|
prevAcc = "/hypha"
|
||||||
|
parts = strings.Split(hyphaName, "/")
|
||||||
|
partsWithParents []string
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, part := range parts {
|
||||||
|
prevAcc += "/" + part
|
||||||
|
partsWithParents = append(partsWithParents, prevAcc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts, partsWithParents
|
||||||
|
}
|
||||||
17
hypview/view_navititle.html
Normal file
17
hypview/view_navititle.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{{define "navititle"}}
|
||||||
|
<h1 class="navi-title">
|
||||||
|
{{- $withParents := .HyphaNamePartsWithParents -}}
|
||||||
|
{{- $parts := .HyphaNameParts -}}
|
||||||
|
<a href="/hypha/{{.HomeHypha}}">
|
||||||
|
{{- .Icon -}}
|
||||||
|
<span aria-hidden="true" class="navi-title__colon">:</span></a>
|
||||||
|
{{- range $i, $part := .HyphaNameParts -}}
|
||||||
|
{{- if gt $i 0 -}}
|
||||||
|
<span aria-hidden="true" class="navi-title__separator">/</span>
|
||||||
|
{{- end -}}
|
||||||
|
<a href="{{index $withParents $i}}" rel="{{if len $parts | eq (inc $i)}}bookmark{{else}}up{{end}}">
|
||||||
|
{{- beautifulName $part -}}
|
||||||
|
</a>
|
||||||
|
{{- end}}
|
||||||
|
</h1>
|
||||||
|
{{end}}
|
||||||
29
hypview/view_rename.html
Normal file
29
hypview/view_rename.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{{define "rename hypha?"}}Rename {{beautifulName .}}?{{end}}
|
||||||
|
{{define "title"}}{{template "rename hypha?" .HyphaName}}{{end}}
|
||||||
|
{{define "body"}}
|
||||||
|
<main class="main-width">
|
||||||
|
<form class="modal" action="/rename/{{.HyphaName}}" method="post" enctype="multipart/form-data">
|
||||||
|
<fieldset class="modal__fieldset">
|
||||||
|
<legend class="modal__title">
|
||||||
|
{{block "rename [[hypha]]?" .HyphaName}}Rename <a href="/hypha/{{.}}">{{beautifulName .}}</a>?{{end}}
|
||||||
|
</legend>
|
||||||
|
<label for="new-name">{{block "new name" .}}New name:{{end}}</label>
|
||||||
|
<input type="text" value="{{.HyphaName}}" required autofocus id="new-name" name="new-name"/>
|
||||||
|
|
||||||
|
<input type="checkbox" id="recursive" name="recursive" value="true" checked/>
|
||||||
|
<label for="recursive">{{block "rename recursively" .}}Rename subhyphae too{{end}}</label>
|
||||||
|
<br>
|
||||||
|
<input type="checkbox" id="redirection" name="redirection" value="true" {{if .LeaveRedirectionDefault}}checked{{end}}/>
|
||||||
|
<label for="redirection">{{block "leave redirection" .}}Leave redirection{{end}}</label>
|
||||||
|
|
||||||
|
<p>{{block "rename tip" .}}Rename carefully. <a href="/help/en/rename">Documentation.</a>{{end}}</p>
|
||||||
|
<button type="submit" value="Confirm" class="btn">
|
||||||
|
{{template "confirm"}}
|
||||||
|
</button>
|
||||||
|
<a href="/hypha/{{.HyphaName}}" class="btn btn_weak">
|
||||||
|
{{template "cancel"}}
|
||||||
|
</a>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{{end}}
|
||||||
@ -2,15 +2,16 @@
|
|||||||
package backlinks
|
package backlinks
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae"
|
|
||||||
"github.com/bouncepaw/mycorrhiza/util"
|
"github.com/bouncepaw/mycorrhiza/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// YieldHyphaBacklinks gets backlinks for the desired hypha, sorts and yields them one by one.
|
// yieldHyphaBacklinks gets backlinks for the desired hypha, sorts and yields them one by one.
|
||||||
func YieldHyphaBacklinks(hyphaName string) <-chan string {
|
func yieldHyphaBacklinks(hyphaName string) <-chan string {
|
||||||
hyphaName = util.CanonicalName(hyphaName)
|
hyphaName = util.CanonicalName(hyphaName)
|
||||||
out := make(chan string)
|
out := make(chan string)
|
||||||
sorted := hyphae.PathographicSort(out)
|
sorted := hyphae.PathographicSort(out)
|
||||||
@ -53,14 +54,33 @@ func IndexBacklinks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BacklinksCount returns the amount of backlinks to the hypha.
|
// BacklinksCount returns the amount of backlinks to the hypha. Pass canonical names.
|
||||||
func BacklinksCount(h hyphae.Hypha) int {
|
func BacklinksCount(hyphaName string) int {
|
||||||
if links, exists := backlinkIndex[h.CanonicalName()]; exists {
|
if links, exists := backlinkIndex[hyphaName]; exists {
|
||||||
return len(links)
|
return len(links)
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func BacklinksFor(hyphaName string) []string {
|
||||||
|
var backlinks []string
|
||||||
|
for b := range yieldHyphaBacklinks(hyphaName) {
|
||||||
|
backlinks = append(backlinks, b)
|
||||||
|
}
|
||||||
|
return backlinks
|
||||||
|
}
|
||||||
|
|
||||||
|
func Orphans() []string {
|
||||||
|
var orphans []string
|
||||||
|
for h := range hyphae.YieldExistingHyphae() {
|
||||||
|
if BacklinksCount(h.CanonicalName()) == 0 {
|
||||||
|
orphans = append(orphans, h.CanonicalName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(orphans)
|
||||||
|
return orphans
|
||||||
|
}
|
||||||
|
|
||||||
// Using set here seems like the most appropriate solution
|
// Using set here seems like the most appropriate solution
|
||||||
type linkSet map[string]struct{}
|
type linkSet map[string]struct{}
|
||||||
|
|
||||||
@ -88,7 +108,7 @@ func fetchText(h hyphae.Hypha) string {
|
|||||||
|
|
||||||
text, err := os.ReadFile(path)
|
text, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
slog.Error("Failed to read file", "path", path, "err", err, "hyphaName", h.CanonicalName())
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return string(text)
|
return string(text)
|
||||||
@ -147,7 +167,7 @@ type backlinkIndexRenaming struct {
|
|||||||
links []string
|
links []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply changes backlink index respective to the operation data
|
// apply changes backlink index respective to the operation data
|
||||||
func (op backlinkIndexRenaming) apply() {
|
func (op backlinkIndexRenaming) apply() {
|
||||||
for _, link := range op.links {
|
for _, link := range op.links {
|
||||||
if lSet, exists := backlinkIndex[link]; exists {
|
if lSet, exists := backlinkIndex[link]; exists {
|
||||||
@ -1,11 +1,13 @@
|
|||||||
package backlinks
|
package backlinks
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/bouncepaw/mycomarkup/v3"
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
"github.com/bouncepaw/mycomarkup/v3/links"
|
"github.com/bouncepaw/mycorrhiza/mycoopts"
|
||||||
"github.com/bouncepaw/mycomarkup/v3/mycocontext"
|
|
||||||
"github.com/bouncepaw/mycomarkup/v3/tools"
|
"git.sr.ht/~bouncepaw/mycomarkup/v5"
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae"
|
"git.sr.ht/~bouncepaw/mycomarkup/v5/links"
|
||||||
|
"git.sr.ht/~bouncepaw/mycomarkup/v5/mycocontext"
|
||||||
|
"git.sr.ht/~bouncepaw/mycomarkup/v5/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpdateBacklinksAfterEdit is a creation/editing hook for backlinks index
|
// UpdateBacklinksAfterEdit is a creation/editing hook for backlinks index
|
||||||
@ -34,15 +36,16 @@ func extractHyphaLinks(h hyphae.Hypha) []string {
|
|||||||
|
|
||||||
// extractHyphaLinksFromContent extracts local hypha links from the provided text.
|
// extractHyphaLinksFromContent extracts local hypha links from the provided text.
|
||||||
func extractHyphaLinksFromContent(hyphaName string, contents string) []string {
|
func extractHyphaLinksFromContent(hyphaName string, contents string) []string {
|
||||||
ctx, _ := mycocontext.ContextFromStringInput(hyphaName, contents)
|
ctx, _ := mycocontext.ContextFromStringInput(contents, mycoopts.MarkupOptions(hyphaName))
|
||||||
linkVisitor, getLinks := tools.LinkVisitor(ctx)
|
linkVisitor, getLinks := tools.LinkVisitor(ctx)
|
||||||
// Ignore the result of BlockTree because we call it for linkVisitor.
|
// Ignore the result of BlockTree because we call it for linkVisitor.
|
||||||
_ = mycomarkup.BlockTree(ctx, linkVisitor)
|
_ = mycomarkup.BlockTree(ctx, linkVisitor)
|
||||||
foundLinks := getLinks()
|
foundLinks := getLinks()
|
||||||
var result []string
|
var result []string
|
||||||
for _, link := range foundLinks {
|
for _, link := range foundLinks {
|
||||||
if link.OfKind(links.LinkLocalHypha) {
|
switch link := link.(type) {
|
||||||
result = append(result, link.TargetHypha())
|
case *links.LocalLink:
|
||||||
|
result = append(result, link.Target(ctx))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
136
internal/categories/categories.go
Normal file
136
internal/categories/categories.go
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
// Package categories provides category management.
|
||||||
|
//
|
||||||
|
// As per the long pondering, this is how categories (cats for short)
|
||||||
|
// work in Mycorrhiza:
|
||||||
|
//
|
||||||
|
// - Cats are not hyphae. Cats are separate entities. This is not as
|
||||||
|
// vibeful as I would have wanted, but seems to be more practical
|
||||||
|
// due to //the reasons//.
|
||||||
|
// - Cats are stored outside of git. Instead, they are stored in a
|
||||||
|
// JSON file, path to which is determined by files.CategoriesJSON.
|
||||||
|
// - Due to not being stored in git, no cat history is tracked, and
|
||||||
|
// cat operations are not mentioned on the recent changes page.
|
||||||
|
// - For cat A, if there are 0 hyphae in the cat, cat A does not
|
||||||
|
// exist. If there are 1 or more hyphae in the cat, cat A exists.
|
||||||
|
//
|
||||||
|
// List of things to do with categories later:
|
||||||
|
//
|
||||||
|
// - Forbid / in cat names.
|
||||||
|
// - Rename categories.
|
||||||
|
// - Delete categories.
|
||||||
|
// - Bind hyphae.
|
||||||
|
package categories
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// ListOfCategories returns unsorted names of all categories.
|
||||||
|
func ListOfCategories() (categoryList []string) {
|
||||||
|
mutex.RLock()
|
||||||
|
for cat, _ := range categoryToHyphae {
|
||||||
|
categoryList = append(categoryList, cat)
|
||||||
|
}
|
||||||
|
mutex.RUnlock()
|
||||||
|
return categoryList
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoriesWithHypha returns what categories have the given hypha. The hypha name must be canonical.
|
||||||
|
func CategoriesWithHypha(hyphaName string) (categoryList []string) {
|
||||||
|
mutex.RLock()
|
||||||
|
defer mutex.RUnlock()
|
||||||
|
if node, ok := hyphaToCategories[hyphaName]; ok {
|
||||||
|
return node.categoryList
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HyphaeInCategory returns what hyphae are in the category. If the returned slice is empty, the category does not exist, and vice versa. The category name must be canonical.
|
||||||
|
func HyphaeInCategory(catName string) (hyphaList []string) {
|
||||||
|
mutex.RLock()
|
||||||
|
defer mutex.RUnlock()
|
||||||
|
if node, ok := categoryToHyphae[catName]; ok {
|
||||||
|
return node.hyphaList
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var mutex sync.RWMutex
|
||||||
|
|
||||||
|
// AddHyphaToCategory adds the hypha to the category and updates the records on the disk. If the hypha is already in the category, nothing happens. Pass canonical names.
|
||||||
|
func AddHyphaToCategory(hyphaName, catName string) {
|
||||||
|
mutex.Lock()
|
||||||
|
if node, ok := hyphaToCategories[hyphaName]; ok {
|
||||||
|
node.storeCategory(catName)
|
||||||
|
} else {
|
||||||
|
hyphaToCategories[hyphaName] = &hyphaNode{categoryList: []string{catName}}
|
||||||
|
}
|
||||||
|
|
||||||
|
if node, ok := categoryToHyphae[catName]; ok {
|
||||||
|
node.storeHypha(hyphaName)
|
||||||
|
} else {
|
||||||
|
categoryToHyphae[catName] = &categoryNode{hyphaList: []string{hyphaName}}
|
||||||
|
}
|
||||||
|
mutex.Unlock()
|
||||||
|
go saveToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveHyphaFromCategory removes the hypha from the category and updates the records on the disk. If the hypha is not in the category, nothing happens. Pass canonical names.
|
||||||
|
func RemoveHyphaFromCategory(hyphaName, catName string) {
|
||||||
|
mutex.Lock()
|
||||||
|
if node, ok := hyphaToCategories[hyphaName]; ok {
|
||||||
|
node.removeCategory(catName)
|
||||||
|
if len(node.categoryList) == 0 {
|
||||||
|
delete(hyphaToCategories, hyphaName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if node, ok := categoryToHyphae[catName]; ok {
|
||||||
|
node.removeHypha(hyphaName)
|
||||||
|
if len(node.hyphaList) == 0 {
|
||||||
|
delete(categoryToHyphae, catName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mutex.Unlock()
|
||||||
|
go saveToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveHyphaFromAllCategories removes the given hypha from all the categories.
|
||||||
|
func RemoveHyphaFromAllCategories(hyphaName string) {
|
||||||
|
cats := CategoriesWithHypha(hyphaName)
|
||||||
|
mutex.Lock()
|
||||||
|
defer mutex.Unlock()
|
||||||
|
for _, cat := range cats {
|
||||||
|
if node, ok := hyphaToCategories[hyphaName]; ok {
|
||||||
|
node.removeCategory(cat)
|
||||||
|
if len(node.categoryList) == 0 {
|
||||||
|
delete(hyphaToCategories, hyphaName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if node, ok := categoryToHyphae[cat]; ok {
|
||||||
|
node.removeHypha(hyphaName)
|
||||||
|
if len(node.hyphaList) == 0 {
|
||||||
|
delete(categoryToHyphae, cat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go saveToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameHyphaInAllCategories finds all mentions of oldName and replaces them with newName. Pass canonical names. Make sure newName is not taken. If oldName is not in any category, RenameHyphaInAllCategories is a no-op.
|
||||||
|
func RenameHyphaInAllCategories(oldName, newName string) {
|
||||||
|
mutex.Lock()
|
||||||
|
defer mutex.Unlock()
|
||||||
|
if node, ok := hyphaToCategories[oldName]; ok {
|
||||||
|
hyphaToCategories[newName] = node
|
||||||
|
delete(hyphaToCategories, oldName) // node should still be in memory 🙏
|
||||||
|
for _, catName := range node.categoryList {
|
||||||
|
if catNode, ok := categoryToHyphae[catName]; ok {
|
||||||
|
catNode.removeHypha(oldName)
|
||||||
|
catNode.storeHypha(newName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go saveToDisk()
|
||||||
|
}
|
||||||
@ -2,23 +2,25 @@ package categories
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"github.com/bouncepaw/mycorrhiza/files"
|
"log/slog"
|
||||||
"github.com/bouncepaw/mycorrhiza/util"
|
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/files"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
var categoryToHyphae = map[string]*categoryNode{}
|
var categoryToHyphae = map[string]*categoryNode{}
|
||||||
var hyphaToCategories = map[string]*hyphaNode{}
|
var hyphaToCategories = map[string]*hyphaNode{}
|
||||||
|
|
||||||
// InitCategories initializes the category system. Call it after the Structure is initialized. This function might terminate the program in case of a bad mood or filesystem faults.
|
// Init initializes the category system. Call it after the Structure is initialized. This function might terminate the program in case of a bad mood or filesystem faults.
|
||||||
func InitCategories() {
|
func Init() error {
|
||||||
var (
|
record, err := readCategoriesFromDisk()
|
||||||
record, err = readCategoriesFromDisk()
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
slog.Error("Failed to read categories from disk", "err", err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cat := range record.Categories {
|
for _, cat := range record.Categories {
|
||||||
@ -29,6 +31,7 @@ func InitCategories() {
|
|||||||
for i, hyphaName := range cat.Hyphae {
|
for i, hyphaName := range cat.Hyphae {
|
||||||
cat.Hyphae[i] = util.CanonicalName(hyphaName)
|
cat.Hyphae[i] = util.CanonicalName(hyphaName)
|
||||||
}
|
}
|
||||||
|
sort.Strings(cat.Hyphae)
|
||||||
categoryToHyphae[cat.Name] = &categoryNode{hyphaList: cat.Hyphae}
|
categoryToHyphae[cat.Name] = &categoryNode{hyphaList: cat.Hyphae}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,53 +45,49 @@ func InitCategories() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Found", len(categoryToHyphae), "categories")
|
slog.Info("Indexed categories", "n", len(categoryToHyphae))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type categoryNode struct {
|
type categoryNode struct {
|
||||||
// TODO: ensure this is sorted
|
|
||||||
hyphaList []string
|
hyphaList []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cn *categoryNode) storeHypha(hypname string) {
|
func (cn *categoryNode) storeHypha(hypname string) {
|
||||||
for _, hyphaName := range cn.hyphaList {
|
i, found := slices.BinarySearch(cn.hyphaList, hypname)
|
||||||
if hyphaName == hypname {
|
if found {
|
||||||
return
|
return
|
||||||
}
|
|
||||||
}
|
}
|
||||||
cn.hyphaList = append(cn.hyphaList, hypname)
|
cn.hyphaList = slices.Insert(cn.hyphaList, i, hypname)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cn *categoryNode) removeHypha(hypname string) {
|
func (cn *categoryNode) removeHypha(hypname string) {
|
||||||
for i, hyphaName := range cn.hyphaList {
|
i, found := slices.BinarySearch(cn.hyphaList, hypname)
|
||||||
if hyphaName == hypname {
|
if !found {
|
||||||
cn.hyphaList[i] = cn.hyphaList[len(cn.hyphaList)-1]
|
return
|
||||||
cn.hyphaList = cn.hyphaList[:len(cn.hyphaList)-1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
cn.hyphaList = slices.Delete(cn.hyphaList, i, i+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
type hyphaNode struct {
|
type hyphaNode struct {
|
||||||
// TODO: ensure this is sorted
|
|
||||||
categoryList []string
|
categoryList []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inserts sorted
|
||||||
func (hn *hyphaNode) storeCategory(cat string) {
|
func (hn *hyphaNode) storeCategory(cat string) {
|
||||||
for _, category := range hn.categoryList {
|
i, found := slices.BinarySearch(hn.categoryList, cat)
|
||||||
if category == cat {
|
if found {
|
||||||
return
|
return
|
||||||
}
|
|
||||||
}
|
}
|
||||||
hn.categoryList = append(hn.categoryList, cat)
|
hn.categoryList = slices.Insert(hn.categoryList, i, cat)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hn *hyphaNode) removeCategory(cat string) {
|
func (hn *hyphaNode) removeCategory(cat string) {
|
||||||
for i, category := range hn.categoryList {
|
i, found := slices.BinarySearch(hn.categoryList, cat)
|
||||||
if category == cat {
|
if !found {
|
||||||
hn.categoryList[i] = hn.categoryList[len(hn.categoryList)-1]
|
return
|
||||||
hn.categoryList = hn.categoryList[:len(hn.categoryList)-1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
hn.categoryList = slices.Delete(hn.categoryList, i, i+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
type catFileRecord struct {
|
type catFileRecord struct {
|
||||||
@ -124,9 +123,7 @@ func readCategoriesFromDisk() (catFileRecord, error) {
|
|||||||
var fileMutex sync.Mutex
|
var fileMutex sync.Mutex
|
||||||
|
|
||||||
func saveToDisk() {
|
func saveToDisk() {
|
||||||
var (
|
var record catFileRecord
|
||||||
record catFileRecord
|
|
||||||
)
|
|
||||||
for name, node := range categoryToHyphae {
|
for name, node := range categoryToHyphae {
|
||||||
record.Categories = append(record.Categories, catRecord{
|
record.Categories = append(record.Categories, catRecord{
|
||||||
Name: name,
|
Name: name,
|
||||||
@ -135,13 +132,16 @@ func saveToDisk() {
|
|||||||
}
|
}
|
||||||
data, err := json.MarshalIndent(record, "", "\t")
|
data, err := json.MarshalIndent(record, "", "\t")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err) // Better fail now, than later
|
slog.Error("Failed to marshal categories record", "err", err)
|
||||||
|
os.Exit(1) // Better fail now, than later
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: make the data safer somehow?? Back it up before overwriting?
|
// TODO: make the data safer somehow?? Back it up before overwriting?
|
||||||
fileMutex.Lock()
|
fileMutex.Lock()
|
||||||
err = os.WriteFile(files.CategoriesJSON(), data, 0666)
|
err = os.WriteFile(files.CategoriesJSON(), data, 0666)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
slog.Error("Failed to write categories.json", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
fileMutex.Unlock()
|
fileMutex.Unlock()
|
||||||
}
|
}
|
||||||
@ -17,13 +17,13 @@ import (
|
|||||||
// See https://mycorrhiza.wiki/hypha/configuration/fields for the
|
// See https://mycorrhiza.wiki/hypha/configuration/fields for the
|
||||||
// documentation.
|
// documentation.
|
||||||
var (
|
var (
|
||||||
WikiName string
|
WikiName string
|
||||||
NaviTitleIcon string
|
NaviTitleIcon string
|
||||||
UseSiblingHyphaeSidebar bool
|
|
||||||
|
|
||||||
HomeHypha string
|
HomeHypha string
|
||||||
UserHypha string
|
UserHypha string
|
||||||
HeaderLinksHypha string
|
HeaderLinksHypha string
|
||||||
|
RedirectionCategory string
|
||||||
|
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
URL string
|
URL string
|
||||||
@ -52,9 +52,8 @@ var WikiDir string
|
|||||||
// Config represents a Mycorrhiza wiki configuration file. This type is used
|
// Config represents a Mycorrhiza wiki configuration file. This type is used
|
||||||
// only when reading configs.
|
// only when reading configs.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
WikiName string `comment:"This name appears in the header and on various pages."`
|
WikiName string `comment:"This name appears in the header and on various pages."`
|
||||||
NaviTitleIcon string `comment:"This icon is used in the breadcrumbs bar."`
|
NaviTitleIcon string `comment:"This icon is used in the breadcrumbs bar."`
|
||||||
UseSiblingHyphaeSidebar bool `comment:"You are discouraged from using the sibling hyphae sidebar on new wikis. Enable it on old wikis that depend on it heavily."`
|
|
||||||
Hyphae
|
Hyphae
|
||||||
Network
|
Network
|
||||||
Authorization
|
Authorization
|
||||||
@ -64,9 +63,10 @@ type Config struct {
|
|||||||
|
|
||||||
// Hyphae is a section of Config which has fields related to special hyphae.
|
// Hyphae is a section of Config which has fields related to special hyphae.
|
||||||
type Hyphae struct {
|
type Hyphae struct {
|
||||||
HomeHypha string `comment:"This hypha will be the main (index) page of your wiki, served on /."`
|
HomeHypha string `comment:"This hypha will be the main (index) page of your wiki, served on /."`
|
||||||
UserHypha string `comment:"This hypha is used as a prefix for user hyphae."`
|
UserHypha string `comment:"This hypha is used as a prefix for user hyphae."`
|
||||||
HeaderLinksHypha string `comment:"You can also specify a hypha to populate your own custom header links from."`
|
HeaderLinksHypha string `comment:"You can also specify a hypha to populate your own custom header links from."`
|
||||||
|
RedirectionCategory string `comment:"Redirection hyphae will be added to this category. Default: redirection."`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network is a section of Config that has fields related to network stuff.
|
// Network is a section of Config that has fields related to network stuff.
|
||||||
@ -109,22 +109,22 @@ type Telegram struct {
|
|||||||
// configuration. Call it sometime during the initialization.
|
// configuration. Call it sometime during the initialization.
|
||||||
func ReadConfigFile(path string) error {
|
func ReadConfigFile(path string) error {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
WikiName: "Mycorrhiza Wiki",
|
WikiName: "Lyxi's Vault",
|
||||||
NaviTitleIcon: "🍄",
|
NaviTitleIcon: "🦇",
|
||||||
UseSiblingHyphaeSidebar: false,
|
|
||||||
Hyphae: Hyphae{
|
Hyphae: Hyphae{
|
||||||
HomeHypha: "home",
|
HomeHypha: "home",
|
||||||
UserHypha: "u",
|
UserHypha: "u",
|
||||||
HeaderLinksHypha: "",
|
HeaderLinksHypha: "u/alyxbatte/header",
|
||||||
|
RedirectionCategory: "redirection",
|
||||||
},
|
},
|
||||||
Network: Network{
|
Network: Network{
|
||||||
ListenAddr: "127.0.0.1:1737",
|
ListenAddr: "0.0.0.0:1737",
|
||||||
URL: "",
|
URL: "",
|
||||||
},
|
},
|
||||||
Authorization: Authorization{
|
Authorization: Authorization{
|
||||||
UseAuth: false,
|
UseAuth: true,
|
||||||
AllowRegistration: false,
|
AllowRegistration: false,
|
||||||
RegistrationLimit: 0,
|
RegistrationLimit: 1,
|
||||||
Locked: false,
|
Locked: false,
|
||||||
UseWhiteList: false,
|
UseWhiteList: false,
|
||||||
WhiteList: []string{},
|
WhiteList: []string{},
|
||||||
@ -171,10 +171,10 @@ func ReadConfigFile(path string) error {
|
|||||||
// Map the struct to the global variables
|
// Map the struct to the global variables
|
||||||
WikiName = cfg.WikiName
|
WikiName = cfg.WikiName
|
||||||
NaviTitleIcon = cfg.NaviTitleIcon
|
NaviTitleIcon = cfg.NaviTitleIcon
|
||||||
UseSiblingHyphaeSidebar = cfg.UseSiblingHyphaeSidebar
|
|
||||||
HomeHypha = cfg.HomeHypha
|
HomeHypha = cfg.HomeHypha
|
||||||
UserHypha = cfg.UserHypha
|
UserHypha = cfg.UserHypha
|
||||||
HeaderLinksHypha = cfg.HeaderLinksHypha
|
HeaderLinksHypha = cfg.HeaderLinksHypha
|
||||||
|
RedirectionCategory = cfg.RedirectionCategory
|
||||||
if ListenAddr == "" {
|
if ListenAddr == "" {
|
||||||
ListenAddr = cfg.ListenAddr
|
ListenAddr = cfg.ListenAddr
|
||||||
}
|
}
|
||||||
@ -193,8 +193,10 @@ func ReadConfigFile(path string) error {
|
|||||||
TelegramEnabled = (TelegramBotToken != "") && (TelegramBotName != "")
|
TelegramEnabled = (TelegramBotToken != "") && (TelegramBotName != "")
|
||||||
|
|
||||||
// This URL makes much more sense. If no URL is set or the protocol is forgotten, assume HTTP.
|
// This URL makes much more sense. If no URL is set or the protocol is forgotten, assume HTTP.
|
||||||
if (URL == "") || (strings.Index(URL, ":") == -1) {
|
if URL == "" {
|
||||||
URL = "http://" + ListenAddr
|
URL = "http://" + ListenAddr
|
||||||
|
} else if !strings.Contains(URL, ":") {
|
||||||
|
URL = "http://" + URL
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@ -2,10 +2,12 @@
|
|||||||
package files
|
package files
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/cfg"
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/web/static"
|
||||||
)
|
)
|
||||||
|
|
||||||
var paths struct {
|
var paths struct {
|
||||||
@ -16,13 +18,14 @@ var paths struct {
|
|||||||
tokensJSON string
|
tokensJSON string
|
||||||
userCredentialsJSON string
|
userCredentialsJSON string
|
||||||
categoriesJSON string
|
categoriesJSON string
|
||||||
|
interwikiJSON string
|
||||||
}
|
}
|
||||||
|
|
||||||
// HyphaeDir returns the path to hyphae storage.
|
// HyphaeDir returns the path to hyphae storage.
|
||||||
// A separate function is needed to easily know where a general storage path is
|
// A separate function is needed to easily know where a general storage path is
|
||||||
// needed rather than a concrete Git or the whole wiki storage path, so that we
|
// needed rather than a concrete Git or the whole wiki storage path, so that we
|
||||||
// could easily refactor things later if we'll ever support different storages.
|
// could easily refactor things later if we'll ever support different storages.
|
||||||
func HyphaeDir() string { return paths.gitRepo }
|
func HyphaeDir() string { return filepath.ToSlash(paths.gitRepo) }
|
||||||
|
|
||||||
// GitRepo returns the path to the Git repository of the wiki.
|
// GitRepo returns the path to the Git repository of the wiki.
|
||||||
func GitRepo() string { return paths.gitRepo }
|
func GitRepo() string { return paths.gitRepo }
|
||||||
@ -45,9 +48,15 @@ func CategoriesJSON() string { return paths.categoriesJSON }
|
|||||||
// FileInRoot returns full path for the given filename if it was placed in the root of the wiki structure.
|
// FileInRoot returns full path for the given filename if it was placed in the root of the wiki structure.
|
||||||
func FileInRoot(filename string) string { return filepath.Join(cfg.WikiDir, filename) }
|
func FileInRoot(filename string) string { return filepath.Join(cfg.WikiDir, filename) }
|
||||||
|
|
||||||
|
func InterwikiJSON() string { return paths.interwikiJSON }
|
||||||
|
|
||||||
// PrepareWikiRoot ensures all needed directories and files exist and have
|
// PrepareWikiRoot ensures all needed directories and files exist and have
|
||||||
// correct permissions.
|
// correct permissions.
|
||||||
func PrepareWikiRoot() error {
|
func PrepareWikiRoot() error {
|
||||||
|
isFirstInit := false
|
||||||
|
if _, err := os.Stat(cfg.WikiDir); err != nil && os.IsNotExist(err) {
|
||||||
|
isFirstInit = true
|
||||||
|
}
|
||||||
if err := os.MkdirAll(cfg.WikiDir, os.ModeDir|0777); err != nil {
|
if err := os.MkdirAll(cfg.WikiDir, os.ModeDir|0777); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -72,6 +81,43 @@ func PrepareWikiRoot() error {
|
|||||||
|
|
||||||
paths.tokensJSON = filepath.Join(paths.cacheDir, "tokens.json")
|
paths.tokensJSON = filepath.Join(paths.cacheDir, "tokens.json")
|
||||||
paths.categoriesJSON = filepath.Join(cfg.WikiDir, "categories.json")
|
paths.categoriesJSON = filepath.Join(cfg.WikiDir, "categories.json")
|
||||||
|
paths.interwikiJSON = FileInRoot("interwiki.json")
|
||||||
|
|
||||||
|
// Are we initializing the wiki for the first time?
|
||||||
|
if isFirstInit {
|
||||||
|
err := firstTimeInit()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstTimeInit takes care of any tasks that only need to happen the first time the wiki is initialized
|
||||||
|
func firstTimeInit() error {
|
||||||
|
static.InitFS(StaticFiles())
|
||||||
|
|
||||||
|
defaultFavicon, err := static.FS.Open("icon/mushroom.png")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer defaultFavicon.Close()
|
||||||
|
|
||||||
|
outputFileName := filepath.Join(cfg.WikiDir, "static", "favicon.ico")
|
||||||
|
|
||||||
|
outputFile, err := os.Create(outputFileName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer outputFile.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(outputFile, defaultFavicon)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
35
internal/hyphae/deprecated.go
Normal file
35
internal/hyphae/deprecated.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package hyphae
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchMycomarkupFile tries to read text file of the given hypha. If there is no file, empty string is returned.
|
||||||
|
//
|
||||||
|
// TODO: Get rid of this function.
|
||||||
|
func FetchMycomarkupFile(h Hypha) (string, error) {
|
||||||
|
switch h := h.(type) {
|
||||||
|
case *EmptyHypha:
|
||||||
|
return "", nil
|
||||||
|
case *MediaHypha:
|
||||||
|
if !h.HasTextFile() {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
text, err := os.ReadFile(h.TextFilePath())
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", nil
|
||||||
|
} else if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(text), nil
|
||||||
|
case *TextualHypha:
|
||||||
|
text, err := os.ReadFile(h.TextFilePath())
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", nil
|
||||||
|
} else if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(text), nil
|
||||||
|
}
|
||||||
|
panic("unreachable")
|
||||||
|
}
|
||||||
@ -1,9 +1,10 @@
|
|||||||
package hyphae
|
package hyphae
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/bouncepaw/mycorrhiza/util"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExistingHypha is not EmptyHypha. *MediaHypha and *TextualHypha implement this interface.
|
// ExistingHypha is not EmptyHypha. *MediaHypha and *TextualHypha implement this interface.
|
||||||
@ -1,11 +1,11 @@
|
|||||||
package hyphae
|
package hyphae
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/mimetype"
|
"github.com/bouncepaw/mycorrhiza/internal/mimetype"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Index finds all hypha files in the full `path` and saves them to the hypha storage.
|
// Index finds all hypha files in the full `path` and saves them to the hypha storage.
|
||||||
@ -27,11 +27,10 @@ func Index(path string) {
|
|||||||
switch foundHypha := foundHypha.(type) {
|
switch foundHypha := foundHypha.(type) {
|
||||||
case *TextualHypha: // conflict! overwrite
|
case *TextualHypha: // conflict! overwrite
|
||||||
storedHypha.mycoFilePath = foundHypha.mycoFilePath
|
storedHypha.mycoFilePath = foundHypha.mycoFilePath
|
||||||
log.Printf(
|
slog.Info("File collision",
|
||||||
"File collision for hypha ‘%s’, using ‘%s’ rather than ‘%s’\n",
|
"hypha", foundHypha.CanonicalName(),
|
||||||
foundHypha.CanonicalName(),
|
"usingFile", foundHypha.TextFilePath(),
|
||||||
foundHypha.TextFilePath(),
|
"insteadOf", storedHypha.TextFilePath(),
|
||||||
storedHypha.TextFilePath(),
|
|
||||||
)
|
)
|
||||||
case *MediaHypha: // no conflict
|
case *MediaHypha: // no conflict
|
||||||
Insert(ExtendTextualToMedia(storedHypha, foundHypha.mediaFilePath))
|
Insert(ExtendTextualToMedia(storedHypha, foundHypha.mediaFilePath))
|
||||||
@ -43,16 +42,16 @@ func Index(path string) {
|
|||||||
storedHypha.mycoFilePath = foundHypha.mycoFilePath
|
storedHypha.mycoFilePath = foundHypha.mycoFilePath
|
||||||
case *MediaHypha: // conflict! overwrite
|
case *MediaHypha: // conflict! overwrite
|
||||||
storedHypha.mediaFilePath = foundHypha.mediaFilePath
|
storedHypha.mediaFilePath = foundHypha.mediaFilePath
|
||||||
log.Printf(
|
|
||||||
"File collision for hypha ‘%s’, using ‘%s’ rather than ‘%s’\n",
|
slog.Info("File collision",
|
||||||
foundHypha.CanonicalName(),
|
"hypha", foundHypha.CanonicalName(),
|
||||||
foundHypha.MediaFilePath(),
|
"usingFile", foundHypha.MediaFilePath(),
|
||||||
storedHypha.MediaFilePath(),
|
"insteadOf", storedHypha.MediaFilePath(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Println("Indexed", Count(), "hyphae")
|
slog.Info("Indexed hyphae", "n", Count())
|
||||||
}
|
}
|
||||||
|
|
||||||
// indexHelper finds all hypha files in the full `path` and sends them to the
|
// indexHelper finds all hypha files in the full `path` and sends them to the
|
||||||
@ -61,7 +60,8 @@ func Index(path string) {
|
|||||||
func indexHelper(path string, nestLevel uint, ch chan ExistingHypha) {
|
func indexHelper(path string, nestLevel uint, ch chan ExistingHypha) {
|
||||||
nodes, err := os.ReadDir(path)
|
nodes, err := os.ReadDir(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
slog.Error("Failed to read directory", "path", path, "err", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
@ -73,7 +73,7 @@ func indexHelper(path string, nestLevel uint, ch chan ExistingHypha) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
hyphaPartPath = filepath.Join(path, node.Name())
|
hyphaPartPath = filepath.ToSlash(filepath.Join(path, node.Name()))
|
||||||
hyphaName, isText, skip = mimetype.DataFromFilename(hyphaPartPath)
|
hyphaName, isText, skip = mimetype.DataFromFilename(hyphaPartPath)
|
||||||
)
|
)
|
||||||
if !skip {
|
if !skip {
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// Package hyphae manages hypha storage and hypha types.
|
||||||
package hyphae
|
package hyphae
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -7,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// hyphaNamePattern is a pattern which all hyphae names must match.
|
// hyphaNamePattern is a pattern which all hyphae names must match.
|
||||||
var hyphaNamePattern = regexp.MustCompile(`[^?!:#@><*|"'&%{}]+`)
|
var hyphaNamePattern = regexp.MustCompile(`^[^?!:#@><*|"'&%{}]+$`)
|
||||||
|
|
||||||
// IsValidName checks for invalid characters and path traversals.
|
// IsValidName checks for invalid characters and path traversals.
|
||||||
func IsValidName(hyphaName string) bool {
|
func IsValidName(hyphaName string) bool {
|
||||||
@ -1,9 +1,10 @@
|
|||||||
package hyphae
|
package hyphae
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/bouncepaw/mycorrhiza/files"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/files"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MediaHypha struct {
|
type MediaHypha struct {
|
||||||
51
internal/migration/headings.go
Normal file
51
internal/migration/headings.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/files"
|
||||||
|
|
||||||
|
"git.sr.ht/~bouncepaw/mycomarkup/v5/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
var headingMarkerPath string
|
||||||
|
|
||||||
|
func MigrateHeadingsMaybe() {
|
||||||
|
headingMarkerPath = files.FileInRoot(".mycomarkup-heading-migration-marker.txt")
|
||||||
|
if !shouldMigrateHeadings() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
genericLineMigrator(
|
||||||
|
"Migrate headings to the new syntax",
|
||||||
|
tools.MigrateHeadings,
|
||||||
|
"Something went wrong when commiting heading migration: ")
|
||||||
|
createHeadingMarker()
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldMigrateHeadings() bool {
|
||||||
|
file, err := os.Open(headingMarkerPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to check if heading migration is needed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func createHeadingMarker() {
|
||||||
|
err := ioutil.WriteFile(
|
||||||
|
headingMarkerPath,
|
||||||
|
[]byte(`This file is used to mark that the heading migration was successful. If this file is deleted, the migration might happen again depending on the version. You should probably not touch this file at all and let it be.`),
|
||||||
|
0766,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to create heading migration marker", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
96
internal/migration/migration.go
Normal file
96
internal/migration/migration.go
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
// Package migration holds the utilities for migrating from older incompatible Mycomarkup versions.
|
||||||
|
//
|
||||||
|
// Migrations are meant to be removed couple of versions after being introduced.
|
||||||
|
//
|
||||||
|
// Available migrations:
|
||||||
|
// - Rocket links
|
||||||
|
// - Headings
|
||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/history"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func genericLineMigrator(
|
||||||
|
commitMessage string,
|
||||||
|
migrator func(string) string,
|
||||||
|
commitErrorMessage string,
|
||||||
|
) {
|
||||||
|
var (
|
||||||
|
hop = history.
|
||||||
|
Operation(history.TypeMarkupMigration).
|
||||||
|
WithMsg(commitMessage).
|
||||||
|
WithUser(user.WikimindUser())
|
||||||
|
mycoFiles = []string{}
|
||||||
|
)
|
||||||
|
|
||||||
|
for hypha := range hyphae.FilterHyphaeWithText(hyphae.YieldExistingHyphae()) {
|
||||||
|
/// Open file, read from file, modify file. If anything goes wrong, scream and shout.
|
||||||
|
|
||||||
|
file, err := os.OpenFile(hypha.TextFilePath(), os.O_RDWR, 0766)
|
||||||
|
if err != nil {
|
||||||
|
hop.WithErrAbort(err)
|
||||||
|
slog.Error("Failed to open text part file", "path", hypha.TextFilePath(), "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
_, err = io.Copy(&buf, file)
|
||||||
|
if err != nil {
|
||||||
|
hop.WithErrAbort(err)
|
||||||
|
_ = file.Close()
|
||||||
|
slog.Error("Failed to read text part file", "path", hypha.TextFilePath(), "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
oldText = buf.String()
|
||||||
|
newText = migrator(oldText)
|
||||||
|
)
|
||||||
|
if oldText != newText { // This file right here is being migrated for real.
|
||||||
|
mycoFiles = append(mycoFiles, hypha.TextFilePath())
|
||||||
|
|
||||||
|
err = file.Truncate(0)
|
||||||
|
if err != nil {
|
||||||
|
hop.WithErrAbort(err)
|
||||||
|
_ = file.Close()
|
||||||
|
slog.Error("Failed to truncate text part file", "path", hypha.TextFilePath(), "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = file.Seek(0, 0)
|
||||||
|
if err != nil {
|
||||||
|
hop.WithErrAbort(err)
|
||||||
|
_ = file.Close()
|
||||||
|
slog.Error("Failed to seek in text part file", "path", hypha.TextFilePath(), "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = file.WriteString(newText)
|
||||||
|
if err != nil {
|
||||||
|
hop.WithErrAbort(err)
|
||||||
|
_ = file.Close()
|
||||||
|
slog.Error("Failed to write to text part file", "path", hypha.TextFilePath(), "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mycoFiles) == 0 {
|
||||||
|
hop.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if hop.WithFiles(mycoFiles...).Apply().HasErrors() {
|
||||||
|
slog.Error(commitErrorMessage + hop.FirstErrorText())
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Migrated Mycomarkup documents", "n", len(mycoFiles))
|
||||||
|
}
|
||||||
55
internal/migration/rockets.go
Normal file
55
internal/migration/rockets.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/files"
|
||||||
|
|
||||||
|
"git.sr.ht/~bouncepaw/mycomarkup/v5/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
var rocketMarkerPath string
|
||||||
|
|
||||||
|
// MigrateRocketsMaybe checks if the rocket link migration marker exists. If it exists, nothing is done. If it does not, the migration takes place.
|
||||||
|
//
|
||||||
|
// This function writes logs and might terminate the program. Tons of side-effects, stay safe.
|
||||||
|
func MigrateRocketsMaybe() {
|
||||||
|
rocketMarkerPath = files.FileInRoot(".mycomarkup-rocket-link-migration-marker.txt")
|
||||||
|
if !shouldMigrateRockets() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
genericLineMigrator(
|
||||||
|
"Migrate rocket links to the new syntax",
|
||||||
|
tools.MigrateRocketLinks,
|
||||||
|
"Something went wrong when commiting rocket link migration: ",
|
||||||
|
)
|
||||||
|
createRocketLinkMarker()
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldMigrateRockets() bool {
|
||||||
|
file, err := os.Open(rocketMarkerPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to check if rocket migration is needed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func createRocketLinkMarker() {
|
||||||
|
err := ioutil.WriteFile(
|
||||||
|
rocketMarkerPath,
|
||||||
|
[]byte(`This file is used to mark that the rocket link migration was made successfully. If this file is deleted, the migration might happen again depending on the version. You should probably not touch this file at all and let it be.`),
|
||||||
|
0766,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to create rocket link migration marker")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -40,20 +40,33 @@ func DataFromFilename(fullPath string) (name string, isText bool, skip bool) {
|
|||||||
|
|
||||||
var mapMime2Ext = map[string]string{
|
var mapMime2Ext = map[string]string{
|
||||||
"application/octet-stream": "bin",
|
"application/octet-stream": "bin",
|
||||||
"image/jpeg": "jpg",
|
|
||||||
"image/gif": "gif",
|
"image/jpeg": "jpg",
|
||||||
"image/png": "png",
|
"image/gif": "gif",
|
||||||
"image/webp": "webp",
|
"image/png": "png",
|
||||||
"image/svg+xml": "svg",
|
"image/webp": "webp",
|
||||||
"image/x-icon": "ico",
|
"image/svg+xml": "svg",
|
||||||
"application/ogg": "ogg",
|
"image/x-icon": "ico",
|
||||||
"video/webm": "webm",
|
|
||||||
"audio/mp3": "mp3",
|
"application/ogg": "ogg",
|
||||||
"video/mp4": "mp4",
|
"video/webm": "webm",
|
||||||
|
"audio/mp3": "mp3",
|
||||||
|
"audio/mpeg": "mp3",
|
||||||
|
"audio/mpeg3": "mp3",
|
||||||
|
"video/mp4": "mp4",
|
||||||
|
"audio/flac": "flac",
|
||||||
|
|
||||||
|
"audio/wav": "wav",
|
||||||
|
"audio/vnd.wav": "wav",
|
||||||
|
"audio/vnd.wave": "wav",
|
||||||
|
"audio/wave": "wav",
|
||||||
|
"audio/x-pn-wav": "wav",
|
||||||
|
"audio/x-wav": "wav",
|
||||||
}
|
}
|
||||||
|
|
||||||
var mapExt2Mime = map[string]string{
|
var mapExt2Mime = map[string]string{
|
||||||
".bin": "application/octet-stream",
|
".bin": "application/octet-stream",
|
||||||
|
|
||||||
".jpg": "image/jpeg",
|
".jpg": "image/jpeg",
|
||||||
".jpeg": "image/jpeg",
|
".jpeg": "image/jpeg",
|
||||||
".gif": "image/gif",
|
".gif": "image/gif",
|
||||||
@ -61,8 +74,12 @@ var mapExt2Mime = map[string]string{
|
|||||||
".webp": "image/webp",
|
".webp": "image/webp",
|
||||||
".svg": "image/svg+xml",
|
".svg": "image/svg+xml",
|
||||||
".ico": "image/x-icon",
|
".ico": "image/x-icon",
|
||||||
|
|
||||||
".ogg": "application/ogg",
|
".ogg": "application/ogg",
|
||||||
".webm": "video/webm",
|
".webm": "video/webm",
|
||||||
".mp3": "audio/mp3",
|
".mp3": "audio/mpeg",
|
||||||
".mp4": "video/mp4",
|
".mp4": "video/mp4",
|
||||||
|
".flac": "audio/flac",
|
||||||
|
|
||||||
|
"wav": "audio/wav",
|
||||||
}
|
}
|
||||||
@ -3,9 +3,9 @@ package shroom
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae"
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/user"
|
||||||
"github.com/bouncepaw/mycorrhiza/l18n"
|
"github.com/bouncepaw/mycorrhiza/l18n"
|
||||||
"github.com/bouncepaw/mycorrhiza/user"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: get rid of this abomination
|
// TODO: get rid of this abomination
|
||||||
@ -2,11 +2,12 @@ package shroom
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae/backlinks"
|
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/history"
|
"github.com/bouncepaw/mycorrhiza/history"
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae"
|
"github.com/bouncepaw/mycorrhiza/internal/backlinks"
|
||||||
"github.com/bouncepaw/mycorrhiza/user"
|
"github.com/bouncepaw/mycorrhiza/internal/categories"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Delete deletes the hypha and makes a history record about that.
|
// Delete deletes the hypha and makes a history record about that.
|
||||||
@ -16,10 +17,14 @@ func Delete(u *user.User, h hyphae.ExistingHypha) error {
|
|||||||
WithMsg(fmt.Sprintf("Delete ‘%s’", h.CanonicalName())).
|
WithMsg(fmt.Sprintf("Delete ‘%s’", h.CanonicalName())).
|
||||||
WithUser(u)
|
WithUser(u)
|
||||||
|
|
||||||
originalText, _ := FetchTextFile(h)
|
originalText, _ := hyphae.FetchMycomarkupFile(h)
|
||||||
switch h := h.(type) {
|
switch h := h.(type) {
|
||||||
case *hyphae.MediaHypha:
|
case *hyphae.MediaHypha:
|
||||||
hop.WithFilesRemoved(h.MediaFilePath(), h.TextFilePath())
|
if h.HasTextFile() {
|
||||||
|
hop.WithFilesRemoved(h.MediaFilePath(), h.TextFilePath())
|
||||||
|
} else {
|
||||||
|
hop.WithFilesRemoved(h.MediaFilePath())
|
||||||
|
}
|
||||||
case *hyphae.TextualHypha:
|
case *hyphae.TextualHypha:
|
||||||
hop.WithFilesRemoved(h.TextFilePath())
|
hop.WithFilesRemoved(h.TextFilePath())
|
||||||
}
|
}
|
||||||
@ -27,6 +32,7 @@ func Delete(u *user.User, h hyphae.ExistingHypha) error {
|
|||||||
return hop.Errs[0]
|
return hop.Errs[0]
|
||||||
}
|
}
|
||||||
backlinks.UpdateBacklinksAfterDelete(h, originalText)
|
backlinks.UpdateBacklinksAfterDelete(h, originalText)
|
||||||
|
categories.RemoveHyphaFromAllCategories(h.CanonicalName())
|
||||||
hyphae.DeleteHypha(h)
|
hyphae.DeleteHypha(h)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
59
internal/shroom/header_links.go
Normal file
59
internal/shroom/header_links.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package shroom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/mycoopts"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/web/viewutil"
|
||||||
|
|
||||||
|
"git.sr.ht/~bouncepaw/mycomarkup/v5"
|
||||||
|
"git.sr.ht/~bouncepaw/mycomarkup/v5/blocks"
|
||||||
|
"git.sr.ht/~bouncepaw/mycomarkup/v5/mycocontext"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetHeaderLinks initializes header links by reading the configured hypha, if there is any, or resorting to default values.
|
||||||
|
func SetHeaderLinks() {
|
||||||
|
switch userLinksHypha := hyphae.ByName(cfg.HeaderLinksHypha).(type) {
|
||||||
|
case *hyphae.EmptyHypha:
|
||||||
|
setDefaultHeaderLinks()
|
||||||
|
case hyphae.ExistingHypha:
|
||||||
|
contents, err := os.ReadFile(userLinksHypha.TextFilePath())
|
||||||
|
if err != nil || len(contents) == 0 {
|
||||||
|
setDefaultHeaderLinks()
|
||||||
|
} else {
|
||||||
|
text := string(contents)
|
||||||
|
parseHeaderLinks(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDefaultHeaderLinks sets the header links to the default list of: home hypha, recent changes, hyphae list, random hypha.
|
||||||
|
func setDefaultHeaderLinks() {
|
||||||
|
viewutil.HeaderLinks = []viewutil.HeaderLink{
|
||||||
|
{"/recent-changes", "Recent changes"},
|
||||||
|
{"/list", "All hyphae"},
|
||||||
|
{"/random", "Random"},
|
||||||
|
{"/help", "Help"},
|
||||||
|
{"/category", "Categories"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHeaderLinks extracts all rocketlinks from the given text and saves them as header links.
|
||||||
|
func parseHeaderLinks(text string) {
|
||||||
|
viewutil.HeaderLinks = []viewutil.HeaderLink{}
|
||||||
|
ctx, _ := mycocontext.ContextFromStringInput(text, mycoopts.MarkupOptions(""))
|
||||||
|
// We call for side-effects
|
||||||
|
_ = mycomarkup.BlockTree(ctx, func(block blocks.Block) {
|
||||||
|
switch launchpad := block.(type) {
|
||||||
|
case blocks.LaunchPad:
|
||||||
|
for _, rocket := range launchpad.Rockets {
|
||||||
|
viewutil.HeaderLinks = append(viewutil.HeaderLinks, viewutil.HeaderLink{
|
||||||
|
Href: rocket.LinkHref(ctx),
|
||||||
|
Display: rocket.DisplayedText(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
36
internal/shroom/log.go
Normal file
36
internal/shroom/log.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package shroom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func rejectRenameLog(h hyphae.Hypha, u *user.User, errmsg string) {
|
||||||
|
slog.Info("Reject rename",
|
||||||
|
"hyphaName", h.CanonicalName(),
|
||||||
|
"username", u.Name,
|
||||||
|
"errmsg", errmsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectRemoveMediaLog(h hyphae.Hypha, u *user.User, errmsg string) {
|
||||||
|
slog.Info("Reject remove media",
|
||||||
|
"hyphaName", h.CanonicalName(),
|
||||||
|
"username", u.Name,
|
||||||
|
"errmsg", errmsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectEditLog(h hyphae.Hypha, u *user.User, errmsg string) {
|
||||||
|
slog.Info("Reject edit",
|
||||||
|
"hyphaName", h.CanonicalName(),
|
||||||
|
"username", u.Name,
|
||||||
|
"errmsg", errmsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectUploadMediaLog(h hyphae.Hypha, u *user.User, errmsg string) {
|
||||||
|
slog.Info("Reject upload media",
|
||||||
|
"hyphaName", h.CanonicalName(),
|
||||||
|
"username", u.Name,
|
||||||
|
"errmsg", errmsg)
|
||||||
|
}
|
||||||
@ -3,17 +3,24 @@ package shroom
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae/backlinks"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/history"
|
"github.com/bouncepaw/mycorrhiza/history"
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae"
|
"github.com/bouncepaw/mycorrhiza/internal/backlinks"
|
||||||
"github.com/bouncepaw/mycorrhiza/user"
|
"github.com/bouncepaw/mycorrhiza/internal/categories"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/files"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/user"
|
||||||
"github.com/bouncepaw/mycorrhiza/util"
|
"github.com/bouncepaw/mycorrhiza/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Rename renames the old hypha to the new name and makes a history record about that. Call if and only if the user has the permission to rename.
|
// Rename renames the old hypha to the new name and makes a history record about that. Call if and only if the user has the permission to rename.
|
||||||
func Rename(oldHypha hyphae.ExistingHypha, newName string, recursive bool, u *user.User) error {
|
func Rename(oldHypha hyphae.ExistingHypha, newName string, recursive bool, leaveRedirections bool, u *user.User) error {
|
||||||
|
// * bouncepaw hates this function and related renaming functions
|
||||||
if newName == "" {
|
if newName == "" {
|
||||||
rejectRenameLog(oldHypha, u, "no new name given")
|
rejectRenameLog(oldHypha, u, "no new name given")
|
||||||
return errors.New("ui.rename_noname_tip")
|
return errors.New("ui.rename_noname_tip")
|
||||||
@ -36,7 +43,10 @@ func Rename(oldHypha hyphae.ExistingHypha, newName string, recursive bool, u *us
|
|||||||
var (
|
var (
|
||||||
re = regexp.MustCompile(`(?i)` + oldHypha.CanonicalName())
|
re = regexp.MustCompile(`(?i)` + oldHypha.CanonicalName())
|
||||||
replaceName = func(str string) string {
|
replaceName = func(str string) string {
|
||||||
return re.ReplaceAllString(util.CanonicalName(str), newName)
|
namepart := strings.TrimPrefix(str, files.HyphaeDir())
|
||||||
|
// Can we drop that util.CanonicalName?:
|
||||||
|
replaced := re.ReplaceAllString(util.CanonicalName(namepart), newName)
|
||||||
|
return path.Join(files.HyphaeDir(), replaced)
|
||||||
}
|
}
|
||||||
hyphaeToRename = findHyphaeToRename(oldHypha, recursive)
|
hyphaeToRename = findHyphaeToRename(oldHypha, recursive)
|
||||||
renameMap, err = renamingPairs(hyphaeToRename, replaceName)
|
renameMap, err = renamingPairs(hyphaeToRename, replaceName)
|
||||||
@ -60,21 +70,54 @@ func Rename(oldHypha hyphae.ExistingHypha, newName string, recursive bool, u *us
|
|||||||
newName))
|
newName))
|
||||||
}
|
}
|
||||||
|
|
||||||
hop.WithFilesRenamed(renameMap).Apply()
|
hop.WithFilesRenamed(renameMap)
|
||||||
|
|
||||||
if len(hop.Errs) != 0 {
|
if len(hop.Errs) != 0 {
|
||||||
return hop.Errs[0]
|
return hop.Errs[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, h := range hyphaeToRename {
|
for _, h := range hyphaeToRename {
|
||||||
oldName := h.CanonicalName()
|
var (
|
||||||
hyphae.RenameHyphaTo(h, replaceName(h.CanonicalName()), replaceName)
|
oldName = h.CanonicalName()
|
||||||
|
newName = re.ReplaceAllString(oldName, newName)
|
||||||
|
)
|
||||||
|
hyphae.RenameHyphaTo(h, newName, replaceName)
|
||||||
backlinks.UpdateBacklinksAfterRename(h, oldName)
|
backlinks.UpdateBacklinksAfterRename(h, oldName)
|
||||||
|
categories.RenameHyphaInAllCategories(oldName, newName)
|
||||||
|
if leaveRedirections {
|
||||||
|
if err := leaveRedirection(oldName, newName, hop); err != nil {
|
||||||
|
hop.WithErrAbort(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hop.Apply()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const redirectionTemplate = `=> %[1]s | 👁️➡️ %[2]s
|
||||||
|
<= %[1]s | full
|
||||||
|
`
|
||||||
|
|
||||||
|
func leaveRedirection(oldName, newName string, hop *history.Op) error {
|
||||||
|
var (
|
||||||
|
text = fmt.Sprintf(redirectionTemplate, newName, util.BeautifulName(newName))
|
||||||
|
emptyHypha = hyphae.ByName(oldName)
|
||||||
|
)
|
||||||
|
switch emptyHypha := emptyHypha.(type) {
|
||||||
|
case *hyphae.EmptyHypha:
|
||||||
|
h := hyphae.ExtendEmptyToTextual(emptyHypha, filepath.Join(files.HyphaeDir(), oldName+".myco"))
|
||||||
|
hyphae.Insert(h)
|
||||||
|
categories.AddHyphaToCategory(oldName, cfg.RedirectionCategory)
|
||||||
|
defer backlinks.UpdateBacklinksAfterEdit(h, "")
|
||||||
|
return writeTextToDisk(h, []byte(text), hop)
|
||||||
|
default:
|
||||||
|
return errors.New("invalid state for hypha " + oldName + " renamed to " + newName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func findHyphaeToRename(superhypha hyphae.ExistingHypha, recursive bool) []hyphae.ExistingHypha {
|
func findHyphaeToRename(superhypha hyphae.ExistingHypha, recursive bool) []hyphae.ExistingHypha {
|
||||||
hyphaList := []hyphae.ExistingHypha{superhypha}
|
hyphaList := []hyphae.ExistingHypha{superhypha}
|
||||||
if recursive {
|
if recursive {
|
||||||
@ -3,11 +3,11 @@ package shroom
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae"
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
"github.com/bouncepaw/mycorrhiza/util"
|
"github.com/bouncepaw/mycorrhiza/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// YieldHyphaNamesContainingString picks hyphae with have a string in their title, sorts and iterates over them.
|
// YieldHyphaNamesContainingString picks hyphae with have a string in their title, sorts and iterates over them in alphabetical order.
|
||||||
func YieldHyphaNamesContainingString(query string) <-chan string {
|
func YieldHyphaNamesContainingString(query string) <-chan string {
|
||||||
query = util.CanonicalName(strings.TrimSpace(query))
|
query = util.CanonicalName(strings.TrimSpace(query))
|
||||||
out := make(chan string)
|
out := make(chan string)
|
||||||
4
internal/shroom/shroom.go
Normal file
4
internal/shroom/shroom.go
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// Package shroom provides utilities for hypha manipulation.
|
||||||
|
//
|
||||||
|
// Some of them are wrappers around functions provided by package hyphae. They manage history for you.
|
||||||
|
package shroom
|
||||||
@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/history"
|
"github.com/bouncepaw/mycorrhiza/history"
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae"
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
"github.com/bouncepaw/mycorrhiza/user"
|
"github.com/bouncepaw/mycorrhiza/internal/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RemoveMedia removes media from the media hypha and makes a history record about that. If it only had media, the hypha will be deleted. If it also had text, the hypha will become textual.
|
// RemoveMedia removes media from the media hypha and makes a history record about that. If it only had media, the hypha will be deleted. If it also had text, the hypha will become textual.
|
||||||
@ -4,17 +4,19 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/bouncepaw/mycorrhiza/files"
|
|
||||||
"github.com/bouncepaw/mycorrhiza/history"
|
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae"
|
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae/backlinks"
|
|
||||||
"github.com/bouncepaw/mycorrhiza/mimetype"
|
|
||||||
"github.com/bouncepaw/mycorrhiza/user"
|
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/history"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/backlinks"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/files"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/mimetype"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
func historyMessageForTextUpload(h hyphae.Hypha, userMessage string) string {
|
func historyMessageForTextUpload(h hyphae.Hypha, userMessage string) string {
|
||||||
@ -62,24 +64,25 @@ func UploadText(h hyphae.Hypha, data []byte, userMessage string, u *user.User) e
|
|||||||
return errors.New("invalid hypha name")
|
return errors.New("invalid hypha name")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oldText, err := hyphae.FetchMycomarkupFile(h)
|
||||||
|
if err != nil {
|
||||||
|
hop.Abort()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Empty data check
|
// Empty data check
|
||||||
if len(bytes.TrimSpace(data)) == 0 { // if nothing but whitespace
|
if len(bytes.TrimSpace(data)) == 0 && len(oldText) == 0 { // if nothing but whitespace
|
||||||
switch h.(type) {
|
hop.Abort()
|
||||||
case *hyphae.EmptyHypha, *hyphae.MediaHypha:
|
return nil
|
||||||
// Writing no description, it's ok, just like cancel button.
|
|
||||||
hop.Abort()
|
|
||||||
return nil
|
|
||||||
case *hyphae.TextualHypha:
|
|
||||||
// What do you want passing nothing for a textual hypha?
|
|
||||||
return errors.New("No data passed")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// At this point, we have a savable user-generated Mycomarkup document. Gotta save it.
|
// At this point, we have a savable user-generated Mycomarkup document. Gotta save it.
|
||||||
|
|
||||||
switch h := h.(type) {
|
switch h := h.(type) {
|
||||||
case *hyphae.EmptyHypha:
|
case *hyphae.EmptyHypha:
|
||||||
H := hyphae.ExtendEmptyToTextual(h, filepath.Join(files.HyphaeDir(), h.CanonicalName()+".myco"))
|
parts := []string{files.HyphaeDir()}
|
||||||
|
parts = append(parts, strings.Split(h.CanonicalName()+".myco", "\\")...)
|
||||||
|
H := hyphae.ExtendEmptyToTextual(h, filepath.Join(parts...))
|
||||||
|
|
||||||
err := writeTextToDisk(H, data, hop)
|
err := writeTextToDisk(H, data, hop)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -90,14 +93,8 @@ func UploadText(h hyphae.Hypha, data []byte, userMessage string, u *user.User) e
|
|||||||
hyphae.Insert(H)
|
hyphae.Insert(H)
|
||||||
backlinks.UpdateBacklinksAfterEdit(H, "")
|
backlinks.UpdateBacklinksAfterEdit(H, "")
|
||||||
case *hyphae.MediaHypha:
|
case *hyphae.MediaHypha:
|
||||||
oldText, err := FetchTextFile(h)
|
|
||||||
if err != nil {
|
|
||||||
hop.Abort()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: that []byte(...) part should be removed
|
// TODO: that []byte(...) part should be removed
|
||||||
if bytes.Compare(data, []byte(oldText)) == 0 {
|
if bytes.Equal(data, []byte(oldText)) {
|
||||||
// No changes! Just like cancel button
|
// No changes! Just like cancel button
|
||||||
hop.Abort()
|
hop.Abort()
|
||||||
return nil
|
return nil
|
||||||
@ -111,14 +108,14 @@ func UploadText(h hyphae.Hypha, data []byte, userMessage string, u *user.User) e
|
|||||||
|
|
||||||
backlinks.UpdateBacklinksAfterEdit(h, oldText)
|
backlinks.UpdateBacklinksAfterEdit(h, oldText)
|
||||||
case *hyphae.TextualHypha:
|
case *hyphae.TextualHypha:
|
||||||
oldText, err := FetchTextFile(h)
|
oldText, err := hyphae.FetchMycomarkupFile(h)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hop.Abort()
|
hop.Abort()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: that []byte(...) part should be removed
|
// TODO: that []byte(...) part should be removed
|
||||||
if bytes.Compare(data, []byte(oldText)) == 0 {
|
if bytes.Equal(data, []byte(oldText)) {
|
||||||
// No changes! Just like cancel button
|
// No changes! Just like cancel button
|
||||||
hop.Abort()
|
hop.Abort()
|
||||||
return nil
|
return nil
|
||||||
@ -146,7 +143,8 @@ func writeMediaToDisk(h hyphae.Hypha, mime string, data []byte) (string, error)
|
|||||||
var (
|
var (
|
||||||
ext = mimetype.ToExtension(mime)
|
ext = mimetype.ToExtension(mime)
|
||||||
// That's where the file will go
|
// That's where the file will go
|
||||||
uploadedFilePath = filepath.Join(files.HyphaeDir(), h.CanonicalName()+ext)
|
|
||||||
|
uploadedFilePath = filepath.Join(append([]string{files.HyphaeDir()}, strings.Split(h.CanonicalName()+ext, "\\")...)...)
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(uploadedFilePath), 0777); err != nil {
|
if err := os.MkdirAll(filepath.Dir(uploadedFilePath), 0777); err != nil {
|
||||||
@ -203,7 +201,7 @@ func UploadBinary(h hyphae.Hypha, mime string, file multipart.File, u *user.User
|
|||||||
if err := history.Rename(prevFilePath, uploadedFilePath); err != nil {
|
if err := history.Rename(prevFilePath, uploadedFilePath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Printf("Move ‘%s’ to ‘%s’\n", prevFilePath, uploadedFilePath)
|
slog.Info("Move file", "from", prevFilePath, "to", uploadedFilePath)
|
||||||
h.SetMediaFilePath(uploadedFilePath)
|
h.SetMediaFilePath(uploadedFilePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
130
internal/tree/tree.go
Normal file
130
internal/tree/tree.go
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
package tree
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tree returns the subhypha matrix as HTML and names of the next and previous hyphae (or empty strings).
|
||||||
|
func Tree(hyphaName string) (childrenHTML template.HTML, prev, next string) {
|
||||||
|
var (
|
||||||
|
root = child{hyphaName, true, make([]child, 0)}
|
||||||
|
descendantPrefix = hyphaName + "/"
|
||||||
|
parent = path.Dir(hyphaName) // Beware, it might be . and whatnot.
|
||||||
|
slashCount = strings.Count(hyphaName, "/")
|
||||||
|
)
|
||||||
|
for h := range hyphae.YieldExistingHyphae() {
|
||||||
|
name := h.CanonicalName()
|
||||||
|
if strings.HasPrefix(name, descendantPrefix) {
|
||||||
|
var subPath = strings.TrimPrefix(name, descendantPrefix)
|
||||||
|
addHyphaToChild(name, subPath, &root)
|
||||||
|
// A child is not a sibling, so we skip the rest.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skipping non-siblings.
|
||||||
|
if !(path.Dir(name) == parent && slashCount == strings.Count(name, "/")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name < hyphaName) && (name > prev) {
|
||||||
|
prev = name
|
||||||
|
} else if (name > hyphaName) && (name < next || next == "") {
|
||||||
|
next = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subhyphaeMatrix(root.children), prev, next
|
||||||
|
}
|
||||||
|
|
||||||
|
type child struct {
|
||||||
|
name string
|
||||||
|
exists bool
|
||||||
|
children []child
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Subhyphae links are recursive. It may end up looking like that if drawn with
|
||||||
|
pseudographics:
|
||||||
|
╔══════════════╗
|
||||||
|
║Foo ║ The presented hyphae are ./foo and ./foo/bar
|
||||||
|
║╔════════════╗║
|
||||||
|
║║Bar ║║
|
||||||
|
║╚════════════╝║
|
||||||
|
╚══════════════╝
|
||||||
|
*/
|
||||||
|
func childHTML(c *child, w io.Writer) {
|
||||||
|
sort.Slice(c.children, func(i, j int) bool {
|
||||||
|
return c.children[i].name < c.children[j].name
|
||||||
|
})
|
||||||
|
|
||||||
|
_, _ = io.WriteString(w, "<li class=\"subhyphae__entry\">\n<a class=\"subhyphae__link")
|
||||||
|
if !c.exists {
|
||||||
|
_, _ = io.WriteString(w, " wikilink_new")
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, fmt.Sprintf(
|
||||||
|
"\" href=\"/hypha/%s\">%s</a>\n",
|
||||||
|
c.name,
|
||||||
|
util.BeautifulName(path.Base(c.name)),
|
||||||
|
))
|
||||||
|
|
||||||
|
if len(c.children) > 0 {
|
||||||
|
_, _ = io.WriteString(w, "<ul>\n")
|
||||||
|
for _, child := range c.children {
|
||||||
|
childHTML(&child, w)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, "</ul>\n")
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, "</li>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func addHyphaToChild(hyphaName, subPath string, child *child) {
|
||||||
|
// when hyphaName = "root/a/b", subPath = "a/b", and child.name = "root"
|
||||||
|
// addHyphaToChild("root/a/b", "b", child{"root/a"})
|
||||||
|
// when hyphaName = "root/a/b", subPath = "b", and child.name = "root/a"
|
||||||
|
// set .exists=true for "root/a/b", and create it if it isn't there already
|
||||||
|
var exists = !strings.Contains(subPath, "/")
|
||||||
|
if exists {
|
||||||
|
var subchild = findOrCreateSubchild(subPath, child)
|
||||||
|
subchild.exists = true
|
||||||
|
} else {
|
||||||
|
var (
|
||||||
|
firstSlash = strings.IndexRune(subPath, '/')
|
||||||
|
firstDir = subPath[:firstSlash]
|
||||||
|
restOfPath = subPath[firstSlash+1:]
|
||||||
|
subchild = findOrCreateSubchild(firstDir, child)
|
||||||
|
)
|
||||||
|
addHyphaToChild(hyphaName, restOfPath, subchild)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findOrCreateSubchild(name string, baseChild *child) *child {
|
||||||
|
// when name = "a", and baseChild.name = "root"
|
||||||
|
// if baseChild.children contains "root/a", return it
|
||||||
|
// else create it and return that
|
||||||
|
var fullName = baseChild.name + "/" + name
|
||||||
|
for i := range baseChild.children {
|
||||||
|
if baseChild.children[i].name == fullName {
|
||||||
|
return &baseChild.children[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
baseChild.children = append(baseChild.children, child{fullName, false, make([]child, 0)})
|
||||||
|
return &baseChild.children[len(baseChild.children)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func subhyphaeMatrix(children []child) template.HTML {
|
||||||
|
sort.Slice(children, func(i, j int) bool {
|
||||||
|
return children[i].name < children[j].name
|
||||||
|
})
|
||||||
|
var buf strings.Builder
|
||||||
|
for _, child := range children {
|
||||||
|
childHTML(&child, &buf)
|
||||||
|
}
|
||||||
|
return template.HTML(buf.String())
|
||||||
|
}
|
||||||
@ -3,11 +3,11 @@ package user
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/cfg"
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
"github.com/bouncepaw/mycorrhiza/files"
|
"github.com/bouncepaw/mycorrhiza/internal/files"
|
||||||
"github.com/bouncepaw/mycorrhiza/util"
|
"github.com/bouncepaw/mycorrhiza/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -32,24 +32,31 @@ func usersFromFile() []*User {
|
|||||||
return users
|
return users
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
slog.Error("Failed to read users.json", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(contents, &users)
|
err = json.Unmarshal(contents, &users)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
slog.Error("Failed to unmarshal users.json contents", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
u.Name = util.CanonicalName(u.Name)
|
u.Name = util.CanonicalName(u.Name)
|
||||||
if u.Source == "" {
|
if u.Source == "" {
|
||||||
u.Source = "local"
|
u.Source = "local"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Println("Found", len(users), "users")
|
slog.Info("Indexed users", "n", len(users))
|
||||||
return users
|
return users
|
||||||
}
|
}
|
||||||
|
|
||||||
func rememberUsers(userList []*User) {
|
func rememberUsers(userList []*User) {
|
||||||
for _, user := range userList {
|
for _, user := range userList {
|
||||||
|
if !IsValidUsername(user.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
users.Store(user.Name, user)
|
users.Store(user.Name, user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -60,20 +67,22 @@ func readTokensToUsers() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
slog.Error("Failed to read tokens.json", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
var tmp map[string]string
|
var tmp map[string]string
|
||||||
err = json.Unmarshal(contents, &tmp)
|
err = json.Unmarshal(contents, &tmp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
slog.Error("Failed to unmarshal tokens.json contents", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
for token, username := range tmp {
|
for token, username := range tmp {
|
||||||
tokens.Store(token, username)
|
tokens.Store(token, username)
|
||||||
// commenceSession(username, token)
|
// commenceSession(username, token)
|
||||||
}
|
}
|
||||||
log.Println("Found", len(tmp), "active sessions")
|
slog.Info("Indexed active sessions", "n", len(tmp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveUserDatabase stores current user credentials into JSON file by configured path.
|
// SaveUserDatabase stores current user credentials into JSON file by configured path.
|
||||||
@ -91,13 +100,13 @@ func dumpUserCredentials() error {
|
|||||||
|
|
||||||
blob, err := json.MarshalIndent(userList, "", "\t")
|
blob, err := json.MarshalIndent(userList, "", "\t")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
slog.Error("Failed to marshal users.json", "err", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.WriteFile(files.UserCredentialsJSON(), blob, 0666)
|
err = os.WriteFile(files.UserCredentialsJSON(), blob, 0666)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
slog.Error("Failed to write users.json", "err", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,11 +125,11 @@ func dumpTokens() {
|
|||||||
|
|
||||||
blob, err := json.MarshalIndent(tmp, "", "\t")
|
blob, err := json.MarshalIndent(tmp, "", "\t")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
slog.Error("Failed to marshal tokens.json", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = os.WriteFile(files.TokensJSON(), blob, 0666)
|
err = os.WriteFile(files.TokensJSON(), blob, 0666)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("an error occurred in dumpTokens function:", err)
|
slog.Error("Failed to write tokens.json", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4,15 +4,17 @@ import (
|
|||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/cfg"
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
"github.com/bouncepaw/mycorrhiza/util"
|
"github.com/bouncepaw/mycorrhiza/util"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -41,6 +43,9 @@ func LogoutFromRequest(w http.ResponseWriter, rq *http.Request) {
|
|||||||
|
|
||||||
// Register registers the given user. If it fails, a non-nil error is returned.
|
// Register registers the given user. If it fails, a non-nil error is returned.
|
||||||
func Register(username, password, group, source string, force bool) error {
|
func Register(username, password, group, source string, force bool) error {
|
||||||
|
if !IsValidUsername(username) {
|
||||||
|
return fmt.Errorf("illegal username ‘%s’", username)
|
||||||
|
}
|
||||||
username = util.CanonicalName(username)
|
username = util.CanonicalName(username)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
@ -54,6 +59,8 @@ func Register(username, password, group, source string, force bool) error {
|
|||||||
return fmt.Errorf("username ‘%s’ is already taken", username)
|
return fmt.Errorf("username ‘%s’ is already taken", username)
|
||||||
case !force && cfg.RegistrationLimit > 0 && Count() >= cfg.RegistrationLimit:
|
case !force && cfg.RegistrationLimit > 0 && Count() >= cfg.RegistrationLimit:
|
||||||
return fmt.Errorf("reached the limit of registered users (%d)", cfg.RegistrationLimit)
|
return fmt.Errorf("reached the limit of registered users (%d)", cfg.RegistrationLimit)
|
||||||
|
case password == "" && source != "telegram":
|
||||||
|
return fmt.Errorf("password must not be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
@ -72,29 +79,34 @@ func Register(username, password, group, source string, force bool) error {
|
|||||||
return SaveUserDatabase()
|
return SaveUserDatabase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnknownUsername = errors.New("unknown username")
|
||||||
|
ErrWrongPassword = errors.New("wrong password")
|
||||||
|
)
|
||||||
|
|
||||||
// LoginDataHTTP logs such user in and returns string representation of an error if there is any.
|
// LoginDataHTTP logs such user in and returns string representation of an error if there is any.
|
||||||
//
|
//
|
||||||
// The HTTP parameters are used for setting header status (bad request, if it is bad) and saving a cookie.
|
// The HTTP parameters are used for setting header status (bad request, if it is bad) and saving a cookie.
|
||||||
func LoginDataHTTP(w http.ResponseWriter, rq *http.Request, username, password string) string {
|
func LoginDataHTTP(w http.ResponseWriter, username, password string) error {
|
||||||
w.Header().Set("Content-Type", "text/html;charset=utf-8")
|
w.Header().Set("Content-Type", "text/html;charset=utf-8")
|
||||||
if !HasUsername(username) {
|
if !HasUsername(username) {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
log.Println("Unknown username", username, "was entered")
|
slog.Info("Unknown username entered", "username", username)
|
||||||
return "unknown username"
|
return ErrUnknownUsername
|
||||||
}
|
}
|
||||||
if !CredentialsOK(username, password) {
|
if !CredentialsOK(username, password) {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
log.Println("A wrong password was entered for username", username)
|
slog.Info("Wrong password entered", "username", username)
|
||||||
return "wrong password"
|
return ErrWrongPassword
|
||||||
}
|
}
|
||||||
token, err := AddSession(username)
|
token, err := AddSession(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
slog.Error("Failed to add session", "username", username, "err", err)
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return err.Error()
|
return err
|
||||||
}
|
}
|
||||||
http.SetCookie(w, cookie("token", token, time.Now().Add(365*24*time.Hour)))
|
http.SetCookie(w, cookie("token", token, time.Now().Add(365*24*time.Hour)))
|
||||||
return ""
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddSession saves a session for `username` and returns a token to use.
|
// AddSession saves a session for `username` and returns a token to use.
|
||||||
@ -102,7 +114,7 @@ func AddSession(username string) (string, error) {
|
|||||||
token, err := util.RandomString(16)
|
token, err := util.RandomString(16)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
commenceSession(username, token)
|
commenceSession(username, token)
|
||||||
log.Println("New token for", username, "is", token)
|
slog.Info("Added session", "username", username)
|
||||||
}
|
}
|
||||||
return token, err
|
return token, err
|
||||||
}
|
}
|
||||||
@ -1,18 +1,17 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/cfg"
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
var usernamePattern = regexp.MustCompile(`[^?!:#@><*|"'&%{}/]+`)
|
|
||||||
|
|
||||||
// User contains information about a given user required for identification.
|
// User contains information about a given user required for identification.
|
||||||
type User struct {
|
type User struct {
|
||||||
// Name is a username. It must follow hypha naming rules.
|
// Name is a username. It must follow hypha naming rules.
|
||||||
@ -37,10 +36,10 @@ var minimalRights = map[string]int{
|
|||||||
"media": 1,
|
"media": 1,
|
||||||
"edit": 1,
|
"edit": 1,
|
||||||
"upload-binary": 1,
|
"upload-binary": 1,
|
||||||
|
"rename": 1,
|
||||||
"upload-text": 1,
|
"upload-text": 1,
|
||||||
"add-to-category": 1,
|
"add-to-category": 1,
|
||||||
"remove-from-category": 1,
|
"remove-from-category": 1,
|
||||||
"rename": 2,
|
|
||||||
"remove-media": 2,
|
"remove-media": 2,
|
||||||
"update-header-links": 3,
|
"update-header-links": 3,
|
||||||
"delete": 3,
|
"delete": 3,
|
||||||
@ -51,6 +50,7 @@ var minimalRights = map[string]int{
|
|||||||
|
|
||||||
var groups = []string{
|
var groups = []string{
|
||||||
"anon",
|
"anon",
|
||||||
|
"reader",
|
||||||
"editor",
|
"editor",
|
||||||
"trusted",
|
"trusted",
|
||||||
"moderator",
|
"moderator",
|
||||||
@ -60,6 +60,7 @@ var groups = []string{
|
|||||||
// Group — Right level
|
// Group — Right level
|
||||||
var groupRight = map[string]int{
|
var groupRight = map[string]int{
|
||||||
"anon": 0,
|
"anon": 0,
|
||||||
|
"reader": 0,
|
||||||
"editor": 1,
|
"editor": 1,
|
||||||
"trusted": 2,
|
"trusted": 2,
|
||||||
"moderator": 3,
|
"moderator": 3,
|
||||||
@ -136,10 +137,29 @@ func (user *User) ShowLockMaybe(w http.ResponseWriter, rq *http.Request) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sets a new password for the user.
|
||||||
|
func (user *User) ChangePassword(password string) error {
|
||||||
|
if user.Source != "local" {
|
||||||
|
return fmt.Errorf("Only local users can change their passwords.")
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.Password = string(hash)
|
||||||
|
return SaveUserDatabase()
|
||||||
|
}
|
||||||
|
|
||||||
// IsValidUsername checks if the given username is valid.
|
// IsValidUsername checks if the given username is valid.
|
||||||
func IsValidUsername(username string) bool {
|
func IsValidUsername(username string) bool {
|
||||||
return username != "anon" && username != "wikimind" &&
|
for _, r := range username {
|
||||||
usernamePattern.MatchString(strings.TrimSpace(username)) &&
|
if strings.ContainsRune("?!:#@><*|\"'&%{}/", r) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return username != "anon" &&
|
||||||
|
username != "wikimind" &&
|
||||||
usernameIsWhiteListed(username)
|
usernameIsWhiteListed(username)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1,6 +1,9 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import "sync"
|
import (
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
var users sync.Map
|
var users sync.Map
|
||||||
var tokens sync.Map
|
var tokens sync.Map
|
||||||
@ -9,7 +12,7 @@ var tokens sync.Map
|
|||||||
func YieldUsers() chan *User {
|
func YieldUsers() chan *User {
|
||||||
ch := make(chan *User)
|
ch := make(chan *User)
|
||||||
go func(ch chan *User) {
|
go func(ch chan *User) {
|
||||||
users.Range(func(_, v interface{}) bool {
|
users.Range(func(_, v any) bool {
|
||||||
ch <- v.(*User)
|
ch <- v.(*User)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@ -38,6 +41,15 @@ func Count() (i uint64) {
|
|||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HasAnyAdmins() bool {
|
||||||
|
for u := range YieldUsers() {
|
||||||
|
if u.Group == "admin" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// HasUsername checks whether the desired user exists
|
// HasUsername checks whether the desired user exists
|
||||||
func HasUsername(username string) bool {
|
func HasUsername(username string) bool {
|
||||||
_, has := users.Load(username)
|
_, has := users.Load(username)
|
||||||
@ -90,3 +102,24 @@ func terminateSession(token string) {
|
|||||||
tokens.Delete(token)
|
tokens.Delete(token)
|
||||||
dumpTokens()
|
dumpTokens()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func UsersInGroups() (admins []string, moderators []string, editors []string, readers []string) {
|
||||||
|
for u := range YieldUsers() {
|
||||||
|
switch u.Group {
|
||||||
|
// What if we place the users into sorted slices?
|
||||||
|
case "admin":
|
||||||
|
admins = append(admins, u.Name)
|
||||||
|
case "moderator":
|
||||||
|
moderators = append(moderators, u.Name)
|
||||||
|
case "editor", "trusted":
|
||||||
|
editors = append(editors, u.Name)
|
||||||
|
case "reader":
|
||||||
|
readers = append(readers, u.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(admins)
|
||||||
|
sort.Strings(moderators)
|
||||||
|
sort.Strings(editors)
|
||||||
|
sort.Strings(readers)
|
||||||
|
return
|
||||||
|
}
|
||||||
46
internal/version/version.go
Normal file
46
internal/version/version.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"runtime/debug"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/help"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Long is the full version string, including VCS information, that looks like
|
||||||
|
// x.y.z+hash-dirty.
|
||||||
|
var Long string
|
||||||
|
|
||||||
|
// Short is the human-friendly x.y.z part of the long version string.
|
||||||
|
var Short string
|
||||||
|
|
||||||
|
var versionRegexp = regexp.MustCompile(`This is documentation for Mycorrhiza Wiki (.*)\. `)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if b, err := help.Get("en"); err == nil {
|
||||||
|
matches := versionRegexp.FindSubmatch(b)
|
||||||
|
if matches != nil {
|
||||||
|
Short = string(matches[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Long = Short
|
||||||
|
info, ok := debug.ReadBuildInfo()
|
||||||
|
if ok {
|
||||||
|
for _, setting := range info.Settings {
|
||||||
|
if setting.Key == "vcs.revision" {
|
||||||
|
val := setting.Value
|
||||||
|
if len(val) > 7 {
|
||||||
|
val = val[:7]
|
||||||
|
}
|
||||||
|
Long += "+" + val
|
||||||
|
} else if setting.Key == "vcs.modified" {
|
||||||
|
modified, err := strconv.ParseBool(setting.Value)
|
||||||
|
if err == nil && modified {
|
||||||
|
Long += "-dirty"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
195
interwiki/interwiki.go
Normal file
195
interwiki/interwiki.go
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
// Package interwiki provides interwiki capabilities. Most of them, at least.
|
||||||
|
package interwiki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/files"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/util"
|
||||||
|
|
||||||
|
"git.sr.ht/~bouncepaw/mycomarkup/v5/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Init() error {
|
||||||
|
record, err := readInterwiki()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to read interwiki", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, wiki := range record {
|
||||||
|
wiki := wiki // This line is required
|
||||||
|
if err := wiki.canonize(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := addEntry(&wiki); err != nil {
|
||||||
|
slog.Error("Failed to add interwiki entry", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Indexed interwiki map", "n", len(listOfEntries))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dropEmptyStrings(ss []string) (clean []string) {
|
||||||
|
for _, s := range ss {
|
||||||
|
if s != "" {
|
||||||
|
clean = append(clean, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
// difference returns the elements in `a` that aren't in `b`.
|
||||||
|
// Taken from https://stackoverflow.com/a/45428032
|
||||||
|
// CC BY-SA 4.0, no changes made
|
||||||
|
func difference(a, b []string) []string {
|
||||||
|
mb := make(map[string]struct{}, len(b))
|
||||||
|
for _, x := range b {
|
||||||
|
mb[x] = struct{}{}
|
||||||
|
}
|
||||||
|
var diff []string
|
||||||
|
for _, x := range a {
|
||||||
|
if _, found := mb[x]; !found {
|
||||||
|
diff = append(diff, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
func areNamesFree(names []string) (bool, string) {
|
||||||
|
for _, name := range names {
|
||||||
|
if _, found := entriesByName[name]; found {
|
||||||
|
return false, name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var mutex sync.Mutex
|
||||||
|
|
||||||
|
func replaceEntry(oldWiki *Wiki, newWiki *Wiki) error {
|
||||||
|
diff := difference(
|
||||||
|
append(newWiki.Aliases, newWiki.Name),
|
||||||
|
append(oldWiki.Aliases, oldWiki.Name),
|
||||||
|
)
|
||||||
|
if ok, name := areNamesFree(diff); !ok {
|
||||||
|
return errors.New(name)
|
||||||
|
}
|
||||||
|
deleteEntry(oldWiki)
|
||||||
|
return addEntry(newWiki)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteEntry(wiki *Wiki) {
|
||||||
|
mutex.Lock()
|
||||||
|
defer mutex.Unlock()
|
||||||
|
|
||||||
|
// I'm being fancy here. Come on, the code here is already a mess.
|
||||||
|
// Let me have some fun.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
names := append(wiki.Aliases, wiki.Name)
|
||||||
|
for _, name := range names {
|
||||||
|
name := name // I guess we need that
|
||||||
|
delete(entriesByName, name)
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for i, w := range listOfEntries {
|
||||||
|
i, w := i, w
|
||||||
|
if w.Name == wiki.Name {
|
||||||
|
// Drop ith element.
|
||||||
|
listOfEntries[i] = listOfEntries[len(listOfEntries)-1]
|
||||||
|
listOfEntries = listOfEntries[:len(listOfEntries)-1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: There is something clearly wrong with error-returning in this function.
|
||||||
|
func addEntry(wiki *Wiki) error {
|
||||||
|
mutex.Lock()
|
||||||
|
defer mutex.Unlock()
|
||||||
|
wiki.Aliases = dropEmptyStrings(wiki.Aliases)
|
||||||
|
|
||||||
|
var (
|
||||||
|
names = append(wiki.Aliases, wiki.Name)
|
||||||
|
ok, name = areNamesFree(names)
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case !ok:
|
||||||
|
slog.Error("There are multiple uses of the same name", "name", name)
|
||||||
|
return errors.New(name)
|
||||||
|
case len(names) == 0:
|
||||||
|
slog.Error("No names passed for a new interwiki entry")
|
||||||
|
return errors.New("")
|
||||||
|
}
|
||||||
|
|
||||||
|
listOfEntries = append(listOfEntries, wiki)
|
||||||
|
for _, name := range names {
|
||||||
|
entriesByName[name] = wiki
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HrefLinkFormatFor(prefix string) (string, options.InterwikiError) {
|
||||||
|
prefix = util.CanonicalName(prefix)
|
||||||
|
if wiki, ok := entriesByName[prefix]; ok {
|
||||||
|
return wiki.LinkHrefFormat, options.Ok
|
||||||
|
}
|
||||||
|
return "", options.UnknownPrefix
|
||||||
|
}
|
||||||
|
|
||||||
|
func ImgSrcFormatFor(prefix string) (string, options.InterwikiError) {
|
||||||
|
prefix = util.CanonicalName(prefix)
|
||||||
|
if wiki, ok := entriesByName[prefix]; ok {
|
||||||
|
return wiki.ImgSrcFormat, options.Ok
|
||||||
|
}
|
||||||
|
return "", options.UnknownPrefix
|
||||||
|
}
|
||||||
|
|
||||||
|
func readInterwiki() ([]Wiki, error) {
|
||||||
|
var (
|
||||||
|
record []Wiki
|
||||||
|
fileContents, err = os.ReadFile(files.InterwikiJSON())
|
||||||
|
)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(fileContents, &record)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveInterwikiJson() {
|
||||||
|
// Trust me, wiki crashing when an admin takes an administrative action totally makes sense.
|
||||||
|
if data, err := json.MarshalIndent(listOfEntries, "", "\t"); err != nil {
|
||||||
|
slog.Error("Failed to marshal interwiki entries", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
} else if err = os.WriteFile(files.InterwikiJSON(), data, 0666); err != nil {
|
||||||
|
slog.Error("Failed to write interwiki.json", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Saved interwiki.json")
|
||||||
|
|
||||||
|
}
|
||||||
11
interwiki/map.go
Normal file
11
interwiki/map.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package interwiki
|
||||||
|
|
||||||
|
var (
|
||||||
|
listOfEntries []*Wiki
|
||||||
|
entriesByName map[string]*Wiki
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
listOfEntries = []*Wiki{}
|
||||||
|
entriesByName = map[string]*Wiki{}
|
||||||
|
}
|
||||||
147
interwiki/view_interwiki.html
Normal file
147
interwiki/view_interwiki.html
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
{{define "interwiki map"}}Intermap{{end}}
|
||||||
|
{{define "title"}}{{template "interwiki map"}}{{end}}
|
||||||
|
{{define "name"}}Name:{{end}}
|
||||||
|
{{define "aliases"}}Aliases:{{end}}
|
||||||
|
{{define "aliases (,)"}}Aliases (separated by commas):{{end}}
|
||||||
|
{{define "engine"}}Engine:{{end}}
|
||||||
|
{{define "engine/mycorrhiza"}}Mycorrhiza{{end}}
|
||||||
|
{{define "engine/betula"}}Betula{{end}}
|
||||||
|
{{define "engine/agora"}}Agora{{end}}
|
||||||
|
{{define "engine/generic"}}Generic (any website){{end}}
|
||||||
|
{{define "url"}}URL{{end}}
|
||||||
|
{{define "link href format"}}Link href attribute format string:{{end}}
|
||||||
|
{{define "img src format"}}Image src attribute format string:{{end}}
|
||||||
|
{{define "unset map"}}No interwiki map set.{{end}}
|
||||||
|
{{define "add interwiki entry"}}Add interwiki entry{{end}}
|
||||||
|
|
||||||
|
{{define "static map"}}
|
||||||
|
{{if len .Entries}}
|
||||||
|
<ul>
|
||||||
|
{{range $i, $wiki := .Entries}}
|
||||||
|
<li>
|
||||||
|
<dl>
|
||||||
|
<dt>{{template "name"}}</dt>
|
||||||
|
<dd>{{.Name}}</dd>
|
||||||
|
|
||||||
|
<dt>{{template "aliases"}}</dt>
|
||||||
|
{{range .Aliases}}<dd>{{.}}</dd>{{end}}
|
||||||
|
|
||||||
|
<dt>{{template "engine"}}</dt>
|
||||||
|
<dd>{{.Engine}}</dd>
|
||||||
|
|
||||||
|
<dt>{{template "url"}}</dt>
|
||||||
|
<dd><a href="{{.URL}}">{{.URL}}</a></dd>
|
||||||
|
|
||||||
|
<dt>{{template "link href format"}}</dt>
|
||||||
|
<dd>{{.LinkHrefFormat}}</dd>
|
||||||
|
|
||||||
|
<dt>{{template "img src format"}}</dt>
|
||||||
|
<dd>{{.ImgSrcFormat}}</dd>
|
||||||
|
</dl>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p>{{template "unset map"}}</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "authorized map"}}
|
||||||
|
{{if .Error}}
|
||||||
|
<p class="error">{{.Error}}</p>
|
||||||
|
{{end}}
|
||||||
|
{{if len .Entries}}
|
||||||
|
{{range $i, $wiki := .Entries}}
|
||||||
|
<form method="post" action="/interwiki/modify-entry/{{.Name}}">
|
||||||
|
<p>
|
||||||
|
<label for="name{{$i}}" class="required-field">{{template "name"}}</label>
|
||||||
|
<input type="text" id="name{{$i}}" name="name" required
|
||||||
|
value="{{.Name}}">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="aliases{{$i}}">{{template "aliases (,)"}}</label>
|
||||||
|
<input type="text" id="aliases{{$i}}" name="aliases"
|
||||||
|
value="{{range $j, $alias := .Aliases}}{{if gt $j 0}}, {{end}}{{.}}{{end}}">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="url{{$i}}" class="required-field">{{template "url"}}</label>
|
||||||
|
<input type="url" id="url{{$i}}" name="url" required
|
||||||
|
value="{{.URL}}">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="engine{{$i}}" class="required-field">{{template "engine"}}</label>
|
||||||
|
<select name="engine" id="engine{{$i}}" required>
|
||||||
|
<option value="mycorrhiza" {{if eq .Engine "mycorrhiza"}}selected{{end}}>{{template "engine/mycorrhiza"}} 🍄</option>
|
||||||
|
<option value="betula" {{if eq .Engine "betula"}}selected{{end}}>{{template "engine/betula"}} 🌳</option>
|
||||||
|
<option value="agora" {{if eq .Engine "agora"}}selected{{end}}>{{template "engine/agora"}} ἀ</option>
|
||||||
|
<option value="generic" {{if eq .Engine "generic"}}selected{{end}}>{{template "engine/generic"}}</option>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="link-href-format{{$i}}">{{template "link href format"}}</label>
|
||||||
|
<input type="url" id="link-href-format{{$i}}" name="link-href-format"
|
||||||
|
value="{{.LinkHrefFormat}}">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="img-src-format{{$i}}">{{template "img src format"}}</label>
|
||||||
|
<input type="url" id="img-src-format{{$i}}" name="img-src-format"
|
||||||
|
value="{{.ImgSrcFormat}}">
|
||||||
|
</p>
|
||||||
|
<input type="submit" class="btn" value="{{template `save`}}">
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/interwiki/add-entry">
|
||||||
|
<h2>{{template "add interwiki entry"}}</h2>
|
||||||
|
<p>
|
||||||
|
<label for="name" class="required-field">{{template "name"}}</label>
|
||||||
|
<input type="text" id="name" name="name" required
|
||||||
|
placeholder="home_wiki">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="aliases">{{template "aliases (,)"}}</label>
|
||||||
|
<input type="text" id="aliases" name="aliases"
|
||||||
|
placeholder="homewiki, hw">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="url" class="required-field">{{template "url"}}</label>
|
||||||
|
<input type="url" id="url" name="url" required
|
||||||
|
placeholder="https://wiki.example.org">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="engine" class="required-field">{{template "engine"}}</label>
|
||||||
|
<select name="engine" id="engine" required>
|
||||||
|
<option value="mycorrhiza">{{template "engine/mycorrhiza"}} 🍄</option>
|
||||||
|
<option value="betula">{{template "engine/betula"}} 🌳</option>
|
||||||
|
<option value="agora">{{template "engine/agora"}} ἀ</option>
|
||||||
|
<option value="generic">{{template "engine/generic"}}</option>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="link-href-format">{{template "link href format"}}</label>
|
||||||
|
<input type="url" id="link-href-format" name="link-href-format"
|
||||||
|
placeholder="https://wiki.example.org/view/{NAME}">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="img-src-format">{{template "img src format"}}</label>
|
||||||
|
<input type="url" id="img-src-format" name="img-src-format"
|
||||||
|
placeholder="https://wiki.example.org/media/{NAME}">
|
||||||
|
</p>
|
||||||
|
<input type="submit" class="btn" value="Add entry">
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "documentation."}}Documentation.{{end}}
|
||||||
|
{{define "edit separately."}}Edit and save every the entry separately.{{end}}
|
||||||
|
{{define "body"}}
|
||||||
|
<main class="main-width">
|
||||||
|
<h1>{{template "interwiki map"}}</h1>
|
||||||
|
{{if .CanEdit}}
|
||||||
|
<p><a href="/help/en/interwiki">{{template "documentation."}}</a> {{template "edit separately."}}</p>
|
||||||
|
{{template "authorized map" .}}
|
||||||
|
{{else}}
|
||||||
|
<p><a href="/help/en/interwiki">{{template "documentation."}}</a></p>
|
||||||
|
{{template "static map" .}}
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
{{end}}
|
||||||
44
interwiki/view_name_taken.html
Normal file
44
interwiki/view_name_taken.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{{define "body"}}
|
||||||
|
<main class="main-width">
|
||||||
|
<h1>{{block "heading" .}}Name taken{{end}}</h1>
|
||||||
|
<p>{{block "tip" .TakenName}}Name <kbd>{{.}}</kbd> is already taken, please choose a different one.{{end}}</p>
|
||||||
|
<form method="post" action="/interwiki/{{.Action}}">
|
||||||
|
<p>
|
||||||
|
<label for="name" class="required-field">Name:</label>
|
||||||
|
<input type="text" id="name" name="name" required
|
||||||
|
value="{{.Name}}">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="aliases">Aliases (separated by commas):</label>
|
||||||
|
<input type="text" id="aliases" name="aliases"
|
||||||
|
value="{{range $j, $alias := .Aliases}}{{if gt $j 0}}, {{end}}{{.}}{{end}}">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="url" class="required-field">URL:</label>
|
||||||
|
<input type="url" id="url" name="url" required
|
||||||
|
value="{{.URL}}">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="engine" class="required-field">Engine:</label>
|
||||||
|
<select name="engine" id="engine" required>
|
||||||
|
<option value="mycorrhiza" {{if eq .Engine "mycorrhiza"}}selected{{end}}>Mycorrhiza 🍄</option>
|
||||||
|
<option value="betula" {{if eq .Engine "betula"}}selected{{end}}>Betula 🌳</option>
|
||||||
|
<option value="agora" {{if eq .Engine "agora"}}selected{{end}}>Agora ἀ</option>
|
||||||
|
<option value="generic" {{if eq .Engine "generic"}}selected{{end}}>Generic (any website)</option>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="link-href-format">Link href attribute format string:</label>
|
||||||
|
<input type="url" id="link-href-format" name="link-href-format"
|
||||||
|
value="{{.LinkHrefFormat}}">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="img-src-format">Image src attribute format string:</label>
|
||||||
|
<input type="url" id="img-src-format" name="img-src-format"
|
||||||
|
value="{{.ImgSrcFormat}}">
|
||||||
|
</p>
|
||||||
|
<input type="submit" class="btn" value="Save">
|
||||||
|
<a class="btn btn_weak" href="/interwiki">{{template "cancel"}}</a>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{{end}}
|
||||||
133
interwiki/web.go
Normal file
133
interwiki/web.go
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
package interwiki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/web/viewutil"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed *html
|
||||||
|
fs embed.FS
|
||||||
|
ruTranslation = `
|
||||||
|
{{define "interwiki map"}}Интеркарта{{end}}
|
||||||
|
{{define "name"}}Название:{{end}}
|
||||||
|
{{define "aliases"}}Псевдонимы:{{end}}
|
||||||
|
{{define "aliases (,)"}}Псевдонимы (разделённые запятыми):{{end}}
|
||||||
|
{{define "engine"}}Движок:{{end}}
|
||||||
|
{{define "engine/mycorrhiza"}}Микориза{{end}}
|
||||||
|
{{define "engine/betula"}}Бетула{{end}}
|
||||||
|
{{define "engine/agora"}}Агора{{end}}
|
||||||
|
{{define "engine/generic"}}Любой сайт{{end}}
|
||||||
|
{{define "link href format"}}Строка форматирования атрибута href ссылки:{{end}}
|
||||||
|
{{define "img src format"}}Строка форматирования атрибута src изображения:{{end}}
|
||||||
|
{{define "unset map"}}Интеркарта не задана.{{end}}
|
||||||
|
{{define "documentation."}}Документация.{{end}}
|
||||||
|
{{define "edit separately."}}Изменяйте записи по отдельности.{{end}}
|
||||||
|
{{define "add interwiki entry"}}Добавить запись в интеркарту{{end}}
|
||||||
|
`
|
||||||
|
chainInterwiki viewutil.Chain
|
||||||
|
chainNameTaken viewutil.Chain
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitHandlers(rtr *mux.Router) {
|
||||||
|
chainInterwiki = viewutil.CopyEnRuWith(fs, "view_interwiki.html", ruTranslation)
|
||||||
|
chainNameTaken = viewutil.CopyEnRuWith(fs, "view_name_taken.html", ruTranslation)
|
||||||
|
rtr.HandleFunc("/interwiki", handlerInterwiki)
|
||||||
|
rtr.HandleFunc("/interwiki/add-entry", handlerAddEntry).Methods(http.MethodPost)
|
||||||
|
rtr.HandleFunc("/interwiki/modify-entry/{target}", handlerModifyEntry).Methods(http.MethodPost)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readInterwikiEntryFromRequest(rq *http.Request) Wiki {
|
||||||
|
wiki := Wiki{
|
||||||
|
Name: rq.PostFormValue("name"),
|
||||||
|
Aliases: strings.Split(rq.PostFormValue("aliases"), ","),
|
||||||
|
URL: rq.PostFormValue("url"),
|
||||||
|
LinkHrefFormat: rq.PostFormValue("link-href-format"),
|
||||||
|
ImgSrcFormat: rq.PostFormValue("img-src-format"),
|
||||||
|
Engine: WikiEngine(rq.PostFormValue("engine")),
|
||||||
|
}
|
||||||
|
wiki.canonize()
|
||||||
|
return wiki
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlerModifyEntry(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
var (
|
||||||
|
oldData *Wiki
|
||||||
|
ok bool
|
||||||
|
name = mux.Vars(rq)["target"]
|
||||||
|
newData = readInterwikiEntryFromRequest(rq)
|
||||||
|
)
|
||||||
|
|
||||||
|
if oldData, ok = entriesByName[name]; !ok {
|
||||||
|
slog.Info("Could not modify entry",
|
||||||
|
"name", name,
|
||||||
|
"reason", "does not exist")
|
||||||
|
viewutil.HandlerNotFound(w, rq)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := replaceEntry(oldData, &newData); err != nil {
|
||||||
|
slog.Info("Could not modify entry",
|
||||||
|
"name", name,
|
||||||
|
"reason", "one of the proposed aliases or the name is taken",
|
||||||
|
"err", err)
|
||||||
|
viewNameTaken(viewutil.MetaFrom(w, rq), oldData, err.Error(), "modify-entry/"+name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saveInterwikiJson()
|
||||||
|
slog.Info("Modified entry", "name", name)
|
||||||
|
http.Redirect(w, rq, "/interwiki", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlerAddEntry(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
wiki := readInterwikiEntryFromRequest(rq)
|
||||||
|
if err := addEntry(&wiki); err != nil {
|
||||||
|
viewNameTaken(viewutil.MetaFrom(w, rq), &wiki, err.Error(), "add-entry")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveInterwikiJson()
|
||||||
|
http.Redirect(w, rq, "/interwiki", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
type nameTakenData struct {
|
||||||
|
*viewutil.BaseData
|
||||||
|
*Wiki
|
||||||
|
TakenName string
|
||||||
|
Action string
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewNameTaken(meta viewutil.Meta, wiki *Wiki, takenName, action string) {
|
||||||
|
viewutil.ExecutePage(meta, chainNameTaken, nameTakenData{
|
||||||
|
BaseData: &viewutil.BaseData{},
|
||||||
|
Wiki: wiki,
|
||||||
|
TakenName: takenName,
|
||||||
|
Action: action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlerInterwiki(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
viewInterwiki(viewutil.MetaFrom(w, rq))
|
||||||
|
}
|
||||||
|
|
||||||
|
type interwikiData struct {
|
||||||
|
*viewutil.BaseData
|
||||||
|
Entries []*Wiki
|
||||||
|
CanEdit bool
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewInterwiki(meta viewutil.Meta) {
|
||||||
|
viewutil.ExecutePage(meta, chainInterwiki, interwikiData{
|
||||||
|
BaseData: &viewutil.BaseData{},
|
||||||
|
Entries: listOfEntries,
|
||||||
|
CanEdit: meta.U.Group == "admin",
|
||||||
|
Error: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
96
interwiki/wiki.go
Normal file
96
interwiki/wiki.go
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
package interwiki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WikiEngine is an enumeration of supported interwiki targets.
|
||||||
|
type WikiEngine string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Mycorrhiza WikiEngine = "mycorrhiza"
|
||||||
|
Betula WikiEngine = "betula"
|
||||||
|
Agora WikiEngine = "agora"
|
||||||
|
// Generic is any website.
|
||||||
|
Generic WikiEngine = "generic"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (we WikiEngine) Valid() bool {
|
||||||
|
switch we {
|
||||||
|
case Mycorrhiza, Betula, Agora, Generic:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wiki is an entry in the interwiki map.
|
||||||
|
type Wiki struct {
|
||||||
|
// Name is the name of the wiki, and is also one of the possible prefices.
|
||||||
|
Name string `json:"name"`
|
||||||
|
|
||||||
|
// Aliases are alternative prefices you can use instead of Name. This slice can be empty.
|
||||||
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
|
|
||||||
|
// URL is the address of the wiki.
|
||||||
|
URL string `json:"url"`
|
||||||
|
|
||||||
|
// LinkHrefFormat is a format string for interwiki links. See Mycomarkup internal docs hidden deep inside for more information.
|
||||||
|
//
|
||||||
|
// This field is optional. If it is not set, it is derived from other data. See the code.
|
||||||
|
LinkHrefFormat string `json:"link_href_format"`
|
||||||
|
|
||||||
|
ImgSrcFormat string `json:"img_src_format"`
|
||||||
|
|
||||||
|
// Engine is the engine of the wiki. Invalid values will result in a start-up error.
|
||||||
|
Engine WikiEngine `json:"engine"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wiki) canonize() error {
|
||||||
|
switch {
|
||||||
|
case w.Name == "":
|
||||||
|
slog.Error("A site in the interwiki map has no name")
|
||||||
|
return errors.New("site with no name")
|
||||||
|
case w.URL == "":
|
||||||
|
slog.Error("Site in the interwiki map has no URL", "name", w.Name)
|
||||||
|
return errors.New("site with no URL")
|
||||||
|
case !w.Engine.Valid():
|
||||||
|
slog.Error("Site in the interwiki map has an unknown engine",
|
||||||
|
"siteName", w.Name,
|
||||||
|
"engine", w.Engine,
|
||||||
|
)
|
||||||
|
return errors.New("unknown engine")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Name = util.CanonicalName(w.Name)
|
||||||
|
for i, alias := range w.Aliases {
|
||||||
|
w.Aliases[i] = util.CanonicalName(alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.LinkHrefFormat == "" {
|
||||||
|
switch w.Engine {
|
||||||
|
case Mycorrhiza:
|
||||||
|
w.LinkHrefFormat = fmt.Sprintf("%s/hypha/{NAME}", w.URL)
|
||||||
|
case Betula:
|
||||||
|
w.LinkHrefFormat = fmt.Sprintf("%s/{BETULA-NAME}", w.URL)
|
||||||
|
case Agora:
|
||||||
|
w.LinkHrefFormat = fmt.Sprintf("%s/node/{NAME}", w.URL)
|
||||||
|
default:
|
||||||
|
w.LinkHrefFormat = fmt.Sprintf("%s/{NAME}", w.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.ImgSrcFormat == "" {
|
||||||
|
switch w.Engine {
|
||||||
|
case Mycorrhiza:
|
||||||
|
w.ImgSrcFormat = fmt.Sprintf("%s/binary/{NAME}", w.URL)
|
||||||
|
default:
|
||||||
|
w.ImgSrcFormat = fmt.Sprintf("%s/{NAME}", w.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"users_title": "Manage users",
|
|
||||||
"users_create": "Create a new user",
|
|
||||||
"users_reindex": "Reindex users",
|
|
||||||
"users_name": "Name",
|
|
||||||
"users_password": "Password",
|
|
||||||
"users_group": "Group",
|
|
||||||
"users_registered": "Registered at",
|
|
||||||
"users_actions": "Actions",
|
|
||||||
"users_notime": "unknown",
|
|
||||||
"users_edit": "Edit",
|
|
||||||
|
|
||||||
"user_title": "User %s",
|
|
||||||
"user_group_heading": "Change group",
|
|
||||||
"user_update": "Update",
|
|
||||||
"user_delete_heading": "Delete user",
|
|
||||||
"user_delete_tip": "Remove the user from the database. Changes made by the user will be preserved. It will be possible to take this username later.",
|
|
||||||
"user_delete_warn": "Are you sure you want to delete {{.name}} from the database? This action is irreversible.",
|
|
||||||
"user_delete": "Delete",
|
|
||||||
|
|
||||||
"newuser_title": "New user",
|
|
||||||
"newuser_create": "Create"
|
|
||||||
}
|
|
||||||
@ -3,33 +3,29 @@
|
|||||||
"password": "Password",
|
"password": "Password",
|
||||||
|
|
||||||
"register_title": "Register",
|
"register_title": "Register",
|
||||||
"register_header": "Register on {{.name}}",
|
"register_header": "",
|
||||||
"register_button": "Register",
|
"register_button": "Register",
|
||||||
|
|
||||||
"login_title": "Login",
|
|
||||||
"login_header": "Log in to {{.name}}",
|
|
||||||
"login_button": "Log in",
|
|
||||||
|
|
||||||
"logout_title": "Logout?",
|
"logout_title": "",
|
||||||
"logout_header": "Log out?",
|
"logout_header": "Log out?",
|
||||||
"logout_button": "Confirm",
|
"logout_button": "Confirm",
|
||||||
"logout_anon": "You cannot log out because you are not logged in.",
|
"logout_anon": "",
|
||||||
|
|
||||||
"lock_title": "Locked",
|
"lock_title": "Locked",
|
||||||
|
|
||||||
"password_tip": "The server stores your password in an encrypted form; even administrators cannot read it.",
|
"password_tip": "",
|
||||||
"cookie_tip": "By submitting this form you give this wiki a permission to store cookies in your browser. It lets the engine associate your edits with you. You will stay logged in until you log out.",
|
"cookie_tip": "",
|
||||||
"telegram_tip": "You can log in using Telegram. It only works if you have set your @username in Telegram and this username is free on this wiki.",
|
"telegram_tip": "",
|
||||||
|
|
||||||
"noauth": "Authentication is disabled. You can make edits anonymously.",
|
"noauth": "",
|
||||||
"noregister": "Registrations are currently closed. Administrators can make an account for you by hand; contact them.",
|
"noregister": "Registrations are currently closed. Administrators can make an account for you by hand; contact them.",
|
||||||
|
|
||||||
"error_username": "Unknown username.",
|
"error_username": "",
|
||||||
"error_password": "Wrong password.",
|
"error_password": "",
|
||||||
"error_telegram": "Could not authorize using Telegram.",
|
"error_telegram": "",
|
||||||
|
|
||||||
"go_back": "Go back",
|
"go_back": "Go back",
|
||||||
"go_home": "Go home",
|
"go_home": "",
|
||||||
"go_login": "Go to the login page",
|
"go_login": "Go to the login page",
|
||||||
"try_again": "Try again"
|
"try_again": "Try again"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,33 +3,7 @@
|
|||||||
"register": "Register",
|
"register": "Register",
|
||||||
"title_search": "Search by title",
|
"title_search": "Search by title",
|
||||||
"admin_panel": "Admin panel",
|
"admin_panel": "Admin panel",
|
||||||
|
|
||||||
"search_results_title": "Search: {{.query}}",
|
|
||||||
"search_results_query": "Search results for ‘{{.query}}’",
|
|
||||||
"search_results_desc": "Every hypha name has been compared with the query. Hyphae that have matched the query are listed below.",
|
|
||||||
|
|
||||||
"backlinks_title": "Backlinks to {{.hypha_name}}",
|
|
||||||
"backlinks_heading": "Backlinks to {{.hypha_link}}",
|
|
||||||
"backlinks_desc": "Hyphae which have a link to this hypha, embed it as an image or transclude it are listed below.",
|
|
||||||
|
|
||||||
"list_title": "List of pages",
|
|
||||||
"list_heading": "List of hyphae",
|
|
||||||
"list_desc": "This wiki has {{.n}} %s.",
|
|
||||||
"list_desc+one": "hypha",
|
|
||||||
"list_desc+other": "hyphae",
|
|
||||||
|
|
||||||
"edit_link": "Edit text",
|
|
||||||
"logout_link": "Log out",
|
|
||||||
"history_link": "View history",
|
|
||||||
"rename_link": "Rename",
|
|
||||||
"delete_link": "Delete",
|
|
||||||
"text_link": "View markup",
|
|
||||||
"media_link": "Manage media",
|
|
||||||
"backlinks_link": "{{.n}} backlink%s",
|
|
||||||
"backlinks_link+one": "",
|
|
||||||
"backlinks_link+other": "s",
|
|
||||||
|
|
||||||
"sibling_hyphae": "Sibling hyphae",
|
|
||||||
"subhyphae": "Subhyphae",
|
"subhyphae": "Subhyphae",
|
||||||
|
|
||||||
"random_no_hyphae": "There are no hyphae",
|
"random_no_hyphae": "There are no hyphae",
|
||||||
@ -64,30 +38,16 @@
|
|||||||
"act_norights_edit": "You must be an editor to edit a hypha",
|
"act_norights_edit": "You must be an editor to edit a hypha",
|
||||||
"act_norights_upload_media": "You must be an editor to upload media",
|
"act_norights_upload_media": "You must be an editor to upload media",
|
||||||
|
|
||||||
"ask_delete": "Delete %s?",
|
|
||||||
"ask_delete_tip": "In this version of Mycorrhiza Wiki you cannot undelete a deleted hypha but the history can still be accessed.",
|
|
||||||
"ask_remove_media": "Remove media from %s?",
|
"ask_remove_media": "Remove media from %s?",
|
||||||
"ask_really": "Do you really want to {{.verb}} hypha {{.name}}?",
|
"ask_really": "Do you really want to {{.verb}} hypha {{.name}}?",
|
||||||
"ask_delete_verb": "delete",
|
"ask_delete_verb": "delete",
|
||||||
"ask_remove_media_verb": "remove_media",
|
"ask_remove_media_verb": "remove_media",
|
||||||
|
|
||||||
"history_title": "History of %s",
|
|
||||||
|
|
||||||
"recent_title": "{{.n}} recent change%s",
|
|
||||||
"recent_title+one": "",
|
|
||||||
"recent_title+other": "s",
|
|
||||||
"recent_heading": "Recent Changes",
|
|
||||||
"recent_count_pre": "See",
|
|
||||||
"recent_count_post": "recent changes",
|
|
||||||
"recent_subscribe": "Subscribe via {{.rss}}, {{.atom}} or {{.json}}",
|
|
||||||
"recent_subscribe_json": "JSON feed",
|
|
||||||
"recent_empty": "Could not find any recent changes.",
|
|
||||||
|
|
||||||
"diff_title": "Diff of {{.name}} at {{.rev}}",
|
"diff_title": "Diff of {{.name}} at {{.rev}}",
|
||||||
|
|
||||||
"revision_title": "{{.name}} at {{.rev}}",
|
"revision_title": "",
|
||||||
"revision_warning": "Please note that viewing media is not supported in history for now.",
|
"revision_warning": "",
|
||||||
"revision_link": "Get Mycomarkup source of this revision",
|
"revision_link": "",
|
||||||
"revision_no_text": "This hypha had no text at this revision.",
|
"revision_no_text": "This hypha had no text at this revision.",
|
||||||
|
|
||||||
"about_title": "About {{.name}}",
|
"about_title": "About {{.name}}",
|
||||||
@ -98,44 +58,12 @@
|
|||||||
"reindex_no_rights": "You must be an admin to reindex hyphae.",
|
"reindex_no_rights": "You must be an admin to reindex hyphae.",
|
||||||
"header_no_rights": "You must be a moderator to update header links.",
|
"header_no_rights": "You must be a moderator to update header links.",
|
||||||
|
|
||||||
"notexist_heading": "This hypha does not exist",
|
|
||||||
"notexist_norights": "You are not authorized to create new hyphae. Here is what you can do:",
|
|
||||||
"notexist_login": "Log in to your account, if you have one",
|
|
||||||
"notexist_register": "Register a new account",
|
|
||||||
"notexist_write": "Write a text",
|
|
||||||
"notexist_write_tip1": "Write a note, a diary, an article, a story or anything textual using {{.myco}}. Full history of edits to the document will be saved.",
|
|
||||||
"notexist_write_myco": "Mycomarkup",
|
|
||||||
"notexist_write_tip2": "Make sure to follow this wiki's writing conventions if there are any.",
|
|
||||||
"notexist_write_button": "Create",
|
|
||||||
"notexist_media": "Upload a media",
|
|
||||||
"notexist_media_tip1": "Upload a picture, a video or an audio. Most common formats can be accessed from the browser, others can be only downloaded afterwards. You can write a description for the media later.",
|
|
||||||
|
|
||||||
"media_download": "Download media",
|
"media_download": "Download media",
|
||||||
"media_novideo": "Your browser does not support video.",
|
"media_novideo": "Your browser does not support video.",
|
||||||
"media_novideo_link": "Download video",
|
"media_novideo_link": "Download video",
|
||||||
"media_noaudio": "Your browser does not support audio.",
|
"media_noaudio": "Your browser does not support audio.",
|
||||||
"media_noaudio_link": "Download audio",
|
"media_noaudio_link": "Download audio",
|
||||||
|
|
||||||
"media_title": "Media of {{.name}}",
|
|
||||||
"media_empty": "This hypha has no media, you can upload it here.",
|
|
||||||
"media_tip": "You can manage the hypha's media on this page.",
|
|
||||||
"media_what_is": "What is media?",
|
|
||||||
"media_upload": "Upload",
|
|
||||||
"media_stat": "Stat",
|
|
||||||
"media_stat_size": "File size:",
|
|
||||||
"media_size_value": "{{.n}} byte%s",
|
|
||||||
"media_size_value+one": "",
|
|
||||||
"media_size_value+other": "s",
|
|
||||||
"media_stat_mime": "MIME type:",
|
|
||||||
"media_include": "Include",
|
|
||||||
"media_include_tip": "This media is an image. To include it in a hypha, use a syntax like this:",
|
|
||||||
"media_new": "media",
|
|
||||||
"media_new_tip": "You can upload a new media. Please do not upload too big pictures unless you need to because may not want to wait for big pictures to load.",
|
|
||||||
"media_remove": "Remove media",
|
|
||||||
"media_remove_tip": "Please note that you don't have to remove media before uploading a new media.",
|
|
||||||
"media_remove_button": "Remove media",
|
|
||||||
|
|
||||||
"close_dialog": "Close this dialog",
|
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel"
|
||||||
}
|
}
|
||||||
|
|||||||
11
l18n/l18n.go
11
l18n/l18n.go
@ -21,7 +21,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@ -40,6 +40,7 @@ type Localizer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// locales is a filesystem containing all localization files.
|
// locales is a filesystem containing all localization files.
|
||||||
|
//
|
||||||
//go:embed en ru
|
//go:embed en ru
|
||||||
var locales embed.FS
|
var locales embed.FS
|
||||||
|
|
||||||
@ -77,7 +78,7 @@ func init() {
|
|||||||
|
|
||||||
var strings map[string]string
|
var strings map[string]string
|
||||||
if err := json.Unmarshal(contents, &strings); err != nil {
|
if err := json.Unmarshal(contents, &strings); err != nil {
|
||||||
log.Fatalf("error while parsing %s: %v", path, err)
|
slog.Error("Failed to unmarshal localization file", "path", path, "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, value := range strings {
|
for key, value := range strings {
|
||||||
@ -118,7 +119,7 @@ func (t Localizer) GetWithLocale(locale, key string, replacements ...*Replacemen
|
|||||||
|
|
||||||
// If the str doesn't have any substitutions, no need to
|
// If the str doesn't have any substitutions, no need to
|
||||||
// template.Execute.
|
// template.Execute.
|
||||||
if strings.Index(str, "}}") == -1 {
|
if !strings.Contains(str, "}}") {
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,7 +146,7 @@ func (t Localizer) GetPlural(key string, n int, replacements ...*Replacements) s
|
|||||||
|
|
||||||
// As in the original, we skip templating if have nothing to replace
|
// As in the original, we skip templating if have nothing to replace
|
||||||
// (however, it's strange case for plurals)
|
// (however, it's strange case for plurals)
|
||||||
if strings.Index(str, "}}") == -1 {
|
if !strings.Contains(str, "}}") {
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,7 +165,7 @@ func (t Localizer) GetPlural64(key string, n int64, replacements ...*Replacement
|
|||||||
|
|
||||||
// As in the original, we skip templating if have nothing to replace
|
// As in the original, we skip templating if have nothing to replace
|
||||||
// (however, it's strange case for plurals)
|
// (however, it's strange case for plurals)
|
||||||
if strings.Index(str, "}}") == -1 {
|
if !strings.Contains(str, "}}") {
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"users_title": "Менеджер пользователей",
|
|
||||||
"users_create": "Создать пользователя",
|
|
||||||
"users_reindex": "Переиндексировать пользователей",
|
|
||||||
"users_name": "Имя",
|
|
||||||
"users_password": "Пароль",
|
|
||||||
"users_group": "Группа",
|
|
||||||
"users_registered": "Время создания",
|
|
||||||
"users_actions": "Действия",
|
|
||||||
"users_notime": "неизвестно",
|
|
||||||
"users_edit": "Изменить",
|
|
||||||
|
|
||||||
"user_title": "Пользователь %s",
|
|
||||||
"user_group_heading": "Изменить группу",
|
|
||||||
"user_update": "Обновить",
|
|
||||||
"user_delete_heading": "Удалить пользователя",
|
|
||||||
"user_delete_tip": "Удаляет пользователя из базы данных. Правки пользователя будут сохранены. Имя пользователя освободится для повторной регистрации.",
|
|
||||||
"user_delete_warn": "Вы уверены, что хотите удалить {{.name}} из базы данных? Это действие нельзя отменить.",
|
|
||||||
"user_delete": "Удалить",
|
|
||||||
|
|
||||||
"newuser_title": "Новый пользователь",
|
|
||||||
"newuser_create": "Создать"
|
|
||||||
}
|
|
||||||
@ -2,31 +2,31 @@
|
|||||||
"username": "Логин",
|
"username": "Логин",
|
||||||
"password": "Пароль",
|
"password": "Пароль",
|
||||||
|
|
||||||
"register_title": "Регистрация",
|
"register_title": "",
|
||||||
"register_header": "Регистрация на «{{.name}}»",
|
"register_header": "",
|
||||||
"register_button": "Зарегистрироваться",
|
"register_button": "",
|
||||||
|
|
||||||
"login_title": "Вход",
|
"login_title": "Вход",
|
||||||
"login_header": "Вход в «{{.name}}»",
|
"login_header": "",
|
||||||
"login_button": "Войти",
|
"login_button": "",
|
||||||
|
|
||||||
"logout_title": "Выйти?",
|
"logout_title": "",
|
||||||
"logout_header": "Выйти?",
|
"logout_header": "?",
|
||||||
"logout_button": "Подтвердить",
|
"logout_button": "Подтвердить",
|
||||||
"logout_anon": "Вы не можете выйти, потому что ещё не вошли.",
|
"logout_anon": "",
|
||||||
|
|
||||||
"lock_title": "Доступ закрыт",
|
"lock_title": "",
|
||||||
|
|
||||||
"password_tip": "Сервер хранит ваш пароль в зашифрованном виде, даже администраторы не смогут его прочесть.",
|
"password_tip": "",
|
||||||
"cookie_tip": "Отправляя эту форму, вы разрешаете вики хранить cookie в вашем браузере. Это позволит движку связывать ваши правки с вашей учётной записью. Вы будете авторизованы, пока не выйдете из учётной записи.",
|
"cookie_tip": "",
|
||||||
"telegram_tip": "Вы можете войти с помощью Телеграм. Это сработает, если у вашего профиля есть @имя, и оно не занято в этой вики.",
|
"telegram_tip": "Вы можете войти с помощью Телеграм. Это сработает, если у вашего профиля есть @имя, и оно не занято в этой вики.",
|
||||||
|
|
||||||
"noauth": "Аутентификация отключена. Вы можете делать правки анонимно.",
|
"noauth": "",
|
||||||
"noregister": "Регистрация в текущее время недоступна. Администраторы могут вручную создать вам учётную запись, свяжитесь с ними.",
|
"noregister": "Регистрация в текущее время недоступна. Администраторы могут вручную создать вам учётную запись, свяжитесь с ними.",
|
||||||
|
|
||||||
"error_username": "Неизвестное имя пользователя.",
|
"error_username": "",
|
||||||
"error_password": "Неверный пароль.",
|
"error_password": "Неверный пароль.",
|
||||||
"error_telegram": "Не удалось авторизоваться через Телеграм.",
|
"error_telegram": "",
|
||||||
|
|
||||||
"go_back": "Назад",
|
"go_back": "Назад",
|
||||||
"go_home": "Домой",
|
"go_home": "Домой",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"title": "Редактирование «%s»",
|
"title": "Редактирование %s",
|
||||||
|
|
||||||
"new_hypha": "Вы создаёте новую гифу.",
|
"new_hypha": "Вы создаёте новую гифу.",
|
||||||
"tag": "Опишите ваши правки:",
|
"tag": "Опишите ваши правки:",
|
||||||
@ -29,7 +29,7 @@
|
|||||||
"bullets": "Маркир. список",
|
"bullets": "Маркир. список",
|
||||||
"numbers": "Нумер. список",
|
"numbers": "Нумер. список",
|
||||||
|
|
||||||
"help": "{{.link}} о микоразметке",
|
"help": "{{.link}} о Микоразметке",
|
||||||
"help_link": "Подробнее",
|
"help_link": "Подробнее",
|
||||||
|
|
||||||
"selflink": "Ссылка на вас",
|
"selflink": "Ссылка на вас",
|
||||||
|
|||||||
@ -3,35 +3,24 @@
|
|||||||
"register": "Регистрация",
|
"register": "Регистрация",
|
||||||
"title_search": "Поиск по названию",
|
"title_search": "Поиск по названию",
|
||||||
"admin_panel": "Администрирование",
|
"admin_panel": "Администрирование",
|
||||||
|
|
||||||
"search_results_title": "Поиск: {{.query}}",
|
|
||||||
"search_results_query": "Результаты поиска для «{{.query}}»",
|
|
||||||
"search_results_desc": "Название каждой из существующих гиф сопоставлено с запросом. Подходящие гифы приведены ниже.",
|
|
||||||
|
|
||||||
"backlinks_title": "Обратные ссылки на {{.hypha_name}}",
|
"backlinks_title": "Обратные ссылки на {{.hypha_name}}",
|
||||||
"backlinks_heading": "Обратные ссылки на {{.hypha_link}}",
|
"backlinks_heading": "Обратные ссылки на {{.hypha_link}}",
|
||||||
"backlinks_desc": "Ниже перечислены гифы, на которых есть ссылка на эту гифу, трансклюзия этой гифы или эта гифа вставлена как изображение.",
|
"backlinks_desc": "Ниже перечислены гифы, на которых есть ссылка на эту гифу, трансклюзия этой гифы или эта гифа вставлена как изображение.",
|
||||||
|
|
||||||
"list_title": "Список страниц",
|
|
||||||
"list_heading": "Список гиф",
|
|
||||||
"list_desc": "В этой вики {{.n}} %s.",
|
|
||||||
"list_desc+one": "гифа",
|
|
||||||
"list_desc+few": "гифы",
|
|
||||||
"list_desc+many": "гиф",
|
|
||||||
|
|
||||||
"edit_link": "Редактировать",
|
"edit_link": "",
|
||||||
"logout_link": "Выйти",
|
"logout_link": "",
|
||||||
"history_link": "История",
|
"history_link": "",
|
||||||
"rename_link": "Переименовать",
|
"rename_link": "",
|
||||||
"delete_link": "Удалить",
|
"delete_link": "",
|
||||||
"text_link": "Посмотреть разметку",
|
"text_link": "",
|
||||||
"media_link": "Медиа",
|
"media_link": "",
|
||||||
|
"media_link_for_textual": "",
|
||||||
"backlinks_link": "{{.n}} %s сюда",
|
"backlinks_link": "{{.n}} %s сюда",
|
||||||
"backlinks_link+one": "ссылка",
|
"backlinks_link+one": "ссылка",
|
||||||
"backlinks_link+few": "ссылки",
|
"backlinks_link+few": "ссылки",
|
||||||
"backlinks_link+many": "ссылок",
|
"backlinks_link+many": "ссылок",
|
||||||
|
|
||||||
"sibling_hyphae": "Гифы-сиблинги",
|
|
||||||
"subhyphae": "Подгифы",
|
"subhyphae": "Подгифы",
|
||||||
|
|
||||||
"random_no_hyphae": "В этой вики нет гиф",
|
"random_no_hyphae": "В этой вики нет гиф",
|
||||||
@ -66,31 +55,13 @@
|
|||||||
"act_notexist_rename": "Нельзя переименовать эту гифу, потому что она не существует",
|
"act_notexist_rename": "Нельзя переименовать эту гифу, потому что она не существует",
|
||||||
"act_notexist_remove_media": "Нельзя убрать медиа, потому что нет такой гифы",
|
"act_notexist_remove_media": "Нельзя убрать медиа, потому что нет такой гифы",
|
||||||
|
|
||||||
"ask_delete": "Удалить «%s»?",
|
|
||||||
"ask_delete_tip": "В этой версии Микоризы нельзя отменить удаление гифы, но её история останется доступной.",
|
|
||||||
"ask_remove_media": "Убрать медиа у «%s»?",
|
"ask_remove_media": "Убрать медиа у «%s»?",
|
||||||
"ask_really": "Вы действительно хотите {{.verb}} гифу «{{.name}}»?",
|
"ask_really": "Вы действительно хотите {{.verb}} гифу «{{.name}}»?",
|
||||||
"ask_delete_verb": "удалить",
|
|
||||||
"ask_remove_media_verb": "убрать медиа",
|
"ask_remove_media_verb": "убрать медиа",
|
||||||
|
|
||||||
"history_title": "История «%s»",
|
"revision_title": "",
|
||||||
|
"revision_warning": "",
|
||||||
"recent_title": "{{.n}} %s",
|
"revision_link": "",
|
||||||
"recent_title+one": "недавнее изменение",
|
|
||||||
"recent_title+few": "недавних изменения",
|
|
||||||
"recent_title+many": "недавних изменений",
|
|
||||||
"recent_heading": "Недавние изменения",
|
|
||||||
"recent_count_pre": "Отобразить",
|
|
||||||
"recent_count_post": "недавних изменений",
|
|
||||||
"recent_subscribe": "Подписаться через {{.rss}}, {{.atom}} или {{.json}}",
|
|
||||||
"recent_subscribe_json": "JSON-ленту",
|
|
||||||
"recent_empty": "Не удалось найти последние изменения.",
|
|
||||||
|
|
||||||
"diff_title": "Разница для «{{.name}}» из {{.rev}}",
|
|
||||||
|
|
||||||
"revision_title": "{{.name}} из {{.rev}}",
|
|
||||||
"revision_warning": "Обратите внимание, просмотр медиа в истории пока что недоступен.",
|
|
||||||
"revision_link": "Посмотреть код микоразметки для этой ревизии",
|
|
||||||
"revision_no_text": "В этой ревизии гифы не было текста.",
|
"revision_no_text": "В этой ревизии гифы не было текста.",
|
||||||
|
|
||||||
"about_title": "О {{.name}}",
|
"about_title": "О {{.name}}",
|
||||||
@ -100,18 +71,6 @@
|
|||||||
"no_rights": "Недостаточно прав",
|
"no_rights": "Недостаточно прав",
|
||||||
"reindex_no_rights": "Вы должны быть администратором, чтобы переиндексировать гифы.",
|
"reindex_no_rights": "Вы должны быть администратором, чтобы переиндексировать гифы.",
|
||||||
"header_no_rights": "Вы должны быть модератором, чтобы обновить ссылки в заголовке.",
|
"header_no_rights": "Вы должны быть модератором, чтобы обновить ссылки в заголовке.",
|
||||||
|
|
||||||
"notexist_heading": "Эта гифа не существует",
|
|
||||||
"notexist_norights": "У вас нет прав для создания новых гиф. Вы можете:",
|
|
||||||
"notexist_login": "Войти в свою учётную запись, если она у вас есть",
|
|
||||||
"notexist_register": "Создать новую учётную запись",
|
|
||||||
"notexist_write": "Написать текст",
|
|
||||||
"notexist_write_tip1": "Напишите заметку, дневник, статью, рассказ или иной текст с помощью {{.myco}}. Сохраняется полная история правок документа.",
|
|
||||||
"notexist_write_myco": "микоразметки",
|
|
||||||
"notexist_write_tip2": "Не забывайте следовать правилам оформления этой вики, если они имеются.",
|
|
||||||
"notexist_write_button": "Создать",
|
|
||||||
"notexist_media": "Загрузить медиа",
|
|
||||||
"notexist_media_tip1": "Загрузите изображение, видео или аудио. Распространённые форматы можно просматривать из браузера, остальные – просто скачать. Позже вы можете дописать пояснение к этому медиа.",
|
|
||||||
|
|
||||||
"media_download": "Скачать медиа",
|
"media_download": "Скачать медиа",
|
||||||
"media_novideo": "Ваш браузер не поддерживает видео.",
|
"media_novideo": "Ваш браузер не поддерживает видео.",
|
||||||
@ -119,27 +78,6 @@
|
|||||||
"media_noaudio": "Ваш браузер не поддерживает аудио.",
|
"media_noaudio": "Ваш браузер не поддерживает аудио.",
|
||||||
"media_noaudio_link": "Скачать аудио",
|
"media_noaudio_link": "Скачать аудио",
|
||||||
|
|
||||||
"media_title": "Медиа «{{.name}}»",
|
|
||||||
"media_empty": "Эта гифа не имеет медиа, здесь вы можете его загрузить.",
|
|
||||||
"media_tip": "На этой странице вы можете управлять медиа.",
|
|
||||||
"media_what_is": "Что такое медиа?",
|
|
||||||
"media_upload": "Загрузить",
|
|
||||||
"media_stat": "Свойства",
|
|
||||||
"media_stat_size": "Размер файла:",
|
|
||||||
"media_size_value": "{{.n}} %s",
|
|
||||||
"media_size_value+one": "байт",
|
|
||||||
"media_size_value+few": "байта",
|
|
||||||
"media_size_value+many": "байт",
|
|
||||||
"media_stat_mime": "MIME-тип:",
|
|
||||||
"media_include": "Добавление",
|
|
||||||
"media_include_tip": "Это медиа – изображение. Чтобы добавить его в текст гифы, используйте синтаксис ниже:",
|
|
||||||
"media_new": "Прикрепить",
|
|
||||||
"media_new_tip": "Вы можете загрузить новое медиа. Пожалуйста, не загружайте слишком большие изображения без необходимости, чтобы впоследствии не ждать её долгую загрузку.",
|
|
||||||
"media_remove": "Открепить",
|
|
||||||
"media_remove_tip": "Заметьте, чтобы заменить медиа, вам не нужно его перед этим откреплять.",
|
|
||||||
"media_remove_button": "Открепить",
|
|
||||||
|
|
||||||
"close_dialog": "Закрыть этот диалог",
|
|
||||||
"confirm": "Применить",
|
"confirm": "Применить",
|
||||||
"cancel": "Отмена"
|
"cancel": "Отмена"
|
||||||
}
|
}
|
||||||
|
|||||||
69
main.go
69
main.go
@ -1,58 +1,79 @@
|
|||||||
//go:generate go run github.com/valyala/quicktemplate/qtc -dir=views
|
|
||||||
//go:generate go run github.com/valyala/quicktemplate/qtc -dir=tree
|
|
||||||
//go:generate go run github.com/valyala/quicktemplate/qtc -dir=history
|
|
||||||
// Command mycorrhiza is a program that runs a mycorrhiza wiki.
|
// Command mycorrhiza is a program that runs a mycorrhiza wiki.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae/categories"
|
"log/slog"
|
||||||
"github.com/bouncepaw/mycorrhiza/migration"
|
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae/backlinks"
|
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/cfg"
|
|
||||||
"github.com/bouncepaw/mycorrhiza/files"
|
|
||||||
"github.com/bouncepaw/mycorrhiza/history"
|
"github.com/bouncepaw/mycorrhiza/history"
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae"
|
"github.com/bouncepaw/mycorrhiza/internal/backlinks"
|
||||||
"github.com/bouncepaw/mycorrhiza/shroom"
|
"github.com/bouncepaw/mycorrhiza/internal/categories"
|
||||||
"github.com/bouncepaw/mycorrhiza/static"
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
"github.com/bouncepaw/mycorrhiza/user"
|
"github.com/bouncepaw/mycorrhiza/internal/files"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/migration"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/shroom"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/user"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/version"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/interwiki"
|
||||||
"github.com/bouncepaw/mycorrhiza/web"
|
"github.com/bouncepaw/mycorrhiza/web"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/web/static"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/web/viewutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
parseCliArgs()
|
if err := parseCliArgs(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
if err := files.PrepareWikiRoot(); err != nil {
|
if err := files.PrepareWikiRoot(); err != nil {
|
||||||
log.Fatal(err)
|
slog.Error("Failed to prepare wiki root", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cfg.ReadConfigFile(files.ConfigPath()); err != nil {
|
if err := cfg.ReadConfigFile(files.ConfigPath()); err != nil {
|
||||||
log.Fatal(err)
|
slog.Error("Failed to read config", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Running Mycorrhiza Wiki 1.9.0")
|
|
||||||
if err := os.Chdir(files.HyphaeDir()); err != nil {
|
if err := os.Chdir(files.HyphaeDir()); err != nil {
|
||||||
log.Fatal(err)
|
slog.Error("Failed to chdir to hyphae dir",
|
||||||
|
"err", err, "hyphaeDir", files.HyphaeDir())
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
log.Println("Wiki directory is", cfg.WikiDir)
|
slog.Info("Running Mycorrhiza Wiki",
|
||||||
log.Println("Using Git storage at", files.HyphaeDir())
|
"version", version.Short, "wikiDir", cfg.WikiDir)
|
||||||
|
|
||||||
// Init the subsystems:
|
// Init the subsystems:
|
||||||
|
// TODO: keep all crashes in main rather than somewhere there
|
||||||
|
viewutil.Init()
|
||||||
hyphae.Index(files.HyphaeDir())
|
hyphae.Index(files.HyphaeDir())
|
||||||
backlinks.IndexBacklinks()
|
backlinks.IndexBacklinks()
|
||||||
go backlinks.RunBacklinksConveyor()
|
go backlinks.RunBacklinksConveyor()
|
||||||
user.InitUserDatabase()
|
user.InitUserDatabase()
|
||||||
history.Start()
|
if err := history.Start(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
history.InitGitRepo()
|
history.InitGitRepo()
|
||||||
migration.MigrateRocketsMaybe()
|
migration.MigrateRocketsMaybe()
|
||||||
|
migration.MigrateHeadingsMaybe()
|
||||||
shroom.SetHeaderLinks()
|
shroom.SetHeaderLinks()
|
||||||
categories.InitCategories()
|
if err := categories.Init(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := interwiki.Init(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Static files:
|
// Static files:
|
||||||
static.InitFS(files.StaticFiles())
|
static.InitFS(files.StaticFiles())
|
||||||
|
|
||||||
serveHTTP(web.Handler())
|
if !user.HasAnyAdmins() {
|
||||||
|
slog.Error("Your wiki has no admin yet. Run Mycorrhiza with -create-admin <username> option to create an admin.")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := serveHTTP(web.Handler())
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,122 +0,0 @@
|
|||||||
// Package migration holds the utilities for migrating from older incompatible Mycomarkup versions.
|
|
||||||
//
|
|
||||||
// As of, there is rocket link migration only. Migrations are meant to be removed couple of versions after being introduced.
|
|
||||||
package migration
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/bouncepaw/mycomarkup/v3/tools"
|
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/files"
|
|
||||||
"github.com/bouncepaw/mycorrhiza/history"
|
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae"
|
|
||||||
"github.com/bouncepaw/mycorrhiza/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TODO: add heading migration too.
|
|
||||||
|
|
||||||
var rocketMarkerPath string
|
|
||||||
|
|
||||||
// MigrateRocketsMaybe checks if the rocket link migration marker exists. If it exists, nothing is done. If it does not, the migration takes place.
|
|
||||||
//
|
|
||||||
// This function writes logs and might terminate the program. Tons of side-effects, stay safe.
|
|
||||||
func MigrateRocketsMaybe() {
|
|
||||||
rocketMarkerPath = files.FileInRoot(".mycomarkup-rocket-link-migration-marker.txt")
|
|
||||||
if !shouldMigrateRockets() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
hop = history.
|
|
||||||
Operation(history.TypeMarkupMigration).
|
|
||||||
WithMsg("Migrate rocket links to the new syntax").
|
|
||||||
WithUser(user.WikimindUser())
|
|
||||||
mycoFiles = []string{}
|
|
||||||
)
|
|
||||||
|
|
||||||
for hypha := range hyphae.FilterHyphaeWithText(hyphae.YieldExistingHyphae()) {
|
|
||||||
/// Open file, read from file, modify file. If anything goes wrong, scream and shout.
|
|
||||||
|
|
||||||
file, err := os.OpenFile(hypha.TextFilePath(), os.O_RDWR, 0766)
|
|
||||||
if err != nil {
|
|
||||||
hop.WithErrAbort(err)
|
|
||||||
log.Fatal("Something went wrong when opening ", hypha.TextFilePath(), ": ", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf strings.Builder
|
|
||||||
_, err = io.Copy(&buf, file)
|
|
||||||
if err != nil {
|
|
||||||
hop.WithErrAbort(err)
|
|
||||||
_ = file.Close()
|
|
||||||
log.Fatal("Something went wrong when reading ", hypha.TextFilePath(), ": ", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
oldText = buf.String()
|
|
||||||
newText = tools.MigrateRocketLinks(oldText)
|
|
||||||
)
|
|
||||||
if oldText != newText { // This file right here is being migrated for real.
|
|
||||||
mycoFiles = append(mycoFiles, hypha.TextFilePath())
|
|
||||||
|
|
||||||
err = file.Truncate(0)
|
|
||||||
if err != nil {
|
|
||||||
hop.WithErrAbort(err)
|
|
||||||
_ = file.Close()
|
|
||||||
log.Fatal("Something went wrong when truncating ", hypha.TextFilePath(), ": ", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = file.Seek(0, 0)
|
|
||||||
if err != nil {
|
|
||||||
hop.WithErrAbort(err)
|
|
||||||
_ = file.Close()
|
|
||||||
log.Fatal("Something went wrong when seeking in ", hypha.TextFilePath(), ": ", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = file.WriteString(newText)
|
|
||||||
if err != nil {
|
|
||||||
hop.WithErrAbort(err)
|
|
||||||
_ = file.Close()
|
|
||||||
log.Fatal("Something went wrong when writing to ", hypha.TextFilePath(), ": ", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = file.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(mycoFiles) == 0 {
|
|
||||||
hop.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if hop.WithFiles(mycoFiles...).Apply().HasErrors() {
|
|
||||||
log.Fatal("Something went wrong when commiting rocket link migration: ", hop.FirstErrorText())
|
|
||||||
}
|
|
||||||
log.Println("Migrated", len(mycoFiles), "Mycomarkup documents")
|
|
||||||
createRocketLinkMarker()
|
|
||||||
}
|
|
||||||
|
|
||||||
func shouldMigrateRockets() bool {
|
|
||||||
file, err := os.Open(rocketMarkerPath)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalln("When checking if rocket migration is needed:", err.Error())
|
|
||||||
}
|
|
||||||
_ = file.Close()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func createRocketLinkMarker() {
|
|
||||||
err := ioutil.WriteFile(
|
|
||||||
rocketMarkerPath,
|
|
||||||
[]byte(`This file is used to mark that the rocket link migration was made successfully. If this file is deleted, the migration might happen again depending on the version. You should probably not touch this file at all and let it be.`),
|
|
||||||
0766,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalln(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
122
misc/about.go
Normal file
122
misc/about.go
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
package misc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/template" // sic! TODO: make it html/template after the template library migration
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/user"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/version"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/l18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
type L10nEntry struct {
|
||||||
|
_en string
|
||||||
|
_ru string
|
||||||
|
}
|
||||||
|
|
||||||
|
func En(v string) L10nEntry {
|
||||||
|
return L10nEntry{_en: v}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e L10nEntry) Ru(v string) L10nEntry {
|
||||||
|
e._ru = v
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e L10nEntry) Get(lang string) string {
|
||||||
|
if lang == "ru" && e._ru != "" {
|
||||||
|
return e._ru
|
||||||
|
}
|
||||||
|
return e._en
|
||||||
|
}
|
||||||
|
|
||||||
|
const aboutTemplateString = `
|
||||||
|
<main class="main-width">
|
||||||
|
<section class="about-page">
|
||||||
|
<h1>{{ printf (get .L.Title) .Cfg.WikiName }}</h1>
|
||||||
|
<dl>
|
||||||
|
<dt>{{ get .L.Version }}</dt>
|
||||||
|
<dd>{{ .Version }}</dd>
|
||||||
|
{{ if .Cfg.UseAuth }}
|
||||||
|
<dt>{{ get .L.HomeHypha }}</dt>
|
||||||
|
<dd><a href="/">{{ .Cfg.HomeHypha }}</a></dd>
|
||||||
|
|
||||||
|
<dt>{{get .L.Auth}}</dt>
|
||||||
|
<dd>{{ get .L.AuthOn }}</dd>
|
||||||
|
{{if .Cfg.TelegramEnabled}}<dd>{{get .L.TelegramOn}}</dd>{{end}}
|
||||||
|
|
||||||
|
<dt>{{ get .L.UserCount }}</dt>
|
||||||
|
<dd>{{ .UserCount }}</dd>
|
||||||
|
{{if .Cfg.RegistrationLimit}}
|
||||||
|
<dt>{{get .L.RegistrationLimit}}</dt>
|
||||||
|
<dd>{{.RegistrationLimit}}</dd>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<dt>{{ get .L.Admins }}</dt>
|
||||||
|
{{$cfg := .Cfg}}{{ range $i, $username := .Admins }}
|
||||||
|
<dd><a href="/hypha/{{ $cfg.UserHypha }}/{{ $username }}">{{ $username }}</a></dd>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ else }}
|
||||||
|
<dt>{{get .L.Auth}}</dt>
|
||||||
|
<dd>{{ get .L.AuthOff }}</dd>
|
||||||
|
{{ end }}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</main>`
|
||||||
|
|
||||||
|
var aboutData = struct {
|
||||||
|
L map[string]L10nEntry
|
||||||
|
Version string
|
||||||
|
Cfg map[string]interface{}
|
||||||
|
Admins []string
|
||||||
|
UserCount uint64
|
||||||
|
RegistrationLimit uint64
|
||||||
|
}{
|
||||||
|
L: map[string]L10nEntry{
|
||||||
|
"Title": En("About %s").Ru("О %s"),
|
||||||
|
"Version": En("<a href=\"https://mycorrhiza.wiki\">Mycorrhiza Wiki</a> version").Ru("Версия <a href=\"https://mycorrhiza.wiki\">Микоризы</a>"),
|
||||||
|
"UserCount": En("User count").Ru("Число пользователей"),
|
||||||
|
"HomeHypha": En("Home hypha").Ru("Домашняя гифа"),
|
||||||
|
"RegistrationLimit": En("RegistrationLimit").Ru("Максимум пользователей"),
|
||||||
|
"Admins": En("Administrators").Ru("Администраторы"),
|
||||||
|
|
||||||
|
"Auth": En("Authentication").Ru("Аутентификация"),
|
||||||
|
"AuthOn": En("Authentication is on").Ru("Аутентификация включена"),
|
||||||
|
"AuthOff": En("Authentication is off").Ru("Аутентификация не включена"),
|
||||||
|
"TelegramOn": En("Telegram authentication is on").Ru("Вход через Телеграм включён"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func AboutHTML(lc *l18n.Localizer) string {
|
||||||
|
get := func(e L10nEntry) string {
|
||||||
|
return e.Get(lc.Locale)
|
||||||
|
}
|
||||||
|
temp, err := template.New("about wiki").Funcs(template.FuncMap{"get": get}).Parse(aboutTemplateString)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse About template", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
data := aboutData
|
||||||
|
data.Version = version.Short
|
||||||
|
data.Admins = user.ListUsersWithGroup("admin")
|
||||||
|
data.UserCount = user.Count()
|
||||||
|
data.RegistrationLimit = cfg.RegistrationLimit
|
||||||
|
data.Cfg = map[string]interface{}{
|
||||||
|
"UseAuth": cfg.UseAuth,
|
||||||
|
"WikiName": cfg.WikiName,
|
||||||
|
"HomeHypha": cfg.HomeHypha,
|
||||||
|
"TelegramEnabled": cfg.TelegramEnabled,
|
||||||
|
"RegistrationLimit": cfg.RegistrationLimit,
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
err = temp.Execute(&out, data)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to execute About template", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
186
misc/handlers.go
Normal file
186
misc/handlers.go
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
// Package misc provides miscellaneous informative views.
|
||||||
|
package misc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/backlinks"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/files"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/shroom"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/user"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/l18n"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/util"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/web/static"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/web/viewutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitAssetHandlers(rtr *mux.Router) {
|
||||||
|
rtr.HandleFunc("/static/style.css", handlerStyle)
|
||||||
|
rtr.HandleFunc("/robots.txt", handlerRobotsTxt)
|
||||||
|
rtr.PathPrefix("/static/").
|
||||||
|
Handler(http.StripPrefix("/static/", http.FileServer(http.FS(static.FS))))
|
||||||
|
rtr.HandleFunc("/favicon.ico", func(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
http.Redirect(w, rq, "/static/favicon.ico", http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitHandlers(rtr *mux.Router) {
|
||||||
|
rtr.HandleFunc("/list", handlerList)
|
||||||
|
rtr.HandleFunc("/reindex", handlerReindex)
|
||||||
|
rtr.HandleFunc("/update-header-links", handlerUpdateHeaderLinks)
|
||||||
|
rtr.HandleFunc("/random", handlerRandom)
|
||||||
|
rtr.HandleFunc("/about", handlerAbout)
|
||||||
|
rtr.HandleFunc("/title-search/", handlerTitleSearch)
|
||||||
|
initViews()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlerList shows a list of all hyphae in the wiki in random order.
|
||||||
|
func handlerList(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
util.PrepareRq(rq)
|
||||||
|
// TODO: make this more effective, there are too many loops and vars
|
||||||
|
var (
|
||||||
|
hyphaNames = make(chan string)
|
||||||
|
sortedHypha = hyphae.PathographicSort(hyphaNames)
|
||||||
|
entries []listDatum
|
||||||
|
)
|
||||||
|
for hypha := range hyphae.YieldExistingHyphae() {
|
||||||
|
hyphaNames <- hypha.CanonicalName()
|
||||||
|
}
|
||||||
|
close(hyphaNames)
|
||||||
|
for hyphaName := range sortedHypha {
|
||||||
|
switch h := hyphae.ByName(hyphaName).(type) {
|
||||||
|
case *hyphae.TextualHypha:
|
||||||
|
entries = append(entries, listDatum{h.CanonicalName(), ""})
|
||||||
|
case *hyphae.MediaHypha:
|
||||||
|
entries = append(entries, listDatum{h.CanonicalName(), filepath.Ext(h.MediaFilePath())[1:]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewList(viewutil.MetaFrom(w, rq), entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlerReindex reindexes all hyphae by checking the wiki storage directory anew.
|
||||||
|
func handlerReindex(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
util.PrepareRq(rq)
|
||||||
|
if ok := user.CanProceed(rq, "reindex"); !ok {
|
||||||
|
var lc = l18n.FromRequest(rq)
|
||||||
|
viewutil.HttpErr(viewutil.MetaFrom(w, rq), http.StatusForbidden, cfg.HomeHypha, lc.Get("ui.reindex_no_rights"))
|
||||||
|
slog.Info("No rights to reindex")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hyphae.ResetCount()
|
||||||
|
slog.Info("Reindexing hyphae", "hyphaeDir", files.HyphaeDir())
|
||||||
|
hyphae.Index(files.HyphaeDir())
|
||||||
|
backlinks.IndexBacklinks()
|
||||||
|
http.Redirect(w, rq, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlerUpdateHeaderLinks updates header links by reading the configured hypha, if there is any, or resorting to default values.
|
||||||
|
func handlerUpdateHeaderLinks(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
util.PrepareRq(rq)
|
||||||
|
if ok := user.CanProceed(rq, "update-header-links"); !ok {
|
||||||
|
var lc = l18n.FromRequest(rq)
|
||||||
|
viewutil.HttpErr(viewutil.MetaFrom(w, rq), http.StatusForbidden, cfg.HomeHypha, lc.Get("ui.header_no_rights"))
|
||||||
|
slog.Info("No rights to update header links")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("Updated header links")
|
||||||
|
shroom.SetHeaderLinks()
|
||||||
|
http.Redirect(w, rq, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlerRandom redirects to a random hypha.
|
||||||
|
func handlerRandom(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
util.PrepareRq(rq)
|
||||||
|
var (
|
||||||
|
randomHyphaName string
|
||||||
|
amountOfHyphae = hyphae.Count()
|
||||||
|
)
|
||||||
|
if amountOfHyphae == 0 {
|
||||||
|
var lc = l18n.FromRequest(rq)
|
||||||
|
viewutil.HttpErr(viewutil.MetaFrom(w, rq), http.StatusNotFound, cfg.HomeHypha, lc.Get("ui.random_no_hyphae_tip"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i := rand.Intn(amountOfHyphae)
|
||||||
|
for h := range hyphae.YieldExistingHyphae() {
|
||||||
|
if i == 0 {
|
||||||
|
randomHyphaName = h.CanonicalName()
|
||||||
|
}
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
http.Redirect(w, rq, "/hypha/"+randomHyphaName, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlerAbout shows a summary of wiki's software.
|
||||||
|
func handlerAbout(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html;charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
var (
|
||||||
|
lc = l18n.FromRequest(rq)
|
||||||
|
title = lc.Get("ui.about_title", &l18n.Replacements{"name": cfg.WikiName})
|
||||||
|
)
|
||||||
|
_, err := io.WriteString(w, viewutil.Base(
|
||||||
|
viewutil.MetaFrom(w, rq),
|
||||||
|
title,
|
||||||
|
AboutHTML(lc),
|
||||||
|
map[string]string{},
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to write About template", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stylesheets = []string{"default.css", "custom.css"}
|
||||||
|
|
||||||
|
func handlerStyle(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", mime.TypeByExtension(".css"))
|
||||||
|
for _, name := range stylesheets {
|
||||||
|
file, err := static.FS.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, file)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to write stylesheet; proceeding anyway", "err", err)
|
||||||
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlerRobotsTxt(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
|
||||||
|
file, err := static.FS.Open("robots.txt")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, file)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to write robots.txt; proceeding anyway", "err", err)
|
||||||
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlerTitleSearch(w http.ResponseWriter, rq *http.Request) {
|
||||||
|
util.PrepareRq(rq)
|
||||||
|
_ = rq.ParseForm()
|
||||||
|
var (
|
||||||
|
query = rq.FormValue("q")
|
||||||
|
hyphaName = util.CanonicalName(query)
|
||||||
|
_, nameFree = hyphae.AreFreeNames(hyphaName)
|
||||||
|
results []string
|
||||||
|
)
|
||||||
|
for hyphaName := range shroom.YieldHyphaNamesContainingString(query) {
|
||||||
|
results = append(results, hyphaName)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
viewTitleSearch(viewutil.MetaFrom(w, rq), query, hyphaName, !nameFree, results)
|
||||||
|
}
|
||||||
16
misc/view_list.html
Normal file
16
misc/view_list.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{{define "list of hyphae"}}List of hyphae{{end}}
|
||||||
|
{{define "title"}}{{template "list of hyphae"}}{{end}}
|
||||||
|
{{define "body"}}
|
||||||
|
<main class="main-width">
|
||||||
|
<h1>{{template "list of hyphae"}}</h1>
|
||||||
|
<p>{{block "x total" .HyphaCount}}{{.}} total.{{end}}</p>
|
||||||
|
<ol>
|
||||||
|
{{range .Entries}}
|
||||||
|
<li>
|
||||||
|
<a href="/hypha/{{.Name}}">{{beautifulName .Name}}</a>
|
||||||
|
{{if .Ext}}<span class="media-type-badge">{{.Ext}}</span>{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ol>
|
||||||
|
</main>
|
||||||
|
{{end}}
|
||||||
21
misc/view_title_search.html
Normal file
21
misc/view_title_search.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{{define "search:"}}Search: {{.}}{{end}}
|
||||||
|
{{define "title"}}{{template "search:" .Query}}{{end}}
|
||||||
|
{{define "body"}}
|
||||||
|
<main class="main-width">
|
||||||
|
<h1>{{block "search results for" .Query}}Search results for ‘{{.}}’{{end}}</h1>
|
||||||
|
{{if .MatchedHyphaName}}
|
||||||
|
<p>{{block "go to hypha" .}}Go to hypha <a class="wikilink{{if .HasExactMatch | not}} wikilink_new{{end}}" href="/hypha/{{.MatchedHyphaName}}">{{beautifulName .MatchedHyphaName}}</a>.{{end}}</p>
|
||||||
|
{{end}}
|
||||||
|
{{if len .Results}}
|
||||||
|
<ol>
|
||||||
|
{{range .Results}}
|
||||||
|
<li>
|
||||||
|
<a class="wikilink" href="/hypha/{{.}}">{{beautifulName .}}</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ol>
|
||||||
|
{{else}}
|
||||||
|
<p>{{block "search no results" .}}No results{{end}}</p>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
{{end}}
|
||||||
64
misc/views.go
Normal file
64
misc/views.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package misc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/web/viewutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed *html
|
||||||
|
fs embed.FS
|
||||||
|
chainList, chainTitleSearch viewutil.Chain
|
||||||
|
ruTranslation = `
|
||||||
|
{{define "list of hyphae"}}Список гиф{{end}}
|
||||||
|
{{define "search:"}}Поиск: {{.}}{{end}}
|
||||||
|
{{define "search results for"}}Результаты поиска для «{{.}}»{{end}}
|
||||||
|
{{define "search no results"}}Ничего не найдено.{{end}}
|
||||||
|
{{define "x total"}}{{.}} всего.{{end}}
|
||||||
|
{{define "go to hypha"}}Перейти к гифе <a class="wikilink{{if .HasExactMatch | not}} wikilink_new{{end}}" href="/hypha/{{.MatchedHyphaName}}">{{beautifulName .MatchedHyphaName}}</a>.{{end}}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
|
||||||
|
func initViews() {
|
||||||
|
chainList = viewutil.CopyEnRuWith(fs, "view_list.html", ruTranslation)
|
||||||
|
chainTitleSearch = viewutil.CopyEnRuWith(fs, "view_title_search.html", ruTranslation)
|
||||||
|
}
|
||||||
|
|
||||||
|
type listDatum struct {
|
||||||
|
Name string
|
||||||
|
Ext string
|
||||||
|
}
|
||||||
|
|
||||||
|
type listData struct {
|
||||||
|
*viewutil.BaseData
|
||||||
|
Entries []listDatum
|
||||||
|
HyphaCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewList(meta viewutil.Meta, entries []listDatum) {
|
||||||
|
viewutil.ExecutePage(meta, chainList, listData{
|
||||||
|
BaseData: &viewutil.BaseData{},
|
||||||
|
Entries: entries,
|
||||||
|
HyphaCount: hyphae.Count(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type titleSearchData struct {
|
||||||
|
*viewutil.BaseData
|
||||||
|
Query string
|
||||||
|
Results []string
|
||||||
|
MatchedHyphaName string
|
||||||
|
HasExactMatch bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewTitleSearch(meta viewutil.Meta, query string, hyphaName string, hasExactMatch bool, results []string) {
|
||||||
|
viewutil.ExecutePage(meta, chainTitleSearch, titleSearchData{
|
||||||
|
BaseData: &viewutil.BaseData{},
|
||||||
|
Query: query,
|
||||||
|
Results: results,
|
||||||
|
MatchedHyphaName: hyphaName,
|
||||||
|
HasExactMatch: hasExactMatch,
|
||||||
|
})
|
||||||
|
}
|
||||||
115
mycoopts/mycoopts.go
Normal file
115
mycoopts/mycoopts.go
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
package mycoopts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/cfg"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/internal/hyphae"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/interwiki"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/l18n"
|
||||||
|
"github.com/bouncepaw/mycorrhiza/util"
|
||||||
|
|
||||||
|
"git.sr.ht/~bouncepaw/mycomarkup/v5/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MarkupOptions(hyphaName string) options.Options {
|
||||||
|
return options.Options{
|
||||||
|
HyphaName: hyphaName,
|
||||||
|
WebSiteURL: cfg.URL,
|
||||||
|
TransclusionSupported: true,
|
||||||
|
RedLinksSupported: true,
|
||||||
|
InterwikiSupported: true,
|
||||||
|
HyphaExists: func(hyphaName string) bool {
|
||||||
|
switch hyphae.ByName(hyphaName).(type) {
|
||||||
|
case *hyphae.EmptyHypha:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
IterateHyphaNamesWith: func(λ func(string)) {
|
||||||
|
for h := range hyphae.YieldExistingHyphae() {
|
||||||
|
λ(h.CanonicalName())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
HyphaHTMLData: func(hyphaName string) (rawText, binaryBlock string, err error) {
|
||||||
|
switch h := hyphae.ByName(hyphaName).(type) {
|
||||||
|
case *hyphae.EmptyHypha:
|
||||||
|
err = errors.New("Hypha " + hyphaName + " does not exist")
|
||||||
|
case *hyphae.TextualHypha:
|
||||||
|
rawText, err = hyphae.FetchMycomarkupFile(h)
|
||||||
|
case *hyphae.MediaHypha:
|
||||||
|
rawText, err = hyphae.FetchMycomarkupFile(h)
|
||||||
|
binaryBlock = mediaRaw(h)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
},
|
||||||
|
LocalTargetCanonicalName: util.CanonicalName,
|
||||||
|
LocalLinkHref: func(hyphaName string) string {
|
||||||
|
return "/hypha/" + util.CanonicalName(hyphaName)
|
||||||
|
},
|
||||||
|
LocalImgSrc: func(hyphaName string) string {
|
||||||
|
return "/binary/" + util.CanonicalName(hyphaName)
|
||||||
|
},
|
||||||
|
LinkHrefFormatForInterwikiPrefix: interwiki.HrefLinkFormatFor,
|
||||||
|
ImgSrcFormatForInterwikiPrefix: interwiki.ImgSrcFormatFor,
|
||||||
|
}.FillTheRest()
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaRaw(h *hyphae.MediaHypha) string {
|
||||||
|
return Media(h, l18n.New("en", "en"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Media(h *hyphae.MediaHypha, lc *l18n.Localizer) string {
|
||||||
|
name := html.EscapeString(h.CanonicalName())
|
||||||
|
|
||||||
|
switch filepath.Ext(h.MediaFilePath()) {
|
||||||
|
case ".jpg", ".gif", ".png", ".webp", ".svg", ".ico":
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`<div class="binary-container binary-container_with-img">
|
||||||
|
<a href="/binary/%s"><img src="/binary/%s"/></a>
|
||||||
|
</div>`,
|
||||||
|
name, name,
|
||||||
|
)
|
||||||
|
|
||||||
|
case ".ogg", ".webm", ".mp4":
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`<div class="binary-container binary-container_with-video">
|
||||||
|
<video controls>
|
||||||
|
<source src="/binary/%s"/>
|
||||||
|
<p>%s <a href="/binary/%s">%s</a></p>
|
||||||
|
</video>
|
||||||
|
</div>`,
|
||||||
|
name,
|
||||||
|
html.EscapeString(lc.Get("ui.media_novideo")),
|
||||||
|
name,
|
||||||
|
html.EscapeString(lc.Get("ui.media_novideo_link")),
|
||||||
|
)
|
||||||
|
|
||||||
|
case ".mp3", ".wav", ".flac":
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`<div class="binary-container binary-container_with-audio">
|
||||||
|
<audio controls>
|
||||||
|
<source src="/binary/%s"/>
|
||||||
|
<p>%s <a href="/binary/%s">%s</a></p>
|
||||||
|
</audio>
|
||||||
|
</div>`,
|
||||||
|
name,
|
||||||
|
html.EscapeString(lc.Get("ui.media_noaudio")),
|
||||||
|
name,
|
||||||
|
html.EscapeString(lc.Get("ui.media_noaudio_link")),
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`<div class="binary-container binary-container_with-nothing">
|
||||||
|
<p><a href="/binary/%s">%s</a></p>
|
||||||
|
</div>`,
|
||||||
|
name,
|
||||||
|
html.EscapeString(lc.Get("ui.media_download")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package shroom
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae"
|
|
||||||
"github.com/bouncepaw/mycorrhiza/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
func rejectDeleteLog(h hyphae.Hypha, u *user.User, errmsg string) {
|
|
||||||
log.Printf("Reject delete ‘%s’ by @%s: %s\n", h.CanonicalName(), u.Name, errmsg)
|
|
||||||
}
|
|
||||||
func rejectRenameLog(h hyphae.Hypha, u *user.User, errmsg string) {
|
|
||||||
log.Printf("Reject rename ‘%s’ by @%s: %s\n", h.CanonicalName(), u.Name, errmsg)
|
|
||||||
}
|
|
||||||
func rejectRemoveMediaLog(h hyphae.Hypha, u *user.User, errmsg string) {
|
|
||||||
log.Printf("Reject remove media ‘%s’ by @%s: %s\n", h.CanonicalName(), u.Name, errmsg)
|
|
||||||
}
|
|
||||||
func rejectEditLog(h hyphae.Hypha, u *user.User, errmsg string) {
|
|
||||||
log.Printf("Reject edit ‘%s’ by @%s: %s\n", h.CanonicalName(), u.Name, errmsg)
|
|
||||||
}
|
|
||||||
func rejectUploadMediaLog(h hyphae.Hypha, u *user.User, errmsg string) {
|
|
||||||
log.Printf("Reject upload media ‘%s’ by @%s: %s\n", h.CanonicalName(), u.Name, errmsg)
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
// Package shroom provides utilities for hypha manipulation.
|
|
||||||
//
|
|
||||||
// Some of them are wrappers around functions provided by package hyphae. They manage history for you.
|
|
||||||
package shroom
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/bouncepaw/mycorrhiza/hyphae"
|
|
||||||
"github.com/bouncepaw/mycorrhiza/views"
|
|
||||||
|
|
||||||
"github.com/bouncepaw/mycomarkup/v3/globals"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
// TODO: clean this complete and utter mess
|
|
||||||
globals.HyphaExists = func(hyphaName string) bool {
|
|
||||||
switch hyphae.ByName(hyphaName).(type) {
|
|
||||||
case *hyphae.EmptyHypha:
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
globals.HyphaAccess = func(hyphaName string) (rawText, binaryBlock string, err error) {
|
|
||||||
switch h := hyphae.ByName(hyphaName).(type) {
|
|
||||||
case *hyphae.EmptyHypha:
|
|
||||||
err = errors.New("Hypha " + hyphaName + " does not exist")
|
|
||||||
case *hyphae.TextualHypha:
|
|
||||||
rawText, err = FetchTextFile(h)
|
|
||||||
case *hyphae.MediaHypha:
|
|
||||||
rawText, err = FetchTextFile(h)
|
|
||||||
binaryBlock = views.MediaRaw(h)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
globals.HyphaIterate = func(λ func(string)) {
|
|
||||||
for h := range hyphae.YieldExistingHyphae() {
|
|
||||||
λ(h.CanonicalName())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user