Add ICS/webcal HTTP subscription calendars
Users can now subscribe to a remote ICS/webcal feed from the dashboard (name + color), the same way they set up a real calendar or the virtual Birthdays calendar. Subscriptions are read-only, per-user, and share the "/cal/home/<name>/" namespace with real calendars and "birthdays" (name collisions are rejected in both directions). - internal/db: new ics_subscriptions table + CRUD (internal/db/ics.go); CreateCalendarWithColor checks for a colliding subscription name. - internal/icssub: shared HTTP-fetch + TTL cache (15 min) for remote ICS calendars, with webcal:// -> https:// rewriting and stale-on-error fallback, used by both the web UI and the CalDAV backend. - internal/web: dashboard "Subscribe to an ICS/webcal calendar" form, color picker, delete button (internal/web/ics.go, templates/dashboard.templ); month view renders subscription events in their chosen color, read-only (internal/web/calendar.go). - internal/caldav: subscriptions are exposed as read-only calendars (internal/caldav/ics.go) - listed in PROPFIND, events served via GET/REPORT, PUT/DELETE on individual events rejected with 403, but DELETE on the calendar itself unsubscribes; calendar-color is injected the same way as for real calendars and Birthdays. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/yourusername/caldav-server/internal/auth"
|
"github.com/yourusername/caldav-server/internal/auth"
|
||||||
"github.com/yourusername/caldav-server/internal/config"
|
"github.com/yourusername/caldav-server/internal/config"
|
||||||
"github.com/yourusername/caldav-server/internal/db"
|
"github.com/yourusername/caldav-server/internal/db"
|
||||||
|
"github.com/yourusername/caldav-server/internal/icssub"
|
||||||
"github.com/yourusername/caldav-server/internal/store"
|
"github.com/yourusername/caldav-server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,12 +37,13 @@ type Backend struct {
|
|||||||
store *store.Store
|
store *store.Store
|
||||||
dbase *db.DB // may be nil if sharing is not configured
|
dbase *db.DB // may be nil if sharing is not configured
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
icsCache *icssub.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBackend creates a CalDAV backend. dbase may be nil, in which case
|
// NewBackend creates a CalDAV backend. dbase may be nil, in which case
|
||||||
// calendar sharing is disabled (only a user's own calendars are visible).
|
// calendar sharing is disabled (only a user's own calendars are visible).
|
||||||
func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Backend {
|
func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Backend {
|
||||||
return &Backend{cfg: cfg, store: st, dbase: dbase, logger: logger}
|
return &Backend{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icssub.NewCache(icssub.DefaultTTL)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler returns an http.Handler for the /cal/ prefix.
|
// NewHandler returns an http.Handler for the /cal/ prefix.
|
||||||
@@ -100,6 +102,15 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
|
|||||||
cals = append(cals, b.calendarMeta(p.Username, cal.Name, cal.Name))
|
cals = append(cals, b.calendarMeta(p.Username, cal.Name, cal.Name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also include the requester's own read-only ICS/webcal subscriptions.
|
||||||
|
subs, err := b.dbase.ListICSSubscriptions(p.Username)
|
||||||
|
if err != nil {
|
||||||
|
b.logger.Warn("listing ics subscriptions", "error", err)
|
||||||
|
}
|
||||||
|
for _, sub := range subs {
|
||||||
|
cals = append(cals, b.icsSubscriptionCalendarMeta(p.Username, sub))
|
||||||
|
}
|
||||||
|
|
||||||
// Also include any extra calendars that exist on disk but aren't registered
|
// Also include any extra calendars that exist on disk but aren't registered
|
||||||
disk, _ := b.store.ListCollections(p.Username)
|
disk, _ := b.store.ListCollections(p.Username)
|
||||||
configured := make(map[string]bool)
|
configured := make(map[string]bool)
|
||||||
@@ -140,6 +151,10 @@ func (b *Backend) GetCalendar(ctx context.Context, calPath string) (*caldav.Cale
|
|||||||
cal := b.birthdaysCalendarMeta(requester)
|
cal := b.birthdaysCalendarMeta(requester)
|
||||||
return &cal, nil
|
return &cal, nil
|
||||||
}
|
}
|
||||||
|
if sub, err := b.dbase.GetICSSubscription(requester, localName); err == nil {
|
||||||
|
cal := b.icsSubscriptionCalendarMeta(requester, sub)
|
||||||
|
return &cal, nil
|
||||||
|
}
|
||||||
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
|
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -159,6 +174,9 @@ func (b *Backend) GetCalendarObject(ctx context.Context, objPath string, req *ca
|
|||||||
if localName == birthdaysCalendarName {
|
if localName == birthdaysCalendarName {
|
||||||
return b.birthdayCalendarObject(requester, objPath, objID)
|
return b.birthdayCalendarObject(requester, objPath, objID)
|
||||||
}
|
}
|
||||||
|
if sub, err := b.dbase.GetICSSubscription(requester, localName); err == nil {
|
||||||
|
return b.icsSubscriptionCalendarObject(localName, objID, sub)
|
||||||
|
}
|
||||||
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
|
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -180,6 +198,9 @@ func (b *Backend) ListCalendarObjects(ctx context.Context, calPath string, req *
|
|||||||
if localName == birthdaysCalendarName {
|
if localName == birthdaysCalendarName {
|
||||||
return b.listBirthdayCalendarObjects(requester)
|
return b.listBirthdayCalendarObjects(requester)
|
||||||
}
|
}
|
||||||
|
if sub, err := b.dbase.GetICSSubscription(requester, localName); err == nil {
|
||||||
|
return b.listICSSubscriptionCalendarObjects(localName, sub)
|
||||||
|
}
|
||||||
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
|
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -244,6 +265,13 @@ func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error {
|
|||||||
if localName == birthdaysCalendarName {
|
if localName == birthdaysCalendarName {
|
||||||
return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically"))
|
return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically"))
|
||||||
}
|
}
|
||||||
|
// Unlike the birthdays calendar, an ICS subscription can be deleted:
|
||||||
|
// that's how a user unsubscribes from it via CalDAV.
|
||||||
|
if err := b.dbase.DeleteICSSubscription(requester, localName); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if err != db.ErrResourceNotFound {
|
||||||
|
return fmt.Errorf("unregistering ics subscription: %w", err)
|
||||||
|
}
|
||||||
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
|
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -262,6 +290,9 @@ func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calenda
|
|||||||
if localName == birthdaysCalendarName {
|
if localName == birthdaysCalendarName {
|
||||||
return nil, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically"))
|
return nil, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically"))
|
||||||
}
|
}
|
||||||
|
if _, err := b.dbase.GetICSSubscription(requester, localName); err == nil {
|
||||||
|
return nil, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("this calendar is a read-only ics/webcal subscription"))
|
||||||
|
}
|
||||||
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
|
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -289,6 +320,9 @@ func (b *Backend) DeleteCalendarObject(ctx context.Context, objPath string) erro
|
|||||||
if localName == birthdaysCalendarName {
|
if localName == birthdaysCalendarName {
|
||||||
return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically"))
|
return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically"))
|
||||||
}
|
}
|
||||||
|
if _, err := b.dbase.GetICSSubscription(requester, localName); err == nil {
|
||||||
|
return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("this calendar is a read-only ics/webcal subscription"))
|
||||||
|
}
|
||||||
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
|
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -531,6 +565,11 @@ func (h *colorInjectingHandler) injectCalendarColors(ctx context.Context, body [
|
|||||||
if color == "" {
|
if color == "" {
|
||||||
color = defaultBirthdayColor
|
color = defaultBirthdayColor
|
||||||
}
|
}
|
||||||
|
} else if sub, serr := h.backend.dbase.GetICSSubscription(p.Username, localName); serr == nil {
|
||||||
|
color = sub.Color
|
||||||
|
if color == "" {
|
||||||
|
color = defaultICSColor
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
owner, realName, _, rerr := h.backend.resolveCalendar(p.Username, localName, false)
|
owner, realName, _, rerr := h.backend.resolveCalendar(p.Username, localName, false)
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package caldav
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
ical "github.com/emersion/go-ical"
|
||||||
|
"github.com/emersion/go-webdav"
|
||||||
|
"github.com/emersion/go-webdav/caldav"
|
||||||
|
|
||||||
|
"github.com/yourusername/caldav-server/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultICSColor is the display color for an ICS/webcal subscription
|
||||||
|
// calendar when the user hasn't chosen one, matching internal/web's.
|
||||||
|
const defaultICSColor = "#0ea5e9"
|
||||||
|
|
||||||
|
// icsSubscriptionCalendarMeta returns the caldav.Calendar metadata for
|
||||||
|
// owner's ICS subscription sub, exposed under its own chosen name.
|
||||||
|
func (b *Backend) icsSubscriptionCalendarMeta(owner string, sub db.ICSSubscription) caldav.Calendar {
|
||||||
|
return caldav.Calendar{
|
||||||
|
Path: calHomePath() + sub.Name + "/",
|
||||||
|
Name: sub.Name,
|
||||||
|
Description: "Read-only subscription: " + sub.URL,
|
||||||
|
SupportedComponentSet: []string{"VEVENT"},
|
||||||
|
MaxResourceSize: 256 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// icsObjectUID returns the UID a fetched VEVENT should be addressed by:
|
||||||
|
// its own UID property if it has one, otherwise a stable hash of its
|
||||||
|
// position so it still round-trips consistently between requests.
|
||||||
|
func icsObjectUID(ev ical.Event, fallback string) string {
|
||||||
|
if p := ev.Props.Get(ical.PropUID); p != nil && p.Value != "" {
|
||||||
|
return p.Value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// icsObjID builds the object ID (file-name-like, ".ics" suffixed) used to
|
||||||
|
// address a fetched VEVENT within its subscription calendar, derived from
|
||||||
|
// its UID so it stays stable across fetches of the same feed.
|
||||||
|
func icsObjID(uid string) string {
|
||||||
|
sum := sha1.Sum([]byte(uid))
|
||||||
|
return hex.EncodeToString(sum[:]) + ".ics"
|
||||||
|
}
|
||||||
|
|
||||||
|
// listICSSubscriptionCalendarObjects fetches sub's remote calendar (via
|
||||||
|
// b.icsCache) and returns one caldav.CalendarObject per VEVENT.
|
||||||
|
func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.ICSSubscription) ([]caldav.CalendarObject, error) {
|
||||||
|
cal, err := b.icsCache.Get(sub.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching ics subscription %q: %w", sub.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var objs []caldav.CalendarObject
|
||||||
|
for i, ev := range cal.Events() {
|
||||||
|
obj, err := b.encodeICSObject(localName, ev, fmt.Sprintf("event-%d", i))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
objs = append(objs, *obj)
|
||||||
|
}
|
||||||
|
return objs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// icsSubscriptionCalendarObject fetches sub's remote calendar and returns
|
||||||
|
// the single VEVENT whose derived object ID matches objID.
|
||||||
|
func (b *Backend) icsSubscriptionCalendarObject(localName, objID string, sub db.ICSSubscription) (*caldav.CalendarObject, error) {
|
||||||
|
cal, err := b.icsCache.Get(sub.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("fetching ics subscription: %w", err))
|
||||||
|
}
|
||||||
|
for i, ev := range cal.Events() {
|
||||||
|
uid := icsObjectUID(ev, fmt.Sprintf("event-%d", i))
|
||||||
|
if icsObjID(uid) != objID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return b.encodeICSObject(localName, ev, fmt.Sprintf("event-%d", i))
|
||||||
|
}
|
||||||
|
return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("ics subscription event not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeICSObject wraps a single fetched VEVENT ev into its own
|
||||||
|
// caldav.CalendarObject, encoding it as a standalone one-event calendar
|
||||||
|
// the same way every other calendar object in this backend is
|
||||||
|
// represented. fallbackUID is used to derive the object ID/UID if ev has
|
||||||
|
// no UID property of its own.
|
||||||
|
func (b *Backend) encodeICSObject(localName string, ev ical.Event, fallbackUID string) (*caldav.CalendarObject, error) {
|
||||||
|
uid := icsObjectUID(ev, fallbackUID)
|
||||||
|
objID := icsObjID(uid)
|
||||||
|
|
||||||
|
event := ical.NewEvent()
|
||||||
|
event.Props = ev.Props
|
||||||
|
if event.Props.Get(ical.PropUID) == nil {
|
||||||
|
event.Props.SetText(ical.PropUID, uid)
|
||||||
|
}
|
||||||
|
if event.Props.Get(ical.PropDateTimeStamp) == nil {
|
||||||
|
event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
out := ical.NewCalendar()
|
||||||
|
out.Props.SetText(ical.PropVersion, "2.0")
|
||||||
|
out.Props.SetText(ical.PropProductID, "-//nidus//ics-subscription//EN")
|
||||||
|
out.Children = append(out.Children, event.Component)
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := ical.NewEncoder(&buf).Encode(out); err != nil {
|
||||||
|
return nil, fmt.Errorf("encoding ics subscription event: %w", err)
|
||||||
|
}
|
||||||
|
data := []byte(buf.String())
|
||||||
|
|
||||||
|
return &caldav.CalendarObject{
|
||||||
|
Path: calObjectPath(localName, objID),
|
||||||
|
ModTime: time.Now(),
|
||||||
|
ContentLength: int64(len(data)),
|
||||||
|
ETag: fmt.Sprintf(`"ics-%s"`, objID),
|
||||||
|
Data: out,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -105,6 +105,19 @@ CREATE TABLE IF NOT EXISTS birthday_calendars (
|
|||||||
color TEXT NOT NULL DEFAULT ''
|
color TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- User-added read-only calendars backed by a remote ICS/webcal URL (see
|
||||||
|
-- internal/db/ics.go, internal/web/ics.go, internal/caldav/ics.go). The
|
||||||
|
-- local calendar name (like real calendars) is unique per owner and lives
|
||||||
|
-- in the same "/cal/home/<name>/" namespace, so name collisions with the
|
||||||
|
-- calendars table and reservedCalendarNames are checked at creation time.
|
||||||
|
CREATE TABLE IF NOT EXISTS ics_subscriptions (
|
||||||
|
owner TEXT NOT NULL REFERENCES users (username) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (owner, name)
|
||||||
|
);
|
||||||
|
|
||||||
-- Web UI login sessions. Sessions are opaque random tokens stored server
|
-- Web UI login sessions. Sessions are opaque random tokens stored server
|
||||||
-- side (not JWTs) so they can be revoked instantly by deleting the row.
|
-- side (not JWTs) so they can be revoked instantly by deleting the row.
|
||||||
CREATE TABLE IF NOT EXISTS web_sessions (
|
CREATE TABLE IF NOT EXISTS web_sessions (
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ICSSubscription is a user-added read-only calendar backed by a remote
|
||||||
|
// ICS/webcal URL, exposed alongside real calendars under
|
||||||
|
// "/cal/home/<name>/" (see internal/caldav/ics.go).
|
||||||
|
type ICSSubscription struct {
|
||||||
|
Name string
|
||||||
|
URL string
|
||||||
|
Color string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateICSSubscription registers a new ICS subscription owned by owner.
|
||||||
|
// Returns ErrReservedName if name is reserved for a computed calendar (see
|
||||||
|
// reservedCalendarNames), or ErrResourceExists if name is already used by
|
||||||
|
// one of owner's calendars or ICS subscriptions.
|
||||||
|
func (d *DB) CreateICSSubscription(owner, name, url, color string) error {
|
||||||
|
if reservedCalendarNames[strings.ToLower(name)] {
|
||||||
|
return ErrReservedName
|
||||||
|
}
|
||||||
|
// Shares the "/cal/home/<name>/" namespace with real calendars, so
|
||||||
|
// reject a name already taken by one of those.
|
||||||
|
var calExists int
|
||||||
|
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM calendars WHERE owner = ? AND name = ?`, owner, name).Scan(&calExists); err != nil {
|
||||||
|
return fmt.Errorf("checking calendar name: %w", err)
|
||||||
|
}
|
||||||
|
if calExists > 0 {
|
||||||
|
return ErrResourceExists
|
||||||
|
}
|
||||||
|
_, err := d.conn.Exec(`INSERT INTO ics_subscriptions (owner, name, url, color) VALUES (?, ?, ?, ?)`, owner, name, url, color)
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueConstraintErr(err) {
|
||||||
|
return ErrResourceExists
|
||||||
|
}
|
||||||
|
return fmt.Errorf("creating ics subscription: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteICSSubscription removes an ICS subscription. Returns
|
||||||
|
// ErrResourceNotFound if it doesn't exist.
|
||||||
|
func (d *DB) DeleteICSSubscription(owner, name string) error {
|
||||||
|
res, err := d.conn.Exec(`DELETE FROM ics_subscriptions WHERE owner = ? AND name = ?`, owner, name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting ics subscription: %w", err)
|
||||||
|
}
|
||||||
|
return requireRowsAffected(res, ErrResourceNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetICSSubscriptionColor updates an ICS subscription's display color.
|
||||||
|
// Returns ErrResourceNotFound if it doesn't exist.
|
||||||
|
func (d *DB) SetICSSubscriptionColor(owner, name, color string) error {
|
||||||
|
res, err := d.conn.Exec(`UPDATE ics_subscriptions SET color = ? WHERE owner = ? AND name = ?`, color, owner, name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting ics subscription color: %w", err)
|
||||||
|
}
|
||||||
|
return requireRowsAffected(res, ErrResourceNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetICSSubscription returns owner's ICS subscription registered under
|
||||||
|
// name. Returns ErrResourceNotFound if it doesn't exist.
|
||||||
|
func (d *DB) GetICSSubscription(owner, name string) (ICSSubscription, error) {
|
||||||
|
sub := ICSSubscription{Name: name}
|
||||||
|
err := d.conn.QueryRow(`SELECT url, color FROM ics_subscriptions WHERE owner = ? AND name = ?`, owner, name).Scan(&sub.URL, &sub.Color)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ICSSubscription{}, ErrResourceNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ICSSubscription{}, fmt.Errorf("getting ics subscription: %w", err)
|
||||||
|
}
|
||||||
|
return sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListICSSubscriptions returns all of owner's ICS subscriptions, sorted by
|
||||||
|
// name.
|
||||||
|
func (d *DB) ListICSSubscriptions(owner string) ([]ICSSubscription, error) {
|
||||||
|
rows, err := d.conn.Query(`SELECT name, url, color FROM ics_subscriptions WHERE owner = ? ORDER BY name`, owner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing ics subscriptions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var subs []ICSSubscription
|
||||||
|
for rows.Next() {
|
||||||
|
var s ICSSubscription
|
||||||
|
if err := rows.Scan(&s.Name, &s.URL, &s.Color); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning ics subscription: %w", err)
|
||||||
|
}
|
||||||
|
subs = append(subs, s)
|
||||||
|
}
|
||||||
|
return subs, rows.Err()
|
||||||
|
}
|
||||||
@@ -193,6 +193,16 @@ func (d *DB) CreateCalendarWithColor(owner, name, color string) error {
|
|||||||
if reservedCalendarNames[strings.ToLower(name)] {
|
if reservedCalendarNames[strings.ToLower(name)] {
|
||||||
return ErrReservedName
|
return ErrReservedName
|
||||||
}
|
}
|
||||||
|
// The "/cal/home/<name>/" namespace is shared with ICS subscriptions
|
||||||
|
// (see internal/db/ics.go), so a real calendar can't be created under
|
||||||
|
// a name already taken by one of those either.
|
||||||
|
var subExists int
|
||||||
|
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM ics_subscriptions WHERE owner = ? AND name = ?`, owner, name).Scan(&subExists); err != nil {
|
||||||
|
return fmt.Errorf("checking ics subscription name: %w", err)
|
||||||
|
}
|
||||||
|
if subExists > 0 {
|
||||||
|
return ErrResourceExists
|
||||||
|
}
|
||||||
_, err := d.conn.Exec(`INSERT INTO calendars (owner, name, color) VALUES (?, ?, ?)`, owner, name, color)
|
_, err := d.conn.Exec(`INSERT INTO calendars (owner, name, color) VALUES (?, ?, ?)`, owner, name, color)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isUniqueConstraintErr(err) {
|
if isUniqueConstraintErr(err) {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Package icssub fetches and caches remote ICS/webcal calendars, shared by
|
||||||
|
// the web calendar UI (internal/web) and the CalDAV backend
|
||||||
|
// (internal/caldav) so both render identical events for a user's ICS
|
||||||
|
// subscriptions without duplicating fetch/parse/cache logic.
|
||||||
|
package icssub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
ical "github.com/emersion/go-ical"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultTTL is how long a fetched calendar is cached before being
|
||||||
|
// re-fetched on the next access.
|
||||||
|
const DefaultTTL = 15 * time.Minute
|
||||||
|
|
||||||
|
// fetchTimeout bounds how long a single upstream request may take, so one
|
||||||
|
// slow/unreachable subscription can't stall a page render indefinitely.
|
||||||
|
const fetchTimeout = 15 * time.Second
|
||||||
|
|
||||||
|
// maxBodySize caps how much of a remote calendar is read, guarding
|
||||||
|
// against a malicious or misconfigured URL streaming an unbounded
|
||||||
|
// response.
|
||||||
|
const maxBodySize = 32 * 1024 * 1024 // 32 MiB
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
fetchedAt time.Time
|
||||||
|
cal *ical.Calendar
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache fetches remote ICS calendars over HTTP(S), keeping a short-lived
|
||||||
|
// in-memory copy per URL so repeated renders (e.g. every month-view page
|
||||||
|
// load, or CalDAV client polling) don't re-fetch the same subscription
|
||||||
|
// from origin every time.
|
||||||
|
type Cache struct {
|
||||||
|
ttl time.Duration
|
||||||
|
client *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCache creates a Cache with the given TTL (use DefaultTTL if unsure).
|
||||||
|
func NewCache(ttl time.Duration) *Cache {
|
||||||
|
return &Cache{
|
||||||
|
ttl: ttl,
|
||||||
|
client: &http.Client{Timeout: fetchTimeout},
|
||||||
|
entries: make(map[string]entry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the parsed calendar fetched from url, using a cached copy
|
||||||
|
// if it's still within the TTL. If a fresh fetch fails but a previously
|
||||||
|
// fetched copy exists, the stale copy is returned instead of the error,
|
||||||
|
// so a transient network issue doesn't blank out the calendar entirely.
|
||||||
|
func (c *Cache) Get(url string) (*ical.Calendar, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
e, ok := c.entries[url]
|
||||||
|
fresh := ok && time.Since(e.fetchedAt) < c.ttl
|
||||||
|
c.mu.Unlock()
|
||||||
|
if fresh {
|
||||||
|
return e.cal, e.err
|
||||||
|
}
|
||||||
|
|
||||||
|
cal, err := c.fetch(url)
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if err != nil && ok && e.cal != nil {
|
||||||
|
return e.cal, nil
|
||||||
|
}
|
||||||
|
c.entries[url] = entry{fetchedAt: time.Now(), cal: cal, err: err}
|
||||||
|
return cal, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch downloads and parses url, translating a "webcal://" scheme (used
|
||||||
|
// by some calendar-subscription links) to "https://" first, since Go's
|
||||||
|
// http.Client has no built-in handler for it.
|
||||||
|
func (c *Cache) fetch(rawURL string) (*ical.Calendar, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, normalizeURL(rawURL), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("building request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "text/calendar, */*")
|
||||||
|
req.Header.Set("User-Agent", "nidus-ics-subscription/1.0")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching calendar: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("fetching calendar: unexpected status %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
cal, err := ical.NewDecoder(io.LimitReader(resp.Body, maxBodySize)).Decode()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing calendar: %w", err)
|
||||||
|
}
|
||||||
|
return cal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeURL rewrites a "webcal://" URL to "https://" so it can be
|
||||||
|
// fetched with a normal HTTP client.
|
||||||
|
func normalizeURL(u string) string {
|
||||||
|
if rest, ok := strings.CutPrefix(u, "webcal://"); ok {
|
||||||
|
return "https://" + rest
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
+100
-6
@@ -83,9 +83,10 @@ func (s *Server) ownsCalendar(username, cal string) (bool, error) {
|
|||||||
// store.Store/db.DB. If requireWrite is true, a read-only share is
|
// store.Store/db.DB. If requireWrite is true, a read-only share is
|
||||||
// rejected with errCalendarReadOnly.
|
// rejected with errCalendarReadOnly.
|
||||||
func (s *Server) resolveCalRef(username, ref string, requireWrite bool) (owner, name string, err error) {
|
func (s *Server) resolveCalRef(username, ref string, requireWrite bool) (owner, name string, err error) {
|
||||||
if ref == birthdaysCalRef {
|
if ref == birthdaysCalRef || strings.HasPrefix(ref, icsRefPrefix) {
|
||||||
// The birthdays calendar is virtual/computed, not backed by any
|
// Both the birthdays calendar and ICS subscriptions are
|
||||||
// stored calendar object — there's nothing to resolve to.
|
// virtual/computed, not backed by any stored calendar object —
|
||||||
|
// there's nothing to resolve to.
|
||||||
return "", "", errCalendarNotFound
|
return "", "", errCalendarNotFound
|
||||||
}
|
}
|
||||||
if owner, name, ok := strings.Cut(ref, calRefSep); ok {
|
if owner, name, ok := strings.Cut(ref, calRefSep); ok {
|
||||||
@@ -124,6 +125,7 @@ type calendarEntry struct {
|
|||||||
Color string
|
Color string
|
||||||
Writable bool
|
Writable bool
|
||||||
Virtual bool // true for computed calendars (e.g. birthdays) with no backing store objects
|
Virtual bool // true for computed calendars (e.g. birthdays) with no backing store objects
|
||||||
|
ICSURL string // set only for ICS-subscription entries (Ref has icsRefPrefix); the remote URL to fetch events from
|
||||||
}
|
}
|
||||||
|
|
||||||
// birthdaysCalRef is the fixed reference for the synthetic "Birthdays"
|
// birthdaysCalRef is the fixed reference for the synthetic "Birthdays"
|
||||||
@@ -133,6 +135,19 @@ type calendarEntry struct {
|
|||||||
// mistaken for an "owner~name" shared-calendar ref either).
|
// mistaken for an "owner~name" shared-calendar ref either).
|
||||||
const birthdaysCalRef = "@birthdays"
|
const birthdaysCalRef = "@birthdays"
|
||||||
|
|
||||||
|
// icsRefPrefix marks a calendarEntry's Ref as referring to one of
|
||||||
|
// username's own ICS/webcal subscriptions (see internal/db/ics.go). Like
|
||||||
|
// "@" for the birthdays calendar, "!" isn't in resourceNameRe's character
|
||||||
|
// class and can't appear in a calRefSep-joined shared-calendar ref either,
|
||||||
|
// so "!<name>" can't collide with any other kind of ref.
|
||||||
|
const icsRefPrefix = "!"
|
||||||
|
|
||||||
|
// icsCalRef builds the reference string for one of username's own ICS
|
||||||
|
// subscriptions named name.
|
||||||
|
func icsCalRef(name string) string {
|
||||||
|
return icsRefPrefix + name
|
||||||
|
}
|
||||||
|
|
||||||
// defaultBirthdayColor is the display color for the virtual birthdays
|
// defaultBirthdayColor is the display color for the virtual birthdays
|
||||||
// calendar used until the user picks their own from the dashboard (a
|
// calendar used until the user picks their own from the dashboard (a
|
||||||
// pink, distinct from typical user-picked calendar colors).
|
// pink, distinct from typical user-picked calendar colors).
|
||||||
@@ -150,8 +165,8 @@ func (s *Server) birthdayCalendarColor(username string) string {
|
|||||||
|
|
||||||
// listCalendarEntries returns every calendar visible to username: the
|
// listCalendarEntries returns every calendar visible to username: the
|
||||||
// synthetic birthdays calendar, their own calendars (always writable),
|
// synthetic birthdays calendar, their own calendars (always writable),
|
||||||
// and any calendars shared with them (writable only if the share grants
|
// any calendars shared with them (writable only if the share grants write
|
||||||
// write permission).
|
// permission), and their own read-only ICS/webcal subscriptions.
|
||||||
func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) {
|
func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) {
|
||||||
entries := []calendarEntry{
|
entries := []calendarEntry{
|
||||||
{Ref: birthdaysCalRef, Owner: username, Name: "Birthdays", Color: s.birthdayCalendarColor(username), Writable: false, Virtual: true},
|
{Ref: birthdaysCalRef, Owner: username, Name: "Birthdays", Color: s.birthdayCalendarColor(username), Writable: false, Virtual: true},
|
||||||
@@ -185,6 +200,17 @@ func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subs, err := s.dbase.ListICSSubscriptions(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, sub := range subs {
|
||||||
|
entries = append(entries, calendarEntry{
|
||||||
|
Ref: icsCalRef(sub.Name), Owner: username, Name: sub.Name, Color: sub.Color,
|
||||||
|
Writable: false, Virtual: true, ICSURL: sub.URL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
sort.Slice(entries, func(i, j int) bool {
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
if entries[i].Owner != entries[j].Owner {
|
if entries[i].Owner != entries[j].Owner {
|
||||||
return entries[i].Owner < entries[j].Owner
|
return entries[i].Owner < entries[j].Owner
|
||||||
@@ -283,6 +309,13 @@ func (s *Server) buildMonthView(username string, year int, month time.Month) (te
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(entry.Ref, icsRefPrefix) {
|
||||||
|
if err := s.addICSEvents(entry, gridStart, gridEnd, loc, dayIndex, days); err != nil {
|
||||||
|
s.logger.Warn("fetching ics subscription events", "calendar", entry.Name, "error", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
ids, err := s.store.ListObjects(entry.Owner, "cal-"+entry.Name)
|
ids, err := s.store.ListObjects(entry.Owner, "cal-"+entry.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -766,8 +799,14 @@ func eventFormFromICS(id string, data []byte) (templates.EventFormData, error) {
|
|||||||
if len(events) == 0 {
|
if len(events) == 0 {
|
||||||
return templates.EventFormData{}, fmt.Errorf("no VEVENT in %s", id)
|
return templates.EventFormData{}, fmt.Errorf("no VEVENT in %s", id)
|
||||||
}
|
}
|
||||||
ev := events[0]
|
return eventFormFromComponent(id, events[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventFormFromComponent extracts an EventFormData from a single decoded
|
||||||
|
// VEVENT, shared by eventFormFromICS (one event per stored .ics object)
|
||||||
|
// and the ICS-subscription rendering path (many events per fetched
|
||||||
|
// calendar, see addICSEvents).
|
||||||
|
func eventFormFromComponent(id string, ev ical.Event) (templates.EventFormData, error) {
|
||||||
form := templates.EventFormData{ID: id}
|
form := templates.EventFormData{ID: id}
|
||||||
if p := ev.Props.Get(ical.PropSummary); p != nil {
|
if p := ev.Props.Get(ical.PropSummary); p != nil {
|
||||||
form.Summary = p.Value
|
form.Summary = p.Value
|
||||||
@@ -1031,3 +1070,58 @@ func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridSta
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addICSEvents fetches entry's remote ICS/webcal calendar (via s.icsCache,
|
||||||
|
// which caches it for a while so every month-view render doesn't re-fetch
|
||||||
|
// from origin) and places each VEVENT's occurrence onto the month grid,
|
||||||
|
// the same way a stored calendar object would be. There's no per-event
|
||||||
|
// edit page for these (the source is external and read-only), so each
|
||||||
|
// event's LinkURL is left pointing nowhere useful ("#").
|
||||||
|
func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time, loc *time.Location, dayIndex map[string]int, days []templates.MonthDay) error {
|
||||||
|
cal, err := s.icsCache.Get(entry.ICSURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDays = 42
|
||||||
|
for i, ev := range cal.Events() {
|
||||||
|
id := fmt.Sprintf("ics-%d", i)
|
||||||
|
form, err := eventFormFromComponent(id, ev)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
startDay, endDay, err := eventDayRange(form, loc)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if endDay.Before(gridStart) || startDay.After(gridEnd) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if startDay.Before(gridStart) {
|
||||||
|
startDay = gridStart
|
||||||
|
}
|
||||||
|
if endDay.After(gridEnd) {
|
||||||
|
endDay = gridEnd
|
||||||
|
}
|
||||||
|
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)]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
timeText := ""
|
||||||
|
if !form.AllDay {
|
||||||
|
timeText = form.StartTime
|
||||||
|
}
|
||||||
|
days[idx].Events = append(days[idx].Events, templates.EventSummary{
|
||||||
|
ID: id,
|
||||||
|
CalRef: entry.Ref,
|
||||||
|
Color: entry.Color,
|
||||||
|
Summary: form.Summary,
|
||||||
|
TimeText: timeText,
|
||||||
|
AllDay: form.AllDay,
|
||||||
|
LinkURL: "#",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,6 +93,14 @@ func (s *Server) resourceCards(username string) ([]templates.ResourceCard, error
|
|||||||
resources = append(resources, card)
|
resources = append(resources, card)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subs, err := s.dbase.ListICSSubscriptions(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing ics subscriptions: %w", err)
|
||||||
|
}
|
||||||
|
for _, sub := range subs {
|
||||||
|
resources = append(resources, templates.ResourceCard{Kind: "ics", Name: sub.Name, Color: sub.Color, URL: sub.URL})
|
||||||
|
}
|
||||||
|
|
||||||
return resources, nil
|
return resources, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yourusername/caldav-server/internal/db"
|
||||||
|
"github.com/yourusername/caldav-server/internal/web/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// icsURLValid does a light sanity check on a subscription URL: it must be
|
||||||
|
// http(s):// (fetched directly) or webcal:// (rewritten to https:// by
|
||||||
|
// internal/icssub before fetching), and within a reasonable length.
|
||||||
|
func icsURLValid(u string) bool {
|
||||||
|
if len(u) == 0 || len(u) > 2048 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "webcal://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleICSResource handles POST (create) and DELETE (remove) for the
|
||||||
|
// current user's ICS/webcal calendar subscriptions, mounted at
|
||||||
|
// /resources/ics.
|
||||||
|
func (s *Server) handleICSResource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := userFromContext(r.Context())
|
||||||
|
|
||||||
|
// htmx v2 sends DELETE request parameters as URL query parameters, not
|
||||||
|
// a request body (see internal/web/resources.go's handleResource).
|
||||||
|
if r.Method == http.MethodDelete {
|
||||||
|
r.PostForm = r.URL.Query()
|
||||||
|
} else if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(r.PostForm.Get("name"))
|
||||||
|
if !resourceNameRe.MatchString(name) {
|
||||||
|
http.Error(w, "name must be 1-64 letters, digits, '-' or '_'", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
url := strings.TrimSpace(r.PostForm.Get("url"))
|
||||||
|
if !icsURLValid(url) {
|
||||||
|
http.Error(w, "url must be a http://, https:// or webcal:// address", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
color := strings.TrimSpace(r.PostForm.Get("color"))
|
||||||
|
if color != "" && !hexColorRe.MatchString(color) {
|
||||||
|
http.Error(w, "color must be a hex value like #3b82f6", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.dbase.CreateICSSubscription(username, name, url, color); err != nil {
|
||||||
|
if err == db.ErrResourceExists {
|
||||||
|
http.Error(w, "already exists", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == db.ErrReservedName {
|
||||||
|
http.Error(w, "this name is reserved for a computed calendar", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Error("creating ics subscription", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case http.MethodDelete:
|
||||||
|
if err := s.dbase.DeleteICSSubscription(username, name); err != nil && err != db.ErrResourceNotFound {
|
||||||
|
s.logger.Error("deleting ics subscription", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
w.Header().Set("Allow", "POST, DELETE")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The set of cards changed (one added/removed), so re-render the
|
||||||
|
// whole #resources list rather than a single card.
|
||||||
|
resources, err := s.resourceCards(username)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("listing resources", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_ = templates.ResourceList(resources).Render(context.Background(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleICSColor updates the color of one of the current user's ICS
|
||||||
|
// subscriptions, mounted at /resources/ics/color. It re-renders just that
|
||||||
|
// card (not the whole list), since the set of cards doesn't change.
|
||||||
|
func (s *Server) handleICSColor(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())
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(r.PostForm.Get("name"))
|
||||||
|
color := strings.TrimSpace(r.PostForm.Get("color"))
|
||||||
|
if !resourceNameRe.MatchString(name) {
|
||||||
|
http.Error(w, "name must be 1-64 letters, digits, '-' or '_'", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if color != "" && !hexColorRe.MatchString(color) {
|
||||||
|
http.Error(w, "color must be a hex value like #3b82f6", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.dbase.SetICSSubscriptionColor(username, name, color); err != nil && err != db.ErrResourceNotFound {
|
||||||
|
s.logger.Error("setting ics subscription color", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := s.dbase.GetICSSubscription(username, name)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("loading ics subscription", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
card := templates.ResourceCard{Kind: "ics", Name: sub.Name, Color: sub.Color, URL: sub.URL}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_ = templates.ResourceCardView(card).Render(r.Context(), w)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/yourusername/caldav-server/internal/config"
|
"github.com/yourusername/caldav-server/internal/config"
|
||||||
"github.com/yourusername/caldav-server/internal/db"
|
"github.com/yourusername/caldav-server/internal/db"
|
||||||
|
"github.com/yourusername/caldav-server/internal/icssub"
|
||||||
"github.com/yourusername/caldav-server/internal/store"
|
"github.com/yourusername/caldav-server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,11 +22,12 @@ type Server struct {
|
|||||||
store *store.Store
|
store *store.Store
|
||||||
dbase *db.DB
|
dbase *db.DB
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
icsCache *icssub.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer constructs a web UI Server.
|
// NewServer constructs a web UI Server.
|
||||||
func NewServer(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Server {
|
func NewServer(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Server {
|
||||||
return &Server{cfg: cfg, store: st, dbase: dbase, logger: logger}
|
return &Server{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icssub.NewCache(icssub.DefaultTTL)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler returns the http.Handler serving the web UI, mounted at "/web/"
|
// Handler returns the http.Handler serving the web UI, mounted at "/web/"
|
||||||
@@ -46,6 +48,8 @@ func (s *Server) Handler(staticFS http.FileSystem) http.Handler {
|
|||||||
mux.HandleFunc("/resources/calendar/color", s.requireLogin(s.handleCalendarColor))
|
mux.HandleFunc("/resources/calendar/color", s.requireLogin(s.handleCalendarColor))
|
||||||
mux.HandleFunc("/resources/birthdays/color", s.requireLogin(s.handleBirthdayColor))
|
mux.HandleFunc("/resources/birthdays/color", s.requireLogin(s.handleBirthdayColor))
|
||||||
mux.HandleFunc("/resources/addressbook", s.requireLogin(s.handleAddressBookResource))
|
mux.HandleFunc("/resources/addressbook", s.requireLogin(s.handleAddressBookResource))
|
||||||
|
mux.HandleFunc("/resources/ics", s.requireLogin(s.handleICSResource))
|
||||||
|
mux.HandleFunc("/resources/ics/color", s.requireLogin(s.handleICSColor))
|
||||||
mux.HandleFunc("/files/{path...}", s.requireLogin(s.handleFiles))
|
mux.HandleFunc("/files/{path...}", s.requireLogin(s.handleFiles))
|
||||||
mux.HandleFunc("/contacts", s.requireLogin(s.handleContactsHome))
|
mux.HandleFunc("/contacts", s.requireLogin(s.handleContactsHome))
|
||||||
mux.HandleFunc("/contacts/{book}", s.requireLogin(s.handleContactsList))
|
mux.HandleFunc("/contacts/{book}", s.requireLogin(s.handleContactsList))
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ type ShareRow struct {
|
|||||||
// ResourceCard describes one of the user's own calendars/address books
|
// ResourceCard describes one of the user's own calendars/address books
|
||||||
// plus who it's currently shared with.
|
// plus who it's currently shared with.
|
||||||
type ResourceCard struct {
|
type ResourceCard struct {
|
||||||
Kind string // "calendar" or "addressbook"
|
Kind string // "calendar", "addressbook", or "ics" (read-only ICS subscription)
|
||||||
Name string
|
Name string
|
||||||
Color string // hex color like "#3b82f6"; only used for calendars
|
Color string // hex color like "#3b82f6"; only used for calendars/ics
|
||||||
|
URL string // remote ICS/webcal URL; only used for kind "ics"
|
||||||
Shares []ShareRow
|
Shares []ShareRow
|
||||||
Virtual bool // true for computed calendars (e.g. birthdays): no delete/sharing, just a color picker
|
Virtual bool // true for computed calendars (e.g. birthdays): no delete/sharing, just a color picker
|
||||||
}
|
}
|
||||||
@@ -73,6 +74,32 @@ templ Dashboard(username string, resources []ResourceCard, sharedWithMe []Shared
|
|||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form
|
||||||
|
class="flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4"
|
||||||
|
hx-post="/web/resources/ics"
|
||||||
|
hx-target="#resources"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-on::after-request="if(event.detail.successful) this.reset()"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Subscribe to an ICS/webcal calendar</label>
|
||||||
|
<input name="name" type="text" required pattern="[a-zA-Z0-9_-]{1,64}"
|
||||||
|
placeholder="e.g. holidays"
|
||||||
|
class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm mb-2"/>
|
||||||
|
<input name="url" type="text" required
|
||||||
|
placeholder="https://example.com/calendar.ics or webcal://..."
|
||||||
|
class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Color</label>
|
||||||
|
<input name="color" type="color" value="#10b981"
|
||||||
|
class="w-12 h-9 rounded-md border-gray-300 border p-0.5"/>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ResourceList(resources)
|
@ResourceList(resources)
|
||||||
@@ -114,7 +141,7 @@ templ ResourceCardView(r ResourceCard) {
|
|||||||
<div id={ "resource-" + r.Kind + "-" + r.Name } class="bg-white rounded-lg border border-gray-200 p-5">
|
<div id={ "resource-" + r.Kind + "-" + r.Name } class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<h2 class="font-medium flex items-center gap-2">
|
<h2 class="font-medium flex items-center gap-2">
|
||||||
if r.Kind == "calendar" {
|
if r.Kind == "calendar" || r.Kind == "ics" {
|
||||||
<form
|
<form
|
||||||
hx-post={ colorEndpoint(r) }
|
hx-post={ colorEndpoint(r) }
|
||||||
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
|
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
|
||||||
@@ -136,6 +163,9 @@ templ ResourceCardView(r ResourceCard) {
|
|||||||
if r.Virtual {
|
if r.Virtual {
|
||||||
<span class="text-xs text-gray-400">(computed from contacts)</span>
|
<span class="text-xs text-gray-400">(computed from contacts)</span>
|
||||||
}
|
}
|
||||||
|
if r.Kind == "ics" {
|
||||||
|
<span class="text-xs text-gray-400">(read-only subscription)</span>
|
||||||
|
}
|
||||||
</h2>
|
</h2>
|
||||||
if !r.Virtual {
|
if !r.Virtual {
|
||||||
<button
|
<button
|
||||||
@@ -151,7 +181,11 @@ templ ResourceCardView(r ResourceCard) {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
if !r.Virtual {
|
if r.Kind == "ics" {
|
||||||
|
<p class="text-xs text-gray-500 mb-4 break-all">{ r.URL }</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
if !r.Virtual && r.Kind != "ics" {
|
||||||
<ul class="divide-y divide-gray-100 mb-4">
|
<ul class="divide-y divide-gray-100 mb-4">
|
||||||
for _, sh := range r.Shares {
|
for _, sh := range r.Shares {
|
||||||
<li class="py-2 flex items-center justify-between text-sm">
|
<li class="py-2 flex items-center justify-between text-sm">
|
||||||
@@ -208,6 +242,9 @@ func colorEndpoint(r ResourceCard) string {
|
|||||||
if r.Virtual {
|
if r.Virtual {
|
||||||
return "/web/resources/birthdays/color"
|
return "/web/resources/birthdays/color"
|
||||||
}
|
}
|
||||||
|
if r.Kind == "ics" {
|
||||||
|
return "/web/resources/ics/color"
|
||||||
|
}
|
||||||
return "/web/resources/calendar/color"
|
return "/web/resources/calendar/color"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,10 +260,14 @@ func shareVals(resource, sharedWith string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resourceEndpoint(kind string) string {
|
func resourceEndpoint(kind string) string {
|
||||||
if kind == "calendar" {
|
switch kind {
|
||||||
|
case "calendar":
|
||||||
return "/web/resources/calendar"
|
return "/web/resources/calendar"
|
||||||
}
|
case "ics":
|
||||||
|
return "/web/resources/ics"
|
||||||
|
default:
|
||||||
return "/web/resources/addressbook"
|
return "/web/resources/addressbook"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resourceVals(name string) string {
|
func resourceVals(name string) string {
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ type ShareRow struct {
|
|||||||
// ResourceCard describes one of the user's own calendars/address books
|
// ResourceCard describes one of the user's own calendars/address books
|
||||||
// plus who it's currently shared with.
|
// plus who it's currently shared with.
|
||||||
type ResourceCard struct {
|
type ResourceCard struct {
|
||||||
Kind string // "calendar" or "addressbook"
|
Kind string // "calendar", "addressbook", or "ics" (read-only ICS subscription)
|
||||||
Name string
|
Name string
|
||||||
Color string // hex color like "#3b82f6"; only used for calendars
|
Color string // hex color like "#3b82f6"; only used for calendars/ics
|
||||||
|
URL string // remote ICS/webcal URL; only used for kind "ics"
|
||||||
Shares []ShareRow
|
Shares []ShareRow
|
||||||
Virtual bool // true for computed calendars (e.g. birthdays): no delete/sharing, just a color picker
|
Virtual bool // true for computed calendars (e.g. birthdays): no delete/sharing, just a color picker
|
||||||
}
|
}
|
||||||
@@ -68,7 +69,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"text-2xl font-semibold mb-6\">Your calendars & address books</h1><div class=\"flex flex-col sm:flex-row gap-4 mb-6\"><form class=\"flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4\" hx-post=\"/web/resources/calendar\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) this.reset()\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">New calendar</label> <input name=\"name\" type=\"text\" required pattern=\"[a-zA-Z0-9_-]{1,64}\" placeholder=\"e.g. work\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><div><label class=\"block text-xs text-gray-500 mb-1\">Color</label> <input name=\"color\" type=\"color\" value=\"#3b82f6\" class=\"w-12 h-9 rounded-md border-gray-300 border p-0.5\"></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Add</button></form><form class=\"flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4\" hx-post=\"/web/resources/addressbook\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) this.reset()\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">New address book</label> <input name=\"name\" type=\"text\" required pattern=\"[a-zA-Z0-9_-]{1,64}\" placeholder=\"e.g. contacts\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Add</button></form></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"text-2xl font-semibold mb-6\">Your calendars & address books</h1><div class=\"flex flex-col sm:flex-row gap-4 mb-6\"><form class=\"flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4\" hx-post=\"/web/resources/calendar\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) this.reset()\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">New calendar</label> <input name=\"name\" type=\"text\" required pattern=\"[a-zA-Z0-9_-]{1,64}\" placeholder=\"e.g. work\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><div><label class=\"block text-xs text-gray-500 mb-1\">Color</label> <input name=\"color\" type=\"color\" value=\"#3b82f6\" class=\"w-12 h-9 rounded-md border-gray-300 border p-0.5\"></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Add</button></form><form class=\"flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4\" hx-post=\"/web/resources/addressbook\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) this.reset()\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">New address book</label> <input name=\"name\" type=\"text\" required pattern=\"[a-zA-Z0-9_-]{1,64}\" placeholder=\"e.g. contacts\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Add</button></form><form class=\"flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4\" hx-post=\"/web/resources/ics\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) this.reset()\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">Subscribe to an ICS/webcal calendar</label> <input name=\"name\" type=\"text\" required pattern=\"[a-zA-Z0-9_-]{1,64}\" placeholder=\"e.g. holidays\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm mb-2\"> <input name=\"url\" type=\"text\" required placeholder=\"https://example.com/calendar.ics or webcal://...\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><div><label class=\"block text-xs text-gray-500 mb-1\">Color</label> <input name=\"color\" type=\"color\" value=\"#10b981\" class=\"w-12 h-9 rounded-md border-gray-300 border p-0.5\"></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Add</button></form></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Owner)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Owner)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 86, Col: 45}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 113, Col: 45}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -106,7 +107,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 86, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 113, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -119,7 +120,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Kind)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Kind)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 87, Col: 47}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 114, Col: 47}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -132,7 +133,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Permission)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Permission)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 89, Col: 83}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 116, Col: 83}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -234,7 +235,7 @@ func ResourceCardView(r ResourceCard) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("resource-" + r.Kind + "-" + r.Name)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("resource-" + r.Kind + "-" + r.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 114, Col: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 141, Col: 46}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -244,7 +245,7 @@ func ResourceCardView(r ResourceCard) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if r.Kind == "calendar" {
|
if r.Kind == "calendar" || r.Kind == "ics" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<form hx-post=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<form hx-post=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
@@ -252,7 +253,7 @@ func ResourceCardView(r ResourceCard) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(colorEndpoint(r))
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(colorEndpoint(r))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 119, Col: 32}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 146, Col: 32}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -265,7 +266,7 @@ func ResourceCardView(r ResourceCard) templ.Component {
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 120, Col: 54}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 147, Col: 54}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -278,7 +279,7 @@ func ResourceCardView(r ResourceCard) templ.Component {
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 124, Col: 53}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 151, Col: 53}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -291,7 +292,7 @@ func ResourceCardView(r ResourceCard) templ.Component {
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(colorOrDefault(r.Color))
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(colorOrDefault(r.Color))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 128, Col: 38}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 155, Col: 38}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -305,7 +306,7 @@ func ResourceCardView(r ResourceCard) templ.Component {
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 134, Col: 12}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 161, Col: 12}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -318,7 +319,7 @@ func ResourceCardView(r ResourceCard) templ.Component {
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(r.Kind)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(r.Kind)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 135, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 162, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -329,204 +330,229 @@ func ResourceCardView(r ResourceCard) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if r.Virtual {
|
if r.Virtual {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span class=\"text-xs text-gray-400\">(computed from contacts)</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span class=\"text-xs text-gray-400\">(computed from contacts)</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</h2>")
|
if r.Kind == "ics" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"text-xs text-gray-400\">(read-only subscription)</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</h2>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if !r.Virtual {
|
if !r.Virtual {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceEndpoint(r.Kind))
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceEndpoint(r.Kind))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 143, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 173, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-vals=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" hx-vals=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceVals(r.Name))
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceVals(r.Name))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 144, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 174, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-confirm=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-confirm=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("Delete " + r.Kind + " " + r.Name + "? This removes all its data and cannot be undone.")
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("Delete " + r.Kind + " " + r.Name + "? This removes all its data and cannot be undone.")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 147, Col: 105}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 177, Col: 105}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">Delete</button>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">Delete</button>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if !r.Virtual {
|
if r.Kind == "ics" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<ul class=\"divide-y divide-gray-100 mb-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<p class=\"text-xs text-gray-500 mb-4 break-all\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
for _, sh := range r.Shares {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<li class=\"py-2 flex items-center justify-between text-sm\"><span>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(sh.SharedWith)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(r.URL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 158, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 185, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span> <span class=\"flex items-center gap-3\"><span class=\"text-xs uppercase tracking-wide text-gray-500\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !r.Virtual && r.Kind != "ics" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<ul class=\"divide-y divide-gray-100 mb-4\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, sh := range r.Shares {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<li class=\"py-2 flex items-center justify-between text-sm\"><span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(sh.Permission)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(sh.SharedWith)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 160, Col: 82}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 192, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</span> <button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</span> <span class=\"flex items-center gap-3\"><span class=\"text-xs uppercase tracking-wide text-gray-500\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(sh.Permission)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 163, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 194, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" hx-vals=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</span> <button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareVals(r.Name, sh.SharedWith))
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 164, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 197, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-target=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" hx-vals=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareVals(r.Name, sh.SharedWith))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 165, Col: 56}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 198, Col: 50}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" hx-swap=\"outerHTML\" hx-confirm=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" hx-target=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var24 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove access for " + sh.SharedWith + "?")
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 167, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 199, Col: 56}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">Remove</button></span></li>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" hx-swap=\"outerHTML\" hx-confirm=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(r.Shares) == 0 {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<li class=\"py-2 text-sm text-gray-400\">Not shared with anyone yet.</li>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</ul><form class=\"flex flex-col sm:flex-row sm:items-end gap-2\" hx-post=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var25 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove access for " + sh.SharedWith + "?")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 181, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 201, Col: 63}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" hx-target=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">Remove</button></span></li>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(r.Shares) == 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<li class=\"py-2 text-sm text-gray-400\">Not shared with anyone yet.</li>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</ul><form class=\"flex flex-col sm:flex-row sm:items-end gap-2\" hx-post=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var26 string
|
var templ_7745c5c3_Var26 string
|
||||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 182, Col: 52}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 215, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"resource\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" hx-target=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var27 string
|
var templ_7745c5c3_Var27 string
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 185, Col: 55}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 216, Col: 52}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">Username</label> <input name=\"shared_with\" type=\"text\" required class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><div><label class=\"block text-xs text-gray-500 mb-1\">Permission</label> <select name=\"permission\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"><option value=\"read\">read</option> <option value=\"write\">write</option></select></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Share</button></form>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"resource\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var28 string
|
||||||
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 219, Col: 55}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">Username</label> <input name=\"shared_with\" type=\"text\" required class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><div><label class=\"block text-xs text-gray-500 mb-1\">Permission</label> <select name=\"permission\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"><option value=\"read\">read</option> <option value=\"write\">write</option></select></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Share</button></form>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -538,6 +564,9 @@ func colorEndpoint(r ResourceCard) string {
|
|||||||
if r.Virtual {
|
if r.Virtual {
|
||||||
return "/web/resources/birthdays/color"
|
return "/web/resources/birthdays/color"
|
||||||
}
|
}
|
||||||
|
if r.Kind == "ics" {
|
||||||
|
return "/web/resources/ics/color"
|
||||||
|
}
|
||||||
return "/web/resources/calendar/color"
|
return "/web/resources/calendar/color"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,10 +582,14 @@ func shareVals(resource, sharedWith string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resourceEndpoint(kind string) string {
|
func resourceEndpoint(kind string) string {
|
||||||
if kind == "calendar" {
|
switch kind {
|
||||||
|
case "calendar":
|
||||||
return "/web/resources/calendar"
|
return "/web/resources/calendar"
|
||||||
}
|
case "ics":
|
||||||
|
return "/web/resources/ics"
|
||||||
|
default:
|
||||||
return "/web/resources/addressbook"
|
return "/web/resources/addressbook"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resourceVals(name string) string {
|
func resourceVals(name string) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user