mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
GET /v1/public/calendar/{token} serves an RFC 5545 calendar under a
token-as-secret URL (private Google/Outlook link model). Staff feeds
contain only that member's bookings; the tenant feed tags each event
with the staff member. Window is -30d to +180d; cancelled bookings map
to STATUS:CANCELLED, waitlisted/pending to TENTATIVE + TRANSPARENT.
Summaries resolve service or class-session titles, descriptions carry
localized contact labels, locations resolve to names.
Feed tokens live on tenants/staff (migration 00015) and are managed via
GET /v1/calendar/feed plus POST /v1/calendar/feed/rotate — rotating a
scope invalidates the old URL. ICS rendering is hand-rolled: TEXT
escaping, CRLF, 75-octet folding on rune boundaries.
Settings gains a "Kalendář (ICS)" card with copyable URLs and two-step
rotation per scope; demo mode points at the real seeded tokens.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
105 lines
2.8 KiB
Go
105 lines
2.8 KiB
Go
package calendar
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Event is a single entry rendered into an ICS feed. Fields are already
|
|
// resolved to display text; the renderer handles escaping and folding.
|
|
type Event struct {
|
|
UID string
|
|
StartsAt time.Time
|
|
EndsAt time.Time
|
|
Stamp time.Time
|
|
Summary string
|
|
Description string
|
|
Location string
|
|
Status string // "confirmed" | "tentative" | "cancelled"
|
|
Transparent bool // TRANSP:TRANSPARENT — does not block free/busy
|
|
}
|
|
|
|
const icsTimestampLayout = "20060102T150405Z"
|
|
|
|
// RenderCalendar produces an RFC 5545 VCALENDAR body with CRLF line endings
|
|
// and 75-octet folding.
|
|
func RenderCalendar(name string, timezone string, events []Event) string {
|
|
var b strings.Builder
|
|
writeLine(&b, "BEGIN:VCALENDAR")
|
|
writeLine(&b, "VERSION:2.0")
|
|
writeLine(&b, "PRODID:-//Bookra//Bookra//EN")
|
|
writeLine(&b, "CALSCALE:GREGORIAN")
|
|
writeLine(&b, "METHOD:PUBLISH")
|
|
writeLine(&b, "X-WR-CALNAME:"+escapeText(name))
|
|
if timezone != "" {
|
|
writeLine(&b, "X-WR-TIMEZONE:"+timezone)
|
|
}
|
|
for _, event := range events {
|
|
writeLine(&b, "BEGIN:VEVENT")
|
|
writeLine(&b, "UID:"+event.UID)
|
|
stamp := event.Stamp
|
|
if stamp.IsZero() {
|
|
stamp = event.StartsAt
|
|
}
|
|
writeLine(&b, "DTSTAMP:"+stamp.UTC().Format(icsTimestampLayout))
|
|
writeLine(&b, "DTSTART:"+event.StartsAt.UTC().Format(icsTimestampLayout))
|
|
writeLine(&b, "DTEND:"+event.EndsAt.UTC().Format(icsTimestampLayout))
|
|
writeLine(&b, "SUMMARY:"+escapeText(event.Summary))
|
|
if event.Description != "" {
|
|
writeLine(&b, "DESCRIPTION:"+escapeText(event.Description))
|
|
}
|
|
if event.Location != "" {
|
|
writeLine(&b, "LOCATION:"+escapeText(event.Location))
|
|
}
|
|
switch event.Status {
|
|
case "cancelled":
|
|
writeLine(&b, "STATUS:CANCELLED")
|
|
case "tentative":
|
|
writeLine(&b, "STATUS:TENTATIVE")
|
|
}
|
|
if event.Transparent {
|
|
writeLine(&b, "TRANSP:TRANSPARENT")
|
|
}
|
|
writeLine(&b, "END:VEVENT")
|
|
}
|
|
writeLine(&b, "END:VCALENDAR")
|
|
return b.String()
|
|
}
|
|
|
|
// escapeText applies RFC 5545 §3.3.11 TEXT escaping.
|
|
func escapeText(value string) string {
|
|
replacer := strings.NewReplacer(
|
|
`\`, `\\`,
|
|
";", `\;`,
|
|
",", `\,`,
|
|
"\n", `\n`,
|
|
"\r", "",
|
|
)
|
|
return replacer.Replace(value)
|
|
}
|
|
|
|
// writeLine emits one content line folded at 75 octets per RFC 5545 §3.1.
|
|
// Continuation lines start with a single space; folds happen on rune
|
|
// boundaries so multi-byte characters are never split.
|
|
func writeLine(b *strings.Builder, line string) {
|
|
const maxOctets = 75
|
|
octets := 0
|
|
first := true
|
|
for _, r := range line {
|
|
width := len(string(r))
|
|
limit := maxOctets
|
|
if !first {
|
|
limit-- // continuation lines carry a leading space
|
|
}
|
|
if octets+width > limit {
|
|
b.WriteString("\r\n ")
|
|
octets = 0
|
|
first = false
|
|
limit = maxOctets - 1
|
|
}
|
|
b.WriteRune(r)
|
|
octets += width
|
|
}
|
|
b.WriteString("\r\n")
|
|
}
|