Add Calendar integration (read-only)
@@ -12,6 +12,7 @@ Minimal self-hosted personal wiki. Folders are pages.
|
||||
- **Wikilinks**: `[[query]]` or `[[query::label]]`. They open a search, so links still work after a page is renamed. Typing `[[` in the editor suggests page names. Embed files with ``.
|
||||
- **Movie import**: create a page from OMDb metadata. Asks once for a free [OMDb API key](https://www.omdbapi.com/apikey.aspx) and keeps it in the browser.
|
||||
- **Special folders**: a [photo diary](#diary) and a [fitness dashboard](#fitness).
|
||||
- **Calendars**: Nextcloud calendars mirrored into the wiki. They add events and weight to the diary, and a [maintenance widget](#calendars) to every page.
|
||||
- **Quick-add bookmarklet**: save the current tab to a wiki page.
|
||||
- **Companion app**: an optional desktop helper, downloadable from the wiki footer. It opens wiki files in local apps and folders in the file manager.
|
||||
|
||||
@@ -33,6 +34,8 @@ GOOS=linux GOARCH=arm go build -o datascape . # QNAP NAS
|
||||
| `-cache` | `./cache` | Thumbnail cache |
|
||||
| `-user`, `-pass` | | Basic auth (omit to disable) |
|
||||
| `-reindex-interval` | `30m` | Search index rebuild (`0` = off) |
|
||||
| `-calendars` | `Calendars` | [Calendars](#calendars) folder, relative to `-dir` (empty = off) |
|
||||
| `-tz` | system | Time zone for calendars and the diary, e.g. `Europe/Berlin` |
|
||||
|
||||
## `.page-settings`
|
||||
|
||||
@@ -43,7 +46,7 @@ Per-folder `key = value` file. The **view** button in the `Files` header sets th
|
||||
| `view` | `list`, `thumbnail` |
|
||||
| `sort` | `name`, `modified`, `size` |
|
||||
| `order` | `asc`, `desc` |
|
||||
| `type` | `diary`, `fitness` |
|
||||
| `type` | `diary`, `fitness`, `calendars` |
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
@@ -51,7 +54,7 @@ All use Alt+Shift.
|
||||
|
||||
| Where | Keys |
|
||||
|-------|------|
|
||||
| Anywhere | `E` edit, `N` new page, `M` move, `F` search |
|
||||
| Anywhere | `E` edit, `N` new page, `M` move, `F` search, `H` maintenance |
|
||||
| Editor | `S` save, `1`–`3` heading, `B`/`I` bold/italic, `C`/`K` code/code block, `L` link, `P` wikilink, `G` embed, `U`/`O`/`X` bullet/numbered/task list, `Q` quote, `R` rule, `T` format table, `D`/`W` date (ISO/long), `V` movie, `Y` delete line |
|
||||
|
||||
## Diary
|
||||
@@ -72,6 +75,46 @@ Bookmarkable shortcuts: `<diary>/today/`, `<diary>/this-month/`, `<diary>/this-y
|
||||
|
||||
With `type = fitness`, a folder shows weight charts (daily and weekly mean, with your goal) below its content. Copy the [Waistline](https://github.com/davidhealey/waistline) app's export into the folder as `waistline_export.json`. Each new export replaces the old file.
|
||||
|
||||
## Calendars
|
||||
|
||||
```
|
||||
Calendars/
|
||||
.page-settings ← type = calendars, roles, sync credentials
|
||||
Personal.ics ← one file per calendar
|
||||
Gewicht.ics
|
||||
…
|
||||
```
|
||||
|
||||
Pages only read these local files and never contact the calendar server. The built-in sync downloads all calendars of a CalDAV calendar home in the background. Or you can put the `.ics` files there yourself.
|
||||
|
||||
```
|
||||
type = calendars
|
||||
|
||||
# Roles: file names without .ics
|
||||
diary = Personal, Feiertage
|
||||
weight = Gewicht
|
||||
maintenance = Maintainance
|
||||
|
||||
# Sync (Nextcloud)
|
||||
caldav-url = https://cloud.example/remote.php/dav/calendars/me/
|
||||
caldav-user = me
|
||||
caldav-pass = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx
|
||||
sync-interval = 15m
|
||||
```
|
||||
|
||||
| Key | |
|
||||
|-----|-|
|
||||
| `diary` | Events shown under each diary day: all-day first, then by start time |
|
||||
| `weight` | Titles like `95,6kg`, shown under the diary day's events |
|
||||
| `maintenance` | Tasks in the header widget (with the number due today) and on the calendars page |
|
||||
| `caldav-url` | Calendar home. Empty = no sync |
|
||||
| `caldav-user`, `caldav-pass` | Use a Nextcloud app password (Settings → Security), not the account password |
|
||||
| `sync-interval` | Default `15m`. `0` = only at startup and with **Sync now** |
|
||||
|
||||
Changes apply without a restart. Each calendar is saved as `<display name>.ics`. A download that fails or is not valid iCalendar keeps the old file. Datascape never serves dotfiles, so the password in `.page-settings` cannot be downloaded. It is still in every backup of the wiki folder.
|
||||
|
||||
The calendars page lists the maintenance tasks (repeat rule, last date, next date) and the sync status of each calendar.
|
||||
|
||||
## Quick-add bookmarklet
|
||||
|
||||
Replace `wiki.host` and `/Topics/Bookmarks/` with your host and target page:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<h2 id="maintenance">Maintenance</h2>
|
||||
{{if .Maintenance}}
|
||||
<div class="cal-scroll"><table class="data-table panel cal-table">
|
||||
<thead><tr><th>Task</th><th>Repeats</th><th>Last</th><th>Next</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Maintenance}}
|
||||
<tr><td>{{.Summary}}</td><td class="muted">{{.Rule}}</td><td class="nowrap">{{.Last}}</td><td class="nowrap">{{.Next}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{else if .HasMaintenance}}
|
||||
<p class="muted">No maintenance tasks within a year of today.</p>
|
||||
{{else}}
|
||||
<p class="muted">No maintenance calendar. Set <code>maintenance = <name></code> in this folder's <code>.page-settings</code>.</p>
|
||||
{{end}}
|
||||
|
||||
<h2 id="calendars">Calendars</h2>
|
||||
{{if .Calendars}}
|
||||
<div class="cal-scroll"><table class="data-table panel cal-table">
|
||||
<thead><tr><th>Calendar</th><th>Role</th><th>Events</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Calendars}}
|
||||
<tr>
|
||||
<td>{{.Name}}{{if .Error}}<div class="cal-error">{{.Error}}</div>{{end}}</td>
|
||||
<td class="muted">{{.Roles}}</td>
|
||||
<td>{{.Events}}</td>
|
||||
<td class="nowrap">{{.Updated}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{else}}
|
||||
<p class="muted">No <code>.ics</code> files in this folder yet.</p>
|
||||
{{end}}
|
||||
{{if .IsSyncDir}}
|
||||
{{if .SyncEnabled}}
|
||||
<form class="cal-sync row" method="post" action="?calsync">
|
||||
<button type="submit" class="btn">Sync now</button>
|
||||
<span class="muted">{{if .LastRun}}Last sync: {{.LastRun}}{{else}}Not synced yet{{end}}</span>
|
||||
</form>
|
||||
{{if .LastError}}<p class="cal-error">{{.LastError}}</p>{{end}}
|
||||
{{else}}
|
||||
<p class="muted">Sync is off. Set <code>caldav-url</code>, <code>caldav-user</code> and <code>caldav-pass</code> in this folder's <code>.page-settings</code>.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,16 @@
|
||||
<span class="dropdown maint">
|
||||
<button type="button" class="btn maint-toggle{{if not .Count}} is-idle{{end}}" data-action="maint-menu" aria-haspopup="true" aria-expanded="false" title="Maintenance (H)">{{icon "maintenance"}}<span class="maint-count">{{.Count}}</span></button>
|
||||
<div class="dropdown-menu align-right maint-menu">
|
||||
<div class="panel-header">Today</div>
|
||||
<div class="maint-list">
|
||||
{{range .Today}}<span class="maint-when">{{.When}}</span><span>{{.Summary}}</span>
|
||||
{{else}}<span class="muted">Nothing due</span>{{end}}
|
||||
</div>
|
||||
<div class="panel-header">Next 7 days</div>
|
||||
<div class="maint-list">
|
||||
{{range .Upcoming}}<span class="maint-when">{{.When}}</span><span>{{.Summary}}</span>
|
||||
{{else}}<span class="muted">Nothing planned</span>{{end}}
|
||||
</div>
|
||||
{{if .PageURL}}<a class="btn btn-block maint-all" href="{{.PageURL}}">All maintenance →</a>{{end}}
|
||||
</div>
|
||||
</span>
|
||||
@@ -10,4 +10,8 @@
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if or .Events .Weight}}<div class="diary-day-cal muted">
|
||||
{{if .Events}}<p>{{.Events}}</p>{{end}}
|
||||
{{if .Weight}}<p>{{.Weight}}</p>{{end}}
|
||||
</div>{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
e.preventDefault();
|
||||
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
|
||||
break;
|
||||
case 'H':
|
||||
var maint = document.querySelector('[data-action="maint-menu"]');
|
||||
if (!maint) return;
|
||||
e.preventDefault();
|
||||
maint.click();
|
||||
break;
|
||||
case 'F':
|
||||
var input = document.querySelector('.search-input');
|
||||
if (!input) return;
|
||||
@@ -58,12 +64,14 @@ document.addEventListener('keydown', function (e) {
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var trigger = document.querySelector('[data-action="actions-menu"]');
|
||||
if (!trigger) return;
|
||||
wireDropdown(trigger);
|
||||
var menu = trigger.parentElement.querySelector('.dropdown-menu');
|
||||
if (!menu) return;
|
||||
trigger.addEventListener('click', function () {
|
||||
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false);
|
||||
['actions-menu', 'maint-menu'].forEach(function (action) {
|
||||
var trigger = document.querySelector('[data-action="' + action + '"]');
|
||||
if (!trigger) return;
|
||||
wireDropdown(trigger);
|
||||
var menu = trigger.parentElement.querySelector('.dropdown-menu');
|
||||
if (!menu) return;
|
||||
trigger.addEventListener('click', function () {
|
||||
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
||||
<path d="M2 4h12M6 4V2h4v2M4 4l1 10h6l1-10M7 7v4M9 7v4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 259 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
||||
<path d="M11 2l3 3-8 8H3v-3zM9 4l3 3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 241 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
||||
<path d="M2 3h12v11H2zM2 6h12M5 1v2M11 1v2M5 10l2 2 4-4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
||||
<path d="M2 8h8M7 5l3 3-3 3M13 2v12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 240 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
||||
<path d="M8 3v10M3 8h10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
||||
<path d="M13 8a5 5 0 1 1-1.5-3.5M12 1v4H8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 246 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
|
||||
<path d="M1 14V3h5l1 2h8v9zM8 12V8M6 10l2-2 2 2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -31,6 +31,7 @@
|
||||
{{end}}
|
||||
<div class="header-actions row">
|
||||
{{if .EditMode}}{{block "headerActions" .}}{{end}}{{else}}
|
||||
{{maintenanceWidget}}
|
||||
<span class="dropdown actions">
|
||||
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
|
||||
<div class="dropdown-menu align-right actions-menu">
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{{define "headScripts"}}<script src="/_/page/actions.js"></script>{{end}}
|
||||
|
||||
{{define "headerActions"}}{{if .CanEdit}}
|
||||
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW</button>
|
||||
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL</button>
|
||||
<button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button>
|
||||
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button>
|
||||
{{if not .IsRoot}}
|
||||
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE</button>
|
||||
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE</button>
|
||||
<button class="btn btn-block" onclick="movePage()" title="Move page (M)"><span class="btn-icon">{{icon "move"}}</span><span class="btn-label">MOVE</span></button>
|
||||
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page"><span class="btn-icon">{{icon "delete"}}</span><span class="btn-label">DELETE</span></button>
|
||||
{{end}}
|
||||
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT</a>
|
||||
<a class="btn btn-block" href="?edit" title="Edit page (E)"><span class="btn-icon">{{icon "edit"}}</span><span class="btn-label">EDIT</span></a>
|
||||
{{end}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
{{end}}
|
||||
|
||||
{{define "headerActions"}}
|
||||
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index">REBUILD INDEX</button>
|
||||
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index"><span class="btn-icon">{{icon "rebuild"}}</span><span class="btn-label">REBUILD INDEX</span></button>
|
||||
{{end}}
|
||||
|
||||
{{define "footerExtras"}}
|
||||
|
||||
@@ -389,6 +389,10 @@ main > h2 {
|
||||
.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; }
|
||||
|
||||
.actions-special > .btn {text-align: left;}
|
||||
/* Header actions carry an icon and a label: the mobile dropdown shows the
|
||||
label, the inline desktop bar (see responsive block) the icon. */
|
||||
.btn-icon { display: none; }
|
||||
.btn-icon svg { display: block; }
|
||||
|
||||
/* === Suggestion dropdown (header search + editor link picker) ===
|
||||
Anchored to a position:relative host. Mirrors .dropdown-menu visuals with
|
||||
@@ -797,6 +801,37 @@ aside.sidebar:empty { display: none; }
|
||||
.diary-cal-grid td.cal-current,
|
||||
.diary-cal-grid td.cal-current a { color: var(--primary-hover); }
|
||||
.btn-block.cal-current { color: var(--primary-hover); }
|
||||
/* Calendar events and weight under a diary day heading. */
|
||||
.diary-day-cal { margin: var(--space-3) 0 0; }
|
||||
.diary-day-cal p { margin: 0; }
|
||||
|
||||
/* === Calendars ===
|
||||
Header maintenance chip + dropdown, and the calendars folder page. */
|
||||
.maint-toggle { display: inline-flex; align-items: center; }
|
||||
.maint-count { margin-left: 0.3rem; }
|
||||
.maint-toggle.is-idle { color: var(--text-muted); }
|
||||
.maint-menu {
|
||||
min-width: 20rem;
|
||||
max-width: calc(100vw - 2rem);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
white-space: normal;
|
||||
}
|
||||
/* Two columns so dates line up; an empty when-cell keeps the grid intact. */
|
||||
.maint-list {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: var(--space-2);
|
||||
row-gap: 0.15rem;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.maint-list > .muted { grid-column: 1 / -1; }
|
||||
.maint-list + .panel-header { margin-top: var(--space-3); }
|
||||
.maint-when { color: var(--text-muted); white-space: nowrap; }
|
||||
.maint-all { margin-top: var(--space-3); padding: 0.3rem 0 0; border-top: var(--border-dashed); }
|
||||
.cal-scroll { overflow-x: auto; }
|
||||
.cal-table .nowrap { white-space: nowrap; }
|
||||
.cal-error { color: var(--danger); font-size: var(--font-sm); }
|
||||
.cal-sync { margin-top: var(--space-3); }
|
||||
|
||||
/* === Fitness dashboard ===
|
||||
Server-rendered inline SVG charts. Geometry comes precomputed from Go;
|
||||
@@ -840,7 +875,9 @@ aside.sidebar:empty { display: none; }
|
||||
@media (min-width: 1101px) {
|
||||
.actions-toggle, .actions-special {display: none;}
|
||||
.actions, .actions-menu, .actions.dropdown-menu.is-open {display: contents;}
|
||||
.actions-menu .btn-block { width: auto; padding: 0;}
|
||||
.actions-menu .btn-block { width: auto; padding: 0; align-items: center; }
|
||||
.actions-menu .btn-icon { display: inline-block; }
|
||||
.actions-menu .btn-label { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-ical"
|
||||
"github.com/teambition/rrule-go"
|
||||
)
|
||||
|
||||
// calendarsDir is the absolute path of the calendars folder (-calendars).
|
||||
// Empty disables the diary lines, the maintenance widget and the sync. Every
|
||||
// calendar is one <Name>.ics file in this folder; the folder's .page-settings
|
||||
// assigns calendars to roles and holds the CalDAV sync credentials.
|
||||
var calendarsDir string
|
||||
|
||||
// === Settings ===
|
||||
|
||||
type calendarSettings struct {
|
||||
// Roles: calendar names (file basenames without .ics).
|
||||
Diary []string
|
||||
Weight []string
|
||||
Maintenance []string
|
||||
|
||||
CaldavURL string
|
||||
CaldavUser string
|
||||
CaldavPass string
|
||||
SyncInterval time.Duration // 0 = startup and manual only
|
||||
}
|
||||
|
||||
// readCalendarSettings reads the calendar keys from dir's .page-settings.
|
||||
// Read on every use, so edits apply without a restart.
|
||||
func readCalendarSettings(dir string) calendarSettings {
|
||||
kv := readSettingsFile(dir)
|
||||
s := calendarSettings{
|
||||
Diary: splitCalendarList(kv["diary"]),
|
||||
Weight: splitCalendarList(kv["weight"]),
|
||||
Maintenance: splitCalendarList(kv["maintenance"]),
|
||||
CaldavURL: kv["caldav-url"],
|
||||
CaldavUser: kv["caldav-user"],
|
||||
CaldavPass: kv["caldav-pass"],
|
||||
SyncInterval: 15 * time.Minute,
|
||||
}
|
||||
if v, ok := kv["sync-interval"]; ok {
|
||||
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
|
||||
s.SyncInterval = d
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func splitCalendarList(v string) []string {
|
||||
var out []string
|
||||
for _, name := range strings.Split(v, ",") {
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// calendarPath returns the file of calendar name in dir, or "" when the name
|
||||
// would escape the folder.
|
||||
func calendarPath(dir, name string) string {
|
||||
if name == "" || strings.ContainsAny(name, `/\`) || strings.HasPrefix(name, ".") {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(dir, name+".ics")
|
||||
}
|
||||
|
||||
// === Model ===
|
||||
|
||||
// calEvent is one expanded occurrence.
|
||||
type calEvent struct {
|
||||
Calendar string
|
||||
UID string
|
||||
Summary string
|
||||
Start time.Time // local time; all-day events start at local midnight
|
||||
End time.Time // exclusive
|
||||
AllDay bool
|
||||
Stamp time.Time // LAST-MODIFIED, else DTSTAMP
|
||||
Series *calSeries // nil for single events
|
||||
}
|
||||
|
||||
// calSeries describes a recurring event as a whole.
|
||||
type calSeries struct {
|
||||
Calendar string
|
||||
UID string
|
||||
Summary string
|
||||
Rule string // human-readable, e.g. "every 2 weeks (Sa)"
|
||||
}
|
||||
|
||||
// calItem is a parsed VEVENT: a single event, a recurring master, or a
|
||||
// RECURRENCE-ID override attached to its master.
|
||||
type calItem struct {
|
||||
uid string
|
||||
summary string
|
||||
start time.Time
|
||||
end time.Time
|
||||
allDay bool
|
||||
days int // all-day length in days; keeps occurrences DST-safe
|
||||
stamp time.Time
|
||||
cancelled bool
|
||||
|
||||
set *rrule.Set // nil = single event
|
||||
series *calSeries // set together with set
|
||||
overrides map[int64]*calItem // RECURRENCE-ID (unix) → replacement
|
||||
}
|
||||
|
||||
// calFile is one parsed calendar file.
|
||||
type calFile struct {
|
||||
modTime time.Time
|
||||
size int64
|
||||
items []*calItem
|
||||
events int // VEVENT components in the file
|
||||
}
|
||||
|
||||
// === Store ===
|
||||
|
||||
// calStore caches parsed calendar files by path. An entry is reused while the
|
||||
// file's modtime and size are unchanged, so a request costs one stat per
|
||||
// calendar. Parsed files are never mutated, so callers may keep using a
|
||||
// snapshot after the lock is released.
|
||||
var calStore = struct {
|
||||
sync.Mutex
|
||||
files map[string]*calFile
|
||||
}{files: map[string]*calFile{}}
|
||||
|
||||
// loadCalendar returns the parsed calendar at path; nil when it does not exist
|
||||
// or cannot be parsed (logged).
|
||||
func loadCalendar(path string) *calFile {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
calStore.Lock()
|
||||
defer calStore.Unlock()
|
||||
if f := calStore.files[path]; f != nil && f.modTime.Equal(info.ModTime()) && f.size == info.Size() {
|
||||
return f
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
f, err := parseCalendar(filepath.Base(path), data)
|
||||
if err != nil {
|
||||
log.Printf("calendar %s: %v", path, err)
|
||||
delete(calStore.files, path)
|
||||
return nil
|
||||
}
|
||||
f.modTime = info.ModTime()
|
||||
f.size = info.Size()
|
||||
calStore.files[path] = f
|
||||
return f
|
||||
}
|
||||
|
||||
// calendarOccurrences expands every event of the named calendars in dir that
|
||||
// overlaps [from, to). The result is sorted by start, then summary.
|
||||
func calendarOccurrences(dir string, names []string, from, to time.Time) []calEvent {
|
||||
var out []calEvent
|
||||
for _, name := range names {
|
||||
p := calendarPath(dir, name)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
f := loadCalendar(p)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
for _, it := range f.items {
|
||||
out = it.expand(name, from, to, out)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if !out[i].Start.Equal(out[j].Start) {
|
||||
return out[i].Start.Before(out[j].Start)
|
||||
}
|
||||
return out[i].Summary < out[j].Summary
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (it *calItem) occurrence(calendar string, start, end time.Time) calEvent {
|
||||
return calEvent{
|
||||
Calendar: calendar,
|
||||
UID: it.uid,
|
||||
Summary: it.summary,
|
||||
Start: start.In(time.Local),
|
||||
End: end.In(time.Local),
|
||||
AllDay: it.allDay,
|
||||
Stamp: it.stamp,
|
||||
}
|
||||
}
|
||||
|
||||
// overlaps reports whether [start, end) intersects [from, to). Zero-length
|
||||
// events count when they start inside the window.
|
||||
func overlaps(start, end, from, to time.Time) bool {
|
||||
if !start.Before(to) {
|
||||
return false
|
||||
}
|
||||
return end.After(from) || !start.Before(from)
|
||||
}
|
||||
|
||||
// expand appends the item's occurrences in [from, to) to out.
|
||||
func (it *calItem) expand(calendar string, from, to time.Time, out []calEvent) []calEvent {
|
||||
if it.cancelled {
|
||||
return out
|
||||
}
|
||||
if it.set == nil {
|
||||
if overlaps(it.start, it.end, from, to) {
|
||||
out = append(out, it.occurrence(calendar, it.start, it.end))
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Start the search early enough to catch occurrences that began before
|
||||
// the window but still run into it.
|
||||
after := from.Add(-it.end.Sub(it.start)).Add(-48 * time.Hour)
|
||||
for _, s := range it.set.Between(after, to, true) {
|
||||
if _, overridden := it.overrides[s.Unix()]; overridden {
|
||||
continue
|
||||
}
|
||||
e := it.occurrenceEnd(s)
|
||||
if overlaps(s, e, from, to) {
|
||||
ev := it.occurrence(calendar, s, e)
|
||||
ev.Series = it.series
|
||||
out = append(out, ev)
|
||||
}
|
||||
}
|
||||
// Overrides are placed on their own dates: a moved occurrence can enter
|
||||
// or leave the window independently of its original slot.
|
||||
for _, o := range it.overrides {
|
||||
if o.cancelled || !overlaps(o.start, o.end, from, to) {
|
||||
continue
|
||||
}
|
||||
ev := o.occurrence(calendar, o.start, o.end)
|
||||
ev.Series = it.series
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// occurrenceEnd returns the end of the occurrence starting at s.
|
||||
func (it *calItem) occurrenceEnd(s time.Time) time.Time {
|
||||
if it.allDay {
|
||||
return s.AddDate(0, 0, it.days)
|
||||
}
|
||||
return s.Add(it.end.Sub(it.start))
|
||||
}
|
||||
|
||||
// === Parsing ===
|
||||
|
||||
// calParser holds per-file parse state: the file name for log messages, the
|
||||
// TZIDs already warned about, and resolved locations.
|
||||
type calParser struct {
|
||||
file string
|
||||
zones map[string]*time.Location
|
||||
warned map[string]bool
|
||||
}
|
||||
|
||||
func parseCalendar(file string, data []byte) (*calFile, error) {
|
||||
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) // UTF-8 BOM
|
||||
cal, err := ical.NewDecoder(bytes.NewReader(data)).Decode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := &calParser{file: file, zones: map[string]*time.Location{}, warned: map[string]bool{}}
|
||||
f := &calFile{}
|
||||
masters := map[string]*calItem{}
|
||||
type override struct {
|
||||
item *calItem
|
||||
recurID time.Time
|
||||
}
|
||||
var overrides []override
|
||||
for _, comp := range cal.Children {
|
||||
if comp.Name != ical.CompEvent {
|
||||
continue
|
||||
}
|
||||
f.events++
|
||||
it, recurID, err := p.item(comp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !recurID.IsZero() {
|
||||
overrides = append(overrides, override{it, recurID})
|
||||
continue
|
||||
}
|
||||
f.items = append(f.items, it)
|
||||
if it.uid != "" {
|
||||
masters[it.uid] = it
|
||||
}
|
||||
}
|
||||
// Overrides can come before their master in the file, so attach them in
|
||||
// a second pass. One without a recurring master is shown as a single event.
|
||||
for _, o := range overrides {
|
||||
m := masters[o.item.uid]
|
||||
if m == nil || m.set == nil {
|
||||
f.items = append(f.items, o.item)
|
||||
continue
|
||||
}
|
||||
if o.item.summary == "" {
|
||||
o.item.summary = m.summary
|
||||
}
|
||||
m.overrides[o.recurID.Unix()] = o.item
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// item parses one VEVENT. recurID is non-zero for RECURRENCE-ID overrides.
|
||||
func (p *calParser) item(comp *ical.Component) (it *calItem, recurID time.Time, err error) {
|
||||
startProp := comp.Props.Get(ical.PropDateTimeStart)
|
||||
if startProp == nil {
|
||||
return nil, time.Time{}, errors.New("no DTSTART")
|
||||
}
|
||||
starts, allDay, err := p.times(startProp)
|
||||
if err != nil || len(starts) == 0 {
|
||||
return nil, time.Time{}, fmt.Errorf("DTSTART: %v", err)
|
||||
}
|
||||
it = &calItem{start: starts[0], allDay: allDay}
|
||||
it.uid = calText(comp.Props.Get(ical.PropUID))
|
||||
it.summary = strings.TrimSpace(calText(comp.Props.Get(ical.PropSummary)))
|
||||
|
||||
it.end = it.start
|
||||
if prop := comp.Props.Get(ical.PropDateTimeEnd); prop != nil {
|
||||
if ends, _, err := p.times(prop); err == nil && len(ends) > 0 {
|
||||
it.end = ends[0]
|
||||
}
|
||||
} else if prop := comp.Props.Get(ical.PropDuration); prop != nil {
|
||||
if d, err := prop.Duration(); err == nil {
|
||||
if allDay {
|
||||
it.end = it.start.AddDate(0, 0, int(math.Round(d.Hours()/24)))
|
||||
} else {
|
||||
it.end = it.start.Add(d)
|
||||
}
|
||||
}
|
||||
} else if allDay {
|
||||
it.end = it.start.AddDate(0, 0, 1)
|
||||
}
|
||||
if it.end.Before(it.start) {
|
||||
it.end = it.start
|
||||
}
|
||||
if allDay {
|
||||
it.days = calendarDaysBetween(it.start, it.end)
|
||||
if it.days < 1 {
|
||||
it.days = 1
|
||||
}
|
||||
it.end = it.start.AddDate(0, 0, it.days)
|
||||
}
|
||||
|
||||
if strings.EqualFold(calText(comp.Props.Get(ical.PropStatus)), "CANCELLED") {
|
||||
it.cancelled = true
|
||||
}
|
||||
for _, name := range []string{ical.PropLastModified, ical.PropDateTimeStamp} {
|
||||
if prop := comp.Props.Get(name); prop != nil {
|
||||
if ts, _, err := p.times(prop); err == nil && len(ts) > 0 {
|
||||
it.stamp = ts[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if prop := comp.Props.Get(ical.PropRecurrenceID); prop != nil {
|
||||
ids, _, err := p.times(prop)
|
||||
if err != nil || len(ids) == 0 {
|
||||
return nil, time.Time{}, fmt.Errorf("RECURRENCE-ID: %v", err)
|
||||
}
|
||||
return it, ids[0], nil
|
||||
}
|
||||
|
||||
rule := comp.Props.Get(ical.PropRecurrenceRule)
|
||||
rdates := comp.Props.Values(ical.PropRecurrenceDates)
|
||||
if rule == nil && len(rdates) == 0 {
|
||||
return it, time.Time{}, nil
|
||||
}
|
||||
set := &rrule.Set{}
|
||||
ruleText := ""
|
||||
if rule != nil {
|
||||
opt, err := rrule.StrToROptionInLocation(rule.Value, it.start.Location())
|
||||
if err != nil {
|
||||
p.warn("rrule:"+rule.Value, "cannot parse RRULE %q of %q: %v", rule.Value, it.summary, err)
|
||||
return it, time.Time{}, nil
|
||||
}
|
||||
opt.Dtstart = it.start
|
||||
r, err := rrule.NewRRule(*opt)
|
||||
if err != nil {
|
||||
p.warn("rrule:"+rule.Value, "cannot build RRULE %q of %q: %v", rule.Value, it.summary, err)
|
||||
return it, time.Time{}, nil
|
||||
}
|
||||
set.RRule(r)
|
||||
ruleText = describeRRule(opt)
|
||||
} else {
|
||||
// Without an RRULE the set only holds RDATEs; DTSTART is always the
|
||||
// first occurrence.
|
||||
set.RDate(it.start)
|
||||
ruleText = "on selected dates"
|
||||
}
|
||||
for i := range rdates {
|
||||
if ts, _, err := p.times(&rdates[i]); err == nil {
|
||||
for _, t := range ts {
|
||||
set.RDate(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, prop := range comp.Props.Values(ical.PropExceptionDates) {
|
||||
if ts, _, err := p.times(&prop); err == nil {
|
||||
for _, t := range ts {
|
||||
set.ExDate(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
it.set = set
|
||||
it.overrides = map[int64]*calItem{}
|
||||
it.series = &calSeries{
|
||||
Calendar: strings.TrimSuffix(p.file, ".ics"),
|
||||
UID: it.uid,
|
||||
Summary: it.summary,
|
||||
Rule: ruleText,
|
||||
}
|
||||
return it, time.Time{}, nil
|
||||
}
|
||||
|
||||
// calText unescapes a TEXT value. Unlike go-ical's Text it keeps unescaped
|
||||
// commas: clients write titles like "95,6kg" without the required backslash,
|
||||
// and go-ical would cut them at the comma. Unknown escapes are kept as is.
|
||||
func calText(prop *ical.Prop) string {
|
||||
if prop == nil {
|
||||
return ""
|
||||
}
|
||||
v := prop.Value
|
||||
if !strings.Contains(v, `\`) {
|
||||
return v
|
||||
}
|
||||
var sb strings.Builder
|
||||
for i := 0; i < len(v); i++ {
|
||||
c := v[i]
|
||||
if c != '\\' || i+1 == len(v) {
|
||||
sb.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
i++
|
||||
switch n := v[i]; n {
|
||||
case 'n', 'N':
|
||||
sb.WriteByte('\n')
|
||||
case '\\', ';', ',':
|
||||
sb.WriteByte(n)
|
||||
default:
|
||||
sb.WriteByte('\\')
|
||||
sb.WriteByte(n)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// times parses a DATE or DATE-TIME property. Comma-separated lists (EXDATE,
|
||||
// RDATE) yield several values. Dates and floating times use the local zone; an
|
||||
// unknown TZID falls back to it with a warning.
|
||||
func (p *calParser) times(prop *ical.Prop) ([]time.Time, bool, error) {
|
||||
loc := time.Local
|
||||
if tzid := prop.Params.Get(ical.PropTimezoneID); tzid != "" {
|
||||
loc = p.zone(tzid)
|
||||
}
|
||||
var out []time.Time
|
||||
allDay := false
|
||||
for _, v := range strings.Split(prop.Value, ",") {
|
||||
v = strings.TrimSpace(v)
|
||||
var t time.Time
|
||||
var err error
|
||||
switch {
|
||||
case len(v) == 8:
|
||||
t, err = time.ParseInLocation("20060102", v, time.Local)
|
||||
allDay = true
|
||||
case len(v) == 16 && strings.HasSuffix(v, "Z"):
|
||||
t, err = time.Parse("20060102T150405Z", v)
|
||||
case len(v) == 15:
|
||||
t, err = time.ParseInLocation("20060102T150405", v, loc)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported value %q", v)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, allDay, nil
|
||||
}
|
||||
|
||||
func (p *calParser) zone(tzid string) *time.Location {
|
||||
if loc, ok := p.zones[tzid]; ok {
|
||||
return loc
|
||||
}
|
||||
loc, err := time.LoadLocation(tzid)
|
||||
if err != nil {
|
||||
// Some clients prefix the IANA name, e.g.
|
||||
// "/freeassociation.sourceforge.net/Tzfile/Europe/Berlin".
|
||||
parts := strings.Split(strings.Trim(tzid, "/"), "/")
|
||||
if len(parts) >= 2 {
|
||||
loc, err = time.LoadLocation(strings.Join(parts[len(parts)-2:], "/"))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
p.warn("tz:"+tzid, "unknown TZID %q, using local time", tzid)
|
||||
loc = time.Local
|
||||
}
|
||||
p.zones[tzid] = loc
|
||||
return loc
|
||||
}
|
||||
|
||||
func (p *calParser) warn(key, format string, args ...any) {
|
||||
if p.warned[key] {
|
||||
return
|
||||
}
|
||||
p.warned[key] = true
|
||||
log.Printf("calendar %s: "+format, append([]any{p.file}, args...)...)
|
||||
}
|
||||
|
||||
// calendarDaysBetween counts calendar days from a to b (both local midnights),
|
||||
// independent of DST shifts.
|
||||
func calendarDaysBetween(a, b time.Time) int {
|
||||
ay, am, ad := a.Date()
|
||||
by, bm, bd := b.Date()
|
||||
ua := time.Date(ay, am, ad, 0, 0, 0, 0, time.UTC)
|
||||
ub := time.Date(by, bm, bd, 0, 0, 0, 0, time.UTC)
|
||||
return int(ub.Sub(ua).Hours() / 24)
|
||||
}
|
||||
|
||||
// === Formatting ===
|
||||
|
||||
var germanWeekdays = [...]string{"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"}
|
||||
|
||||
// calDayLabel formats a date as "Sa 27.09.".
|
||||
func calDayLabel(t time.Time) string {
|
||||
return fmt.Sprintf("%s %02d.%02d.", germanWeekdays[t.Weekday()], t.Day(), int(t.Month()))
|
||||
}
|
||||
|
||||
// describeRRule turns common rules into short text ("weekly (Mo)",
|
||||
// "every 2 weeks (Sa)", "monthly (day 1)"). Anything with parts it does not
|
||||
// know falls back to the raw rule.
|
||||
func describeRRule(opt *rrule.ROption) string {
|
||||
if len(opt.Bysetpos) > 0 || len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 ||
|
||||
len(opt.Byhour) > 0 || len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 {
|
||||
return opt.RRuleString()
|
||||
}
|
||||
units := map[rrule.Frequency][2]string{
|
||||
rrule.DAILY: {"daily", "days"},
|
||||
rrule.WEEKLY: {"weekly", "weeks"},
|
||||
rrule.MONTHLY: {"monthly", "months"},
|
||||
rrule.YEARLY: {"yearly", "years"},
|
||||
}
|
||||
u, ok := units[opt.Freq]
|
||||
if !ok {
|
||||
return opt.RRuleString()
|
||||
}
|
||||
text := u[0]
|
||||
if opt.Interval > 1 {
|
||||
text = fmt.Sprintf("every %d %s", opt.Interval, u[1])
|
||||
}
|
||||
var details []string
|
||||
for _, wd := range opt.Byweekday {
|
||||
name := germanWeekdays[(wd.Day()+1)%7]
|
||||
if n := wd.N(); n != 0 {
|
||||
name = strconv.Itoa(n) + ". " + name
|
||||
if n == -1 {
|
||||
name = "last " + germanWeekdays[(wd.Day()+1)%7]
|
||||
}
|
||||
}
|
||||
details = append(details, name)
|
||||
}
|
||||
for _, d := range opt.Bymonthday {
|
||||
details = append(details, fmt.Sprintf("day %d", d))
|
||||
}
|
||||
for _, m := range opt.Bymonth {
|
||||
if m >= 1 && m <= 12 {
|
||||
details = append(details, germanMonths[time.Month(m)])
|
||||
}
|
||||
}
|
||||
if len(details) > 0 {
|
||||
text += " (" + strings.Join(details, ", ") + ")"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// === Diary lines ===
|
||||
|
||||
// calDayLines is the calendar content shown under a diary day.
|
||||
type calDayLines struct {
|
||||
Events string // "Feiertag, 10:00 Brunch, 19:30 Kino"
|
||||
Weight string // "95,6 kg"
|
||||
}
|
||||
|
||||
var weightTitle = regexp.MustCompile(`(?i)^\s*(\d+(?:[.,]\d+)?)\s*kg\s*$`)
|
||||
|
||||
// calendarDayLines builds the diary lines for every day in [from, to), keyed
|
||||
// by "YYYY-MM-DD". Events come from the diary role, weights from the weight
|
||||
// role. Days without either are absent.
|
||||
func calendarDayLines(from, to time.Time) map[string]calDayLines {
|
||||
out := map[string]calDayLines{}
|
||||
if calendarsDir == "" {
|
||||
return out
|
||||
}
|
||||
s := readCalendarSettings(calendarsDir)
|
||||
|
||||
type dayEvents struct{ allDay, timed []calEvent }
|
||||
byDay := map[string]*dayEvents{}
|
||||
for _, ev := range calendarOccurrences(calendarsDir, s.Diary, from, to) {
|
||||
if ev.AllDay {
|
||||
// Multi-day all-day events show on every day they cover.
|
||||
for d := ev.Start; d.Before(ev.End); d = d.AddDate(0, 0, 1) {
|
||||
key := d.Format("2006-01-02")
|
||||
if byDay[key] == nil {
|
||||
byDay[key] = &dayEvents{}
|
||||
}
|
||||
byDay[key].allDay = append(byDay[key].allDay, ev)
|
||||
}
|
||||
continue
|
||||
}
|
||||
key := ev.Start.Format("2006-01-02")
|
||||
if byDay[key] == nil {
|
||||
byDay[key] = &dayEvents{}
|
||||
}
|
||||
byDay[key].timed = append(byDay[key].timed, ev)
|
||||
}
|
||||
for key, d := range byDay {
|
||||
sort.SliceStable(d.allDay, func(i, j int) bool { return d.allDay[i].Summary < d.allDay[j].Summary })
|
||||
var parts []string
|
||||
for _, ev := range d.allDay {
|
||||
if ev.Summary != "" {
|
||||
parts = append(parts, ev.Summary)
|
||||
}
|
||||
}
|
||||
// Timed events are already sorted by start, then summary.
|
||||
for _, ev := range d.timed {
|
||||
parts = append(parts, strings.TrimSpace(ev.Start.Format("15:04")+" "+ev.Summary))
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
out[key] = calDayLines{Events: strings.Join(parts, ", ")}
|
||||
}
|
||||
}
|
||||
|
||||
stamps := map[string]time.Time{}
|
||||
for _, ev := range calendarOccurrences(calendarsDir, s.Weight, from, to) {
|
||||
m := weightTitle.FindStringSubmatch(ev.Summary)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
key := ev.Start.Format("2006-01-02")
|
||||
// Several entries on one day: the last edited one wins.
|
||||
if prev, ok := stamps[key]; ok && ev.Stamp.Before(prev) {
|
||||
continue
|
||||
}
|
||||
stamps[key] = ev.Stamp
|
||||
lines := out[key]
|
||||
lines.Weight = strings.ReplaceAll(m[1], ".", ",") + " kg"
|
||||
out[key] = lines
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeCal writes a VCALENDAR with the given VEVENT bodies to dir/name.ics.
|
||||
func writeCal(t *testing.T, dir, name string, events ...string) {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\n")
|
||||
for _, ev := range events {
|
||||
b.WriteString("BEGIN:VEVENT\r\n")
|
||||
for _, line := range strings.Split(strings.TrimSpace(ev), "\n") {
|
||||
b.WriteString(strings.TrimSpace(line) + "\r\n")
|
||||
}
|
||||
b.WriteString("END:VEVENT\r\n")
|
||||
}
|
||||
b.WriteString("END:VCALENDAR\r\n")
|
||||
if err := os.WriteFile(filepath.Join(dir, name+".ics"), []byte(b.String()), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func withBerlin(t *testing.T) {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation("Europe/Berlin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prev := time.Local
|
||||
time.Local = loc
|
||||
t.Cleanup(func() { time.Local = prev })
|
||||
}
|
||||
|
||||
func day(y int, m time.Month, d int) time.Time {
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
func summaries(evs []calEvent) []string {
|
||||
var out []string
|
||||
for _, ev := range evs {
|
||||
out = append(out, ev.Start.Format("2006-01-02 15:04")+" "+ev.Summary)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertStrings(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("got:\n %s\nwant:\n %s", strings.Join(got, "\n "), strings.Join(want, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarSingleAndAllDay(t *testing.T) {
|
||||
withBerlin(t)
|
||||
dir := t.TempDir()
|
||||
writeCal(t, dir, "P",
|
||||
`UID:a
|
||||
SUMMARY:Zahnarzt
|
||||
DTSTART;TZID=Europe/Berlin:20261002T090000
|
||||
DTEND;TZID=Europe/Berlin:20261002T100000`,
|
||||
`UID:b
|
||||
SUMMARY:Urlaub
|
||||
DTSTART;VALUE=DATE:20261001
|
||||
DTEND;VALUE=DATE:20261004`,
|
||||
`UID:c
|
||||
SUMMARY:UTC call
|
||||
DTSTART:20261002T160000Z
|
||||
DURATION:PT1H`,
|
||||
)
|
||||
got := summaries(calendarOccurrences(dir, []string{"P"}, day(2026, 10, 2), day(2026, 10, 3)))
|
||||
assertStrings(t, got, []string{
|
||||
"2026-10-01 00:00 Urlaub",
|
||||
"2026-10-02 09:00 Zahnarzt",
|
||||
"2026-10-02 18:00 UTC call",
|
||||
})
|
||||
// DTEND of all-day events is exclusive.
|
||||
if got := calendarOccurrences(dir, []string{"P"}, day(2026, 10, 4), day(2026, 10, 5)); len(got) != 0 {
|
||||
t.Fatalf("all-day event leaked past DTEND: %v", summaries(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarRecurrence(t *testing.T) {
|
||||
withBerlin(t)
|
||||
dir := t.TempDir()
|
||||
writeCal(t, dir, "M",
|
||||
`UID:backup
|
||||
SUMMARY:Backup
|
||||
DTSTART;TZID=Europe/Berlin:20260907T030000
|
||||
DTEND;TZID=Europe/Berlin:20260907T040000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||
EXDATE;TZID=Europe/Berlin:20260914T030000,20260921T030000`,
|
||||
`UID:backup
|
||||
RECURRENCE-ID;TZID=Europe/Berlin:20260928T030000
|
||||
SUMMARY:Backup (moved)
|
||||
DTSTART;TZID=Europe/Berlin:20260929T050000
|
||||
DTEND;TZID=Europe/Berlin:20260929T060000`,
|
||||
`UID:backup
|
||||
RECURRENCE-ID;TZID=Europe/Berlin:20261005T030000
|
||||
DTSTART;TZID=Europe/Berlin:20261005T030000
|
||||
STATUS:CANCELLED`,
|
||||
`UID:sheets
|
||||
SUMMARY:Bettwäsche
|
||||
DTSTART;VALUE=DATE:20260905
|
||||
RRULE:FREQ=WEEKLY;INTERVAL=2`,
|
||||
)
|
||||
got := summaries(calendarOccurrences(dir, []string{"M"}, day(2026, 9, 1), day(2026, 10, 20)))
|
||||
assertStrings(t, got, []string{
|
||||
"2026-09-05 00:00 Bettwäsche",
|
||||
"2026-09-07 03:00 Backup",
|
||||
"2026-09-19 00:00 Bettwäsche",
|
||||
"2026-09-29 05:00 Backup (moved)",
|
||||
"2026-10-03 00:00 Bettwäsche",
|
||||
"2026-10-12 03:00 Backup",
|
||||
"2026-10-17 00:00 Bettwäsche",
|
||||
"2026-10-19 03:00 Backup",
|
||||
})
|
||||
// Wall-clock time survives the DST change on 2026-10-25.
|
||||
got = summaries(calendarOccurrences(dir, []string{"M"}, day(2026, 10, 26), day(2026, 10, 27)))
|
||||
assertStrings(t, got, []string{"2026-10-26 03:00 Backup"})
|
||||
|
||||
evs := calendarOccurrences(dir, []string{"M"}, day(2026, 9, 5), day(2026, 9, 8))
|
||||
if evs[0].Series == nil || evs[0].Series.Rule != "every 2 weeks" {
|
||||
t.Fatalf("sheets rule = %+v", evs[0].Series)
|
||||
}
|
||||
if evs[1].Series == nil || evs[1].Series.Rule != "weekly (Mo)" {
|
||||
t.Fatalf("backup rule = %+v", evs[1].Series)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarUnknownTZIDFallsBack(t *testing.T) {
|
||||
withBerlin(t)
|
||||
dir := t.TempDir()
|
||||
writeCal(t, dir, "P",
|
||||
`UID:x
|
||||
SUMMARY:Windows zone
|
||||
DTSTART;TZID=W. Europe Standard Time:20261002T090000
|
||||
DTEND;TZID=W. Europe Standard Time:20261002T100000`,
|
||||
`UID:y
|
||||
SUMMARY:Prefixed zone
|
||||
DTSTART;TZID=/freeassociation.sourceforge.net/Tzfile/Europe/Berlin:20261002T110000`,
|
||||
)
|
||||
got := summaries(calendarOccurrences(dir, []string{"P"}, day(2026, 10, 2), day(2026, 10, 3)))
|
||||
assertStrings(t, got, []string{
|
||||
"2026-10-02 09:00 Windows zone",
|
||||
"2026-10-02 11:00 Prefixed zone",
|
||||
})
|
||||
}
|
||||
|
||||
func TestCalendarDayLines(t *testing.T) {
|
||||
withBerlin(t)
|
||||
dir := t.TempDir()
|
||||
prev := calendarsDir
|
||||
calendarsDir = dir
|
||||
t.Cleanup(func() { calendarsDir = prev })
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, ".page-settings"),
|
||||
[]byte("type = calendars\ndiary = Personal, Feiertage\nweight = Gewicht\nmaintenance = Maintainance\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeCal(t, dir, "Personal",
|
||||
`UID:1
|
||||
SUMMARY:Kino
|
||||
DTSTART;TZID=Europe/Berlin:20261003T193000`,
|
||||
`UID:2
|
||||
SUMMARY:Brunch\, Anna
|
||||
DTSTART;TZID=Europe/Berlin:20261003T100000`,
|
||||
)
|
||||
writeCal(t, dir, "Feiertage",
|
||||
`UID:3
|
||||
SUMMARY:Tag der Deutschen Einheit
|
||||
DTSTART;VALUE=DATE:20261003`,
|
||||
)
|
||||
writeCal(t, dir, "Gewicht",
|
||||
`UID:4
|
||||
SUMMARY:95.9 KG
|
||||
DTSTART;VALUE=DATE:20261003
|
||||
DTSTAMP:20261003T060000Z`,
|
||||
`UID:5
|
||||
SUMMARY:95,6kg
|
||||
DTSTART;VALUE=DATE:20261003
|
||||
DTSTAMP:20261003T070000Z`,
|
||||
`UID:6
|
||||
SUMMARY:not a weight
|
||||
DTSTART;VALUE=DATE:20261004`,
|
||||
)
|
||||
writeCal(t, dir, "Maintainance",
|
||||
`UID:7
|
||||
SUMMARY:Backup
|
||||
DTSTART;VALUE=DATE:20261003`,
|
||||
)
|
||||
|
||||
lines := calendarDayLines(day(2026, 10, 1), day(2026, 10, 10))
|
||||
want := calDayLines{
|
||||
Events: "Tag der Deutschen Einheit, 10:00 Brunch, Anna, 19:30 Kino",
|
||||
Weight: "95,6 kg",
|
||||
}
|
||||
if lines["2026-10-03"] != want {
|
||||
t.Fatalf("got %+v, want %+v", lines["2026-10-03"], want)
|
||||
}
|
||||
if _, ok := lines["2026-10-04"]; ok {
|
||||
t.Fatalf("unparsable weight produced a line: %+v", lines["2026-10-04"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarSync(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
prev := calendarsDir
|
||||
calendarsDir = dir
|
||||
t.Cleanup(func() { calendarsDir = prev })
|
||||
|
||||
ics := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:1\r\nSUMMARY:95,6kg\r\nDTSTART;VALUE=DATE:20261003\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if u, p, _ := r.BasicAuth(); u != "me" || p != "secret" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case r.Method == "PROPFIND" && r.URL.Path == "/dav/calendars/me/":
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
io.WriteString(w, `<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
|
||||
<d:response><d:href>/dav/calendars/me/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||
<d:response><d:href>/dav/calendars/me/gewicht/</d:href><d:propstat><d:prop><d:displayname>Gewicht</d:displayname><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||
<d:response><d:href>/dav/calendars/me/broken/</d:href><d:propstat><d:prop><d:displayname>Broken/Name</d:displayname><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||
<d:response><d:href>/dav/calendars/me/inbox/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/><cal:schedule-inbox/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||
</d:multistatus>`)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/dav/calendars/me/gewicht/" && r.URL.RawQuery == "export":
|
||||
io.WriteString(w, ics)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/dav/calendars/me/broken/":
|
||||
io.WriteString(w, "<html>login</html>")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// An existing file must survive a broken download.
|
||||
if err := os.WriteFile(filepath.Join(dir, "Broken_Name.ics"), []byte("old"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results, err := syncCalendars(calendarSettings{CaldavURL: srv.URL + "/dav/calendars/me", CaldavUser: "me", CaldavPass: "secret"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 2 || results["Gewicht"].Error != "" || results["Broken_Name"].Error == "" {
|
||||
t.Fatalf("results = %+v", results)
|
||||
}
|
||||
if got, _ := os.ReadFile(filepath.Join(dir, "Gewicht.ics")); string(got) != ics {
|
||||
t.Fatalf("Gewicht.ics = %q", got)
|
||||
}
|
||||
if got, _ := os.ReadFile(filepath.Join(dir, "Broken_Name.ics")); string(got) != "old" {
|
||||
t.Fatalf("broken download replaced the file: %q", got)
|
||||
}
|
||||
|
||||
if _, err := syncCalendars(calendarSettings{CaldavURL: srv.URL + "/dav/calendars/me/", CaldavUser: "me", CaldavPass: "wrong"}); err == nil || !strings.Contains(err.Error(), "authentication") {
|
||||
t.Fatalf("wrong password: err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasDotSegment(t *testing.T) {
|
||||
for p, want := range map[string]bool{
|
||||
"/Calendars/.page-settings": true,
|
||||
"/.auth-key": true,
|
||||
"/a/.hidden/b.txt": true,
|
||||
"/Calendars/Personal.ics": false,
|
||||
"/a/b.c/": false,
|
||||
"/a/../.auth-key": true,
|
||||
"/": false,
|
||||
} {
|
||||
if got := hasDotSegment(p); got != want {
|
||||
t.Errorf("hasDotSegment(%q) = %v, want %v", p, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
pageTypeHandlers = append(pageTypeHandlers, &calendarsHandler{})
|
||||
}
|
||||
|
||||
// calendarsHandler renders folders whose .page-settings declares
|
||||
// type = calendars: the maintenance table and the sync status. Markdown
|
||||
// content and the listing stay visible, as in the fitness folder.
|
||||
type calendarsHandler struct{}
|
||||
|
||||
func (c *calendarsHandler) redirect(root, fsPath, urlPath string, r *http.Request) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (c *calendarsHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
|
||||
s := readPageSettings(fsPath)
|
||||
if s == nil || s.Type != "calendars" {
|
||||
return nil
|
||||
}
|
||||
return &specialPage{Content: renderCalendarsPage(fsPath), SuppressTOC: true}
|
||||
}
|
||||
|
||||
type calFileRow struct {
|
||||
Name string
|
||||
Roles string
|
||||
Events string
|
||||
Updated string // file modification time
|
||||
Error string // last sync or parse error
|
||||
}
|
||||
|
||||
type calPageData struct {
|
||||
HasMaintenance bool
|
||||
Maintenance []maintSeriesRow
|
||||
Calendars []calFileRow
|
||||
// Sync is only shown for the configured calendars folder.
|
||||
IsSyncDir bool
|
||||
SyncEnabled bool
|
||||
LastRun string
|
||||
LastError string
|
||||
}
|
||||
|
||||
var calPageTmpl = template.Must(template.ParseFS(assets, "assets/calendar/page.html"))
|
||||
|
||||
func renderCalendarsPage(dir string) template.HTML {
|
||||
s := readCalendarSettings(dir)
|
||||
data := calPageData{
|
||||
HasMaintenance: len(s.Maintenance) > 0,
|
||||
Maintenance: maintenanceSeries(dir, s.Maintenance, time.Now()),
|
||||
IsSyncDir: calendarsDir != "" && dir == calendarsDir,
|
||||
SyncEnabled: s.CaldavURL != "",
|
||||
}
|
||||
|
||||
var results map[string]calSyncResult
|
||||
if data.IsSyncDir {
|
||||
var lastRun time.Time
|
||||
lastRun, data.LastError, results = calSyncSnapshot()
|
||||
if !lastRun.IsZero() {
|
||||
data.LastRun = lastRun.Format("2006-01-02 15:04")
|
||||
}
|
||||
}
|
||||
|
||||
roles := map[string][]string{}
|
||||
for role, names := range map[string][]string{"diary": s.Diary, "weight": s.Weight, "maintenance": s.Maintenance} {
|
||||
for _, n := range names {
|
||||
roles[n] = append(roles[n], role)
|
||||
}
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || strings.HasPrefix(name, ".") || !strings.EqualFold(filepath.Ext(name), ".ics") {
|
||||
continue
|
||||
}
|
||||
name = strings.TrimSuffix(name, filepath.Ext(name))
|
||||
sort.Strings(roles[name])
|
||||
row := calFileRow{Name: name, Roles: strings.Join(roles[name], ", "), Error: results[name].Error}
|
||||
if info, err := e.Info(); err == nil {
|
||||
row.Updated = info.ModTime().Format("2006-01-02 15:04")
|
||||
}
|
||||
if f := loadCalendar(filepath.Join(dir, e.Name())); f != nil {
|
||||
row.Events = strconv.Itoa(f.events)
|
||||
} else if row.Error == "" {
|
||||
row.Error = "cannot parse file"
|
||||
}
|
||||
data.Calendars = append(data.Calendars, row)
|
||||
delete(results, name)
|
||||
}
|
||||
// Calendars whose download failed before a file ever existed.
|
||||
for name, r := range results {
|
||||
if r.Error != "" {
|
||||
data.Calendars = append(data.Calendars, calFileRow{Name: name, Error: r.Error})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(data.Calendars, func(i, j int) bool { return data.Calendars[i].Name < data.Calendars[j].Name })
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := calPageTmpl.Execute(&buf, data); err != nil {
|
||||
log.Printf("calendars page template: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The calendar sync mirrors every calendar of a CalDAV calendar home (e.g.
|
||||
// Nextcloud's /remote.php/dav/calendars/<user>/) into calendarsDir as
|
||||
// <display name>.ics. It runs in the background only: page renders read the
|
||||
// local files and never touch the network. A failed run leaves the previous
|
||||
// files in place.
|
||||
|
||||
// calSyncMaxBody bounds a single calendar download.
|
||||
const calSyncMaxBody = 64 << 20
|
||||
|
||||
var calSyncClient = &http.Client{Timeout: 60 * time.Second}
|
||||
|
||||
// calSyncResult is the outcome of the last run for one calendar.
|
||||
type calSyncResult struct {
|
||||
Updated time.Time // last successful download
|
||||
Error string
|
||||
}
|
||||
|
||||
var calSync struct {
|
||||
run sync.Mutex // serializes runs
|
||||
|
||||
mu sync.Mutex // guards the fields below
|
||||
lastRun time.Time
|
||||
lastError string // config/discovery error of the last run
|
||||
results map[string]calSyncResult
|
||||
}
|
||||
|
||||
// startCalendarSync checks the settings once a minute and syncs when the
|
||||
// interval since the last run has passed. The first check happens right
|
||||
// away. Settings are read every time, so enabling the sync or changing the
|
||||
// interval needs no restart.
|
||||
func startCalendarSync() {
|
||||
go func() {
|
||||
for {
|
||||
s := readCalendarSettings(calendarsDir)
|
||||
if s.CaldavURL != "" {
|
||||
calSync.mu.Lock()
|
||||
last := calSync.lastRun
|
||||
calSync.mu.Unlock()
|
||||
if last.IsZero() || (s.SyncInterval > 0 && time.Since(last) >= s.SyncInterval) {
|
||||
runCalendarSync()
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Minute)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// runCalendarSync performs one sync run and records the outcome.
|
||||
func runCalendarSync() {
|
||||
calSync.run.Lock()
|
||||
defer calSync.run.Unlock()
|
||||
|
||||
results, err := syncCalendars(readCalendarSettings(calendarsDir))
|
||||
|
||||
calSync.mu.Lock()
|
||||
defer calSync.mu.Unlock()
|
||||
calSync.lastRun = time.Now()
|
||||
calSync.lastError = ""
|
||||
if err != nil {
|
||||
calSync.lastError = err.Error()
|
||||
log.Printf("calendar sync: %v", err)
|
||||
}
|
||||
if calSync.results == nil {
|
||||
calSync.results = map[string]calSyncResult{}
|
||||
}
|
||||
for name, r := range results {
|
||||
prev := calSync.results[name]
|
||||
if r.Updated.IsZero() {
|
||||
r.Updated = prev.Updated
|
||||
}
|
||||
calSync.results[name] = r
|
||||
if r.Error != "" {
|
||||
log.Printf("calendar sync %s: %s", name, r.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// calSyncSnapshot returns a copy of the sync status for display.
|
||||
func calSyncSnapshot() (lastRun time.Time, lastError string, results map[string]calSyncResult) {
|
||||
calSync.mu.Lock()
|
||||
defer calSync.mu.Unlock()
|
||||
results = make(map[string]calSyncResult, len(calSync.results))
|
||||
for k, v := range calSync.results {
|
||||
results[k] = v
|
||||
}
|
||||
return calSync.lastRun, calSync.lastError, results
|
||||
}
|
||||
|
||||
// handleCalendarSync runs a sync now (POST ?calsync on the calendars page)
|
||||
// and returns to the page.
|
||||
func (h *handler) handleCalendarSync(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||
if calendarsDir == "" || fsPath != calendarsDir {
|
||||
http.Error(w, "not the calendars folder", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if readCalendarSettings(calendarsDir).CaldavURL == "" {
|
||||
http.Error(w, "sync is not configured (caldav-url)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
runCalendarSync()
|
||||
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// syncCalendars downloads every calendar of the calendar home. The returned
|
||||
// map has one entry per calendar that was attempted; err is set when nothing
|
||||
// could be attempted (bad settings, discovery failed).
|
||||
func syncCalendars(s calendarSettings) (map[string]calSyncResult, error) {
|
||||
home, err := url.Parse(s.CaldavURL)
|
||||
if err != nil || (home.Scheme != "http" && home.Scheme != "https") {
|
||||
return nil, fmt.Errorf("invalid caldav-url %q", s.CaldavURL)
|
||||
}
|
||||
if !strings.HasSuffix(home.Path, "/") {
|
||||
home.Path += "/"
|
||||
}
|
||||
cals, err := davDiscoverCalendars(home, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := map[string]calSyncResult{}
|
||||
used := map[string]bool{}
|
||||
for _, c := range cals {
|
||||
name := calendarFileName(c.name, c.href)
|
||||
if used[strings.ToLower(name)] {
|
||||
name += " (" + path.Base(strings.TrimSuffix(c.href, "/")) + ")"
|
||||
}
|
||||
used[strings.ToLower(name)] = true
|
||||
|
||||
if err := syncOneCalendar(home, c.href, name, s); err != nil {
|
||||
results[name] = calSyncResult{Error: err.Error()}
|
||||
continue
|
||||
}
|
||||
results[name] = calSyncResult{Updated: time.Now()}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// syncOneCalendar downloads one calendar via Nextcloud's ?export and writes
|
||||
// it when the content changed. Invalid downloads (HTML error pages, broken
|
||||
// iCalendar) never replace the existing file.
|
||||
func syncOneCalendar(home *url.URL, href, name string, s calendarSettings) error {
|
||||
u, err := home.Parse(href)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.RawQuery = "export"
|
||||
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth(s.CaldavUser, s.CaldavPass)
|
||||
req.Header.Set("Accept", "text/calendar")
|
||||
resp, err := calSyncClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download: %s", resp.Status)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, calSyncMaxBody+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) > calSyncMaxBody {
|
||||
return errors.New("download: calendar larger than 64 MB")
|
||||
}
|
||||
if _, err := parseCalendar(name+".ics", data); err != nil {
|
||||
return fmt.Errorf("invalid calendar data: %v", err)
|
||||
}
|
||||
|
||||
p := calendarPath(calendarsDir, name)
|
||||
if p == "" {
|
||||
return fmt.Errorf("unusable file name %q", name)
|
||||
}
|
||||
if old, err := os.ReadFile(p); err == nil && bytes.Equal(old, data) {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(calendarsDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(p, data, 0644)
|
||||
}
|
||||
|
||||
// calendarFileName turns a display name into a safe file basename. Falls back
|
||||
// to the last segment of the calendar's href.
|
||||
func calendarFileName(display, href string) string {
|
||||
clean := strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || strings.ContainsRune(`/\:*?"<>|`, r) {
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, display)
|
||||
clean = strings.TrimLeft(strings.TrimSpace(clean), ".")
|
||||
if clean == "" {
|
||||
clean = strings.TrimLeft(path.Base(strings.TrimSuffix(href, "/")), ".")
|
||||
}
|
||||
if clean == "" {
|
||||
clean = "calendar"
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
// === Discovery ===
|
||||
|
||||
type davCalendar struct {
|
||||
href string
|
||||
name string
|
||||
}
|
||||
|
||||
const davPropfindBody = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>`
|
||||
|
||||
type davMultistatus struct {
|
||||
Responses []struct {
|
||||
Href string `xml:"DAV: href"`
|
||||
Propstats []struct {
|
||||
Status string `xml:"DAV: status"`
|
||||
Prop struct {
|
||||
DisplayName string `xml:"DAV: displayname"`
|
||||
ResourceType struct {
|
||||
Types []struct {
|
||||
XMLName xml.Name
|
||||
} `xml:",any"`
|
||||
} `xml:"DAV: resourcetype"`
|
||||
} `xml:"DAV: prop"`
|
||||
} `xml:"DAV: propstat"`
|
||||
} `xml:"DAV: response"`
|
||||
}
|
||||
|
||||
// davDiscoverCalendars lists the calendar collections directly below home
|
||||
// with one PROPFIND (Depth 1).
|
||||
func davDiscoverCalendars(home *url.URL, s calendarSettings) ([]davCalendar, error) {
|
||||
req, err := http.NewRequest("PROPFIND", home.String(), strings.NewReader(davPropfindBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(s.CaldavUser, s.CaldavPass)
|
||||
req.Header.Set("Depth", "1")
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
resp, err := calSyncClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discovery: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return nil, errors.New("discovery: authentication failed (check caldav-user / caldav-pass)")
|
||||
}
|
||||
if resp.StatusCode != http.StatusMultiStatus {
|
||||
return nil, fmt.Errorf("discovery: %s", resp.Status)
|
||||
}
|
||||
var ms davMultistatus
|
||||
if err := xml.NewDecoder(io.LimitReader(resp.Body, calSyncMaxBody)).Decode(&ms); err != nil {
|
||||
return nil, fmt.Errorf("discovery: %v", err)
|
||||
}
|
||||
|
||||
var out []davCalendar
|
||||
for _, r := range ms.Responses {
|
||||
for _, ps := range r.Propstats {
|
||||
if !strings.Contains(ps.Status, " 200 ") {
|
||||
continue
|
||||
}
|
||||
isCalendar := false
|
||||
for _, t := range ps.Prop.ResourceType.Types {
|
||||
if t.XMLName.Space == "urn:ietf:params:xml:ns:caldav" && t.XMLName.Local == "calendar" {
|
||||
isCalendar = true
|
||||
}
|
||||
}
|
||||
if isCalendar {
|
||||
out = append(out, davCalendar{href: r.Href, name: strings.TrimSpace(ps.Prop.DisplayName)})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("discovery: no calendars found at caldav-url")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -533,6 +533,8 @@ type diarySection struct {
|
||||
EditURL string // year-file section edit URL ("" = no edit button)
|
||||
Body template.HTML // rendered markdown body (excludes the heading line)
|
||||
Photos []diaryPhoto
|
||||
Events string // day's calendar events, comma-separated (level 3 only)
|
||||
Weight string // day's weight from the weight calendar (level 3 only)
|
||||
}
|
||||
|
||||
type diaryContentData struct {
|
||||
@@ -623,6 +625,7 @@ func renderDiaryYear(yearFS, yearURL string) template.HTML {
|
||||
|
||||
out := buildFileSections(sections, photos, yearURL)
|
||||
out = appendVirtualEntries(out, sections, photos, year, yearURL)
|
||||
attachCalendarLines(out, year)
|
||||
return renderDiaryContent(out)
|
||||
}
|
||||
|
||||
@@ -746,6 +749,28 @@ func appendVirtualEntries(existing []diarySection, sections [][]byte, photos []d
|
||||
return out
|
||||
}
|
||||
|
||||
// attachCalendarLines fills the events and weight lines of every day section
|
||||
// (real or virtual) from the calendars' diary and weight roles.
|
||||
func attachCalendarLines(sections []diarySection, year int) {
|
||||
lines := calendarDayLines(
|
||||
time.Date(year, time.January, 1, 0, 0, 0, 0, time.Local),
|
||||
time.Date(year+1, time.January, 1, 0, 0, 0, 0, time.Local))
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range sections {
|
||||
s := §ions[i]
|
||||
if s.Level != 3 {
|
||||
continue
|
||||
}
|
||||
if _, _, _, ok := parseISODate(s.ID); !ok {
|
||||
continue
|
||||
}
|
||||
l := lines[s.ID[:10]]
|
||||
s.Events, s.Weight = l.Events, l.Weight
|
||||
}
|
||||
}
|
||||
|
||||
// isRealDateSection reports whether a rendered diarySection is one of the
|
||||
// date-headed slots (`## YYYY-MM` or `### YYYY-MM-DD`) the virtual-entry
|
||||
// interleave sorts against.
|
||||
|
||||
@@ -2,4 +2,8 @@ module datascape
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require github.com/yuin/goldmark v1.8.2
|
||||
require (
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608
|
||||
github.com/teambition/rrule-go v1.8.2
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
)
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"log"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// calendarsURL is the page URL of calendarsDir ("/Calendars/").
|
||||
var calendarsURL string
|
||||
|
||||
// maintenanceHorizon is how many days after today the header widget lists.
|
||||
const maintenanceHorizon = 7
|
||||
|
||||
type maintItem struct {
|
||||
When string // "03:00" today; "Sa 27.09." or "Sa 27.09. 03:00" later
|
||||
Summary string
|
||||
}
|
||||
|
||||
type maintWidgetData struct {
|
||||
Count int // occurrences today (the badge)
|
||||
Today []maintItem
|
||||
Upcoming []maintItem
|
||||
PageURL string
|
||||
}
|
||||
|
||||
var maintWidgetTmpl = template.Must(template.New("widget.html").
|
||||
Funcs(template.FuncMap{"icon": readIcon}).
|
||||
ParseFS(assets, "assets/calendar/widget.html"))
|
||||
|
||||
// maintenanceWidget renders the header chip and its dropdown. It is a
|
||||
// template func so layout.html can call it without every page's data struct
|
||||
// carrying it. Empty when no maintenance calendar is configured.
|
||||
func maintenanceWidget() template.HTML {
|
||||
if calendarsDir == "" {
|
||||
return ""
|
||||
}
|
||||
names := readCalendarSettings(calendarsDir).Maintenance
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||
tomorrow := today.AddDate(0, 0, 1)
|
||||
|
||||
data := maintWidgetData{PageURL: calendarsURL}
|
||||
for _, ev := range calendarOccurrences(calendarsDir, names, today, today.AddDate(0, 0, maintenanceHorizon+1)) {
|
||||
// Anything that started before tomorrow is due today, including
|
||||
// multi-day events still running.
|
||||
if ev.Start.Before(tomorrow) {
|
||||
item := maintItem{Summary: ev.Summary}
|
||||
if !ev.AllDay && !ev.Start.Before(today) {
|
||||
item.When = ev.Start.Format("15:04")
|
||||
}
|
||||
data.Today = append(data.Today, item)
|
||||
continue
|
||||
}
|
||||
when := calDayLabel(ev.Start)
|
||||
if !ev.AllDay {
|
||||
when += " " + ev.Start.Format("15:04")
|
||||
}
|
||||
data.Upcoming = append(data.Upcoming, maintItem{When: when, Summary: ev.Summary})
|
||||
}
|
||||
data.Count = len(data.Today)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := maintWidgetTmpl.Execute(&buf, data); err != nil {
|
||||
log.Printf("maintenance widget template: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
// maintSeriesRow is one task in the calendars page's maintenance table.
|
||||
type maintSeriesRow struct {
|
||||
Summary string
|
||||
Rule string
|
||||
Last string // most recent occurrence before today
|
||||
Next string // first occurrence today or later
|
||||
next time.Time
|
||||
}
|
||||
|
||||
// maintenanceWindow bounds the table's occurrence search, in days each way
|
||||
// from today. A year covers yearly tasks.
|
||||
const maintenanceWindow = 366
|
||||
|
||||
// maintenanceSeries lists every task of the named calendars in dir: one row
|
||||
// per recurring series with its last and next date, plus upcoming single
|
||||
// events. Sorted by next date; series without a next date come last.
|
||||
func maintenanceSeries(dir string, names []string, now time.Time) []maintSeriesRow {
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||
evs := calendarOccurrences(dir, names, today.AddDate(0, 0, -maintenanceWindow), today.AddDate(0, 0, maintenanceWindow))
|
||||
|
||||
type key struct{ calendar, uid string }
|
||||
rows := map[key]*maintSeriesRow{}
|
||||
var order []*maintSeriesRow
|
||||
for _, ev := range evs {
|
||||
if ev.Series == nil {
|
||||
if ev.Start.Before(today) {
|
||||
continue
|
||||
}
|
||||
order = append(order, &maintSeriesRow{
|
||||
Summary: ev.Summary,
|
||||
Rule: "once",
|
||||
Next: calDayLabel(ev.Start),
|
||||
next: ev.Start,
|
||||
})
|
||||
continue
|
||||
}
|
||||
k := key{ev.Calendar, ev.Series.UID}
|
||||
row := rows[k]
|
||||
if row == nil {
|
||||
row = &maintSeriesRow{Summary: ev.Series.Summary, Rule: ev.Series.Rule}
|
||||
rows[k] = row
|
||||
order = append(order, row)
|
||||
}
|
||||
// Occurrences arrive sorted by start: keep overwriting Last, take
|
||||
// the first one from today on as Next.
|
||||
if ev.Start.Before(today) {
|
||||
row.Last = calDayLabel(ev.Start)
|
||||
} else if row.next.IsZero() {
|
||||
row.next = ev.Start
|
||||
row.Next = calDayLabel(ev.Start)
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]maintSeriesRow, 0, len(order))
|
||||
for _, r := range order {
|
||||
out = append(out, *r)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
a, b := out[i].next, out[j].next
|
||||
if a.IsZero() != b.IsZero() {
|
||||
return b.IsZero()
|
||||
}
|
||||
if !a.Equal(b) {
|
||||
return a.Before(b)
|
||||
}
|
||||
return out[i].Summary < out[j].Summary
|
||||
})
|
||||
return out
|
||||
}
|
||||