diff --git a/app/Http/Controllers/CalendarController.php b/app/Http/Controllers/CalendarController.php
index 1f10b19..e0c9882 100644
--- a/app/Http/Controllers/CalendarController.php
+++ b/app/Http/Controllers/CalendarController.php
@@ -271,7 +271,7 @@ class CalendarController extends Controller
// update the calendar instance row
$instance = CalendarInstance::create([
'calendarid' => $calId,
- 'principaluri' => auth()->user()->principal_uri,
+ 'principaluri' => auth()->user()->uri,
'uri' => Str::uuid(),
'displayname' => $data['name'],
'description' => $data['description'] ?? null,
@@ -300,7 +300,7 @@ class CalendarController extends Controller
$calendar->load([
'meta',
- 'instances' => fn ($q) => $q->where('principaluri', auth()->user()->principal_uri),
+ 'instances' => fn ($q) => $q->where('principaluri', auth()->user()->uri),
]);
/* grab the single instance for convenience in the view */
@@ -329,7 +329,7 @@ class CalendarController extends Controller
$calendar->load([
'meta',
'instances' => fn ($q) =>
- $q->where('principaluri', auth()->user()->principal_uri),
+ $q->where('principaluri', auth()->user()->uri),
]);
$instance = $calendar->instances->first(); // may be null but shouldn’t
@@ -353,7 +353,7 @@ class CalendarController extends Controller
// update the instance row
$calendar->instances()
- ->where('principaluri', auth()->user()->principal_uri)
+ ->where('principaluri', auth()->user()->uri)
->update([
'displayname' => $data['name'],
'description' => $data['description'] ?? '',
diff --git a/app/Http/Controllers/CalendarSettingsController.php b/app/Http/Controllers/CalendarSettingsController.php
index 4fd75d4..8c16c52 100644
--- a/app/Http/Controllers/CalendarSettingsController.php
+++ b/app/Http/Controllers/CalendarSettingsController.php
@@ -2,9 +2,14 @@
namespace App\Http\Controllers;
+use App\Models\CalendarInstance;
+use App\Models\CalendarMeta;
use App\Models\Subscription;
+use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Redirect;
class CalendarSettingsController extends Controller
{
@@ -108,8 +113,8 @@ class CalendarSettingsController extends Controller
return $this->frame(
'calendar.settings.subscribe',
[
- 'title' => 'Subscribe to a calendar',
- 'sub' => 'Add an `.ics` calender from another service'
+ 'title' => __('calendar.settings.subscribe.title'),
+ 'sub' => __('calendar.settings.subscribe.subtitle'),
]);
}
@@ -139,14 +144,115 @@ class CalendarSettingsController extends Controller
}
+ /**
+ * individual calendar settings
+ */
+ public function calendarForm(Request $request, string $calendarUri)
+ {
+ $user = $request->user();
+ $principalUri = $user->uri;
+
+ // this is the user's "instance" of the calendar (displayname/color/etc live here in sabre)
+ $instance = CalendarInstance::query()
+ ->where('principaluri', $principalUri)
+ ->where('uri', $calendarUri)
+ ->firstOrFail();
+
+ // your app meta (optional row)
+ $meta = $instance->meta;
+
+ // if it's remote
+ $icsUrl = null;
+ if (($meta?->is_remote ?? false) && $meta?->subscription_id) {
+ $icsUrl = Subscription::query()
+ ->whereKey($meta->subscription_id)
+ ->value('source');
+ }
+
+ return $this->frame(
+ 'calendar.settings.calendar',
+ [
+ 'title' => __('calendar.settings.calendar.title'),
+ 'sub' => __('calendar.settings.calendar.subtitle'),
+ 'instance' => $instance,
+ 'meta' => $meta,
+ 'icsUrl' => $icsUrl,
+ 'userTz' => $user->timezone,
+ ]);
+ }
+
+ public function calendarStore(Request $request, string $calendarUri): RedirectResponse
+ {
+ $user = $request->user();
+ $principalUri = $user->uri;
+
+ $instance = CalendarInstance::query()
+ ->where('principaluri', $principalUri)
+ ->where('uri', $calendarUri)
+ ->with('meta')
+ ->firstOrFail();
+
+ $data = $request->validate([
+ 'displayname' => ['required', 'string', 'max:100'],
+ 'description' => ['nullable', 'string', 'max:500'],
+ 'timezone' => ['nullable', 'string', 'max:64'],
+ 'color' => ['nullable', 'regex:/^#[0-9A-F]{6}$/i'],
+ ]);
+
+ $timezone = filled($data['timezone'] ?? null)
+ ? $data['timezone']
+ : $user->timezone;
+
+ $color = $data['color'] ?? $instance->resolvedColor();
+
+ DB::transaction(function () use ($instance, $data, $timezone, $color) {
+
+ // update sabre calendar instance (dav-facing)
+ $instance->update([
+ 'displayname' => $data['displayname'],
+ 'description' => $data['description'] ?? null,
+ 'timezone' => $timezone,
+ 'calendarcolor' => $color,
+ ]);
+
+ // update ui meta (kithkin-facing)
+ CalendarMeta::updateOrCreate(
+ ['calendar_id' => $instance->calendarid],
+ [
+ 'title' => $data['displayname'],
+ 'color' => $color,
+ 'color_fg' => contrast_text_color($color),
+ ]
+ );
+ });
+
+ return Redirect::route('calendar.settings.calendars.show', $calendarUri)
+ ->with('toast', [
+ 'message' => __('calendar.settings.saved'),
+ 'type' => 'success',
+ ]);
+ }
+
+
/**
* content frame handler
*/
private function frame(?string $view = null, array $data = [])
{
+ $user = request()->user();
+
+ /* pull user's calendar instances and adapt ordering keys to calendarorder */
+ $calendars = CalendarInstance::query()
+ ->forUser($user)
+ ->withUiMeta()
+ ->ordered()
+ ->get();
+
return view('calendar.settings.index', [
- 'view' => $view,
- 'data' => $data,
+ 'view' => $view,
+ 'data' => $data,
+ 'calendars' => $calendars,
+ 'timezones' => config('timezones'),
]);
}
}
diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php
index 01b2e00..dda683e 100644
--- a/app/Http/Controllers/SubscriptionController.php
+++ b/app/Http/Controllers/SubscriptionController.php
@@ -2,22 +2,21 @@
namespace App\Http\Controllers;
-use Illuminate\Http\Request;
-use Illuminate\Support\Str;
-use Illuminate\Support\Facades\DB;
+use App\Jobs\SyncSubscription;
use App\Models\Subscription;
use App\Models\Calendar;
use App\Models\CalendarInstance;
use App\Models\CalendarMeta;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Redirect;
class SubscriptionController extends Controller
{
public function index()
{
- $subs = Subscription::where(
- 'principaluri',
- auth()->user()->principal_uri
- )->get();
+ $subs = Subscription::where('principaluri', auth()->user()->uri)->get();
return view('subscription.index', compact('subs'));
}
@@ -36,51 +35,91 @@ class SubscriptionController extends Controller
'refreshrate' => 'nullable|string|max:10',
]);
- DB::transaction(function () use ($request, $data) {
+ $principalUri = $request->user()->uri;
+ $source = rtrim(trim($data['source']), '/');
+ $desc = 'Remote feed: '.$source;
- /* add entry into calendarsubscriptions first */
+ /* if they already subscribed to this exact feed, don’t create duplicates */
+ if (Subscription::where('principaluri', $principalUri)->where('source', $source)->exists()) {
+ return Redirect::route('calendar.index')->with('toast', [
+ 'message' => __('You are already subscribed to that calendar.'),
+ 'type' => 'info',
+ ]);
+ }
+
+ $sub = DB::transaction(function () use ($request, $data, $principalUri, $source, $desc) {
+
+ /* check if a mirror instance already exists */
+ $existingInstance = CalendarInstance::where('principaluri', $principalUri)
+ ->where('description', $desc)
+ ->first();
+
+
+ /* create the calendarsubscriptions record */
$sub = Subscription::create([
- 'uri' => Str::uuid(),
- 'principaluri' => 'principals/'.$request->user()->email,
- 'source' => $data['source'],
- 'displayname' => $data['displayname'] ?: $data['source'],
+ 'uri' => (string) Str::uuid(),
+ 'principaluri' => $principalUri,
+ 'source' => $source,
+ 'displayname' => $data['displayname'] ?: $source,
'calendarcolor' => $data['color'] ?? '#1a1a1a',
'refreshrate' => 'P1D',
'lastmodified' => now()->timestamp,
]);
- /* create the empty "shadow" calendar container */
- $calId = Calendar::create([
- 'synctoken' => 1,
- 'components' => 'VEVENT',
- ])->id;
+ // choose the calendar container
+ if ($existingInstance) {
+ $calId = $existingInstance->calendarid;
- /* create the calendarinstance row attached to the user */
- CalendarInstance::create([
- 'calendarid' => $calId,
- 'principaluri' => $sub->principaluri,
- 'uri' => Str::uuid(),
- 'displayname' => $sub->displayname,
- 'description' => 'Remote feed: '.$sub->source,
- 'calendarcolor' => $sub->calendarcolor,
- 'timezone' => config('app.timezone', 'UTC'),
- ]);
+ // keep the mirror instance’s user-facing bits up to date
+ $existingInstance->update([
+ 'displayname' => $sub->displayname,
+ 'calendarcolor' => $sub->calendarcolor,
+ 'timezone' => config('app.timezone', 'UTC'),
+ ]);
+ } else {
+ // create new empty calendar container
+ $calId = Calendar::create([
+ 'synctoken' => 1,
+ 'components' => 'VEVENT',
+ ])->id;
- /* create our calendar_meta entry */
- CalendarMeta::create([
- 'calendar_id' => $calId,
- 'subscription_id' => $sub->id,
- 'title' => $sub->displayname,
- 'color' => $sub->calendarcolor,
- 'color_fg' => contrast_text_color($sub->calendarcolor),
- 'is_shared' => true,
- 'is_remote' => true,
- ]);
+ // create mirror calendarinstance row
+ CalendarInstance::create([
+ 'calendarid' => $calId,
+ 'principaluri' => $sub->principaluri,
+ 'uri' => Str::uuid(),
+ 'displayname' => $sub->displayname,
+ 'description' => $desc,
+ 'calendarcolor' => $sub->calendarcolor,
+ 'timezone' => config('app.timezone', 'UTC'),
+ ]);
+ }
+
+ // upsert our calendar_meta entry by calendar_id (since that’s your pk)
+ CalendarMeta::updateOrCreate(
+ ['calendar_id' => $calId],
+ [
+ 'subscription_id' => $sub->id,
+ 'title' => $sub->displayname,
+ 'color' => $sub->calendarcolor,
+ 'color_fg' => contrast_text_color($sub->calendarcolor),
+ 'is_shared' => true,
+ 'is_remote' => true,
+ ]
+ );
+
+ return $sub;
});
+ // sync immediately so events appear without waiting for the */10 dispatcher
+ SyncSubscription::dispatch($sub)->afterCommit();
+
return redirect()
->route('calendar.index')
- ->with('toast', __('Subscription added!'));
+ ->with('toast', [
+ 'message' => __('Subscription added! Syncing events now...'),
+ 'type' => 'success',
+ ]);
}
public function edit(Subscription $subscription)
diff --git a/app/Jobs/SyncSubscription.php b/app/Jobs/SyncSubscription.php
index 6224542..0a96eb7 100644
--- a/app/Jobs/SyncSubscription.php
+++ b/app/Jobs/SyncSubscription.php
@@ -12,83 +12,124 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\ConnectionException;
+use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
+use Sabre\VObject\ParseException;
use Sabre\VObject\Reader;
-/**
- * Mirrors a remote iCalendar (ICS) feed into the local Sabre tables.
- *
- * • Runs in the background (ShouldQueue).
- * • Ensures a hidden “mirror” calendar exists once per subscription.
- * • Upserts every VEVENT into calendarobjects + event_meta.
- */
class SyncSubscription implements ShouldQueue
{
use Dispatchable, Queueable, InteractsWithQueue, SerializesModels;
- /** @var \App\Models\Subscription */
public Subscription $subscription;
- /**
- * @param Subscription $subscription The feed to sync.
- */
public function __construct(Subscription $subscription)
{
$this->subscription = $subscription;
}
- /**
- * Main entry-point executed by the queue worker.
- */
public function handle(): void
{
- /**
- * download the remote .ics file with retry and a long timeout */
+ // normalize the source a bit so comparisons/logging are consistent
+ $source = rtrim(trim((string) $this->subscription->source), '/');
+
+ // 1) download the remote feed
try {
- $ics = Http::retry(3, 5000)->timeout(30)
- ->withHeaders(['User-Agent' => 'Kithkin CalDAV Bot'])
- ->get($this->subscription->source)
- ->throw() // throws if not 2xx
- ->body();
- } catch (ConnectionException | \Throwable $e) {
+ $resp = Http::retry(3, 5000)
+ ->timeout(30)
+ ->withHeaders([
+ 'User-Agent' => 'Kithkin CalDAV Bot',
+ 'Accept' => 'text/calendar, text/plain;q=0.9, */*;q=0.8',
+ ])
+ ->get($source);
+
+ $resp->throw();
+
+ $body = $resp->body();
+ $contentType = (string) $resp->header('Content-Type');
+ }
+ catch (ConnectionException | RequestException | \Throwable $e)
+ {
+ $this->markSyncFailed('fetch_failed', $e->getMessage());
+
Log::warning('Feed fetch failed', [
- 'sub' => $this->subscription->id,
- 'msg' => $e->getMessage(),
+ 'sub' => $this->subscription->id,
+ 'url' => $source,
+ 'msg' => $e->getMessage(),
]);
- /* mark the job as failed and let Horizon / queue retry logic handle it */
$this->fail($e);
return;
}
- /**
- * get the mirror calendar, or lazy create it */
- $meta = $this->subscription->meta ?? $this->subscription->meta()->create();
+ // 2) ensure we actually got an ICS payload, not HTML or other content
+ if (! $this->looksLikeIcs($body))
+ {
+ $this->markSyncFailed('not_ical', 'remote feed did not return vcALENDAR data');
+
+ Log::warning('Feed did not return iCalendar data', [
+ 'sub' => $this->subscription->id,
+ 'url' => $source,
+ 'status' => $resp->status(),
+ 'content_type' => $contentType ?? null,
+ 'starts_with' => substr(ltrim($body), 0, 200),
+ ]);
+
+ $this->fail(new \RuntimeException('Remote feed did not return VCALENDAR data.'));
+ return;
+ }
+
+ // 3) get or create mirror calendar
+ $meta = $this->subscription->meta()->first() ?? $this->subscription->meta()->create();
if (! $meta->calendar_id) {
- $meta->calendar_id = $this->createMirrorCalendar($meta);
+ $meta->calendar_id = $this->createMirrorCalendar($meta, $source);
$meta->save();
}
- $calendarId = $meta->calendar_id;
+ $calendarId = (int) $meta->calendar_id;
- /**
- * parse and upsert vevents */
- $vcalendar = Reader::read($ics);
+ // 4) parse the VCALENDAR
+ try {
+ $vcalendar = Reader::read($body);
+ }
+ catch (ParseException | \Throwable $e)
+ {
+ $this->markSyncFailed('parse_failed', $e->getMessage());
- Log::info('Syncing subscription '.$this->subscription->id);
+ Log::warning('ICS parse failed', [
+ 'sub' => $this->subscription->id,
+ 'url' => $source,
+ 'msg' => $e->getMessage(),
+ 'starts_with' => substr(ltrim($body), 0, 200),
+ ]);
- foreach ($vcalendar->VEVENT as $vevent) {
+ $this->fail($e);
+ return;
+ }
+
+ Log::info('Syncing subscription', [
+ 'sub' => $this->subscription->id,
+ 'url' => $source,
+ ]);
+
+ // 5) upsert events
+ foreach (($vcalendar->VEVENT ?? []) as $vevent) {
+ $uid = isset($vevent->UID) ? (string) $vevent->UID : null;
+ $dtStart = $vevent->DTSTART ?? null;
+
+ // skip malformed events (rare, but it happens)
+ if (! $uid || ! $dtStart) {
+ continue;
+ }
- $uid = (string) $vevent->UID;
$now = now()->timestamp;
$blob = (string) $vevent->serialize();
- /** @var Event $object */
$object = Event::updateOrCreate(
['uid' => $uid, 'calendarid' => $calendarId],
[
@@ -101,60 +142,100 @@ class SyncSubscription implements ShouldQueue
]
);
- $startUtc = Carbon::parse($vevent->DTSTART->getDateTime());
- $endUtc = isset($vevent->DTEND)
- ? Carbon::parse($vevent->DTEND->getDateTime())
- : $startUtc;
+ // sabre gives DateTime objects here; Carbon::instance is safest
+ $start = Carbon::instance($dtStart->getDateTime())->utc();
+ $end = isset($vevent->DTEND)
+ ? Carbon::instance($vevent->DTEND->getDateTime())->utc()
+ : $start;
EventMeta::upsertForEvent($object->id, [
'title' => (string) ($vevent->SUMMARY ?? 'Untitled'),
'description' => (string) ($vevent->DESCRIPTION ?? ''),
'location' => (string) ($vevent->LOCATION ?? ''),
- 'all_day' => $vevent->DTSTART->isFloating(),
- 'start_at' => $startUtc->utc(),
- 'end_at' => $endUtc->utc(),
+ 'all_day' => $dtStart->isFloating(),
+ 'start_at' => $start,
+ 'end_at' => $end,
]);
}
- Log::info('Syncing subscription post foreach '.$this->subscription->id);
+ // sync is ok
+ $this->markSyncOk();
+
+ Log::info('Syncing subscription complete', [
+ 'sub' => $this->subscription->id,
+ ]);
+ }
+
+ private function looksLikeIcs(string $body): bool
+ {
+ // cheap + effective: an .ics must include BEGIN:VCALENDAR
+ // allow leading whitespace/bom
+ $haystack = ltrim($body);
+ return str_contains($haystack, 'BEGIN:VCALENDAR');
+ }
+
+ private function mirrorDescription(string $source): string
+ {
+ return 'Remote feed: '.$source;
}
/**
- * Lazily builds the shadow calendar + instance when missing
- * (for legacy subscriptions added before we moved creation to the controller).
+ * Lazily builds the shadow calendar + instance when missing.
*/
- private function createMirrorCalendar($meta): int
+ private function createMirrorCalendar($meta, string $source): int
{
- // check if controller created one already and return it if so
if ($meta->calendar_id) {
- return $meta->calendar_id;
+ return (int) $meta->calendar_id;
}
- // check if a mirror calendar already exists, and return that if so
+ $desc = $this->mirrorDescription($source);
+
$existing = CalendarInstance::where('principaluri', $this->subscription->principaluri)
- ->where('description', 'Remote feed: '.$this->subscription->source)
+ ->where('description', $desc)
->first();
+
if ($existing) {
- return $existing->calendarid;
+ return (int) $existing->calendarid;
}
- // otherwise create the new master calendar in `calendars`
$calendar = Calendar::create([
'synctoken' => 1,
'components' => 'VEVENT',
]);
- // attach an instance for this user
CalendarInstance::create([
'calendarid' => $calendar->id,
'principaluri' => $this->subscription->principaluri,
- 'uri' => Str::uuid(),
+ 'uri' => (string) Str::uuid(),
'displayname' => $this->subscription->displayname,
- 'description' => 'Remote feed: '.$this->subscription->source,
+ 'description' => $desc,
'calendarcolor' => $meta->color ?? '#1a1a1a',
'timezone' => config('app.timezone', 'UTC'),
]);
- return $calendar->id;
+ return (int) $calendar->id;
+ }
+
+ /**
+ * sync helpers
+ */
+ private function markSyncOk(): void
+ {
+ $this->subscription->forceFill([
+ 'last_sync_status' => 'ok',
+ 'last_sync_error' => null,
+ 'last_sync_at' => now(),
+ 'lastmodified' => now()->timestamp, // sabre uses int timestamps
+ ])->save();
+ }
+
+ private function markSyncFailed(string $status, string $message): void
+ {
+ $this->subscription->forceFill([
+ 'last_sync_status' => $status,
+ 'last_sync_error' => mb_substr($message, 0, 5000),
+ 'last_sync_at' => now(),
+ 'lastmodified' => now()->timestamp,
+ ])->save();
}
}
diff --git a/app/Models/CalendarInstance.php b/app/Models/CalendarInstance.php
index 681fd91..c89ddd6 100644
--- a/app/Models/CalendarInstance.php
+++ b/app/Models/CalendarInstance.php
@@ -2,8 +2,10 @@
namespace App\Models;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasOne;
class CalendarInstance extends Model
{
@@ -20,11 +22,73 @@ class CalendarInstance extends Model
'timezone',
];
+ protected $appends = ['is_remote'];
+
public function calendar(): BelongsTo
{
return $this->belongsTo(Calendar::class, 'calendarid');
}
+ // ui meta for this instance’s underlying calendar container
+ public function meta(): HasOne
+ {
+ return $this->hasOne(CalendarMeta::class, 'calendar_id', 'calendarid');
+ }
+
+ // convenient computed flag (defaults false when no meta row exists)
+ public function getIsRemoteAttribute(): bool
+ {
+ return (bool) ($this->meta?->is_remote ?? false);
+ }
+
+ /**
+ *
+ * common scopes
+ */
+
+ public function scopeForUser(Builder $query, User $user): Builder
+ {
+ return $query->where('principaluri', $user->uri);
+ }
+
+ public function scopeOrdered(Builder $query): Builder
+ {
+ return $query->orderBy('calendarorder')->orderBy('displayname');
+ }
+
+ public function scopeWithUiMeta(Builder $query): Builder
+ {
+ return $query->with('meta');
+ }
+
+ /**
+ *
+ * color accessors
+ */
+
+ public function resolvedColor(?string $fallback = null): string
+ {
+ // prefer meta color, fall back to sabre color, then default
+ return $this->meta?->color
+ ?? $this->calendarcolor
+ ?? $fallback
+ ?? '#1a1a1a';
+ }
+
+ public function resolvedColorFg(?string $fallback = null): string
+ {
+ return $this->meta?->color_fg
+ ?? ($this->resolvedColor($fallback)
+ ? contrast_text_color($this->resolvedColor($fallback))
+ : ($fallback ?? '#ffffff'));
+ }
+
+
+ /**
+ *
+ * CalDAV accessors
+ */
+
public function caldavUrl(): string
{
// e.g. https://kithkin.lan/dav/calendars/1/48f888f3-c5c5-…/
diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php
index 64b00b4..0c13d09 100644
--- a/app/Models/Subscription.php
+++ b/app/Models/Subscription.php
@@ -3,16 +3,12 @@
namespace App\Models;
use App\Models\CalendarMeta;
-use App\Models\User;
use Illuminate\Database\Eloquent\Model;
-use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Str;
class Subscription extends Model
{
- /** basic table mapping */
- protected $table = 'calendarsubscriptions';
- public $timestamps = false; // sabre table
+ protected $table = 'calendarsubscriptions';
+ public $timestamps = false;
protected $fillable = [
'uri',
@@ -25,18 +21,25 @@ class Subscription extends Model
'striptodos',
'stripalarms',
'stripattachments',
+
+ // sync fields
+ 'last_sync_status',
+ 'last_sync_error',
+ 'last_sync_at',
+ 'lastmodified',
];
protected $casts = [
'striptodos' => 'bool',
'stripalarms' => 'bool',
'stripattachments' => 'bool',
+
+ // sync fields
+ 'last_sync_at' => 'datetime',
];
- /** relationship to meta row */
public function meta()
{
- return $this->hasOne(\App\Models\CalendarMeta::class,
- 'subscription_id');
+ return $this->hasOne(CalendarMeta::class, 'subscription_id');
}
}
diff --git a/config/timezones.php b/config/timezones.php
new file mode 100644
index 0000000..32f75bf
--- /dev/null
+++ b/config/timezones.php
@@ -0,0 +1,152 @@
+ [ 'TZ' => 'Label', ... ] ]
+ */
+
+ 'Common' => [
+ 'UTC' => 'UTC',
+ ],
+
+ 'United States & Canada' => [
+ 'America/New_York' => 'Eastern (New York)',
+ 'America/Detroit' => 'Eastern (Detroit)',
+ 'America/Toronto' => 'Eastern (Toronto)',
+
+ 'America/Chicago' => 'Central (Chicago)',
+ 'America/Winnipeg' => 'Central (Winnipeg)',
+
+ 'America/Denver' => 'Mountain (Denver)',
+ 'America/Edmonton' => 'Mountain (Edmonton)',
+ 'America/Phoenix' => 'Arizona (Phoenix)',
+
+ 'America/Los_Angeles' => 'Pacific (Los Angeles)',
+ 'America/Vancouver' => 'Pacific (Vancouver)',
+
+ 'America/Anchorage' => 'Alaska (Anchorage)',
+ 'Pacific/Honolulu' => 'Hawaii (Honolulu)',
+ ],
+
+ 'Mexico, Central America & Caribbean' => [
+ 'America/Mexico_City' => 'Mexico (Mexico City)',
+ 'America/Tijuana' => 'Mexico (Tijuana)',
+ 'America/Guatemala' => 'Guatemala',
+ 'America/Costa_Rica' => 'Costa Rica',
+ 'America/Panama' => 'Panama',
+ 'America/Havana' => 'Cuba (Havana)',
+ 'America/Jamaica' => 'Jamaica',
+ 'America/Puerto_Rico' => 'Puerto Rico',
+ ],
+
+ 'South America' => [
+ 'America/Bogota' => 'Colombia (Bogotá)',
+ 'America/Lima' => 'Peru (Lima)',
+ 'America/Santiago' => 'Chile (Santiago)',
+ 'America/Argentina/Buenos_Aires' => 'Argentina (Buenos Aires)',
+ 'America/Sao_Paulo' => 'Brazil (São Paulo)',
+ 'America/Montevideo' => 'Uruguay (Montevideo)',
+ 'America/Caracas' => 'Venezuela (Caracas)',
+ ],
+
+ 'Europe' => [
+ 'Europe/London' => 'United Kingdom (London)',
+ 'Europe/Dublin' => 'Ireland (Dublin)',
+ 'Europe/Lisbon' => 'Portugal (Lisbon)',
+
+ 'Europe/Paris' => 'France (Paris)',
+ 'Europe/Brussels' => 'Belgium (Brussels)',
+ 'Europe/Amsterdam' => 'Netherlands (Amsterdam)',
+ 'Europe/Berlin' => 'Germany (Berlin)',
+ 'Europe/Zurich' => 'Switzerland (Zurich)',
+ 'Europe/Rome' => 'Italy (Rome)',
+ 'Europe/Madrid' => 'Spain (Madrid)',
+ 'Europe/Barcelona' => 'Spain (Barcelona)',
+ 'Europe/Vienna' => 'Austria (Vienna)',
+ 'Europe/Prague' => 'Czechia (Prague)',
+ 'Europe/Warsaw' => 'Poland (Warsaw)',
+ 'Europe/Stockholm' => 'Sweden (Stockholm)',
+ 'Europe/Oslo' => 'Norway (Oslo)',
+ 'Europe/Copenhagen' => 'Denmark (Copenhagen)',
+ 'Europe/Helsinki' => 'Finland (Helsinki)',
+
+ 'Europe/Athens' => 'Greece (Athens)',
+ 'Europe/Bucharest' => 'Romania (Bucharest)',
+ 'Europe/Kyiv' => 'Ukraine (Kyiv)',
+ 'Europe/Istanbul' => 'Turkey (Istanbul)',
+
+ 'Europe/Moscow' => 'Russia (Moscow)',
+ ],
+
+ 'Africa' => [
+ 'Africa/Casablanca' => 'Morocco (Casablanca)',
+ 'Africa/Algiers' => 'Algeria (Algiers)',
+ 'Africa/Tunis' => 'Tunisia (Tunis)',
+
+ 'Africa/Lagos' => 'Nigeria (Lagos)',
+ 'Africa/Accra' => 'Ghana (Accra)',
+
+ 'Africa/Cairo' => 'Egypt (Cairo)',
+ 'Africa/Johannesburg' => 'South Africa (Johannesburg)',
+ 'Africa/Nairobi' => 'Kenya (Nairobi)',
+ ],
+
+ 'Middle East' => [
+ 'Asia/Jerusalem' => 'Israel (Jerusalem)',
+ 'Asia/Beirut' => 'Lebanon (Beirut)',
+ 'Asia/Amman' => 'Jordan (Amman)',
+ 'Asia/Baghdad' => 'Iraq (Baghdad)',
+ 'Asia/Riyadh' => 'Saudi Arabia (Riyadh)',
+ 'Asia/Dubai' => 'UAE (Dubai)',
+ 'Asia/Tehran' => 'Iran (Tehran)',
+ ],
+
+ 'Asia' => [
+ 'Asia/Kolkata' => 'India (Kolkata)',
+ 'Asia/Kathmandu' => 'Nepal (Kathmandu)',
+ 'Asia/Dhaka' => 'Bangladesh (Dhaka)',
+ 'Asia/Karachi' => 'Pakistan (Karachi)',
+ 'Asia/Colombo' => 'Sri Lanka (Colombo)',
+
+ 'Asia/Bangkok' => 'Thailand (Bangkok)',
+ 'Asia/Singapore' => 'Singapore',
+ 'Asia/Kuala_Lumpur' => 'Malaysia (Kuala Lumpur)',
+ 'Asia/Jakarta' => 'Indonesia (Jakarta)',
+
+ 'Asia/Hong_Kong' => 'Hong Kong',
+ 'Asia/Shanghai' => 'China (Shanghai)',
+ 'Asia/Taipei' => 'Taiwan (Taipei)',
+ 'Asia/Seoul' => 'South Korea (Seoul)',
+ 'Asia/Tokyo' => 'Japan (Tokyo)',
+ 'Asia/Manila' => 'Philippines (Manila)',
+
+ 'Asia/Ulaanbaatar' => 'Mongolia (Ulaanbaatar)',
+ ],
+
+ 'Australia & New Zealand' => [
+ 'Australia/Perth' => 'Australia (Perth)',
+ 'Australia/Adelaide' => 'Australia (Adelaide)',
+ 'Australia/Brisbane' => 'Australia (Brisbane)',
+ 'Australia/Sydney' => 'Australia (Sydney)',
+ 'Australia/Melbourne' => 'Australia (Melbourne)',
+ 'Australia/Hobart' => 'Australia (Hobart)',
+
+ 'Pacific/Auckland' => 'New Zealand (Auckland)',
+ 'Pacific/Chatham' => 'New Zealand (Chatham Islands)',
+ ],
+
+ 'Pacific Islands' => [
+ 'Pacific/Fiji' => 'Fiji',
+ 'Pacific/Guam' => 'Guam',
+ 'Pacific/Tahiti' => 'French Polynesia (Tahiti)',
+ 'Pacific/Apia' => 'Samoa (Apia)',
+ 'Pacific/Port_Moresby'=> 'Papua New Guinea (Port Moresby)',
+ ],
+];
diff --git a/database/migrations/2025_08_01_000001_unique_mirror_constraint.php b/database/migrations/2025_08_01_000001_unique_mirror_constraint.php
index f523825..8feaed7 100644
--- a/database/migrations/2025_08_01_000001_unique_mirror_constraint.php
+++ b/database/migrations/2025_08_01_000001_unique_mirror_constraint.php
@@ -7,18 +7,16 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
- Schema::table('calendarinstances', function (Blueprint $table) {
- $table->unique(
- ['principaluri', 'description'],
- 'uniq_user_feed_mirror'
- );
+ // enforce "one subscription per feed per user"
+ Schema::table('calendarsubscriptions', function (Blueprint $table) {
+ $table->unique(['principaluri', 'source'], 'uniq_subscription_principal_source');
});
}
public function down(): void
{
- Schema::table('calendarinstances', function (Blueprint $table) {
- $table->dropUnique('uniq_user_feed_mirror');
+ Schema::table('calendarsubscriptions', function (Blueprint $table) {
+ $table->dropUnique('uniq_subscription_principal_source');
});
}
};
diff --git a/database/migrations/2026_01_21_000000_add_sync_meta_fields_to_subscriptions.php b/database/migrations/2026_01_21_000000_add_sync_meta_fields_to_subscriptions.php
new file mode 100644
index 0000000..f484496
--- /dev/null
+++ b/database/migrations/2026_01_21_000000_add_sync_meta_fields_to_subscriptions.php
@@ -0,0 +1,36 @@
+string('last_sync_status', 20)->nullable()->after('lastmodified');
+ $table->text('last_sync_error')->nullable()->after('last_sync_status');
+ $table->timestamp('last_sync_at')->nullable()->after('last_sync_error');
+
+ // optional but handy for querying in the ui
+ $table->index('last_sync_status');
+ $table->index('last_sync_at');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('calendarsubscriptions', function (Blueprint $table) {
+ $table->dropIndex(['last_sync_status']);
+ $table->dropIndex(['last_sync_at']);
+
+ $table->dropColumn([
+ 'last_sync_status',
+ 'last_sync_error',
+ 'last_sync_at',
+ ]);
+ });
+ }
+};
diff --git a/lang/en/calendar.php b/lang/en/calendar.php
index e7ad524..7a6731c 100644
--- a/lang/en/calendar.php
+++ b/lang/en/calendar.php
@@ -12,17 +12,29 @@ return [
|
*/
- // settings
+ 'color' => 'Color',
+ 'description' => 'Description',
+ 'ics' => [
+ 'url' => 'ICS URL',
+ ],
+ 'name' => 'Calendar name',
'settings' => [
+ 'calendar' => [
+ 'title' => 'Calendar settings',
+ 'subtitle' => 'Details and settings for :calendar .'
+ ],
'language_region' => [
'title' => 'Language and region',
'subtitle' => 'Choose your default language, region, and formatting preferences for calendars. These affect how dates and times are displayed throughout Kithkin.',
],
+ 'my_calendars' => 'Settings for my calendars',
'subscribe' => [
'title' => 'Subscribe to a calendar',
'subtitle' => 'Add an `.ics` calendar from another service',
],
+ 'saved' => 'Your calendar settings have been saved!',
'title' => 'Calendar settings',
],
+ 'timezone_help' => 'You can override your default time zone here.'
];
diff --git a/lang/en/common.php b/lang/en/common.php
index 442cfa6..5e3945e 100644
--- a/lang/en/common.php
+++ b/lang/en/common.php
@@ -22,5 +22,7 @@ return [
'password' => 'Password',
'save_changes' => 'Save changes',
'settings' => 'Settings',
+ 'timezone' => 'Time zone',
+ 'timezone_select' => 'Select a time zone',
];
diff --git a/resources/css/etc/layout.css b/resources/css/etc/layout.css
index 18ab955..e9017fb 100644
--- a/resources/css/etc/layout.css
+++ b/resources/css/etc/layout.css
@@ -146,6 +146,10 @@ main {
@apply bg-cyan-500;
}
}
+
+ span {
+ @apply flex items-center flex-row gap-2;
+ }
}
}
}
@@ -197,6 +201,9 @@ main {
/* page subtitle section */
.description {
+ strong {
+ @apply font-semibold;
+ }
}
/* everything below the description (i.e., the content pane) */
diff --git a/resources/css/lib/accordion.css b/resources/css/lib/accordion.css
index 9f79fb2..ce1a5ab 100644
--- a/resources/css/lib/accordion.css
+++ b/resources/css/lib/accordion.css
@@ -39,4 +39,8 @@ details {
> .content {
@apply mt-2;
}
+
+ > ul.content {
+ @apply flex flex-col gap-2;
+ }
}
diff --git a/resources/css/lib/calendar.css b/resources/css/lib/calendar.css
index a852d74..cc684ad 100644
--- a/resources/css/lib/calendar.css
+++ b/resources/css/lib/calendar.css
@@ -73,6 +73,22 @@
}
}
+/* calendar list in the left bar */
+#calendar-toggles {
+
+ /* show menu on hover */
+ li:hover {
+
+ }
+
+ /* limit calendar titles to 1 line */
+ .checkbox-label {
+ > span {
+ @apply line-clamp-1;
+ }
+ }
+}
+
/* animations */
@keyframes event-slide {
from {
diff --git a/resources/css/lib/input.css b/resources/css/lib/input.css
index 0946776..5f29236 100644
--- a/resources/css/lib/input.css
+++ b/resources/css/lib/input.css
@@ -6,7 +6,8 @@ input[type="password"],
input[type="text"],
input[type="url"],
input[type="search"],
-select {
+select,
+textarea {
@apply border-md border-gray-800 bg-white rounded-md shadow-input;
@apply focus:border-primary focus:ring-2 focus:ring-offset-2 focus:ring-cyan-600;
transition: box-shadow 125ms ease-in-out,
diff --git a/resources/svg/icons/circle-small.svg b/resources/svg/icons/circle-small.svg
new file mode 100644
index 0000000..091282e
--- /dev/null
+++ b/resources/svg/icons/circle-small.svg
@@ -0,0 +1 @@
+
diff --git a/resources/svg/icons/circle.svg b/resources/svg/icons/circle.svg
new file mode 100644
index 0000000..d199c5c
--- /dev/null
+++ b/resources/svg/icons/circle.svg
@@ -0,0 +1 @@
+
diff --git a/resources/svg/icons/share.svg b/resources/svg/icons/share.svg
new file mode 100644
index 0000000..9fb6280
--- /dev/null
+++ b/resources/svg/icons/share.svg
@@ -0,0 +1 @@
+
diff --git a/resources/svg/icons/solid/bomb.svg b/resources/svg/icons/solid/bomb.svg
index 630f69c..f696364 100644
--- a/resources/svg/icons/solid/bomb.svg
+++ b/resources/svg/icons/solid/bomb.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/resources/svg/icons/solid/calendar-sync.svg b/resources/svg/icons/solid/calendar-sync.svg
index 272cadb..87fcab5 100644
--- a/resources/svg/icons/solid/calendar-sync.svg
+++ b/resources/svg/icons/solid/calendar-sync.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/resources/svg/icons/solid/calendar.svg b/resources/svg/icons/solid/calendar.svg
index 187c517..41a940d 100644
--- a/resources/svg/icons/solid/calendar.svg
+++ b/resources/svg/icons/solid/calendar.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/resources/svg/icons/solid/circle-small.svg b/resources/svg/icons/solid/circle-small.svg
new file mode 100644
index 0000000..6c1d2b7
--- /dev/null
+++ b/resources/svg/icons/solid/circle-small.svg
@@ -0,0 +1 @@
+
diff --git a/resources/svg/icons/solid/circle.svg b/resources/svg/icons/solid/circle.svg
new file mode 100644
index 0000000..7ab572b
--- /dev/null
+++ b/resources/svg/icons/solid/circle.svg
@@ -0,0 +1 @@
+
diff --git a/resources/svg/icons/solid/globe.svg b/resources/svg/icons/solid/globe.svg
index baf1c45..963e9d4 100644
--- a/resources/svg/icons/solid/globe.svg
+++ b/resources/svg/icons/solid/globe.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/resources/svg/icons/solid/info-circle.svg b/resources/svg/icons/solid/info-circle.svg
index a34df2d..5d2e7db 100644
--- a/resources/svg/icons/solid/info-circle.svg
+++ b/resources/svg/icons/solid/info-circle.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/resources/svg/icons/solid/key.svg b/resources/svg/icons/solid/key.svg
index f01c013..24795f0 100644
--- a/resources/svg/icons/solid/key.svg
+++ b/resources/svg/icons/solid/key.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/resources/svg/icons/solid/pin.svg b/resources/svg/icons/solid/pin.svg
index 776a55a..6c4d5bd 100644
--- a/resources/svg/icons/solid/pin.svg
+++ b/resources/svg/icons/solid/pin.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/resources/svg/icons/solid/share.svg b/resources/svg/icons/solid/share.svg
new file mode 100644
index 0000000..15cb32d
--- /dev/null
+++ b/resources/svg/icons/solid/share.svg
@@ -0,0 +1 @@
+
diff --git a/resources/views/account/settings/addresses.blade.php b/resources/views/account/settings/addresses.blade.php
index 011d9ba..c910432 100644
--- a/resources/views/account/settings/addresses.blade.php
+++ b/resources/views/account/settings/addresses.blade.php
@@ -109,7 +109,7 @@
{{-- save --}}
+
+@php
+ /** @var \App\Models\CalendarInstance $instance */
+ $instance = $data['instance'];
+ $meta = $data['meta'] ?? null;
+
+ $isRemote = (bool) ($meta?->is_remote ?? false);
+
+ $color = old('color', $instance->resolvedColor());
+
+ $timezone = old('timezone',
+ $instance->timezone
+ ?? ($data['userTz'] ?? 'UTC')
+ );
+@endphp
+
+
diff --git a/resources/views/calendar/settings/index.blade.php b/resources/views/calendar/settings/index.blade.php
index 7daeca3..460ea1d 100644
--- a/resources/views/calendar/settings/index.blade.php
+++ b/resources/views/calendar/settings/index.blade.php
@@ -4,7 +4,7 @@
{{ __('common.calendar') }}
-
+
@@ -20,7 +20,7 @@
@isset($view)
- @include($view, $data ?? [])
+ @include($view)
@else
{{ __('Pick an option in the sidebar…') }}
@endisset
diff --git a/resources/views/calendar/settings/subscribe.blade.php b/resources/views/calendar/settings/subscribe.blade.php
index 131f0dd..c031a09 100644
--- a/resources/views/calendar/settings/subscribe.blade.php
+++ b/resources/views/calendar/settings/subscribe.blade.php
@@ -5,33 +5,44 @@
It's possible (and likely) that some of the things you can add to your calendar here will not work in other calendar apps or services. You'll always see that data here, though.
-