Server-side endpoint GET /api/projects/{id}/timeline.ics returns a
VCALENDAR + one VEVENT per actual deadline (VALUE=DATE all-day) and
appointment (UTC timestamp). Projected / milestone / off_script rows
are deliberately skipped — faraday-Q6 / m's pick: a calendar feed
must never carry predicted dates the user never confirmed, otherwise
Outlook fills with rule_code-derived events that erode trust.
FormatTimelineICS reuses the existing caldav_ical.go escape helpers
and writes through the same canonical UIDs (paliad-deadline-<id> +
paliad-appointment-<id>) so a re-subscribe updates entries instead
of duplicating them. Stable across re-exports = lawyer-safe.
Visibility piggybacks on ProjectionService.For + ProjectService.GetByID
(same gates as the chart page handler). Content-Disposition filename
slugged for portable ASCII so Outlook + Apple Calendar agree.
4 tests pin the contract: only deadline/appointment kinds emit
VEVENTs; undated rows skip cleanly; RFC 5545 §3.3.11 escaping for
; , \ \\n; empty input still produces a valid VCALENDAR.
i18n: 1 new key DE+EN.
Design ref: docs/design-project-chart-2026-05-09.md §7.8.
292 lines
8.3 KiB
Go
292 lines
8.3 KiB
Go
package services
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
)
|
|
|
|
// Minimal RFC 5545 (iCalendar) writer + reader for VEVENT blocks.
|
|
//
|
|
// Why hand-rolled instead of github.com/emersion/go-ical?
|
|
// - The Appointment schema is small (Title, Description, Location, Start, End,
|
|
// plus the UID Paliad generates) so a 100-line formatter does the job.
|
|
// - Avoids two third-party dependencies (go-ical + go-webdav) for ~6
|
|
// iCal properties and 4 WebDAV verbs.
|
|
//
|
|
// Property escaping: per RFC 5545 §3.3.11 we escape `\` `;` `,` and `\n`
|
|
// in TEXT values; everything else passes through. Lines are not folded
|
|
// (folding is optional per §3.1).
|
|
|
|
const (
|
|
calProductID = "-//Paliad//Paliad Appointments//EN"
|
|
calVersion = "2.0"
|
|
icalDateUTC = "20060102T150405Z"
|
|
icalDateOnly = "20060102"
|
|
)
|
|
|
|
// terminUID is the canonical CalDAV UID for a Paliad Appointment. Paliad-owned
|
|
// events round-trip through this format; foreign events have arbitrary
|
|
// UIDs and are ignored on pull.
|
|
func terminUID(id string) string {
|
|
return "paliad-appointment-" + id + "@paliad.de"
|
|
}
|
|
|
|
// deadlineUID is the canonical iCal UID for a Paliad Deadline exported via
|
|
// the chart's iCal feed (t-paliad-177 Slice 2). Distinct prefix from
|
|
// terminUID so subscribers can't confuse the two — and so a re-export
|
|
// updates the same calendar entry instead of duplicating it.
|
|
func deadlineUID(id string) string {
|
|
return "paliad-deadline-" + id + "@paliad.de"
|
|
}
|
|
|
|
// extractAppointmentID returns the Paliad Appointment id (uuid string) embedded in a
|
|
// terminUID, or "" when the UID isn't ours.
|
|
func extractAppointmentID(uid string) string {
|
|
const prefix = "paliad-appointment-"
|
|
const suffix = "@paliad.de"
|
|
if !strings.HasPrefix(uid, prefix) || !strings.HasSuffix(uid, suffix) {
|
|
return ""
|
|
}
|
|
return uid[len(prefix) : len(uid)-len(suffix)]
|
|
}
|
|
|
|
// formatAppointment renders a single VCALENDAR + VEVENT for an Appointment.
|
|
// Output uses CRLF line endings as required by RFC 5545.
|
|
func formatAppointment(t *models.Appointment) string {
|
|
var b strings.Builder
|
|
w := func(line string) {
|
|
b.WriteString(line)
|
|
b.WriteString("\r\n")
|
|
}
|
|
w("BEGIN:VCALENDAR")
|
|
w("PRODID:" + calProductID)
|
|
w("VERSION:" + calVersion)
|
|
w("BEGIN:VEVENT")
|
|
w("UID:" + terminUID(t.ID.String()))
|
|
w("DTSTAMP:" + time.Now().UTC().Format(icalDateUTC))
|
|
w("DTSTART:" + t.StartAt.UTC().Format(icalDateUTC))
|
|
if t.EndAt != nil {
|
|
w("DTEND:" + t.EndAt.UTC().Format(icalDateUTC))
|
|
}
|
|
// Prepend "[PENDING] " on the SUMMARY when the appointment is awaiting
|
|
// 4-eye approval (t-paliad-138). External clients (Outlook etc.) thus
|
|
// reflect the unverified state honestly — silence on a pending change
|
|
// would be a worse outcome than visible-but-flagged.
|
|
summary := t.Title
|
|
if t.ApprovalStatus == "pending" {
|
|
summary = "[PENDING] " + t.Title
|
|
}
|
|
w("SUMMARY:" + escapeText(summary))
|
|
if t.Description != nil && *t.Description != "" {
|
|
w("DESCRIPTION:" + escapeText(*t.Description))
|
|
}
|
|
if t.Location != nil && *t.Location != "" {
|
|
w("LOCATION:" + escapeText(*t.Location))
|
|
}
|
|
w("END:VEVENT")
|
|
w("END:VCALENDAR")
|
|
return b.String()
|
|
}
|
|
|
|
// FormatTimelineICS renders a single VCALENDAR with one VEVENT per
|
|
// timeline row that is a real actual (kind == "deadline" or
|
|
// "appointment"). Projected / milestone rows are deliberately skipped
|
|
// (design §7.8, faraday-Q6 / m's pick: trust-erosion otherwise — a
|
|
// calendar should never fire predicted dates the user never confirmed).
|
|
//
|
|
// Deadlines render as all-day events (DTSTART;VALUE=DATE) because the
|
|
// substrate marshals due_date as UTC-midnight; appointments render as
|
|
// timestamped UTC events. Both UIDs are stable across re-exports so an
|
|
// Outlook subscriber sees deduped entries on every refresh.
|
|
func FormatTimelineICS(events []TimelineEvent, projectTitle string) string {
|
|
var b strings.Builder
|
|
w := func(line string) {
|
|
b.WriteString(line)
|
|
b.WriteString("\r\n")
|
|
}
|
|
w("BEGIN:VCALENDAR")
|
|
w("PRODID:" + calProductID)
|
|
w("VERSION:" + calVersion)
|
|
if projectTitle != "" {
|
|
w("X-WR-CALNAME:" + escapeText("Paliad — "+projectTitle))
|
|
}
|
|
now := time.Now().UTC().Format(icalDateUTC)
|
|
for _, ev := range events {
|
|
if ev.Date == nil {
|
|
continue
|
|
}
|
|
switch ev.Kind {
|
|
case "deadline":
|
|
w("BEGIN:VEVENT")
|
|
if ev.DeadlineID != nil {
|
|
w("UID:" + deadlineUID(ev.DeadlineID.String()))
|
|
} else {
|
|
// Synthetic UID — shouldn't happen for actuals, but be defensive.
|
|
w("UID:paliad-timeline-" + now + "@paliad.de")
|
|
}
|
|
w("DTSTAMP:" + now)
|
|
w("DTSTART;VALUE=DATE:" + ev.Date.UTC().Format(icalDateOnly))
|
|
w("SUMMARY:" + escapeText(ev.Title))
|
|
if ev.Description != "" {
|
|
w("DESCRIPTION:" + escapeText(ev.Description))
|
|
}
|
|
w("END:VEVENT")
|
|
case "appointment":
|
|
w("BEGIN:VEVENT")
|
|
if ev.AppointmentID != nil {
|
|
w("UID:" + terminUID(ev.AppointmentID.String()))
|
|
} else {
|
|
w("UID:paliad-timeline-" + now + "@paliad.de")
|
|
}
|
|
w("DTSTAMP:" + now)
|
|
w("DTSTART:" + ev.Date.UTC().Format(icalDateUTC))
|
|
w("SUMMARY:" + escapeText(ev.Title))
|
|
if ev.Description != "" {
|
|
w("DESCRIPTION:" + escapeText(ev.Description))
|
|
}
|
|
w("END:VEVENT")
|
|
default:
|
|
// milestone / projected / off_script are visualisation-only —
|
|
// never written to a calendar feed (design §7.8 + faraday-Q6).
|
|
continue
|
|
}
|
|
}
|
|
w("END:VCALENDAR")
|
|
return b.String()
|
|
}
|
|
|
|
func escapeText(s string) string {
|
|
r := strings.NewReplacer(
|
|
`\`, `\\`,
|
|
";", `\;`,
|
|
",", `\,`,
|
|
"\n", `\n`,
|
|
"\r", "",
|
|
)
|
|
return r.Replace(s)
|
|
}
|
|
|
|
func unescapeText(s string) string {
|
|
// Order matters: \\ → marker → final
|
|
out := strings.Builder{}
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if c == '\\' && i+1 < len(s) {
|
|
next := s[i+1]
|
|
switch next {
|
|
case 'n', 'N':
|
|
out.WriteByte('\n')
|
|
case ';', ',', '\\':
|
|
out.WriteByte(next)
|
|
default:
|
|
out.WriteByte(c)
|
|
out.WriteByte(next)
|
|
}
|
|
i++
|
|
continue
|
|
}
|
|
out.WriteByte(c)
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
// parsedEvent is the subset of VEVENT fields Paliad cares about.
|
|
type parsedEvent struct {
|
|
UID string
|
|
Summary string
|
|
Description string
|
|
Location string
|
|
DTStart *time.Time
|
|
DTEnd *time.Time
|
|
}
|
|
|
|
// parseICalendar walks the byte stream and returns one parsed VEVENT per
|
|
// occurrence. Robust against folded lines (RFC 5545 §3.1) — a leading
|
|
// space/tab continues the previous line.
|
|
func parseICalendar(body string) ([]parsedEvent, error) {
|
|
out := []parsedEvent{}
|
|
sc := bufio.NewScanner(strings.NewReader(body))
|
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
|
|
var unfolded []string
|
|
for sc.Scan() {
|
|
line := strings.TrimRight(sc.Text(), "\r")
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
if (line[0] == ' ' || line[0] == '\t') && len(unfolded) > 0 {
|
|
unfolded[len(unfolded)-1] += line[1:]
|
|
continue
|
|
}
|
|
unfolded = append(unfolded, line)
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return nil, fmt.Errorf("scan ical: %w", err)
|
|
}
|
|
|
|
var current *parsedEvent
|
|
for _, line := range unfolded {
|
|
switch {
|
|
case line == "BEGIN:VEVENT":
|
|
current = &parsedEvent{}
|
|
case line == "END:VEVENT":
|
|
if current != nil {
|
|
out = append(out, *current)
|
|
}
|
|
current = nil
|
|
case current != nil:
|
|
name, value := splitProperty(line)
|
|
switch strings.ToUpper(name) {
|
|
case "UID":
|
|
current.UID = value
|
|
case "SUMMARY":
|
|
current.Summary = unescapeText(value)
|
|
case "DESCRIPTION":
|
|
current.Description = unescapeText(value)
|
|
case "LOCATION":
|
|
current.Location = unescapeText(value)
|
|
case "DTSTART":
|
|
if t, ok := parseICalTime(value); ok {
|
|
current.DTStart = &t
|
|
}
|
|
case "DTEND":
|
|
if t, ok := parseICalTime(value); ok {
|
|
current.DTEnd = &t
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// splitProperty splits "NAME[;params]:value" into ("NAME", "value"),
|
|
// ignoring all parameters (Paliad rounds-trip in UTC so TZID is unused).
|
|
func splitProperty(line string) (string, string) {
|
|
colon := strings.IndexByte(line, ':')
|
|
if colon < 0 {
|
|
return line, ""
|
|
}
|
|
head := line[:colon]
|
|
value := line[colon+1:]
|
|
if semi := strings.IndexByte(head, ';'); semi >= 0 {
|
|
head = head[:semi]
|
|
}
|
|
return head, value
|
|
}
|
|
|
|
// parseICalTime accepts "20240101T120000Z", "20240101T120000" (floating),
|
|
// or "20240101" (date-only) and returns UTC.
|
|
func parseICalTime(value string) (time.Time, bool) {
|
|
formats := []string{icalDateUTC, "20060102T150405", "20060102"}
|
|
for _, f := range formats {
|
|
if t, err := time.Parse(f, value); err == nil {
|
|
return t.UTC(), true
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
}
|