Add Calendar integration (read-only)

This commit is contained in:
2026-09-25 11:19:28 +02:00
parent c84f233bbb
commit 4116a0c332
25 changed files with 1808 additions and 32 deletions
+71 -15
View File
@@ -17,6 +17,7 @@ import (
"strconv"
"strings"
"time"
_ "time/tzdata" // calendar TZIDs must resolve even without system zoneinfo
)
//go:embed assets
@@ -38,10 +39,13 @@ func hashAsset(name string) string {
}
// tmplFuncs is shared by every layout-based template: the edit template appends
// editorBundleVersion to its CodeMirror <script> src to cache-bust the bundle.
// editorBundleVersion to its CodeMirror <script> src to cache-bust the bundle;
// the layout header renders the maintenance widget.
var tmplFuncs = template.FuncMap{
"editorBundleVersion": func() string { return editorBundleVersion },
"fileIcon": fileIcon,
"icon": readIcon,
"maintenanceWidget": maintenanceWidget,
}
var (
@@ -88,8 +92,18 @@ func main() {
user := flag.String("user", "", "basic auth username (empty = no auth)")
pass := flag.String("pass", "", "basic auth password")
reindexInterval := flag.Duration("reindex-interval", 30*time.Minute, "periodic search index rebuild interval (0 disables)")
calendars := flag.String("calendars", "Calendars", "calendars folder, relative to -dir (empty disables)")
tz := flag.String("tz", "", "time zone for calendars and the diary, e.g. Europe/Berlin (default: system)")
flag.Parse()
if *tz != "" {
loc, err := time.LoadLocation(*tz)
if err != nil {
log.Fatalf("-tz: %v", err)
}
time.Local = loc
}
root, err := filepath.Abs(*wikiDir)
if err != nil {
log.Fatal(err)
@@ -108,6 +122,15 @@ func main() {
initMarkdown()
if *calendars != "" {
calendarsDir = filepath.Join(root, filepath.FromSlash(*calendars))
rel, err := filepath.Rel(root, calendarsDir)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
log.Fatalf("-calendars must be a folder inside -dir")
}
calendarsURL = "/" + filepath.ToSlash(rel) + "/"
}
authKey, err := loadOrCreateAuthKey(root)
if err != nil {
log.Fatal(err)
@@ -158,6 +181,10 @@ func main() {
}(*reindexInterval)
}
if calendarsDir != "" {
startCalendarSync()
}
log.Printf("datascape listening on %s, wiki at %s", *addr, root)
log.Fatal(http.ListenAndServe(*addr, nil))
}
@@ -188,6 +215,14 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Dotfiles are never served: .page-settings can hold the CalDAV
// password and .auth-key signs sessions. Nothing links to them (listing,
// tree and search hide them already).
if hasDotSegment(r.URL.Path) {
http.NotFound(w, r)
return
}
if strings.HasPrefix(r.URL.Path, thumbURLPrefix+"/") {
h.handleThumb(w, r)
return
@@ -255,6 +290,17 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
// hasDotSegment reports whether any segment of the cleaned URL path starts
// with a dot.
func hasDotSegment(p string) bool {
for _, seg := range strings.Split(path.Clean("/"+p), "/") {
if strings.HasPrefix(seg, ".") {
return true
}
}
return false
}
// slashedURL returns u's path with a trailing slash appended and the query
// string intact — the canonical page form that relative destinations resolve
// against.
@@ -414,6 +460,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
h.handleSettings(w, r, urlPath, fsPath)
return
}
if query.Has("calsync") {
h.handleCalendarSync(w, r, urlPath, fsPath)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
@@ -525,12 +575,28 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
// Returns nil if the file does not exist.
// Format: one "key = value" pair per line; lines starting with # are comments.
func readPageSettings(dir string) *pageSettings {
kv := readSettingsFile(dir)
if kv == nil {
return nil
}
// Unknown or missing values fall back to the defaults.
return &pageSettings{
Type: kv["type"],
View: validateView(kv["view"]),
Sort: validateSort(kv["sort"]),
Order: validateOrder(kv["order"]),
}
}
// readSettingsFile returns every key of dir's .page-settings (last one wins),
// or nil if the file does not exist. Other features (e.g. the calendars
// folder) keep their own keys in the same file.
func readSettingsFile(dir string) map[string]string {
data, err := os.ReadFile(filepath.Join(dir, ".page-settings"))
if err != nil {
return nil
}
// Defaults; overridden only by valid values present in the file.
s := &pageSettings{View: viewList, Sort: sortName, Order: orderAsc}
kv := map[string]string{}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
@@ -540,17 +606,7 @@ func readPageSettings(dir string) *pageSettings {
if len(parts) != 2 {
continue
}
value := strings.TrimSpace(parts[1])
switch strings.TrimSpace(parts[0]) {
case "type":
s.Type = value
case "view":
s.View = validateView(value)
case "sort":
s.Sort = validateSort(value)
case "order":
s.Order = validateOrder(value)
}
kv[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
return s
return kv
}