Add ICS import/export and shared calendar support to calendar UI
- Combine own and shared calendars into a single /web/calendar month view, each event colored per its source calendar. - New event creation now uses a calendar <select> (only writable calendars offered); editing keeps the event's original calendar fixed. - Shared calendars use an "owner~name" reference in URLs, resolved via resolveCalRef which also enforces read/write permissions from calendar_shares (read-only shares can view/export but not edit/delete). - Add per-calendar ICS export (single event and export-all) and import, splitting multi-VEVENT uploads into individual stored objects while preserving VTIMEZONE definitions and source VERSION/PRODID. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+401
-83
@@ -1,10 +1,13 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -13,6 +16,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
ical "github.com/emersion/go-ical"
|
ical "github.com/emersion/go-ical"
|
||||||
|
"github.com/yourusername/caldav-server/internal/db"
|
||||||
"github.com/yourusername/caldav-server/internal/web/templates"
|
"github.com/yourusername/caldav-server/internal/web/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +29,19 @@ var eventIDRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}\.ics$`)
|
|||||||
const dateLayout = "2006-01-02"
|
const dateLayout = "2006-01-02"
|
||||||
const timeLayout = "15:04"
|
const timeLayout = "15:04"
|
||||||
|
|
||||||
|
// calRefSep separates the owner and calendar name in a shared calendar's
|
||||||
|
// reference string (e.g. "alice~vacations"), mirroring (but independent
|
||||||
|
// from) internal/caldav's sharedNameSep convention for its own URL
|
||||||
|
// namespace.
|
||||||
|
const calRefSep = "~"
|
||||||
|
|
||||||
|
// errCalendarNotFound/errCalendarReadOnly are sentinel errors returned by
|
||||||
|
// resolveCalRef, translated by callers into 404/403 responses.
|
||||||
|
var (
|
||||||
|
errCalendarNotFound = errors.New("calendar not found")
|
||||||
|
errCalendarReadOnly = errors.New("calendar is read-only")
|
||||||
|
)
|
||||||
|
|
||||||
// newEventID generates a random filename for a new event object,
|
// newEventID generates a random filename for a new event object,
|
||||||
// mirroring the "<id>.ics" convention used by the CalDAV backend
|
// mirroring the "<id>.ics" convention used by the CalDAV backend
|
||||||
// (internal/caldav).
|
// (internal/caldav).
|
||||||
@@ -36,9 +53,13 @@ func newEventID() (string, error) {
|
|||||||
return hex.EncodeToString(buf) + ".ics", nil
|
return hex.EncodeToString(buf) + ".ics", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ownsCalendar reports whether cal is one of username's own calendars
|
// sharedCalRef builds the reference string used in URLs for a calendar
|
||||||
// (never one shared with them by another user — the web calendar UI only
|
// shared with the requesting user by owner.
|
||||||
// manages a user's own calendars, consistent with contacts/files).
|
func sharedCalRef(owner, name string) string {
|
||||||
|
return owner + calRefSep + name
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownsCalendar reports whether cal is one of username's own calendars.
|
||||||
func (s *Server) ownsCalendar(username, cal string) (bool, error) {
|
func (s *Server) ownsCalendar(username, cal string) (bool, error) {
|
||||||
if !resourceNameRe.MatchString(cal) {
|
if !resourceNameRe.MatchString(cal) {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -55,28 +76,90 @@ func (s *Server) ownsCalendar(username, cal string) (bool, error) {
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleCalendarsHome(w http.ResponseWriter, r *http.Request) {
|
// resolveCalRef resolves a calendar reference (either a bare name for
|
||||||
if r.Method != http.MethodGet {
|
// username's own calendar, or "<owner>~<name>" for a calendar shared with
|
||||||
w.Header().Set("Allow", "GET")
|
// username) into the concrete (owner, name) pair to use when addressing
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
// store.Store/db.DB. If requireWrite is true, a read-only share is
|
||||||
return
|
// rejected with errCalendarReadOnly.
|
||||||
|
func (s *Server) resolveCalRef(username, ref string, requireWrite bool) (owner, name string, err error) {
|
||||||
|
if owner, name, ok := strings.Cut(ref, calRefSep); ok {
|
||||||
|
if !resourceNameRe.MatchString(owner) || !resourceNameRe.MatchString(name) {
|
||||||
|
return "", "", errCalendarNotFound
|
||||||
}
|
}
|
||||||
username := userFromContext(r.Context())
|
share, err := s.dbase.CalendarShareFor(owner, name, username)
|
||||||
cals, err := s.dbase.ListCalendars(username)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("listing calendars", "error", err)
|
if errors.Is(err, db.ErrShareNotFound) {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
return "", "", errCalendarNotFound
|
||||||
return
|
|
||||||
}
|
}
|
||||||
sort.Slice(cals, func(i, j int) bool { return cals[i].Name < cals[j].Name })
|
return "", "", err
|
||||||
|
}
|
||||||
var summaries []templates.CalendarSummary
|
if requireWrite && share.Permission != db.PermWrite {
|
||||||
for _, c := range cals {
|
return "", "", errCalendarReadOnly
|
||||||
summaries = append(summaries, templates.CalendarSummary{Name: c.Name, Color: c.Color})
|
}
|
||||||
|
return owner, name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
ok, err := s.ownsCalendar(username, ref)
|
||||||
_ = templates.CalendarsHome(username, summaries).Render(context.Background(), w)
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "", "", errCalendarNotFound
|
||||||
|
}
|
||||||
|
return username, ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEntry is one calendar (own or shared) visible to a user in the
|
||||||
|
// combined month view.
|
||||||
|
type calendarEntry struct {
|
||||||
|
Ref string // path-safe reference: "name" or "owner~name"
|
||||||
|
Owner string // owner's username
|
||||||
|
Name string // calendar's own name (unqualified)
|
||||||
|
Color string
|
||||||
|
Writable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// listCalendarEntries returns every calendar visible to username: their
|
||||||
|
// own calendars (always writable) plus any calendars shared with them
|
||||||
|
// (writable only if the share grants write permission).
|
||||||
|
func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) {
|
||||||
|
var entries []calendarEntry
|
||||||
|
|
||||||
|
own, err := s.dbase.ListCalendars(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, c := range own {
|
||||||
|
entries = append(entries, calendarEntry{
|
||||||
|
Ref: c.Name, Owner: username, Name: c.Name, Color: c.Color, Writable: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
shared, err := s.dbase.CalendarsSharedWith(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, sh := range shared {
|
||||||
|
color, err := s.dbase.GetCalendarColor(sh.Owner, sh.CalendarName)
|
||||||
|
if err != nil {
|
||||||
|
color = ""
|
||||||
|
}
|
||||||
|
entries = append(entries, calendarEntry{
|
||||||
|
Ref: sharedCalRef(sh.Owner, sh.CalendarName),
|
||||||
|
Owner: sh.Owner,
|
||||||
|
Name: sh.CalendarName,
|
||||||
|
Color: color,
|
||||||
|
Writable: sh.Permission == db.PermWrite,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
if entries[i].Owner != entries[j].Owner {
|
||||||
|
return entries[i].Owner < entries[j].Owner
|
||||||
|
}
|
||||||
|
return entries[i].Name < entries[j].Name
|
||||||
|
})
|
||||||
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleCalendarMonth(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleCalendarMonth(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -86,24 +169,12 @@ func (s *Server) handleCalendarMonth(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
username := userFromContext(r.Context())
|
username := userFromContext(r.Context())
|
||||||
cal := r.PathValue("cal")
|
|
||||||
ok, err := s.ownsCalendar(username, cal)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("checking calendar ownership", "error", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
year, month := parseYearMonth(r)
|
year, month := parseYearMonth(r)
|
||||||
color, _ := s.dbase.GetCalendarColor(username, cal)
|
|
||||||
|
|
||||||
data, err := s.buildMonthView(username, cal, color, year, month)
|
data, err := s.buildMonthView(username, year, month)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("building month view", "calendar", cal, "error", err)
|
s.logger.Error("building month view", "error", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -126,12 +197,18 @@ func parseYearMonth(r *http.Request) (year int, month time.Month) {
|
|||||||
return year, month
|
return year, month
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMonthView loads every event in cal, then places each occurrence's
|
// buildMonthView loads every event from every calendar visible to
|
||||||
// days onto a 6-week grid covering the requested month (plus enough
|
// username (own + shared), then places each occurrence's days onto a
|
||||||
// leading/trailing days of neighboring months to fill full weeks).
|
// 6-week grid covering the requested month (plus enough leading/trailing
|
||||||
// Recurring events (RRULE) are not expanded — only an event's own
|
// days of neighboring months to fill full weeks). Recurring events
|
||||||
// DTSTART/DTEND span is considered.
|
// (RRULE) are not expanded — only an event's own DTSTART/DTEND span is
|
||||||
func (s *Server) buildMonthView(username, cal, color string, year int, month time.Month) (templates.MonthViewData, error) {
|
// considered.
|
||||||
|
func (s *Server) buildMonthView(username string, year int, month time.Month) (templates.MonthViewData, error) {
|
||||||
|
entries, err := s.listCalendarEntries(username)
|
||||||
|
if err != nil {
|
||||||
|
return templates.MonthViewData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
loc := time.Local
|
loc := time.Local
|
||||||
first := time.Date(year, month, 1, 0, 0, 0, 0, loc)
|
first := time.Date(year, month, 1, 0, 0, 0, 0, loc)
|
||||||
// Monday-first offset: time.Weekday has Sunday=0..Saturday=6.
|
// Monday-first offset: time.Weekday has Sunday=0..Saturday=6.
|
||||||
@@ -156,18 +233,29 @@ func (s *Server) buildMonthView(username, cal, color string, year int, month tim
|
|||||||
}
|
}
|
||||||
gridEnd := gridStart.AddDate(0, 0, totalDays-1)
|
gridEnd := gridStart.AddDate(0, 0, totalDays-1)
|
||||||
|
|
||||||
ids, err := s.store.ListObjects(username, "cal-"+cal)
|
hasWritable := false
|
||||||
|
var calSummaries []templates.CalendarSummary
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Writable {
|
||||||
|
hasWritable = true
|
||||||
|
}
|
||||||
|
calSummaries = append(calSummaries, templates.CalendarSummary{
|
||||||
|
Ref: entry.Ref, Name: entry.Name, Owner: entry.Owner, Color: entry.Color,
|
||||||
|
Shared: entry.Owner != username, Writable: entry.Writable,
|
||||||
|
})
|
||||||
|
|
||||||
|
ids, err := s.store.ListObjects(entry.Owner, "cal-"+entry.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return templates.MonthViewData{}, err
|
continue
|
||||||
}
|
}
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
data, err := s.store.GetObject(username, "cal-"+cal, id)
|
data, err := s.store.GetObject(entry.Owner, "cal-"+entry.Name, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
form, err := eventFormFromICS(id, data)
|
form, err := eventFormFromICS(id, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("decoding calendar object", "calendar", cal, "id", id, "error", err)
|
s.logger.Warn("decoding calendar object", "calendar", entry.Name, "id", id, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
startDay, endDay, err := eventDayRange(form, loc)
|
startDay, endDay, err := eventDayRange(form, loc)
|
||||||
@@ -183,8 +271,8 @@ func (s *Server) buildMonthView(username, cal, color string, year int, month tim
|
|||||||
if endDay.After(gridEnd) {
|
if endDay.After(gridEnd) {
|
||||||
endDay = gridEnd
|
endDay = gridEnd
|
||||||
}
|
}
|
||||||
// Cap the number of days a single event can add to the grid, in
|
// Cap the number of days a single event can add to the grid,
|
||||||
// case of malformed data with a wildly distant DTEND.
|
// in case of malformed data with a wildly distant DTEND.
|
||||||
for d, n := startDay, 0; !d.After(endDay) && n < totalDays; d, n = d.AddDate(0, 0, 1), n+1 {
|
for d, n := startDay, 0; !d.After(endDay) && n < totalDays; d, n = d.AddDate(0, 0, 1), n+1 {
|
||||||
idx, ok := dayIndex[d.Format(dateLayout)]
|
idx, ok := dayIndex[d.Format(dateLayout)]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -196,12 +284,15 @@ func (s *Server) buildMonthView(username, cal, color string, year int, month tim
|
|||||||
}
|
}
|
||||||
days[idx].Events = append(days[idx].Events, templates.EventSummary{
|
days[idx].Events = append(days[idx].Events, templates.EventSummary{
|
||||||
ID: id,
|
ID: id,
|
||||||
|
CalRef: entry.Ref,
|
||||||
|
Color: entry.Color,
|
||||||
Summary: form.Summary,
|
Summary: form.Summary,
|
||||||
TimeText: timeText,
|
TimeText: timeText,
|
||||||
AllDay: form.AllDay,
|
AllDay: form.AllDay,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Keep each day's events in a stable, readable order: all-day events
|
// Keep each day's events in a stable, readable order: all-day events
|
||||||
// first, then timed events sorted by start time.
|
// first, then timed events sorted by start time.
|
||||||
@@ -223,17 +314,17 @@ func (s *Server) buildMonthView(username, cal, color string, year int, month tim
|
|||||||
prevMonth := first.AddDate(0, -1, 0)
|
prevMonth := first.AddDate(0, -1, 0)
|
||||||
nextMonth := first.AddDate(0, 1, 0)
|
nextMonth := first.AddDate(0, 1, 0)
|
||||||
monthURL := func(y int, m time.Month) string {
|
monthURL := func(y int, m time.Month) string {
|
||||||
return fmt.Sprintf("/web/calendar/%s?year=%d&month=%d", cal, y, int(m))
|
return fmt.Sprintf("/web/calendar?year=%d&month=%d", y, int(m))
|
||||||
}
|
}
|
||||||
|
|
||||||
return templates.MonthViewData{
|
return templates.MonthViewData{
|
||||||
Cal: cal,
|
Calendars: calSummaries,
|
||||||
Color: color,
|
HasWritable: hasWritable,
|
||||||
MonthLabel: first.Format("January 2006"),
|
MonthLabel: first.Format("January 2006"),
|
||||||
Weeks: weeks,
|
Weeks: weeks,
|
||||||
PrevMonthURL: monthURL(prevMonth.Year(), prevMonth.Month()),
|
PrevMonthURL: monthURL(prevMonth.Year(), prevMonth.Month()),
|
||||||
NextMonthURL: monthURL(nextMonth.Year(), nextMonth.Month()),
|
NextMonthURL: monthURL(nextMonth.Year(), nextMonth.Month()),
|
||||||
TodayURL: "/web/calendar/" + cal,
|
TodayURL: "/web/calendar",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,45 +351,85 @@ func eventDayRange(form templates.EventFormData, loc *time.Location) (start, end
|
|||||||
|
|
||||||
func (s *Server) handleEventNew(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleEventNew(w http.ResponseWriter, r *http.Request) {
|
||||||
username := userFromContext(r.Context())
|
username := userFromContext(r.Context())
|
||||||
cal := r.PathValue("cal")
|
|
||||||
ok, err := s.ownsCalendar(username, cal)
|
entries, err := s.listCalendarEntries(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("checking calendar ownership", "error", err)
|
s.logger.Error("listing calendars", "error", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
var options []templates.CalendarOption
|
||||||
http.NotFound(w, r)
|
for _, e := range entries {
|
||||||
|
if !e.Writable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := e.Name
|
||||||
|
if e.Owner != username {
|
||||||
|
label = e.Name + " (" + e.Owner + ")"
|
||||||
|
}
|
||||||
|
options = append(options, templates.CalendarOption{Ref: e.Ref, Label: label})
|
||||||
|
}
|
||||||
|
if len(options) == 0 {
|
||||||
|
http.Error(w, "you don't have any calendar you can add events to", http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
form := newEventFormDefaults(cal, r.URL.Query().Get("date"))
|
selected := r.URL.Query().Get("calendar")
|
||||||
|
if selected == "" {
|
||||||
|
selected = options[0].Ref
|
||||||
|
}
|
||||||
|
form := newEventFormDefaults(selected, r.URL.Query().Get("date"))
|
||||||
|
form.Calendars = options
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_ = templates.EventForm(username, form, "").Render(context.Background(), w)
|
_ = templates.EventForm(username, form, "").Render(context.Background(), w)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
s.saveEventFromForm(w, r, username, cal, "")
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ref := strings.TrimSpace(r.PostForm.Get("calendar"))
|
||||||
|
owner, name, err := s.resolveCalRef(username, ref, true)
|
||||||
|
if err != nil {
|
||||||
|
s.handleCalRefError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.saveEventFromForm(w, r, owner, name, ref, "", options)
|
||||||
default:
|
default:
|
||||||
w.Header().Set("Allow", "GET, POST")
|
w.Header().Set("Allow", "GET, POST")
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCalRefError translates a resolveCalRef error into the
|
||||||
|
// appropriate HTTP response.
|
||||||
|
func (s *Server) handleCalRefError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errCalendarNotFound):
|
||||||
|
http.NotFound(w, r)
|
||||||
|
case errors.Is(err, errCalendarReadOnly):
|
||||||
|
http.Error(w, "this calendar is shared read-only", http.StatusForbidden)
|
||||||
|
default:
|
||||||
|
s.logger.Error("resolving calendar reference", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// newEventFormDefaults builds the initial form values for a brand new
|
// newEventFormDefaults builds the initial form values for a brand new
|
||||||
// event. If dateParam is a valid "YYYY-MM-DD" (e.g. from clicking a day
|
// event. If dateParam is a valid "YYYY-MM-DD" (e.g. from clicking a day
|
||||||
// cell in the month grid), the event defaults to an all-day event on that
|
// cell in the month grid), the event defaults to an all-day event on that
|
||||||
// date; otherwise it defaults to a one-hour timed event starting at the
|
// date; otherwise it defaults to a one-hour timed event starting at the
|
||||||
// next full hour today.
|
// next full hour today.
|
||||||
func newEventFormDefaults(cal, dateParam string) templates.EventFormData {
|
func newEventFormDefaults(calRef, dateParam string) templates.EventFormData {
|
||||||
if _, err := time.Parse(dateLayout, dateParam); err == nil {
|
if _, err := time.Parse(dateLayout, dateParam); err == nil {
|
||||||
return templates.EventFormData{Cal: cal, AllDay: true, StartDate: dateParam, EndDate: dateParam}
|
return templates.EventFormData{CalRef: calRef, AllDay: true, StartDate: dateParam, EndDate: dateParam}
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
start := now.Truncate(time.Hour).Add(time.Hour)
|
start := now.Truncate(time.Hour).Add(time.Hour)
|
||||||
end := start.Add(time.Hour)
|
end := start.Add(time.Hour)
|
||||||
return templates.EventFormData{
|
return templates.EventFormData{
|
||||||
Cal: cal,
|
CalRef: calRef,
|
||||||
StartDate: start.Format(dateLayout),
|
StartDate: start.Format(dateLayout),
|
||||||
StartTime: start.Format(timeLayout),
|
StartTime: start.Format(timeLayout),
|
||||||
EndDate: end.Format(dateLayout),
|
EndDate: end.Format(dateLayout),
|
||||||
@@ -308,22 +439,22 @@ func newEventFormDefaults(cal, dateParam string) templates.EventFormData {
|
|||||||
|
|
||||||
func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) {
|
||||||
username := userFromContext(r.Context())
|
username := userFromContext(r.Context())
|
||||||
cal := r.PathValue("cal")
|
ref := r.PathValue("ref")
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
ok, err := s.ownsCalendar(username, cal)
|
|
||||||
|
owner, name, err := s.resolveCalRef(username, ref, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("checking calendar ownership", "error", err)
|
s.handleCalRefError(w, r, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok || !eventIDRe.MatchString(id) {
|
if !eventIDRe.MatchString(id) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
data, err := s.store.GetObject(username, "cal-"+cal, id)
|
data, err := s.store.GetObject(owner, "cal-"+name, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -334,11 +465,28 @@ func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
form.Cal = cal
|
form.CalRef = ref
|
||||||
|
label := name
|
||||||
|
if owner != username {
|
||||||
|
label = name + " (" + owner + ")"
|
||||||
|
}
|
||||||
|
form.CalendarLabel = label
|
||||||
|
form.Writable = owner == username
|
||||||
|
if !form.Writable {
|
||||||
|
if share, err := s.dbase.CalendarShareFor(owner, name, username); err == nil {
|
||||||
|
form.Writable = share.Permission == db.PermWrite
|
||||||
|
}
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_ = templates.EventForm(username, form, "").Render(context.Background(), w)
|
_ = templates.EventForm(username, form, "").Render(context.Background(), w)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
s.saveEventFromForm(w, r, username, cal, id)
|
if owner2, _, err := s.resolveCalRef(username, ref, true); err != nil {
|
||||||
|
s.handleCalRefError(w, r, err)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
owner = owner2
|
||||||
|
}
|
||||||
|
s.saveEventFromForm(w, r, owner, name, ref, id, nil)
|
||||||
default:
|
default:
|
||||||
w.Header().Set("Allow", "GET, POST")
|
w.Header().Set("Allow", "GET, POST")
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -352,23 +500,23 @@ func (s *Server) handleEventDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
username := userFromContext(r.Context())
|
username := userFromContext(r.Context())
|
||||||
cal := r.PathValue("cal")
|
ref := r.PathValue("ref")
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
ok, err := s.ownsCalendar(username, cal)
|
|
||||||
|
owner, name, err := s.resolveCalRef(username, ref, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("checking calendar ownership", "error", err)
|
s.handleCalRefError(w, r, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok || !eventIDRe.MatchString(id) {
|
if !eventIDRe.MatchString(id) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.DeleteObject(username, "cal-"+cal, id); err != nil {
|
if err := s.store.DeleteObject(owner, "cal-"+name, id); err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/web/calendar/"+cal, http.StatusSeeOther)
|
http.Redirect(w, r, "/web/calendar", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// eventFormInput holds the parsed, not-yet-validated values submitted by
|
// eventFormInput holds the parsed, not-yet-validated values submitted by
|
||||||
@@ -398,8 +546,13 @@ func parseEventForm(r *http.Request) eventFormInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// saveEventFromForm handles both event creation (id == "") and editing
|
// saveEventFromForm handles both event creation (id == "") and editing
|
||||||
// (id preserves the existing event's UID).
|
// (id preserves the existing event's UID). owner/name identify the
|
||||||
func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, username, cal, id string) {
|
// resolved (and already permission-checked) storage location; ref is the
|
||||||
|
// calendar reference used for redirect/re-render URLs; options is only
|
||||||
|
// non-nil when creating a new event (to re-render the calendar <select>
|
||||||
|
// on a validation error).
|
||||||
|
func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, owner, name, ref, id string, options []templates.CalendarOption) {
|
||||||
|
username := userFromContext(r.Context())
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -408,8 +561,16 @@ func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, usern
|
|||||||
|
|
||||||
reRender := func(errMsg string) {
|
reRender := func(errMsg string) {
|
||||||
form := templates.EventFormData{
|
form := templates.EventFormData{
|
||||||
Cal: cal, ID: id, Summary: in.Summary, Description: in.Description, Location: in.Location,
|
CalRef: ref, ID: id, Summary: in.Summary, Description: in.Description, Location: in.Location,
|
||||||
AllDay: in.AllDay, StartDate: in.StartDate, StartTime: in.StartTime, EndDate: in.EndDate, EndTime: in.EndTime,
|
AllDay: in.AllDay, StartDate: in.StartDate, StartTime: in.StartTime, EndDate: in.EndDate, EndTime: in.EndTime,
|
||||||
|
Calendars: options,
|
||||||
|
}
|
||||||
|
if id != "" {
|
||||||
|
label := name
|
||||||
|
if owner != username {
|
||||||
|
label = name + " (" + owner + ")"
|
||||||
|
}
|
||||||
|
form.CalendarLabel = label
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_ = templates.EventForm(username, form, errMsg).Render(context.Background(), w)
|
_ = templates.EventForm(username, form, errMsg).Render(context.Background(), w)
|
||||||
@@ -437,7 +598,7 @@ func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, usern
|
|||||||
id = newID
|
id = newID
|
||||||
uid = strings.TrimSuffix(id, ".ics")
|
uid = strings.TrimSuffix(id, ".ics")
|
||||||
} else {
|
} else {
|
||||||
data, err := s.store.GetObject(username, "cal-"+cal, id)
|
data, err := s.store.GetObject(owner, "cal-"+name, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -459,13 +620,13 @@ func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, usern
|
|||||||
}
|
}
|
||||||
|
|
||||||
newCal := buildEventCalendar(uid, in, start, end)
|
newCal := buildEventCalendar(uid, in, start, end)
|
||||||
if err := s.saveEvent(username, cal, id, newCal); err != nil {
|
if err := s.saveEvent(owner, name, id, newCal); err != nil {
|
||||||
s.logger.Error("saving event", "error", err)
|
s.logger.Error("saving event", "error", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/web/calendar/"+cal, http.StatusSeeOther)
|
http.Redirect(w, r, "/web/calendar", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// computeEventTimes validates and converts the submitted form fields into
|
// computeEventTimes validates and converts the submitted form fields into
|
||||||
@@ -545,13 +706,13 @@ func buildEventCalendar(uid string, in eventFormInput, start, end time.Time) *ic
|
|||||||
return cal
|
return cal
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) saveEvent(username, cal, id string, calendar *ical.Calendar) error {
|
func (s *Server) saveEvent(owner, cal, id string, calendar *ical.Calendar) error {
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
enc := ical.NewEncoder(&buf)
|
enc := ical.NewEncoder(&buf)
|
||||||
if err := enc.Encode(calendar); err != nil {
|
if err := enc.Encode(calendar); err != nil {
|
||||||
return fmt.Errorf("encoding ical: %w", err)
|
return fmt.Errorf("encoding ical: %w", err)
|
||||||
}
|
}
|
||||||
return s.store.PutObject(username, "cal-"+cal, id, []byte(buf.String()))
|
return s.store.PutObject(owner, "cal-"+cal, id, []byte(buf.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// eventFormFromICS decodes a single-VEVENT .ics object into the shared
|
// eventFormFromICS decodes a single-VEVENT .ics object into the shared
|
||||||
@@ -626,3 +787,160 @@ func eventFormFromICS(id string, data []byte) (templates.EventFormData, error) {
|
|||||||
|
|
||||||
return form, nil
|
return form, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCalendarExportOne exports a single event as a standalone .ics
|
||||||
|
// file.
|
||||||
|
func (s *Server) handleEventExportOne(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.Header().Set("Allow", "GET")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := userFromContext(r.Context())
|
||||||
|
ref := r.PathValue("ref")
|
||||||
|
id := r.PathValue("id")
|
||||||
|
|
||||||
|
owner, name, err := s.resolveCalRef(username, ref, false)
|
||||||
|
if err != nil {
|
||||||
|
s.handleCalRefError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !eventIDRe.MatchString(id) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := s.store.GetObject(owner, "cal-"+name, id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="`+id+`"`)
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCalendarExportAll exports every event in a calendar as one
|
||||||
|
// concatenated .ics file.
|
||||||
|
func (s *Server) handleCalendarExportAll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.Header().Set("Allow", "GET")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := userFromContext(r.Context())
|
||||||
|
ref := r.PathValue("ref")
|
||||||
|
|
||||||
|
owner, name, err := s.resolveCalRef(username, ref, false)
|
||||||
|
if err != nil {
|
||||||
|
s.handleCalRefError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, err := s.store.ListObjects(owner, "cal-"+name)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("listing calendar objects", "calendar", name, "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`.ics"`)
|
||||||
|
for _, id := range ids {
|
||||||
|
data, err := s.store.GetObject(owner, "cal-"+name, id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Each stored object is already a complete, self-contained
|
||||||
|
// VCALENDAR document, so concatenating their raw bytes produces a
|
||||||
|
// file most clients will happily re-import (though it is not a
|
||||||
|
// single strictly-valid multi-event VCALENDAR document).
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCalendarImport accepts an uploaded .ics file that may contain one
|
||||||
|
// or more VCALENDAR blocks, each possibly holding multiple VEVENTs, and
|
||||||
|
// stores each VEVENT as its own object (mirroring how the CalDAV backend
|
||||||
|
// stores one event per object).
|
||||||
|
func (s *Server) handleCalendarImport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", "POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := userFromContext(r.Context())
|
||||||
|
ref := r.PathValue("ref")
|
||||||
|
|
||||||
|
owner, name, err := s.resolveCalRef(username, ref, true)
|
||||||
|
if err != nil {
|
||||||
|
s.handleCalRefError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseMultipartForm(maxUploadMemory); err != nil {
|
||||||
|
http.Error(w, "invalid upload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, _, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "no file provided", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("reading import file", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imported := 0
|
||||||
|
dec := ical.NewDecoder(bytes.NewReader(body))
|
||||||
|
for {
|
||||||
|
srcCal, err := dec.Decode()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("stopping ics import on parse error", "calendar", name, "error", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
version := "2.0"
|
||||||
|
if p := srcCal.Props.Get(ical.PropVersion); p != nil && p.Value != "" {
|
||||||
|
version = p.Value
|
||||||
|
}
|
||||||
|
prodID := "-//nidus//web calendar//EN"
|
||||||
|
if p := srcCal.Props.Get(ical.PropProductID); p != nil && p.Value != "" {
|
||||||
|
prodID = p.Value
|
||||||
|
}
|
||||||
|
var timezones []*ical.Component
|
||||||
|
for _, child := range srcCal.Children {
|
||||||
|
if child.Name == ical.CompTimezone {
|
||||||
|
timezones = append(timezones, child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ev := range srcCal.Events() {
|
||||||
|
id, err := newEventID()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("generating event id", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out := ical.NewCalendar()
|
||||||
|
out.Props.SetText(ical.PropVersion, version)
|
||||||
|
out.Props.SetText(ical.PropProductID, prodID)
|
||||||
|
out.Children = append(out.Children, timezones...)
|
||||||
|
out.Children = append(out.Children, ev.Component)
|
||||||
|
|
||||||
|
if err := s.saveEvent(owner, name, id, out); err != nil {
|
||||||
|
s.logger.Warn("saving imported event", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/web/calendar?imported="+strconv.Itoa(imported), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,11 +54,13 @@ func (s *Server) Handler(staticFS http.FileSystem) http.Handler {
|
|||||||
mux.HandleFunc("/contacts/{book}/{id}/edit", s.requireLogin(s.handleContactEdit))
|
mux.HandleFunc("/contacts/{book}/{id}/edit", s.requireLogin(s.handleContactEdit))
|
||||||
mux.HandleFunc("/contacts/{book}/{id}/delete", s.requireLogin(s.handleContactDelete))
|
mux.HandleFunc("/contacts/{book}/{id}/delete", s.requireLogin(s.handleContactDelete))
|
||||||
mux.HandleFunc("/contacts/{book}/{id}/export", s.requireLogin(s.handleContactExportOne))
|
mux.HandleFunc("/contacts/{book}/{id}/export", s.requireLogin(s.handleContactExportOne))
|
||||||
mux.HandleFunc("/calendar", s.requireLogin(s.handleCalendarsHome))
|
mux.HandleFunc("/calendar", s.requireLogin(s.handleCalendarMonth))
|
||||||
mux.HandleFunc("/calendar/{cal}", s.requireLogin(s.handleCalendarMonth))
|
mux.HandleFunc("/calendar/new", s.requireLogin(s.handleEventNew))
|
||||||
mux.HandleFunc("/calendar/{cal}/new", s.requireLogin(s.handleEventNew))
|
mux.HandleFunc("/calendar/{ref}/import", s.requireLogin(s.handleCalendarImport))
|
||||||
mux.HandleFunc("/calendar/{cal}/{id}/edit", s.requireLogin(s.handleEventEdit))
|
mux.HandleFunc("/calendar/{ref}/export", s.requireLogin(s.handleCalendarExportAll))
|
||||||
mux.HandleFunc("/calendar/{cal}/{id}/delete", s.requireLogin(s.handleEventDelete))
|
mux.HandleFunc("/calendar/{ref}/{id}/edit", s.requireLogin(s.handleEventEdit))
|
||||||
|
mux.HandleFunc("/calendar/{ref}/{id}/delete", s.requireLogin(s.handleEventDelete))
|
||||||
|
mux.HandleFunc("/calendar/{ref}/{id}/export", s.requireLogin(s.handleEventExportOne))
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,22 @@ package templates
|
|||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
// CalendarSummary is one of the user's own calendars, listed on the
|
// CalendarSummary is one calendar (own or shared) shown in the combined
|
||||||
// calendar home page.
|
// month view's legend.
|
||||||
type CalendarSummary struct {
|
type CalendarSummary struct {
|
||||||
|
Ref string // path-safe reference: "name" or "owner~name"
|
||||||
Name string
|
Name string
|
||||||
|
Owner string // owner's username
|
||||||
Color string // hex color like "#3b82f6", "" if unset
|
Color string // hex color like "#3b82f6", "" if unset
|
||||||
|
Shared bool // true if owned by someone other than the viewer
|
||||||
|
Writable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventSummary is a single event shown inside a month-view day cell.
|
// EventSummary is a single event shown inside a month-view day cell.
|
||||||
type EventSummary struct {
|
type EventSummary struct {
|
||||||
ID string
|
ID string
|
||||||
|
CalRef string
|
||||||
|
Color string
|
||||||
Summary string
|
Summary string
|
||||||
TimeText string // e.g. "14:00" or "" for all-day events
|
TimeText string // e.g. "14:00" or "" for all-day events
|
||||||
AllDay bool
|
AllDay bool
|
||||||
@@ -26,11 +32,12 @@ type MonthDay struct {
|
|||||||
Events []EventSummary
|
Events []EventSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
// MonthViewData is everything the month grid template needs to render one
|
// MonthViewData is everything the combined month grid template needs to
|
||||||
// month of a single calendar.
|
// render one month across every calendar (own + shared) visible to the
|
||||||
|
// viewer.
|
||||||
type MonthViewData struct {
|
type MonthViewData struct {
|
||||||
Cal string
|
Calendars []CalendarSummary
|
||||||
Color string
|
HasWritable bool // true if the viewer can create events in at least one calendar
|
||||||
MonthLabel string // e.g. "August 2026"
|
MonthLabel string // e.g. "August 2026"
|
||||||
Weeks [][]MonthDay
|
Weeks [][]MonthDay
|
||||||
PrevMonthURL string
|
PrevMonthURL string
|
||||||
@@ -38,9 +45,18 @@ type MonthViewData struct {
|
|||||||
TodayURL string
|
TodayURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CalendarOption is one entry in the "new event" calendar <select>.
|
||||||
|
type CalendarOption struct {
|
||||||
|
Ref string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
// EventFormData pre-fills the create/edit event form.
|
// EventFormData pre-fills the create/edit event form.
|
||||||
type EventFormData struct {
|
type EventFormData struct {
|
||||||
Cal string
|
CalRef string // resolved/fixed calendar reference (edit), or the selected one (new)
|
||||||
|
CalendarLabel string // fixed, read-only display label used when editing
|
||||||
|
Calendars []CalendarOption // populated only for new-event forms
|
||||||
|
Writable bool // false when editing an event in a read-only shared calendar
|
||||||
ID string // empty when creating a new event
|
ID string // empty when creating a new event
|
||||||
Summary string
|
Summary string
|
||||||
Description string
|
Description string
|
||||||
@@ -52,67 +68,76 @@ type EventFormData struct {
|
|||||||
EndTime string // "HH:MM", empty when AllDay
|
EndTime string // "HH:MM", empty when AllDay
|
||||||
}
|
}
|
||||||
|
|
||||||
templ CalendarsHome(username string, calendars []CalendarSummary) {
|
|
||||||
@Layout("Calendar", username) {
|
|
||||||
<h1 class="text-2xl font-semibold mb-6">Calendar</h1>
|
|
||||||
if len(calendars) == 0 {
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
You don't have any calendars yet — create one from the
|
|
||||||
<a href="/web/" class="text-indigo-600 hover:underline">dashboard</a> first.
|
|
||||||
</p>
|
|
||||||
} else {
|
|
||||||
<ul class="divide-y divide-gray-200 bg-white rounded-lg border border-gray-200">
|
|
||||||
for _, c := range calendars {
|
|
||||||
<li class="px-4 py-3 flex items-center gap-3 text-sm">
|
|
||||||
<span class="w-3 h-3 rounded-full shrink-0" style={ "background-color: " + colorOrDefault(c.Color) }></span>
|
|
||||||
<a href={ templ.URL("/web/calendar/" + c.Name) } class="font-medium text-indigo-600 hover:underline">{ c.Name }</a>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ MonthView(username string, data MonthViewData) {
|
templ MonthView(username string, data MonthViewData) {
|
||||||
@Layout("Calendar", username) {
|
@Layout("Calendar", username) {
|
||||||
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
||||||
<div>
|
<h1 class="text-2xl font-semibold">Calendar</h1>
|
||||||
<a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">← Calendars</a>
|
|
||||||
<h1 class="text-2xl font-semibold flex items-center gap-2">
|
|
||||||
<span class="w-3 h-3 rounded-full shrink-0" style={ "background-color: " + colorOrDefault(data.Color) }></span>
|
|
||||||
{ data.Cal }
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 items-center flex-wrap">
|
<div class="flex gap-2 items-center flex-wrap">
|
||||||
<a href={ templ.URL(data.PrevMonthURL) } class="bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">←</a>
|
<a href={ templ.URL(data.PrevMonthURL) } class="bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">←</a>
|
||||||
<a href={ templ.URL(data.TodayURL) } class="bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">Today</a>
|
<a href={ templ.URL(data.TodayURL) } class="bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">Today</a>
|
||||||
<a href={ templ.URL(data.NextMonthURL) } class="bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">→</a>
|
<a href={ templ.URL(data.NextMonthURL) } class="bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">→</a>
|
||||||
<span class="text-lg font-medium ml-2">{ data.MonthLabel }</span>
|
<span class="text-lg font-medium ml-2">{ data.MonthLabel }</span>
|
||||||
<a href={ templ.URL("/web/calendar/" + data.Cal + "/new") }
|
if data.HasWritable {
|
||||||
|
<a href="/web/calendar/new"
|
||||||
class="bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700 ml-2">
|
class="bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700 ml-2">
|
||||||
New event
|
New event
|
||||||
</a>
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-7 gap-px bg-gray-200 border border-gray-200 rounded-lg overflow-hidden text-sm">
|
if len(data.Calendars) == 0 {
|
||||||
|
<p class="text-sm text-gray-500 mb-6">
|
||||||
|
You don't have any calendars yet — create one from the
|
||||||
|
<a href="/web/" class="text-indigo-600 hover:underline">dashboard</a> first.
|
||||||
|
</p>
|
||||||
|
} else {
|
||||||
|
<div class="grid grid-cols-7 gap-px bg-gray-200 border border-gray-200 rounded-lg overflow-hidden text-sm mb-6">
|
||||||
for _, wd := range weekdayLabels() {
|
for _, wd := range weekdayLabels() {
|
||||||
<div class="bg-gray-50 px-2 py-1.5 font-medium text-gray-500 text-xs text-center">{ wd }</div>
|
<div class="bg-gray-50 px-2 py-1.5 font-medium text-gray-500 text-xs text-center">{ wd }</div>
|
||||||
}
|
}
|
||||||
for _, week := range data.Weeks {
|
for _, week := range data.Weeks {
|
||||||
for _, day := range week {
|
for _, day := range week {
|
||||||
@monthDayCell(data.Cal, day)
|
@monthDayCell(day)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 divide-y divide-gray-200">
|
||||||
|
for _, c := range data.Calendars {
|
||||||
|
@calendarLegendRow(c)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ monthDayCell(cal string, day MonthDay) {
|
templ calendarLegendRow(c CalendarSummary) {
|
||||||
|
<div class="px-4 py-3 flex items-center gap-3 text-sm flex-wrap">
|
||||||
|
<span class="w-3 h-3 rounded-full shrink-0" style={ "background-color: " + colorOrDefault(c.Color) }></span>
|
||||||
|
<span class="font-medium">{ c.Name }</span>
|
||||||
|
if c.Shared {
|
||||||
|
<span class="text-xs text-gray-500">shared by { c.Owner }</span>
|
||||||
|
}
|
||||||
|
if !c.Writable {
|
||||||
|
<span class="text-xs text-gray-400">(read-only)</span>
|
||||||
|
}
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<a href={ templ.URL("/web/calendar/" + c.Ref + "/export") } class="text-indigo-600 hover:underline text-xs">Export .ics</a>
|
||||||
|
if c.Writable {
|
||||||
|
<form method="POST" action={ templ.URL("/web/calendar/" + c.Ref + "/import") } enctype="multipart/form-data" class="flex items-center gap-1">
|
||||||
|
<input type="file" name="file" accept=".ics,text/calendar" required class="text-xs"/>
|
||||||
|
<button type="submit" class="text-indigo-600 hover:underline text-xs">Import</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ monthDayCell(day MonthDay) {
|
||||||
<div class={ "bg-white min-h-[6rem] p-1.5 flex flex-col gap-1", templ.KV("bg-gray-50 text-gray-400", !day.InMonth) }>
|
<div class={ "bg-white min-h-[6rem] p-1.5 flex flex-col gap-1", templ.KV("bg-gray-50 text-gray-400", !day.InMonth) }>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<a
|
<a
|
||||||
href={ templ.URL("/web/calendar/" + cal + "/new?date=" + day.Date) }
|
href={ templ.URL("/web/calendar/new?date=" + day.Date) }
|
||||||
class={ "text-xs font-medium rounded-full w-5 h-5 flex items-center justify-center", templ.KV("bg-indigo-600 text-white", day.IsToday), templ.KV("hover:bg-gray-100", !day.IsToday) }
|
class={ "text-xs font-medium rounded-full w-5 h-5 flex items-center justify-center", templ.KV("bg-indigo-600 text-white", day.IsToday), templ.KV("hover:bg-gray-100", !day.IsToday) }
|
||||||
title="New event"
|
title="New event"
|
||||||
>
|
>
|
||||||
@@ -121,10 +146,12 @@ templ monthDayCell(cal string, day MonthDay) {
|
|||||||
</div>
|
</div>
|
||||||
for _, ev := range day.Events {
|
for _, ev := range day.Events {
|
||||||
<a
|
<a
|
||||||
href={ templ.URL("/web/calendar/" + cal + "/" + ev.ID + "/edit") }
|
href={ templ.URL("/web/calendar/" + ev.CalRef + "/" + ev.ID + "/edit") }
|
||||||
class="block truncate rounded bg-indigo-50 text-indigo-700 px-1.5 py-0.5 text-xs hover:bg-indigo-100"
|
class="block truncate rounded px-1.5 py-0.5 text-xs hover:opacity-80"
|
||||||
|
style={ "background-color: " + colorOrDefault(ev.Color) + "22; color: " + colorOrDefault(ev.Color) }
|
||||||
title={ ev.Summary }
|
title={ ev.Summary }
|
||||||
>
|
>
|
||||||
|
<span class="inline-block w-1.5 h-1.5 rounded-full mr-1" style={ "background-color: " + colorOrDefault(ev.Color) }></span>
|
||||||
if !ev.AllDay && ev.TimeText != "" {
|
if !ev.AllDay && ev.TimeText != "" {
|
||||||
<span class="font-medium">{ ev.TimeText }</span>
|
<span class="font-medium">{ ev.TimeText }</span>
|
||||||
}
|
}
|
||||||
@@ -136,7 +163,7 @@ templ monthDayCell(cal string, day MonthDay) {
|
|||||||
|
|
||||||
templ EventForm(username string, data EventFormData, errMsg string) {
|
templ EventForm(username string, data EventFormData, errMsg string) {
|
||||||
@Layout("Calendar", username) {
|
@Layout("Calendar", username) {
|
||||||
<a href={ templ.URL("/web/calendar/" + data.Cal) } class="text-sm text-indigo-600 hover:underline">← { data.Cal }</a>
|
<a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">← Calendar</a>
|
||||||
<h1 class="text-2xl font-semibold mt-2 mb-6">
|
<h1 class="text-2xl font-semibold mt-2 mb-6">
|
||||||
if data.ID == "" {
|
if data.ID == "" {
|
||||||
New event
|
New event
|
||||||
@@ -147,11 +174,30 @@ templ EventForm(username string, data EventFormData, errMsg string) {
|
|||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
<p class="mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{ errMsg }</p>
|
<p class="mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{ errMsg }</p>
|
||||||
}
|
}
|
||||||
|
if data.ID != "" && !data.Writable {
|
||||||
|
<p class="mb-4 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2">
|
||||||
|
This calendar was shared with you as read-only — you can view this event but not change it.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action={ eventFormAction(data) }
|
action={ eventFormAction(data) }
|
||||||
class="bg-white rounded-lg border border-gray-200 p-6 space-y-6 max-w-xl"
|
class="bg-white rounded-lg border border-gray-200 p-6 space-y-6 max-w-xl"
|
||||||
>
|
>
|
||||||
|
<fieldset disabled?={ data.ID != "" && !data.Writable } class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Calendar</label>
|
||||||
|
if data.ID == "" {
|
||||||
|
<select name="calendar" class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm">
|
||||||
|
for _, c := range data.Calendars {
|
||||||
|
<option value={ c.Ref } selected?={ c.Ref == data.CalRef }>{ c.Label }</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
} else {
|
||||||
|
<p class="mt-1 text-sm text-gray-600">{ data.CalendarLabel }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700">Title</label>
|
<label class="block text-sm font-medium text-gray-700">Title</label>
|
||||||
<input name="summary" type="text" required value={ data.Summary }
|
<input name="summary" type="text" required value={ data.Summary }
|
||||||
@@ -196,16 +242,22 @@ templ EventForm(username string, data EventFormData, errMsg string) {
|
|||||||
<textarea name="description" rows="4"
|
<textarea name="description" rows="4"
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm">{ data.Description }</textarea>
|
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm">{ data.Description }</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="flex items-center justify-between pt-2">
|
<div class="flex items-center justify-between pt-2">
|
||||||
|
if data.ID == "" || data.Writable {
|
||||||
<button type="submit" class="bg-indigo-600 text-white rounded-md px-4 py-2 text-sm font-medium hover:bg-indigo-700">
|
<button type="submit" class="bg-indigo-600 text-white rounded-md px-4 py-2 text-sm font-medium hover:bg-indigo-700">
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
if data.ID != "" {
|
}
|
||||||
<form method="POST" action={ templ.URL("/web/calendar/" + data.Cal + "/" + data.ID + "/delete") } onsubmit="return confirm('Delete this event?')">
|
if data.ID != "" && data.Writable {
|
||||||
|
<form method="POST" action={ templ.URL("/web/calendar/" + data.CalRef + "/" + data.ID + "/delete") } onsubmit="return confirm('Delete this event?')">
|
||||||
<button type="submit" class="text-red-600 hover:underline text-sm">Delete event</button>
|
<button type="submit" class="text-red-600 hover:underline text-sm">Delete event</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
if data.ID != "" {
|
||||||
|
<a href={ templ.URL("/web/calendar/" + data.CalRef + "/" + data.ID + "/export") } class="text-indigo-600 hover:underline text-sm">Export .ics</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -215,9 +267,9 @@ templ EventForm(username string, data EventFormData, errMsg string) {
|
|||||||
|
|
||||||
func eventFormAction(data EventFormData) templ.SafeURL {
|
func eventFormAction(data EventFormData) templ.SafeURL {
|
||||||
if data.ID == "" {
|
if data.ID == "" {
|
||||||
return templ.URL("/web/calendar/" + data.Cal + "/new")
|
return templ.URL("/web/calendar/new")
|
||||||
}
|
}
|
||||||
return templ.URL("/web/calendar/" + data.Cal + "/" + data.ID + "/edit")
|
return templ.URL("/web/calendar/" + data.CalRef + "/" + data.ID + "/edit")
|
||||||
}
|
}
|
||||||
|
|
||||||
func weekdayLabels() []string {
|
func weekdayLabels() []string {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user