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.
420 lines
15 KiB
Go
420 lines
15 KiB
Go
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/m/projax/caldav"
|
|
"github.com/m/projax/web"
|
|
)
|
|
|
|
// fakeCalDAVServer is a minimal in-memory CalDAV server: a PROPFIND on
|
|
// /dav/calendars/m/ returns a fixed two-calendar list, REPORT on each
|
|
// calendar returns whichever VTODOs the test seeded into todos[url],
|
|
// and PUT to a calendar URL captures the body so the test can assert
|
|
// on what projax wrote. Mirrors the pattern in dashboard_events_test.go
|
|
// but tailored to the Phase 5j flows.
|
|
type fakeCalDAVServer struct {
|
|
mu sync.Mutex
|
|
srv *httptest.Server
|
|
calendars []caldav.Calendar
|
|
todos map[string][]string // calendarURL → list of VTODO ICS docs
|
|
puts map[string]string // url → body of the latest PUT to that url
|
|
}
|
|
|
|
func newFakeCalDAVServer(t *testing.T, cals []caldav.Calendar) *fakeCalDAVServer {
|
|
t.Helper()
|
|
f := &fakeCalDAVServer{
|
|
todos: map[string][]string{},
|
|
puts: map[string]string{},
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/dav/calendars/m/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "PROPFIND" {
|
|
f.mu.Lock()
|
|
cs := f.calendars
|
|
f.mu.Unlock()
|
|
w.WriteHeader(207)
|
|
_, _ = io.WriteString(w, propfindMultistatus(cs))
|
|
return
|
|
}
|
|
http.Error(w, "method "+r.Method, http.StatusMethodNotAllowed)
|
|
})
|
|
// Per-calendar handler. Keyed by URL PATH so both the registration
|
|
// loop and the test's seed lookup (`fake.todos[calURL]`) resolve to
|
|
// the same map entry regardless of how the httptest host gets baked
|
|
// into the full URL.
|
|
for _, c := range cals {
|
|
path := urlPathOf(c.URL)
|
|
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case "REPORT":
|
|
f.mu.Lock()
|
|
body := buildReportMultistatus(path, f.todos[path])
|
|
f.mu.Unlock()
|
|
w.WriteHeader(207)
|
|
_, _ = io.WriteString(w, body)
|
|
case "PUT":
|
|
body, _ := io.ReadAll(r.Body)
|
|
f.mu.Lock()
|
|
f.puts[r.URL.String()] = string(body)
|
|
f.todos[path] = append(f.todos[path], string(body))
|
|
f.mu.Unlock()
|
|
w.Header().Set("ETag", `"fresh"`)
|
|
w.WriteHeader(http.StatusCreated)
|
|
default:
|
|
http.Error(w, "method "+r.Method, http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
}
|
|
f.srv = httptest.NewServer(mux)
|
|
f.calendars = make([]caldav.Calendar, len(cals))
|
|
// Rewrite URLs to point at the httptest server's host.
|
|
for i, c := range cals {
|
|
f.calendars[i] = caldav.Calendar{
|
|
URL: f.srv.URL + urlPathOf(c.URL),
|
|
HRef: urlPathOf(c.URL),
|
|
DisplayName: c.DisplayName,
|
|
Color: c.Color,
|
|
}
|
|
}
|
|
t.Cleanup(f.srv.Close)
|
|
return f
|
|
}
|
|
|
|
func urlPathOf(absURL string) string {
|
|
u, _ := url.Parse(absURL)
|
|
return u.Path
|
|
}
|
|
|
|
// propfindMultistatus builds the PROPFIND response for the slice of
|
|
// calendars. Includes the collection itself + each calendar entry, plus
|
|
// an "inbox" non-calendar that ListCalendars must filter out.
|
|
func propfindMultistatus(cals []caldav.Calendar) string {
|
|
var b strings.Builder
|
|
b.WriteString(`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">`)
|
|
b.WriteString(`<d:response><d:href>/dav/calendars/m/</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>`)
|
|
for _, c := range cals {
|
|
b.WriteString(`<d:response><d:href>` + urlPathOf(c.URL) + `</d:href><d:propstat><d:prop><d:displayname>` + c.DisplayName + `</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>`)
|
|
}
|
|
b.WriteString(`</d:multistatus>`)
|
|
return b.String()
|
|
}
|
|
|
|
// buildReportMultistatus wraps a slice of VTODO ICS docs into a REPORT
|
|
// multistatus body, one <d:response> per VTODO.
|
|
func buildReportMultistatus(calPath string, vtodos []string) string {
|
|
if len(vtodos) == 0 {
|
|
return `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav"></d:multistatus>`
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString(`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">`)
|
|
for i, ics := range vtodos {
|
|
b.WriteString(`<d:response><d:href>` + calPath + "t" + itoa(i) + `.ics</d:href><d:propstat><d:prop><d:getetag>"e` + itoa(i) + `"</d:getetag><cal:calendar-data>`)
|
|
b.WriteString(ics)
|
|
b.WriteString(`</cal:calendar-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`)
|
|
}
|
|
b.WriteString(`</d:multistatus>`)
|
|
return b.String()
|
|
}
|
|
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
var buf [20]byte
|
|
i := len(buf)
|
|
neg := false
|
|
if n < 0 {
|
|
neg = true
|
|
n = -n
|
|
}
|
|
for n > 0 {
|
|
i--
|
|
buf[i] = byte('0' + n%10)
|
|
n /= 10
|
|
}
|
|
if neg {
|
|
i--
|
|
buf[i] = '-'
|
|
}
|
|
return string(buf[i:])
|
|
}
|
|
|
|
// seedItemUnderDev inserts a fresh projax item under dev and returns
|
|
// its id + primary path. Callers defer cleanup.
|
|
func seedItemUnderDev(t *testing.T, pool *pgxpool.Pool, slug, title string) (id, primaryPath string) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
var dev string
|
|
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
|
t.Fatalf("dev: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_ids)
|
|
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[])
|
|
returning id`,
|
|
title, slug, dev,
|
|
).Scan(&id); err != nil {
|
|
t.Fatalf("seed item: %v", err)
|
|
}
|
|
return id, "dev." + slug
|
|
}
|
|
|
|
// TestDetailLinkExistingCalendar walks the original ask end-to-end:
|
|
// 1. Fake CalDAV server exposes 3 calendars + zero VTODOs.
|
|
// 2. Seed an unlinked projax item under dev.
|
|
// 3. GET /i/{path} — assert the "link existing" <select> renders with
|
|
// all 3 calendars.
|
|
// 4. POST /i/{path}/caldav/link-existing with one URL.
|
|
// 5. GET /i/{path} again — assert the linked URL is gone from the
|
|
// picker (already linked) but appears in the tasks section.
|
|
func TestDetailLinkExistingCalendar(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
|
|
cals := []caldav.Calendar{
|
|
{URL: "https://dav.test/dav/calendars/m/Family/", DisplayName: "Family"},
|
|
{URL: "https://dav.test/dav/calendars/m/Travel/", DisplayName: "Travel"},
|
|
{URL: "https://dav.test/dav/calendars/m/Vacations-2026/", DisplayName: "Vacations 2026"},
|
|
}
|
|
fake := newFakeCalDAVServer(t, cals)
|
|
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
|
|
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
|
slug := "caldav-link-" + stamp
|
|
id, primary := seedItemUnderDev(t, pool, slug, "Caldav link test")
|
|
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
|
|
|
h := srv.Routes()
|
|
|
|
// Step 3: picker renders with three calendars.
|
|
_, body := get(t, h, "/i/"+primary)
|
|
for _, want := range []string{
|
|
`action="/i/` + primary + `/caldav/link-existing"`,
|
|
`>Family<`,
|
|
`>Travel<`,
|
|
`>Vacations 2026<`,
|
|
`+ Create new list`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("unlinked detail page missing %q", want)
|
|
}
|
|
}
|
|
|
|
// Step 4: POST link-existing. Pick the Vacations 2026 calendar.
|
|
pickedURL := fake.calendars[2].URL
|
|
form := url.Values{"calendar_url": {pickedURL}}
|
|
resp, _ := post(t, h, "/i/"+primary+"/caldav/link-existing", form)
|
|
if resp != http.StatusSeeOther {
|
|
t.Fatalf("link-existing POST → %d, want 303", resp)
|
|
}
|
|
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1 and ref_id=$2`, id, pickedURL)
|
|
|
|
// Step 5: picker no longer offers Vacations 2026 (already linked);
|
|
// the tasks section now shows the linked calendar's block.
|
|
_, body = get(t, h, "/i/"+primary)
|
|
if strings.Contains(body, `<option value="`+pickedURL+`">Vacations 2026</option>`) {
|
|
t.Errorf("picker should NOT offer the already-linked Vacations 2026 URL")
|
|
}
|
|
if !strings.Contains(body, "Vacations 2026") {
|
|
t.Errorf("tasks section should display the linked Vacations 2026 list")
|
|
}
|
|
if !strings.Contains(body, `data-cal="`+pickedURL+`"`) {
|
|
t.Errorf("tasks section missing cal-block for the linked URL")
|
|
}
|
|
}
|
|
|
|
// TestVTodoCreateAttachesProjaxCategory exercises the tag-on-create
|
|
// half of Phase 5j. Posting the Add-task form from /i/{path} must send
|
|
// a VTODO whose CATEGORIES contains `projax:<path>` so a shared list
|
|
// can later be filtered per-item.
|
|
func TestVTodoCreateAttachesProjaxCategory(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
|
|
cals := []caldav.Calendar{
|
|
{URL: "https://dav.test/dav/calendars/m/Shared/", DisplayName: "Shared"},
|
|
}
|
|
fake := newFakeCalDAVServer(t, cals)
|
|
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
|
|
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
|
slug := "caldav-tag-" + stamp
|
|
id, primary := seedItemUnderDev(t, pool, slug, "Tag-on-create test")
|
|
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
|
calURL := fake.calendars[0].URL
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if _, err := pool.Exec(ctx,
|
|
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
|
values ($1, 'caldav-list', $2, 'contains')`,
|
|
id, calURL,
|
|
); err != nil {
|
|
t.Fatalf("seed link: %v", err)
|
|
}
|
|
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1`, id)
|
|
|
|
h := srv.Routes()
|
|
form := url.Values{
|
|
"calendar_url": {calURL},
|
|
"summary": {"Buy travel gear"},
|
|
}
|
|
resp, _ := post(t, h, "/i/"+primary+"/caldav/todo/todo-create", form)
|
|
if resp != http.StatusSeeOther && resp != http.StatusOK {
|
|
t.Fatalf("todo-create POST → %d", resp)
|
|
}
|
|
|
|
// Inspect what the fake CalDAV server received.
|
|
fake.mu.Lock()
|
|
defer fake.mu.Unlock()
|
|
if len(fake.puts) == 0 {
|
|
t.Fatalf("expected at least one PUT to the fake CalDAV server")
|
|
}
|
|
var got string
|
|
for _, body := range fake.puts {
|
|
got = body
|
|
break
|
|
}
|
|
wantTag := "projax:" + primary
|
|
if !strings.Contains(got, "CATEGORIES:"+wantTag) {
|
|
t.Errorf("PUT body missing CATEGORIES tag %q. Body:\n%s", wantTag, got)
|
|
}
|
|
}
|
|
|
|
// TestDetailFilterByProjaxCategory exercises the read-side filter:
|
|
// when the linked list has ANY projax: tag, the detail page only shows
|
|
// the VTODOs whose CATEGORIES include THIS item's tag. VTODOs tagged
|
|
// for OTHER items must NOT leak through.
|
|
func TestDetailFilterByProjaxCategory(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
|
|
cals := []caldav.Calendar{
|
|
{URL: "https://dav.test/dav/calendars/m/Vacations-2026/", DisplayName: "Vacations 2026"},
|
|
}
|
|
fake := newFakeCalDAVServer(t, cals)
|
|
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
|
|
calURL := fake.calendars[0].URL
|
|
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
|
idA, primaryA := seedItemUnderDev(t, pool, "trip-a-"+stamp, "Trip A")
|
|
idB, primaryB := seedItemUnderDev(t, pool, "trip-b-"+stamp, "Trip B")
|
|
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, idA, idB)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
for _, id := range []string{idA, idB} {
|
|
if _, err := pool.Exec(ctx,
|
|
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
|
values ($1, 'caldav-list', $2, 'contains')`,
|
|
id, calURL,
|
|
); err != nil {
|
|
t.Fatalf("seed link: %v", err)
|
|
}
|
|
}
|
|
defer pool.Exec(context.Background(), `delete from projax.item_links where ref_id=$1`, calURL)
|
|
|
|
// Three VTODOs on the SHARED list: one tagged for A, one for B, one
|
|
// for both.
|
|
tagA := "projax:" + primaryA
|
|
tagB := "projax:" + primaryB
|
|
fake.mu.Lock()
|
|
fake.todos[urlPathOf(calURL)] = []string{
|
|
todoICS("uid-only-a", "Book flight A", []string{tagA}),
|
|
todoICS("uid-only-b", "Book flight B", []string{tagB}),
|
|
todoICS("uid-shared", "Travel insurance", []string{tagA, tagB}),
|
|
}
|
|
fake.mu.Unlock()
|
|
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/i/"+primaryA)
|
|
if !strings.Contains(body, "Book flight A") {
|
|
t.Errorf("Trip A detail missing tagged-A summary")
|
|
}
|
|
if strings.Contains(body, "Book flight B") {
|
|
t.Errorf("Trip A detail leaked tagged-B summary — filter broken")
|
|
}
|
|
if !strings.Contains(body, "Travel insurance") {
|
|
t.Errorf("Trip A detail missing dual-tagged summary (multi-tag contract)")
|
|
}
|
|
|
|
// Trip B sees the mirror image: B + shared, not A.
|
|
_, body = get(t, h, "/i/"+primaryB)
|
|
if strings.Contains(body, "Book flight A") {
|
|
t.Errorf("Trip B detail leaked tagged-A summary")
|
|
}
|
|
if !strings.Contains(body, "Book flight B") {
|
|
t.Errorf("Trip B detail missing tagged-B summary")
|
|
}
|
|
if !strings.Contains(body, "Travel insurance") {
|
|
t.Errorf("Trip B detail missing dual-tagged summary")
|
|
}
|
|
}
|
|
|
|
// TestDetailUntaggedListShowsAll proves the legacy fallback: a linked
|
|
// list with ZERO projax: tags is treated as unmanaged — every VTODO
|
|
// renders, untouched. Without this users with pre-5j lists would see
|
|
// the detail page suddenly hide all their existing tasks.
|
|
func TestDetailUntaggedListShowsAll(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
|
|
cals := []caldav.Calendar{
|
|
{URL: "https://dav.test/dav/calendars/m/Home/", DisplayName: "Home"},
|
|
}
|
|
fake := newFakeCalDAVServer(t, cals)
|
|
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
|
|
calURL := fake.calendars[0].URL
|
|
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
|
id, primary := seedItemUnderDev(t, pool, "home-legacy-"+stamp, "Home legacy")
|
|
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if _, err := pool.Exec(ctx,
|
|
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
|
values ($1, 'caldav-list', $2, 'contains')`,
|
|
id, calURL,
|
|
); err != nil {
|
|
t.Fatalf("seed link: %v", err)
|
|
}
|
|
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1`, id)
|
|
|
|
fake.mu.Lock()
|
|
fake.todos[urlPathOf(calURL)] = []string{
|
|
todoICS("legacy-1", "Pick up bread", nil),
|
|
todoICS("legacy-2", "Call dentist", []string{"home", "errands"}),
|
|
}
|
|
fake.mu.Unlock()
|
|
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/i/"+primary)
|
|
if !strings.Contains(body, "Pick up bread") {
|
|
t.Errorf("untagged-list detail missing legacy todo 'Pick up bread'")
|
|
}
|
|
if !strings.Contains(body, "Call dentist") {
|
|
t.Errorf("untagged-list detail missing legacy todo with non-projax categories")
|
|
}
|
|
}
|
|
|
|
// todoICS builds a minimal VTODO ICS doc with optional CATEGORIES.
|
|
func todoICS(uid, summary string, categories []string) string {
|
|
cat := ""
|
|
if len(categories) > 0 {
|
|
cat = "CATEGORIES:" + strings.Join(categories, ",") + "\r\n"
|
|
}
|
|
return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + uid + "\r\nSUMMARY:" + summary + "\r\nSTATUS:NEEDS-ACTION\r\n" + cat + "END:VTODO\r\nEND:VCALENDAR"
|
|
}
|