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:
@@ -11,9 +11,10 @@ type ShareRow struct {
|
||||
// ResourceCard describes one of the user's own calendars/address books
|
||||
// plus who it's currently shared with.
|
||||
type ResourceCard struct {
|
||||
Kind string // "calendar" or "addressbook"
|
||||
Kind string // "calendar", "addressbook", or "ics" (read-only ICS subscription)
|
||||
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
|
||||
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
|
||||
</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>
|
||||
|
||||
@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 class="flex items-center justify-between mb-3">
|
||||
<h2 class="font-medium flex items-center gap-2">
|
||||
if r.Kind == "calendar" {
|
||||
if r.Kind == "calendar" || r.Kind == "ics" {
|
||||
<form
|
||||
hx-post={ colorEndpoint(r) }
|
||||
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
|
||||
@@ -136,6 +163,9 @@ templ ResourceCardView(r ResourceCard) {
|
||||
if r.Virtual {
|
||||
<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>
|
||||
if !r.Virtual {
|
||||
<button
|
||||
@@ -151,7 +181,11 @@ templ ResourceCardView(r ResourceCard) {
|
||||
}
|
||||
</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">
|
||||
for _, sh := range r.Shares {
|
||||
<li class="py-2 flex items-center justify-between text-sm">
|
||||
@@ -208,6 +242,9 @@ func colorEndpoint(r ResourceCard) string {
|
||||
if r.Virtual {
|
||||
return "/web/resources/birthdays/color"
|
||||
}
|
||||
if r.Kind == "ics" {
|
||||
return "/web/resources/ics/color"
|
||||
}
|
||||
return "/web/resources/calendar/color"
|
||||
}
|
||||
|
||||
@@ -223,10 +260,14 @@ func shareVals(resource, sharedWith string) string {
|
||||
}
|
||||
|
||||
func resourceEndpoint(kind string) string {
|
||||
if kind == "calendar" {
|
||||
switch kind {
|
||||
case "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 {
|
||||
|
||||
Reference in New Issue
Block a user