m's ask: per-item CalDAV linking should support existing lists, not
just create-new. Athena's design update extended it: also tag VTODOs
on create so multiple projax items can SHARE one CalDAV list, with
projax doing tag-based slicing on read.
Three layers, one branch:
## 1. Link-existing picker (the original ask)
- New POST /i/{path}/caldav/link-existing handler validates the
submitted calendar_url is in the discoverable PROPFIND set (defence
against crafted forms pointing at arbitrary HTTP servers), then
inserts the item_link row with display_name + color metadata
preserved from the discovery payload.
- handleDetail + renderTasksSection pre-load
availableCalendarsForItem(ctx, links) — calendars from
s.CalDAV.Client.ListCalendars MINUS the ones already linked to this
item. Errors degrade to an empty picker (non-fatal).
- tasks_section.tmpl gains a .caldav-actions block rendering the
picker (<select> of available calendars) when AvailableCalendars
is non-empty AND the Create-new button (when the item has no
linked list yet). Same surface serves both the "first link" flow
and the "+ link another" flow per athena's brief.
## 2. Tag-on-create (CATEGORIES carries projax:<path>)
- caldav package gains Categories []string on Todo + the same on
VTodoEdit. BuildVTodoICS emits a CATEGORIES line when non-empty;
parseVTodos parses CATEGORIES comma-list into the slice with per-
entry unescape per RFC 5545.
- handleCalDAVTodoAction action="todo-create" passes
`Categories: []{ProjaxCategoryFor(it.PrimaryPath())}` into
VTodoEdit so every per-item Add submits a tagged VTODO.
- ApplyVTodoEdit intentionally ignores the Categories field —
edit/complete/delete paths preserve existing CATEGORIES via the
unknown-property pass-through that's been tested since Phase 5
(TestApplyVTodoEditPreservesUnknown).
## 3. Per-item filter (managed-vs-legacy)
- detailTodos now calls caldav.AnyTodoHasProjaxTag(todos) to decide
whether the linked list is projax-managed (any projax: tag
anywhere) or legacy/unmanaged (zero projax: tags).
- Managed → filter to VTODOs whose CATEGORIES include this
item's projax:<path>. Multiple projax: tags are AND-of-OR — a
VTODO with two projax tags appears on both items per athena's
multi-tag contract.
- Legacy → show every VTODO untouched. Existing pre-5j users with
untagged lists keep seeing everything; the detail page doesn't
suddenly hide their tasks.
## Helpers (caldav package, exported)
- ProjaxCategoryFor(primaryPath) → "projax:<path>" string
- HasProjaxTag(t) bool → any projax: prefix
- HasProjaxTagFor(t, primaryPath) bool → exact projax:<path>
- AnyTodoHasProjaxTag(todos) bool → list-level signal
## Tests
caldav unit (caldav/projax_tags_test.go):
- TestProjaxCategoryFor / TestHasProjaxTagAndFor /
TestAnyTodoHasProjaxTag / TestBuildVTodoICSEmitsCategories /
TestParseVTodosMultiCategory.
web integration (web/caldav_link_existing_test.go) — single fake
CalDAV server (httptest) answering PROPFIND + REPORT + PUT, then
four end-to-end probes:
- TestDetailLinkExistingCalendar — three calendars discoverable,
picker renders, POST link-existing creates the link, second GET
drops the linked URL from the picker.
- TestVTodoCreateAttachesProjaxCategory — Add-task POST writes a
VTODO whose CATEGORIES contains projax:<path>.
- TestDetailFilterByProjaxCategory — one calendar shared between
Trip A and Trip B with three tagged VTODOs; A sees A+shared,
B sees B+shared, neither sees the other's tagged-only VTODO.
- TestDetailUntaggedListShowsAll — linked list with zero projax
tags renders ALL VTODOs (legacy fallback).
Full web + caldav suites green. Pre-existing
db/TestBackfillTagsFromArea failure unchanged.
Net: +795 / -14.
539 lines
16 KiB
Go
539 lines
16 KiB
Go
package caldav
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// parseVTodos extracts every VTODO block from a calendar-data string. Hand-
|
|
// rolled because importing a full iCalendar parser for the half-dozen fields
|
|
// projax cares about is overkill. Tolerates folded lines per RFC 5545 §3.1.
|
|
func parseVTodos(ics string) []Todo {
|
|
ics = unfold(ics)
|
|
lines := strings.Split(ics, "\n")
|
|
var out []Todo
|
|
var inTodo bool
|
|
var cur Todo
|
|
for _, ln := range lines {
|
|
ln = strings.TrimRight(ln, "\r")
|
|
if ln == "BEGIN:VTODO" {
|
|
inTodo = true
|
|
cur = Todo{Status: "NEEDS-ACTION"}
|
|
continue
|
|
}
|
|
if ln == "END:VTODO" {
|
|
if cur.UID != "" {
|
|
out = append(out, cur)
|
|
}
|
|
inTodo = false
|
|
continue
|
|
}
|
|
if !inTodo {
|
|
continue
|
|
}
|
|
key, val := splitLine(ln)
|
|
switch key {
|
|
case "UID":
|
|
cur.UID = val
|
|
case "SUMMARY":
|
|
cur.Summary = unescapeText(val)
|
|
case "STATUS":
|
|
cur.Status = strings.ToUpper(val)
|
|
case "PRIORITY":
|
|
if n, err := strconv.Atoi(val); err == nil {
|
|
cur.Priority = n
|
|
}
|
|
case "DUE":
|
|
if t, ok := parseICalTime(val); ok {
|
|
cur.Due = &t
|
|
}
|
|
case "LAST-MODIFIED":
|
|
if t, ok := parseICalTime(val); ok {
|
|
cur.LastModified = &t
|
|
}
|
|
case "CATEGORIES":
|
|
// CATEGORIES is comma-separated per RFC 5545. Some clients emit
|
|
// multiple CATEGORIES lines; we merge by appending. The unescape
|
|
// is per-entry because commas inside a category value MUST be
|
|
// escaped (`\,`), so we split on bare commas only after unescape.
|
|
for _, raw := range strings.Split(val, ",") {
|
|
t := strings.TrimSpace(unescapeText(raw))
|
|
if t == "" {
|
|
continue
|
|
}
|
|
cur.Categories = append(cur.Categories, t)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ProjaxCategoryFor returns the projax-namespaced CATEGORIES entry for
|
|
// the given primary-path (e.g. "projax:admin.vacations.greece"). Used by
|
|
// both the write side (tag-on-create) and the read side (per-item filter).
|
|
func ProjaxCategoryFor(primaryPath string) string {
|
|
return "projax:" + primaryPath
|
|
}
|
|
|
|
// HasProjaxTag reports whether the VTODO carries any `projax:` category.
|
|
// Used to decide whether the per-item filter kicks in: a list with at
|
|
// least one projax: tag is "managed" by projax and the detail page only
|
|
// shows todos matching THIS item's path; a list with zero projax: tags
|
|
// is a legacy/unmanaged list and the detail page shows everything.
|
|
func HasProjaxTag(t Todo) bool {
|
|
for _, c := range t.Categories {
|
|
if strings.HasPrefix(c, "projax:") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HasProjaxTagFor reports whether the VTODO carries the specific
|
|
// `projax:<primaryPath>` category. A todo can carry multiple projax: tags
|
|
// (when it belongs to multiple projax items) — any match returns true.
|
|
func HasProjaxTagFor(t Todo, primaryPath string) bool {
|
|
want := ProjaxCategoryFor(primaryPath)
|
|
for _, c := range t.Categories {
|
|
if c == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AnyTodoHasProjaxTag reports whether the slice contains at least one
|
|
// projax-tagged VTODO. The detail page uses this to decide between the
|
|
// projax-managed filter (show only matching) and the legacy unmanaged
|
|
// path (show all).
|
|
func AnyTodoHasProjaxTag(todos []Todo) bool {
|
|
for _, t := range todos {
|
|
if HasProjaxTag(t) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// parseVEvents extracts every VEVENT block from a calendar-data string.
|
|
// Mirrors parseVTodos but for read-only event listing (no writeback). DTSTART
|
|
// with VALUE=DATE marks the event all-day; the parser inspects the raw line
|
|
// before splitLine drops params. RRULE presence flips Recurring=true; the
|
|
// rule itself is intentionally NOT parsed — projax surfaces the literal
|
|
// DTSTART occurrence and a recurring badge.
|
|
func parseVEvents(ics string) []Event {
|
|
ics = unfold(ics)
|
|
lines := strings.Split(ics, "\n")
|
|
var out []Event
|
|
var inEvent bool
|
|
var cur Event
|
|
for _, ln := range lines {
|
|
ln = strings.TrimRight(ln, "\r")
|
|
if ln == "BEGIN:VEVENT" {
|
|
inEvent = true
|
|
cur = Event{}
|
|
continue
|
|
}
|
|
if ln == "END:VEVENT" {
|
|
if cur.UID != "" {
|
|
out = append(out, cur)
|
|
}
|
|
inEvent = false
|
|
continue
|
|
}
|
|
if !inEvent {
|
|
continue
|
|
}
|
|
key, val := splitLine(ln)
|
|
switch key {
|
|
case "UID":
|
|
cur.UID = val
|
|
case "SUMMARY":
|
|
cur.Summary = unescapeText(val)
|
|
case "DESCRIPTION":
|
|
cur.Description = unescapeText(val)
|
|
case "LOCATION":
|
|
cur.Location = unescapeText(val)
|
|
case "DTSTART":
|
|
if t, ok := parseICalTime(val); ok {
|
|
cur.Start = t
|
|
}
|
|
if hasDateOnlyParam(ln) {
|
|
cur.AllDay = true
|
|
}
|
|
case "DTEND":
|
|
if t, ok := parseICalTime(val); ok {
|
|
cur.End = t
|
|
}
|
|
case "RRULE":
|
|
cur.Recurring = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// hasDateOnlyParam reports whether the property line carried VALUE=DATE
|
|
// (rather than DATE-TIME) before the value separator. This matters because
|
|
// splitLine throws params away, so the caller has to inspect the raw line
|
|
// to know if the date is all-day or has a clock component.
|
|
func hasDateOnlyParam(ln string) bool {
|
|
colon := strings.Index(ln, ":")
|
|
if colon < 0 {
|
|
return false
|
|
}
|
|
head := ln[:colon]
|
|
semi := strings.Index(head, ";")
|
|
if semi < 0 {
|
|
return false
|
|
}
|
|
params := strings.ToUpper(head[semi+1:])
|
|
for _, p := range strings.Split(params, ";") {
|
|
if p == "VALUE=DATE" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// unfold collapses RFC 5545 line continuations (a CRLF followed by a single
|
|
// SP or HT continues the previous line).
|
|
func unfold(s string) string {
|
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
|
var b strings.Builder
|
|
lines := strings.Split(s, "\n")
|
|
for i, ln := range lines {
|
|
if i > 0 && len(ln) > 0 && (ln[0] == ' ' || ln[0] == '\t') {
|
|
b.WriteString(ln[1:])
|
|
continue
|
|
}
|
|
if i > 0 {
|
|
b.WriteByte('\n')
|
|
}
|
|
b.WriteString(ln)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// splitLine separates "KEY;PARAMS:VALUE" into ("KEY", "VALUE"). Params dropped
|
|
// — we don't need TZID etc. for v1.
|
|
func splitLine(ln string) (string, string) {
|
|
colon := strings.Index(ln, ":")
|
|
if colon < 0 {
|
|
return "", ""
|
|
}
|
|
head := ln[:colon]
|
|
val := ln[colon+1:]
|
|
if semi := strings.Index(head, ";"); semi >= 0 {
|
|
head = head[:semi]
|
|
}
|
|
return head, val
|
|
}
|
|
|
|
// parseICalTime recognises both `YYYYMMDDTHHMMSSZ` (UTC) and bare `YYYYMMDD`.
|
|
// Floating local-time forms are coerced to UTC for ranking — single user, no
|
|
// tz acrobatics needed at v1.
|
|
func parseICalTime(v string) (time.Time, bool) {
|
|
v = strings.TrimSpace(v)
|
|
if len(v) == 8 {
|
|
if t, err := time.Parse("20060102", v); err == nil {
|
|
return t, true
|
|
}
|
|
}
|
|
if len(v) >= 15 {
|
|
layouts := []string{"20060102T150405Z", "20060102T150405"}
|
|
for _, l := range layouts {
|
|
if t, err := time.Parse(l, v); err == nil {
|
|
return t, true
|
|
}
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
// unescapeText reverses RFC 5545 §3.3.11 text encoding.
|
|
func unescapeText(s string) string {
|
|
s = strings.ReplaceAll(s, `\n`, "\n")
|
|
s = strings.ReplaceAll(s, `\N`, "\n")
|
|
s = strings.ReplaceAll(s, `\,`, ",")
|
|
s = strings.ReplaceAll(s, `\;`, ";")
|
|
s = strings.ReplaceAll(s, `\\`, `\`)
|
|
return s
|
|
}
|
|
|
|
// escapeText applies RFC 5545 §3.3.11 escaping. CR/LF become \n; backslash,
|
|
// comma, and semicolon are backslash-escaped. Everything else passes through.
|
|
func escapeText(s string) string {
|
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
|
s = strings.ReplaceAll(s, "\r\n", `\n`)
|
|
s = strings.ReplaceAll(s, "\n", `\n`)
|
|
s = strings.ReplaceAll(s, "\r", `\n`)
|
|
s = strings.ReplaceAll(s, `,`, `\,`)
|
|
s = strings.ReplaceAll(s, `;`, `\;`)
|
|
return s
|
|
}
|
|
|
|
// foldLine wraps a single logical iCal line so no physical line exceeds 75
|
|
// octets, prepending a single space to each continuation line as per RFC 5545
|
|
// §3.1. Folding is octet-based, not rune-based — but we keep care not to split
|
|
// in the middle of a UTF-8 sequence.
|
|
func foldLine(line string) string {
|
|
const limit = 75
|
|
if len(line) <= limit {
|
|
return line
|
|
}
|
|
var b strings.Builder
|
|
for i := 0; i < len(line); {
|
|
end := i + limit
|
|
if i > 0 {
|
|
// Continuation lines reserve one octet for the leading space.
|
|
end = i + (limit - 1)
|
|
}
|
|
if end > len(line) {
|
|
end = len(line)
|
|
}
|
|
// Back off so we don't split inside a multi-byte UTF-8 sequence.
|
|
for end < len(line) && end > i && (line[end]&0xC0) == 0x80 {
|
|
end--
|
|
}
|
|
chunk := line[i:end]
|
|
if i > 0 {
|
|
b.WriteString("\r\n ")
|
|
}
|
|
b.WriteString(chunk)
|
|
i = end
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// joinICS folds each logical line and joins them with CRLF terminators
|
|
// (RFC 5545 §3.1 — content lines MUST be terminated with CRLF).
|
|
func joinICS(lines []string) string {
|
|
var b strings.Builder
|
|
for _, ln := range lines {
|
|
b.WriteString(foldLine(ln))
|
|
b.WriteString("\r\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// formatICalUTC formats t in `YYYYMMDDTHHMMSSZ` form (RFC 5545 UTC date-time).
|
|
func formatICalUTC(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
|
|
|
|
// formatICalDate formats t in `YYYYMMDD` form (RFC 5545 DATE).
|
|
func formatICalDate(t time.Time) string { return t.UTC().Format("20060102") }
|
|
|
|
// NewUID generates an RFC 4122 v4 UUID rendered as a hyphenated lowercase
|
|
// string. The "@projax" suffix that some clients append is intentionally
|
|
// omitted — the UID is opaque to projax and we treat it as such.
|
|
func NewUID() string {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
// crypto/rand failure on Linux is extraordinary; fall back to time-based
|
|
// so projax keeps functioning, with the trade-off that the new UID is
|
|
// less random.
|
|
now := time.Now().UnixNano()
|
|
for i := 0; i < 16; i++ {
|
|
b[i] = byte(now >> (i * 4))
|
|
}
|
|
}
|
|
b[6] = (b[6] & 0x0F) | 0x40 // version 4
|
|
b[8] = (b[8] & 0x3F) | 0x80 // variant RFC 4122
|
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
|
}
|
|
|
|
// VTodoEdit describes a partial update to an existing VTODO. Fields left nil
|
|
// are not changed in the stored ICS. To clear a field, supply a pointer to an
|
|
// empty value where allowed; the writer will emit the bare key with no value.
|
|
// Use ClearDue to clear DUE explicitly.
|
|
type VTodoEdit struct {
|
|
Summary *string
|
|
Status *string
|
|
Completed *time.Time // sets COMPLETED; pass time.Time{} via ClearCompleted to remove the line
|
|
Due *time.Time
|
|
ClearDue bool
|
|
Priority *int
|
|
// Categories: optional CATEGORIES list. BuildVTodoICS writes them
|
|
// directly on a fresh VTODO. ApplyVTodoEdit intentionally ignores
|
|
// this field — existing categories pass through unchanged via the
|
|
// unknown-property preserve path, which is what every edit/complete/
|
|
// delete flow wants. Tag-on-create is the only write path that
|
|
// uses it.
|
|
Categories []string
|
|
}
|
|
|
|
// BuildVTodoICS serialises a fresh VTODO as a complete VCALENDAR document,
|
|
// suitable for PUT to a CalDAV server. UID is the only required input; the
|
|
// other VTodoEdit fields populate optional properties. DTSTAMP is set to now.
|
|
func BuildVTodoICS(uid string, e VTodoEdit) string {
|
|
now := time.Now().UTC()
|
|
lines := []string{
|
|
"BEGIN:VCALENDAR",
|
|
"VERSION:2.0",
|
|
"PRODID:-//projax//caldav writeback//EN",
|
|
"CALSCALE:GREGORIAN",
|
|
"BEGIN:VTODO",
|
|
"UID:" + uid,
|
|
"DTSTAMP:" + formatICalUTC(now),
|
|
"CREATED:" + formatICalUTC(now),
|
|
"LAST-MODIFIED:" + formatICalUTC(now),
|
|
}
|
|
if e.Summary != nil {
|
|
lines = append(lines, "SUMMARY:"+escapeText(*e.Summary))
|
|
}
|
|
status := "NEEDS-ACTION"
|
|
if e.Status != nil && *e.Status != "" {
|
|
status = strings.ToUpper(*e.Status)
|
|
}
|
|
lines = append(lines, "STATUS:"+status)
|
|
if status == "COMPLETED" {
|
|
ct := now
|
|
if e.Completed != nil && !e.Completed.IsZero() {
|
|
ct = *e.Completed
|
|
}
|
|
lines = append(lines, "COMPLETED:"+formatICalUTC(ct))
|
|
lines = append(lines, "PERCENT-COMPLETE:100")
|
|
}
|
|
if e.Due != nil && !e.Due.IsZero() {
|
|
lines = append(lines, dueLine(*e.Due))
|
|
}
|
|
if e.Priority != nil {
|
|
lines = append(lines, fmt.Sprintf("PRIORITY:%d", *e.Priority))
|
|
}
|
|
if len(e.Categories) > 0 {
|
|
// RFC 5545 CATEGORIES — comma-separated, single line. Escape commas
|
|
// inside individual entries so the round-trip survives parseVTodos.
|
|
escaped := make([]string, 0, len(e.Categories))
|
|
for _, c := range e.Categories {
|
|
escaped = append(escaped, escapeText(c))
|
|
}
|
|
lines = append(lines, "CATEGORIES:"+strings.Join(escaped, ","))
|
|
}
|
|
lines = append(lines, "END:VTODO", "END:VCALENDAR")
|
|
return joinICS(lines)
|
|
}
|
|
|
|
// dueLine emits a DUE property. If the time has no clock component (00:00:00),
|
|
// it is encoded as DATE (`DUE;VALUE=DATE:YYYYMMDD`); otherwise as UTC
|
|
// date-time. Single-user, single-timezone — no VTIMEZONE acrobatics required.
|
|
func dueLine(t time.Time) string {
|
|
if t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 {
|
|
return "DUE;VALUE=DATE:" + formatICalDate(t)
|
|
}
|
|
return "DUE:" + formatICalUTC(t)
|
|
}
|
|
|
|
// ApplyVTodoEdit returns a new ICS document derived from the existing one with
|
|
// the supplied edits applied. Unknown properties (DESCRIPTION, CATEGORIES,
|
|
// ATTENDEE, X-*) are preserved — only the changed keys are rewritten. Folded
|
|
// lines are normalised on read. LAST-MODIFIED is always bumped to now.
|
|
func ApplyVTodoEdit(ics string, e VTodoEdit) string {
|
|
now := time.Now().UTC()
|
|
lines := strings.Split(unfold(ics), "\n")
|
|
// Helper to locate the VTODO segment so we don't touch VCALENDAR-level keys.
|
|
vtStart, vtEnd := -1, -1
|
|
for i, raw := range lines {
|
|
ln := strings.TrimRight(raw, "\r")
|
|
if ln == "BEGIN:VTODO" {
|
|
vtStart = i
|
|
}
|
|
if ln == "END:VTODO" {
|
|
vtEnd = i
|
|
break
|
|
}
|
|
}
|
|
if vtStart < 0 || vtEnd < 0 {
|
|
// Malformed; fall back to a from-scratch build with a fresh UID so the
|
|
// caller gets *something* well-formed back rather than silent garbage.
|
|
return BuildVTodoICS(NewUID(), e)
|
|
}
|
|
|
|
// Build a set of keys we plan to overwrite. We also drop COMPLETED when
|
|
// status flips away from COMPLETED.
|
|
overwrite := map[string]string{}
|
|
if e.Summary != nil {
|
|
overwrite["SUMMARY"] = "SUMMARY:" + escapeText(*e.Summary)
|
|
}
|
|
if e.Status != nil && *e.Status != "" {
|
|
s := strings.ToUpper(*e.Status)
|
|
overwrite["STATUS"] = "STATUS:" + s
|
|
switch s {
|
|
case "COMPLETED":
|
|
ct := now
|
|
if e.Completed != nil && !e.Completed.IsZero() {
|
|
ct = *e.Completed
|
|
}
|
|
overwrite["COMPLETED"] = "COMPLETED:" + formatICalUTC(ct)
|
|
overwrite["PERCENT-COMPLETE"] = "PERCENT-COMPLETE:100"
|
|
default:
|
|
// reopen / cancel: clear COMPLETED and PERCENT-COMPLETE if present.
|
|
overwrite["COMPLETED"] = ""
|
|
overwrite["PERCENT-COMPLETE"] = ""
|
|
}
|
|
}
|
|
if e.Due != nil && !e.Due.IsZero() {
|
|
overwrite["DUE"] = dueLine(*e.Due)
|
|
}
|
|
if e.ClearDue {
|
|
overwrite["DUE"] = ""
|
|
}
|
|
if e.Priority != nil {
|
|
overwrite["PRIORITY"] = fmt.Sprintf("PRIORITY:%d", *e.Priority)
|
|
}
|
|
// LAST-MODIFIED always bumps.
|
|
overwrite["LAST-MODIFIED"] = "LAST-MODIFIED:" + formatICalUTC(now)
|
|
// DTSTAMP per RFC 5545 also reflects last sync; safe to bump.
|
|
overwrite["DTSTAMP"] = "DTSTAMP:" + formatICalUTC(now)
|
|
|
|
seen := map[string]bool{}
|
|
out := make([]string, 0, len(lines))
|
|
for i, raw := range lines {
|
|
ln := strings.TrimRight(raw, "\r")
|
|
// Pass everything outside the VTODO block through verbatim.
|
|
if i <= vtStart || i >= vtEnd {
|
|
out = append(out, ln)
|
|
continue
|
|
}
|
|
key, _ := splitLine(ln)
|
|
key = strings.ToUpper(key)
|
|
if repl, ok := overwrite[key]; ok {
|
|
seen[key] = true
|
|
if repl == "" {
|
|
continue // explicit clear
|
|
}
|
|
out = append(out, repl)
|
|
continue
|
|
}
|
|
out = append(out, ln)
|
|
}
|
|
// Insert any overwrite keys that weren't present in the source ICS just
|
|
// before END:VTODO. Skip explicit clears (repl == "") so we don't append
|
|
// empty lines.
|
|
endIdx := -1
|
|
for i, ln := range out {
|
|
if strings.TrimRight(ln, "\r") == "END:VTODO" {
|
|
endIdx = i
|
|
break
|
|
}
|
|
}
|
|
if endIdx >= 0 {
|
|
extras := []string{}
|
|
for key, repl := range overwrite {
|
|
if seen[key] || repl == "" {
|
|
continue
|
|
}
|
|
extras = append(extras, repl)
|
|
}
|
|
if len(extras) > 0 {
|
|
before := append([]string{}, out[:endIdx]...)
|
|
before = append(before, extras...)
|
|
before = append(before, out[endIdx:]...)
|
|
out = before
|
|
}
|
|
}
|
|
// joinICS handles fold + CRLF on each logical line.
|
|
return joinICS(out)
|
|
}
|