Integrates ArcGIS for fetching detailed location data; sets up new job to pull locations for missing event locations; sets up Horizon for job monitoring; updates readme a bit; subtle animations and improvements to month view

This commit is contained in:
Andrew Gioia 2025-08-19 13:53:35 -04:00
parent 98fb10bc14
commit 80c368525a
Signed by: andrew
GPG Key ID: FC09694A000800C8
30 changed files with 1372 additions and 173 deletions

View File

@ -6,14 +6,22 @@ APP_URL=http://localhost
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US APP_FAKER_LOCALE=en_US
APP_TIMEZONE=America/New_York # custom APP_TIMEZONE=UTC
APP_MAINTENANCE_DRIVER=file APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database # APP_MAINTENANCE_STORE=database
ADMIN_EMAIL=admin@kithkin.dev ADMIN_EMAIL=admin@kithkin.dev
ADMIN_PASSWORD= ADMIN_PASSWORD=
ADMIN_NAME="Kithkin Admin" ADMIN_FIRSTNAME=Kithkin
ADMIN_LASTNAME=Admin
GEOCODER=arcgis
GEOCODER_USER_AGENT="Kithkin/1.0 (amrou@kithk.in)"
GEOCODER_TIMEOUT=30
GEOCIDER_EMAIL=amrou@kithk.in
ARCGIS_API_KEY=
ARCGIS_STORE_RESULTS=true # set to false to not store results
PHP_CLI_SERVER_WORKERS=4 PHP_CLI_SERVER_WORKERS=4
@ -31,17 +39,19 @@ DB_DATABASE=kithkin
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD= DB_PASSWORD=
SESSION_DRIVER=database SESSION_DRIVER=redis
SESSION_LIFETIME=120 SESSION_LIFETIME=360
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
SESSION_EXPIRE_ON_CLOSE=false
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=redis
CACHE_STORE=database CACHE_STORE=redis
CACHE_DRIVER=redis
# CACHE_PREFIX= # CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1

View File

@ -1,61 +1,65 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p> # Kithkin
<p align="center"> Contacts and calendars for smart people.
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel ## Scheduled jobs
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: Jobs are located in `app/Jobs` and we use Laravel Horizon as an admin frontend.
- [Simple, fast routing engine](https://laravel.com/docs/routing). ### Starting jobs
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications. To start the jobs themselves, run `php artisan schedule:work`. This creates a running process that outputs job runs and other minimal data.
## Learning Laravel #### Production cron
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. On production, this should be setup via cron job like this:
You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch. ```
* * * * * /usr/bin/php /path/to/artisan schedule:run >> /dev/null 2>&1
```
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. ### Monitoring jobs
## Laravel Sponsors Horizon is the monitoring app and frontend. Run `php artisan horizon` to start it. This will create a running process that outputs the results of the jobs in a better format than `schedule:work`.
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). The `/horizon` UI is just a normal route in Kithkin; the `php artisan horizon` process is what actually runs the workers and updates Redis for the dashboard.
### Premium Partners
- **[Vehikl](https://vehikl.com)** ### Working with scheduled jobs
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing To see the list of scheduled jobs and their crons, run `php artisan schedule:list`.
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct ## Application flow notes for my own sanity
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). ### Local calendars
## Security Vulnerabilities #### Local calendar creation
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. Creating a local calendar (not available in the UI yet) hits the `Calendar.php` controller. That `store()` method creates entries in the `calendars`, `calendarinstances`, and `calendar_meta` tables using model functions for each of them.
## License #### Local event creation
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). When the user creates a new event, it gets added to `calendarobjects` where the raw VEVENT data is stored. We have our own utility tables `event_meta` and `locations` for way more metadata and convenience fields so that we don't need to keep parsing the VEVENT blob.
The controller is `EventController.php` and it uses models `Event.php` (calendarobjects), `EventMeta.php` (event_meta), and Location.php (locations, not yet created).
### Remote calendars
Remote calendars are calendars that a user "subscribes" to via .ics URL. These are also called subscription calendars in the app.
#### Remote calendar creation
The user adds a new remote calendar by entering the url in calendar settings, adding a display name, and picking a color.
When a new remote calendar is added, we create a row in `calendarsubscriptions`, `calendars`, and `calendarinstances`, and a corresponding meta data row in `calendar_meta`. This is so verbose because of the way SabreDAV handles remote subscriptions.
* Normally, all Sabre does is add a row to `calendarsubscriptions`. It doesn't contemplate pulling the events down into the database locally--it assumes you're fetching the .ics file fresh and parsing out events every time the user loads the calendar.
* Since we want to be able to search everything and do more, we need to pull the events down into our database. This requires creating corresponding rows in the local calendar tables. It's a little weird Sabre-wise since we have remote calendars populating their local calendar tables, but Sabre doesn't care that these are in there and it means the user can also share them out again.
The controller is `SubscriptionController.php` and it uses the additional model `Subscription.php`.
#### Remote events
When a user creates an event in a subscription calendar, we need to send that back up to the primary server. We also create the event locally as normal.

View File

@ -37,6 +37,9 @@ class CalendarController extends Controller
// get the view and time range // get the view and time range
[$view, $range] = $this->resolveRange($request); [$view, $range] = $this->resolveRange($request);
// date range span
$span = $this->gridSpan($view, $range);
// date range controls // date range controls
$prev = $range['start']->copy()->subMonth()->startOfMonth()->toDateString(); $prev = $range['start']->copy()->subMonth()->startOfMonth()->toDateString();
$next = $range['start']->copy()->addMonth()->startOfMonth()->toDateString(); $next = $range['start']->copy()->addMonth()->startOfMonth()->toDateString();
@ -45,6 +48,11 @@ class CalendarController extends Controller
// get the user's visible calendars from the left bar // get the user's visible calendars from the left bar
$visible = collect($request->query('c', [])); $visible = collect($request->query('c', []));
/**
*
* calendars
*/
// load the user's local calendars // load the user's local calendars
$locals = Calendar::query() $locals = Calendar::query()
->select( ->select(
@ -55,29 +63,35 @@ class CalendarController extends Controller
'ci.timezone as timezone', 'ci.timezone as timezone',
'meta.color as meta_color', 'meta.color as meta_color',
'meta.color_fg as meta_color_fg', 'meta.color_fg as meta_color_fg',
DB::raw('false as is_remote') DB::raw('0 as is_remote')
) )
->join('calendarinstances as ci', 'ci.calendarid', '=', 'calendars.id') ->join('calendarinstances as ci', 'ci.calendarid', '=', 'calendars.id')
->leftJoin('calendar_meta as meta', 'meta.calendar_id', '=', 'calendars.id') ->leftJoin('calendar_meta as meta', 'meta.calendar_id', '=', 'calendars.id')
->where('ci.principaluri', $principal) ->where('ci.principaluri', $principal)
->where(function ($q) {
$q->whereNull('meta.is_remote')
->orWhere('meta.is_remote', false);
})
->orderBy('ci.displayname') ->orderBy('ci.displayname')
->get(); ->get();
// load the users remote/subscription calendars // load the users remote/subscription calendars
$remotes = Subscription::query() $remotes = Calendar::query()
->join('calendar_meta as m', 'm.subscription_id', '=', 'calendarsubscriptions.id')
->where('principaluri', $principal)
->orderBy('displayname')
->select( ->select(
'calendarsubscriptions.id', 'calendars.id',
'calendarsubscriptions.displayname', 'ci.displayname',
'calendarsubscriptions.calendarcolor', 'ci.calendarcolor',
'calendarsubscriptions.uri as slug', 'ci.uri as slug',
DB::raw('NULL as timezone'), 'ci.timezone as timezone',
'm.color as meta_color', 'meta.color as meta_color',
'm.color_fg as meta_color_fg', 'meta.color_fg as meta_color_fg',
DB::raw('true as is_remote') DB::raw('1 as is_remote')
) )
->join('calendarinstances as ci', 'ci.calendarid', '=', 'calendars.id')
->join('calendar_meta as meta', 'meta.calendar_id', '=', 'calendars.id')
->where('ci.principaluri', $principal)
->where('meta.is_remote', true)
->orderBy('ci.displayname')
->get(); ->get();
// merge local and remote, and add the visibility flag // merge local and remote, and add the visibility flag
@ -90,11 +104,16 @@ class CalendarController extends Controller
// handy lookup: [id => calendar row] // handy lookup: [id => calendar row]
$calendar_map = $calendars->keyBy('id'); $calendar_map = $calendars->keyBy('id');
/**
*
* get events for calendars in range
*/
// get all the events in one query // get all the events in one query
$events = Event::forCalendarsInRange( $events = Event::forCalendarsInRange(
$calendars->pluck('id'), $calendars->pluck('id'),
$range['start'], $span['start'],
$range['end'] $span['end']
)->map(function ($e) use ($calendar_map) { )->map(function ($e) use ($calendar_map) {
// event's calendar // event's calendar
@ -116,7 +135,8 @@ class CalendarController extends Controller
'id' => $e->id, 'id' => $e->id,
'calendar_id' => $e->calendarid, 'calendar_id' => $e->calendarid,
'calendar_slug' => $cal->slug, 'calendar_slug' => $cal->slug,
'title' => $e->meta->title ?? '(no title)', 'title' => $e->meta->title ?? 'No title',
'description' => $e->meta->description ?? 'No description.',
'start' => $start_utc->toIso8601String(), 'start' => $start_utc->toIso8601String(),
'end' => optional($end_utc)->toIso8601String(), 'end' => optional($end_utc)->toIso8601String(),
'start_ui' => $start_local->format('g:ia'), 'start_ui' => $start_local->format('g:ia'),
@ -128,6 +148,11 @@ class CalendarController extends Controller
]; ];
})->keyBy('id'); })->keyBy('id');
/**
*
* mini calendar
*/
// create the mini calendar grid based on the mini cal controls // create the mini calendar grid based on the mini cal controls
$mini_anchor = $request->query('mini', $range['start']->toDateString()); $mini_anchor = $request->query('mini', $range['start']->toDateString());
$mini_start = Carbon::parse($mini_anchor)->startOfMonth(); $mini_start = Carbon::parse($mini_anchor)->startOfMonth();
@ -137,7 +162,49 @@ class CalendarController extends Controller
'today' => Carbon::today()->startOfMonth()->toDateString(), 'today' => Carbon::today()->startOfMonth()->toDateString(),
'label' => $mini_start->format('F Y'), 'label' => $mini_start->format('F Y'),
]; ];
$mini = $this->buildMiniGrid($mini_start, $events);
// compute the mini's 42-day span (Mon..Sun, 6 rows)
$mini_grid_start = $mini_start->copy()->startOfWeek(Carbon::MONDAY);
$mini_grid_end = $mini_start->copy()->endOfMonth()->endOfWeek(Carbon::SUNDAY);
if ($mini_grid_start->diffInDays($mini_grid_end) + 1 < 42) {
$mini_grid_end->addWeek();
}
// fetch events specifically for the mini-span
$mini_events = Event::forCalendarsInRange(
$calendars->pluck('id'),
$mini_grid_start,
$mini_grid_end
)->map(function ($e) use ($calendar_map) {
$cal = $calendar_map[$e->calendarid];
$start_utc = $e->meta->start_at ?? Carbon::createFromTimestamp($e->firstoccurence);
$end_utc = $e->meta->end_at ?? ($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null);
$tz = $cal->timezone ?? config('app.timezone');
return [
'id' => $e->id,
'calendar_id' => $e->calendarid,
'calendar_slug' => $cal->slug,
'title' => $e->meta->title ?? 'No title',
'description' => $e->meta->description ?? 'No description.',
'start' => $start_utc->toIso8601String(),
'end' => optional($end_utc)->toIso8601String(),
'timezone' => $tz,
'visible' => $cal->visible,
'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a',
'color_fg' => $cal->meta_color_fg ?? '#ffffff',
];
})->keyBy('id');
// now build the mini from mini_events (not from $events)
$mini = $this->buildMiniGrid($mini_start, $mini_events);
/**
*
* main calendar grid
*/
// create the calendar grid of days // create the calendar grid of days
$grid = $this->buildCalendarGrid($view, $range, $events); $grid = $this->buildCalendarGrid($view, $range, $events);
@ -323,6 +390,31 @@ class CalendarController extends Controller
* Private helpers * Private helpers
*/ */
/**
* Span actually rendered by the grid.
* Month startOfMonth->startOfWeek .. endOfMonth->endOfWeek
*/
private function gridSpan(string $view, array $range): array
{
switch ($view) {
case 'week':
$start = $range['start']->copy(); // resolveRange already did startOfWeek
$end = $range['start']->copy()->addDays(6);
break;
case '4day':
$start = $range['start']->copy(); // resolveRange already did startOfDay
$end = $range['start']->copy()->addDays(3);
break;
default: // month
$start = $range['start']->copy()->startOfMonth()->startOfWeek();
$end = $range['end']->copy()->endOfMonth()->endOfWeek();
}
return ['start' => $start, 'end' => $end];
}
/** /**
* normalise $view and $date into a carbon range * normalise $view and $date into a carbon range
* *
@ -376,11 +468,22 @@ class CalendarController extends Controller
*/ */
private function buildCalendarGrid(string $view, array $range, Collection $events): array private function buildCalendarGrid(string $view, array $range, Collection $events): array
{ {
// index events by YYYY-MM-DD for quick lookup */ // use the same span the events were fetched for (month padded to full weeks, etc.)
['start' => $grid_start, 'end' => $grid_end] = $this->gridSpan($view, $range);
// today checks
$tz = auth()->user()->timezone ?? config('app.timezone', 'UTC');
$today = \Carbon\Carbon::today($tz);
// index events by YYYY-MM-DD for quick lookup
$events_by_day = []; $events_by_day = [];
foreach ($events as $ev) { foreach ($events as $ev) {
$start = Carbon::parse($ev['start'])->tz($ev['timezone']); $evTz = $ev['timezone'] ?? $tz;
$end = $ev['end'] ? Carbon::parse($ev['end'])->tz($ev['timezone']) : $start;
$start = Carbon::parse($ev['start'])->tz($evTz);
$end = $ev['end']
? Carbon::parse($ev['end'])->tz($evTz)
: $start;
// spread multi-day events // spread multi-day events
for ($d = $start->copy()->startOfDay(); for ($d = $start->copy()->startOfDay();
@ -392,32 +495,16 @@ class CalendarController extends Controller
} }
} }
// determine span of days for the selected view
switch ($view) {
case 'week':
$grid_start = $range['start']->copy();
$grid_end = $range['start']->copy()->addDays(6);
break;
case '4day':
$grid_start = $range['start']->copy();
$grid_end = $range['start']->copy()->addDays(3);
break;
default: /* month */
$grid_start = $range['start']->copy()->startOfWeek(); // Sunday start
$grid_end = $range['end']->copy()->endOfWeek();
}
// view span bounds and build day objects // view span bounds and build day objects
$days = []; $days = [];
for ($day = $grid_start->copy(); $day->lte($grid_end); $day->addDay()) { for ($day = $grid_start->copy(); $day->lte($grid_end); $day->addDay()) {
$iso = $day->toDateString(); $iso = $day->toDateString();
$days[] = [ $days[] = [
'date' => $iso, 'date' => $iso,
'label' => $day->format('j'), 'label' => $day->format('j'),
'in_month' => $day->month === $range['start']->month, 'in_month' => $day->month === $range['start']->month,
'is_today' => $day->isSameDay(Carbon::today()), 'is_today' => $day->isSameDay($today),
'events' => array_fill_keys($events_by_day[$iso] ?? [], true), 'events' => array_fill_keys($events_by_day[$iso] ?? [], true),
]; ];
} }
@ -453,10 +540,15 @@ class CalendarController extends Controller
/* map event-ids by yyyy-mm-dd */ /* map event-ids by yyyy-mm-dd */
$byDay = []; $byDay = [];
$tzFallback = auth()->user()->timezone ?? config('app.timezone', 'UTC');
foreach ($events as $ev) { foreach ($events as $ev) {
$s = Carbon::parse($ev['start']); $evTz = $ev['timezone'] ?? $tzFallback;
$e = $ev['end'] ? Carbon::parse($ev['end']) : $s;
for ($d = $s->copy()->startOfDay(); $d->lte($e); $d->addDay()) { $s = Carbon::parse($ev['start'])->tz($evTz);
$e = $ev['end'] ? Carbon::parse($ev['end'])->tz($evTz) : $s;
for ($d = $s->copy()->startOfDay(); $d->lte($e->copy()->endOfDay()); $d->addDay()) {
$byDay[$d->toDateString()][] = $ev['id']; $byDay[$d->toDateString()][] = $ev['id'];
} }
} }

View File

@ -2,9 +2,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Subscription;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
class CalendarSettingsController extends Controller class CalendarSettingsController extends Controller
{ {
@ -34,18 +34,19 @@ class CalendarSettingsController extends Controller
'color' => ['nullable', 'regex:/^#[0-9A-F]{6}$/i'], 'color' => ['nullable', 'regex:/^#[0-9A-F]{6}$/i'],
]); ]);
DB::table('calendarsubscriptions')->insert([ // create the calendarsubscription and calendar_meta rows in one call via Subscription model
'uri' => Str::uuid(), // local id Subscription::createWithMeta(
'principaluri' => 'principals/'.$request->user()->email, $request->user(),
[
'source' => $data['source'], 'source' => $data['source'],
'displayname' => $data['displayname'] ?: $data['source'], 'displayname' => $data['displayname'] ?: $data['source'],
'calendarcolor' => $data['color'], 'calendarcolor' => $data['color'] ?? '#1a1a1a',
'refreshrate' => 'P1D', // daily // you can add 'refreshrate' => 'P1D' here if you like
'lastmodified' => now()->timestamp, ]
]); );
return redirect() return redirect()
->route('calendar.settings') ->route('calendar.index')
->with('toast', __('Subscription added successfully!')); ->with('toast', __('Subscription added successfully!'));
} }

View File

@ -4,7 +4,11 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use App\Models\Subscription; use App\Models\Subscription;
use App\Models\Calendar;
use App\Models\CalendarInstance;
use App\Models\CalendarMeta;
class SubscriptionController extends Controller class SubscriptionController extends Controller
{ {
@ -28,11 +32,51 @@ class SubscriptionController extends Controller
$data = $request->validate([ $data = $request->validate([
'source' => 'required|url', 'source' => 'required|url',
'displayname' => 'nullable|string|max:255', 'displayname' => 'nullable|string|max:255',
'calendarcolor' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/', 'color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
'refreshrate' => 'nullable|string|max:10', 'refreshrate' => 'nullable|string|max:10',
]); ]);
Subscription::createWithMeta($request->user(), $data); DB::transaction(function () use ($request, $data) {
/* add entry into calendarsubscriptions first */
$sub = Subscription::create([
'uri' => Str::uuid(),
'principaluri' => 'principals/'.$request->user()->email,
'source' => $data['source'],
'displayname' => $data['displayname'] ?: $data['source'],
'calendarcolor' => $data['color'] ?? '#1a1a1a',
'refreshrate' => 'P1D',
'lastmodified' => now()->timestamp,
]);
/* create the empty "shadow" calendar container */
$calId = Calendar::create([
'synctoken' => 1,
'components' => 'VEVENT',
])->id;
/* 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'),
]);
/* 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,
]);
});
return redirect() return redirect()
->route('calendar.index') ->route('calendar.index')

View File

@ -0,0 +1,145 @@
<?php
namespace App\Jobs;
use App\Models\EventMeta;
use App\Models\Location;
use App\Services\Location\Geocoder;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
class GeocodeEventLocations implements ShouldQueue
{
use Dispatchable, Queueable, InteractsWithQueue, SerializesModels;
// process up to n records this run; null = no cap
public function __construct(
public ?int $limit = null
) {}
// convenience helpers
public static function push(?int $limit = null): void {
dispatch(new static($limit));
}
public static function runNow(?int $limit = null): void {
dispatch_sync(new static($limit));
}
// handle geocoding
public function handle(Geocoder $geocoder): void
{
// working counters
$processed = 0;
$created = 0;
$updated = 0;
$skipped = 0;
$failed = 0;
Log::info('GeocodeEventLocations: start', ['limit' => $this->limit]);
// events that have a non-empty location string but no linked location row yet
$todo = EventMeta::query()
->whereNull('location_id')
->whereNotNull('location')
->where('location', '<>', '')
->orderBy('event_id'); // important for chunkById
$stop = false;
// log total to process (before limit)
$total = (clone $todo)->count();
Log::info('[geo] starting GeocodeEventLocations', ['total' => $total, 'limit' => $this->limit]);
// chunk through event_meta rows
$todo->chunkById(200, function ($chunk) use ($geocoder, &$processed, &$created, &$updated, &$skipped, &$failed, &$stop) {
foreach ($chunk as $meta) {
if ($stop) {
return false; // stop further chunking
}
// respect limit if provided
if ($this->limit !== null && $processed >= $this->limit) {
$stop = true;
return false;
}
try {
// geocode the free-form location string
$norm = $geocoder->forward($meta->location);
// skip obvious non-address labels or unresolved queries
if (!$norm || (!$norm['lat'] && !$norm['street'])) {
$skipped++;
$processed++;
continue;
}
// normalized match key to reduce duplicates
$lookup = [
'display_name' => $norm['display_name'],
'street' => $norm['street'],
'city' => $norm['city'],
'state' => $norm['state'],
'postal' => $norm['postal'],
'country' => $norm['country'],
];
// try to match an existing location (by normalized fields)
$existing = Location::where($lookup)->first();
// fall back to raw string match against any pre-seeded label rows
if (!$existing && $meta->location) {
$existing = Location::where('display_name', $meta->location)
->orWhere('raw_address', $meta->location)
->first();
}
// reuse existing location or create a new one with coords
$loc = $existing ?? Location::firstOrCreate(
$lookup,
[
'raw_address' => $norm['raw_address'],
'lat' => $norm['lat'],
'lon' => $norm['lon'],
]
);
// if we matched an existing row missing coords, backfill once
if ($existing && (is_null($existing->lat) || is_null($existing->lon))) {
$existing->lat = $norm['lat'];
$existing->lon = $norm['lon'];
$existing->raw_address ??= $norm['raw_address'];
$existing->save();
$updated++;
}
if ($loc->wasRecentlyCreated) {
$created++;
}
// link event_meta → locations
$meta->location_id = $loc->id;
$meta->save();
$processed++;
} catch (\Throwable $e) {
$failed++;
$processed++;
Log::warning('GeocodeEventLocations: failed', [
'event_id' => $meta->event_id,
'location' => $meta->location,
'error' => $e->getMessage(),
]);
}
}
}, 'event_id');
Log::info('GeocodeEventLocations: done', compact('processed', 'created', 'updated', 'skipped', 'failed'));
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace App\Jobs;
use App\Models\Calendar;
use App\Models\CalendarInstance;
use App\Models\Event;
use App\Models\EventMeta;
use App\Models\Subscription;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\ConnectionException;
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\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 */
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) {
Log::warning('Feed fetch failed', [
'sub' => $this->subscription->id,
'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();
if (! $meta->calendar_id) {
$meta->calendar_id = $this->createMirrorCalendar($meta);
$meta->save();
}
$calendarId = $meta->calendar_id;
/**
* parse and upsert vevents */
$vcalendar = Reader::read($ics);
Log::info('Syncing subscription '.$this->subscription->id);
foreach ($vcalendar->VEVENT as $vevent) {
$uid = (string) $vevent->UID;
$now = now()->timestamp;
$blob = (string) $vevent->serialize();
/** @var Event $object */
$object = Event::updateOrCreate(
['uid' => $uid, 'calendarid' => $calendarId],
[
'uri' => Str::uuid().'.ics',
'lastmodified' => $now,
'etag' => md5($blob),
'size' => strlen($blob),
'componenttype' => 'VEVENT',
'calendardata' => $blob,
]
);
$startUtc = Carbon::parse($vevent->DTSTART->getDateTime());
$endUtc = isset($vevent->DTEND)
? Carbon::parse($vevent->DTEND->getDateTime())
: $startUtc;
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(),
]);
}
Log::info('Syncing subscription post foreach '.$this->subscription->id);
}
/**
* Lazily builds the shadow calendar + instance when missing
* (for legacy subscriptions added before we moved creation to the controller).
*/
private function createMirrorCalendar($meta): int
{
// check if controller created one already and return it if so
if ($meta->calendar_id) {
return $meta->calendar_id;
}
// check if a mirror calendar already exists, and return that if so
$existing = CalendarInstance::where('principaluri', $this->subscription->principaluri)
->where('description', 'Remote feed: '.$this->subscription->source)
->first();
if ($existing) {
return $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(),
'displayname' => $this->subscription->displayname,
'description' => 'Remote feed: '.$this->subscription->source,
'calendarcolor' => $meta->color ?? '#1a1a1a',
'timezone' => config('app.timezone', 'UTC'),
]);
return $calendar->id;
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Models\Subscription;
class SyncSubscriptionsDispatcher implements ShouldQueue
{
use Dispatchable, Queueable, InteractsWithQueue, SerializesModels;
public function handle(): void
{
// fan-out: dispatch a SyncSubscription for every feed
Subscription::with('meta')
->cursor()
->each(fn ($sub) => SyncSubscription::dispatch($sub));
}
}

View File

@ -2,6 +2,8 @@
namespace App\Models; namespace App\Models;
use App\Models\Subscription;
use App\Models\CalendarInstance;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@ -15,8 +17,8 @@ class Calendar extends Model
/* add mass-assignment for these cols */ /* add mass-assignment for these cols */
protected $fillable = [ protected $fillable = [
'displayname', 'synctoken',
'description', 'components',
]; ];
/* all event components (VEVENT, VTODO, …) */ /* all event components (VEVENT, VTODO, …) */

View File

@ -9,6 +9,7 @@ class CalendarInstance extends Model
{ {
protected $table = 'calendarinstances'; protected $table = 'calendarinstances';
public $timestamps = false; public $timestamps = false;
protected $fillable = [ protected $fillable = [
'calendarid', 'calendarid',
'principaluri', 'principaluri',
@ -16,7 +17,7 @@ class CalendarInstance extends Model
'displayname', 'displayname',
'description', 'description',
'calendarcolor', 'calendarcolor',
'timezone' 'timezone',
]; ];
public function calendar(): BelongsTo public function calendar(): BelongsTo

View File

@ -14,13 +14,19 @@ class CalendarMeta extends Model
/* add mass-assignment for these cols */ /* add mass-assignment for these cols */
protected $fillable = [ protected $fillable = [
'calendar_id', 'calendar_id',
'subscription_id',
'mirror_calendar_id',
'title',
'color', 'color',
'color_fg', 'color_fg',
'created_at', 'is_shared',
'edited_at', 'is_remote',
'settings',
]; ];
protected $casts = [ protected $casts = [
'is_shared' => 'boolean',
'is_remote' => 'boolean',
'settings' => 'array', 'settings' => 'array',
]; ];
@ -34,4 +40,26 @@ class CalendarMeta extends Model
{ {
return $this->belongsTo(Calendar::class, 'calendar_id'); return $this->belongsTo(Calendar::class, 'calendar_id');
} }
/**
* Upsert meta for a remote-feed subscription.
*/
public static function forSubscription(Subscription $sub): self
{
return static::updateOrCreate(
// ---- unique match-key (subscription_id is unique, nullable) ----
['subscription_id' => $sub->id],
// ---- columns to fill / update ----
[
'title' => $sub->displayname,
'color' => $sub->calendarcolor ?? '#1a1a1a',
'color_fg' => contrast_text_color($sub->calendarcolor ?? '#1a1a1a'),
'is_shared' => true,
'is_remote' => true,
// mirror_calendar_id is set later by the sync-job once the
// shadow “mirror” calendar has been created.
],
);
}
} }

View File

@ -8,57 +8,66 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
class Event extends Model class Event extends Model
{ {
/** table and key */
protected $table = 'calendarobjects'; // Sabre table protected $table = 'calendarobjects'; // Sabre table
public $timestamps = false;
protected $primaryKey = 'id'; protected $primaryKey = 'id';
public $timestamps = false; // no created_at / updated_at
/* add mass-assignment for these cols */ /** mass assignment */
protected $fillable = [ protected $fillable = [
'calendarid', 'calendarid',
'uid',
'uri', 'uri',
'lastmodified', 'lastmodified',
'etag', 'etag',
'size', 'size',
'componenttype', 'componenttype',
'uid',
'calendardata', 'calendardata',
'firstoccurence',
'lastoccurence',
]; ];
/* owning calendar */ /** column casting */
protected $casts = [
'lastmodified' => 'integer',
'firstoccurence' => 'integer',
'lastoccurence' => 'integer',
'size' => 'integer',
];
/**
* relationships
**/
public function calendar(): BelongsTo public function calendar(): BelongsTo
{ {
return $this->belongsTo(Calendar::class, 'calendarid'); return $this->belongsTo(Calendar::class, 'calendarid');
} }
/* ui-specific metadata (category, reminders, extra json) */
public function meta(): HasOne public function meta(): HasOne
{ {
return $this->hasOne(EventMeta::class, 'event_id'); return $this->hasOne(EventMeta::class, 'event_id');
} }
/** /**
* filter events in event_meta by start and end time * scopes and helpers
*/ **/
public function scopeInRange($query, $start, $end) public function scopeInRange($query, $start, $end)
{ {
return $query->whereHas('meta', function ($q) use ($start, $end) { return $query->whereHas('meta', function ($q) use ($start, $end) {
$q->where('start_at', '<=', $end) $q->where('start_at', '<=', $end)
->where(function ($qq) use ($start) { ->where(function ($qq) use ($start) {
$qq->where('end_at', '>=', $start) $qq->where('end_at', '>=', $start)
->orWhereNull('end_at'); // open-ended events ->orWhereNull('end_at');
}); });
}); });
} }
/**
* ccnvenience wrapper for calendar controller
*/
public static function forCalendarsInRange($calendarIds, $start, $end) public static function forCalendarsInRange($calendarIds, $start, $end)
{ {
return static::query() return static::query()
->with('meta') // eager-load meta once ->with('meta')
->whereIn('calendarid', $calendarIds) ->whereIn('calendarid', $calendarIds)
->inRange($start, $end) // ← the scope above ->inRange($start, $end)
->get(); ->get();
} }
} }

View File

@ -12,6 +12,7 @@ class EventMeta extends Model
public $incrementing = false; public $incrementing = false;
protected $fillable = [ protected $fillable = [
'event_id',
'title', 'title',
'description', 'description',
'location', 'location',
@ -32,9 +33,39 @@ class EventMeta extends Model
'extra' => 'array', 'extra' => 'array',
]; ];
/**
* convenience wrapper that mimics an “UPSERT” for meta rows.
*
* @param int $eventId calendarobjects.id
* @param array $attrs keyed the same way youd pass to updateOrCreate
* @return static
*/
public static function upsertForEvent(int $eventId, array $attrs): self
{
$values = array_merge($attrs, ['event_id' => $eventId]);
return static::updateOrCreate(
['event_id' => $eventId], // lookup
$values // insert/update
);
}
/**
*
* relationships
*/
/* back-reference to Sabres calendarobjects row */ /* back-reference to Sabres calendarobjects row */
public function event(): BelongsTo public function event(): BelongsTo
{ {
return $this->belongsTo(Event::class, 'event_id'); return $this->belongsTo(Event::class, 'event_id');
} }
/* back-reference to location record */
public function location(): BelongsTo
{
return $this->belongsTo(Location::class, 'location_id');
}
} }

29
app/Models/Location.php Normal file
View File

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Location extends Model
{
protected $table = 'locations';
protected $fillable = [
'display_name',
'raw_address',
'street',
'city',
'state',
'postal',
'country',
'lat',
'lon',
'phone',
'hours_json',
];
protected $casts = [
'lat' => 'float',
'lon' => 'float',
];
}

View File

@ -2,10 +2,11 @@
namespace App\Models; namespace App\Models;
use App\Models\CalendarMeta;
use App\Models\User;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Models\User;
class Subscription extends Model class Subscription extends Model
{ {
@ -38,42 +39,4 @@ class Subscription extends Model
return $this->hasOne(\App\Models\CalendarMeta::class, return $this->hasOne(\App\Models\CalendarMeta::class,
'subscription_id'); 'subscription_id');
} }
/**
* Create a remote calendar subscription and its UI metadata (calendar_meta).
*/
public static function createWithMeta(User $user, array $data): self
{
return DB::transaction(function () use ($user, $data) {
/** insert into calendarsubscriptions */
$sub = self::create([
'uri' => (string) Str::uuid(),
'principaluri' => $user->principal_uri,
'source' => $data['source'],
'displayname' => $data['displayname'] ?? $data['source'],
'calendarcolor' => $data['calendarcolor'] ?? null,
'refreshrate' => $data['refreshrate'] ?? 'P1D',
'calendarorder' => 0,
'striptodos' => false,
'stripalarms' => false,
'stripattachments' => false,
'lastmodified' => now()->timestamp,
]);
/** create corresponding calendar_meta row */
DB::table('calendar_meta')->insert([
'subscription_id' => $sub->id,
'is_remote' => true,
'title' => $sub->displayname,
'color' => $sub->calendarcolor ?? '#1a1a1a',
'color_fg' => contrast_text_color($sub->calendarcolor ?? '#1a1a1a'),
'is_shared' => true,
'created_at' => now(),
'updated_at' => now(),
]);
return $sub;
});
}
} }

View File

@ -0,0 +1,36 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
parent::boot();
// Horizon::routeSmsNotificationsTo('15556667777');
// Horizon::routeMailNotificationsTo('example@example.com');
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewHorizon', function ($user = null) {
return in_array(optional($user)->email, [
//
]);
});
}
}

View File

@ -0,0 +1,162 @@
<?php
namespace App\Services\Location;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class Geocoder
{
public function __construct(
private array $cfg = []
) {
$this->cfg = config('services.geocoding');
}
/**
* Forward geocode a free-form string.
* Returns a normalized array for your `locations` table or null.
*/
public function forward(string $query): ?array
{
$provider = $this->cfg['provider'] ?? 'arcgis';
// Treat obvious non-address labels as non-geocodable (e.g., "Home", "Office")
if (mb_strlen(trim($query)) < 4) {
return null;
}
return match ($provider) {
'arcgis' => $this->forwardArcgis($query),
default => null,
};
}
/**
* Reverse geocode lon/lat address. (Optional)
*/
public function reverse(float $lat, float $lon): ?array
{
$provider = $this->cfg['provider'] ?? 'arcgis';
return match ($provider) {
'arcgis' => $this->reverseArcgis($lat, $lon),
default => null,
};
}
/* ---------------- ArcGIS World Geocoding ---------------- */
private function http()
{
return Http::retry(3, 500)
->timeout((int) ($this->cfg['timeout'] ?? 20))
->withHeaders([
'User-Agent' => $this->cfg['user_agent'] ?? 'Kithkin/Geocoder',
]);
}
private function arcgisBase(): string
{
return rtrim($this->cfg['arcgis']['endpoint'], '/');
}
private function forwardArcgis(string $query): ?array
{
$params = [
'singleLine' => $query,
'outFields' => $this->cfg['arcgis']['out_fields'] ?? '*',
'maxLocations'=> $this->cfg['arcgis']['max_results'] ?? 1,
'f' => 'pjson',
'token' => $this->cfg['arcgis']['api_key'],
];
// If your plan permits/requests it:
if (!empty($this->cfg['arcgis']['store'])) {
$params['forStorage'] = 'true';
}
$res = $this->http()->get($this->arcgisBase().'/findAddressCandidates', $params);
if (!$res->ok()) {
Log::warning('ArcGIS forward geocode failed', ['status' => $res->status(), 'q' => $query]);
return null;
}
$json = $res->json();
$cand = Arr::first($json['candidates'] ?? []);
if (!$cand) {
return null;
}
return $this->normalizeArcgisCandidate($cand, $query);
}
private function reverseArcgis(float $lat, float $lon): ?array
{
$params = [
// ArcGIS expects x=lon, y=lat
'location' => "{$lon},{$lat}",
'f' => 'pjson',
'token' => $this->cfg['arcgis']['api_key'],
];
if (!empty($this->cfg['arcgis']['store'])) {
$params['forStorage'] = 'true';
}
$res = $this->http()->get($this->arcgisBase().'/reverseGeocode', $params);
if (!$res->ok()) {
Log::warning('ArcGIS reverse geocode failed', ['status' => $res->status()]);
return null;
}
$j = $res->json();
$addr = $j['address'] ?? null;
if (!$addr) {
return null;
}
return [
'display_name' => $addr['LongLabel'] ?? $addr['Match_addr'] ?? null,
'raw_address' => $addr['Match_addr'] ?? null,
'street' => $addr['Address'] ?? null,
'city' => $addr['City'] ?? null,
'state' => $addr['Region'] ?? null,
'postal' => $addr['Postal'] ?? null,
'country' => $addr['CountryCode'] ?? null,
// reverseGeocode returns location as x/y too:
'lat' => Arr::get($j, 'location.y'),
'lon' => Arr::get($j, 'location.x'),
];
}
private function normalizeArcgisCandidate(array $c, string $query): array
{
$loc = $c['location'] ?? [];
$attr = $c['attributes'] ?? [];
// Prefer LongLabel → address → Place_addr
$display = $attr['LongLabel']
?? $c['address']
?? $attr['Place_addr']
?? $query;
// ArcGIS often returns both 'Address' and 'Place_addr'; use either.
$street = $attr['Address'] ?? $attr['Place_addr'] ?? null;
return [
'display_name' => $display,
'raw_address' => $query,
'street' => $street,
'city' => $attr['City'] ?? null,
'state' => $attr['Region'] ?? null,
'postal' => $attr['Postal'] ?? null,
'country' => $attr['CountryCode'] ?? null,
// location.x = lon, location.y = lat
'lat' => $loc['y'] ?? null,
'lon' => $loc['x'] ?? null,
];
}
}

View File

@ -1,18 +1,43 @@
<?php <?php
use App\Jobs\GeocodeEventLocations;
use App\Jobs\SyncSubscriptionsDispatcher;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// //
}) })
->withSchedule(function (Schedule $schedule) {
// subscription sync
$schedule->job(new SyncSubscriptionsDispatcher)
->everyTenMinutes()
->withoutOverlapping()
->onOneServer();
// geocode missing events (every 10 mins, avoid overlap, keep jobs on one server if we scale)
$schedule->job(new GeocodeEventLocations)
->everyTenMinutes()
->withoutOverlapping()
->onOneServer();
// horizon metrics snapshot
$schedule->command('horizon:snapshot')
->everyFiveMinutes()
->onOneServer();
})
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //
})->create(); })
->create();

View File

@ -3,4 +3,5 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\DavServiceProvider::class, App\Providers\DavServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
]; ];

View File

@ -9,8 +9,10 @@
"php": "^8.2", "php": "^8.2",
"blade-ui-kit/blade-icons": "^1.8", "blade-ui-kit/blade-icons": "^1.8",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/horizon": "^5.33",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"sabre/dav": "^4.7" "sabre/dav": "^4.7",
"sabre/vobject": "^4.0"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",

82
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "d70fc92c0f938b08d8e0050b99c7ed1c", "content-hash": "d3584a1b5a5ab23ee966b3b52e112076",
"packages": [ "packages": [
{ {
"name": "blade-ui-kit/blade-icons", "name": "blade-ui-kit/blade-icons",
@ -1350,6 +1350,86 @@
}, },
"time": "2025-07-22T15:41:55+00:00" "time": "2025-07-22T15:41:55+00:00"
}, },
{
"name": "laravel/horizon",
"version": "v5.33.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/horizon.git",
"reference": "50057bca1f1dcc9fbd5ff6d65143833babd784b3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/horizon/zipball/50057bca1f1dcc9fbd5ff6d65143833babd784b3",
"reference": "50057bca1f1dcc9fbd5ff6d65143833babd784b3",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-pcntl": "*",
"ext-posix": "*",
"illuminate/contracts": "^9.21|^10.0|^11.0|^12.0",
"illuminate/queue": "^9.21|^10.0|^11.0|^12.0",
"illuminate/support": "^9.21|^10.0|^11.0|^12.0",
"nesbot/carbon": "^2.17|^3.0",
"php": "^8.0",
"ramsey/uuid": "^4.0",
"symfony/console": "^6.0|^7.0",
"symfony/error-handler": "^6.0|^7.0",
"symfony/polyfill-php83": "^1.28",
"symfony/process": "^6.0|^7.0"
},
"require-dev": {
"mockery/mockery": "^1.0",
"orchestra/testbench": "^7.0|^8.0|^9.0|^10.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^9.0|^10.4|^11.5",
"predis/predis": "^1.1|^2.0"
},
"suggest": {
"ext-redis": "Required to use the Redis PHP driver.",
"predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0)."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Horizon": "Laravel\\Horizon\\Horizon"
},
"providers": [
"Laravel\\Horizon\\HorizonServiceProvider"
]
},
"branch-alias": {
"dev-master": "6.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Horizon\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Dashboard and code-driven configuration for Laravel queues.",
"keywords": [
"laravel",
"queue"
],
"support": {
"issues": "https://github.com/laravel/horizon/issues",
"source": "https://github.com/laravel/horizon/tree/v5.33.1"
},
"time": "2025-06-16T13:48:30+00:00"
},
{ {
"name": "laravel/prompts", "name": "laravel/prompts",
"version": "v0.3.6", "version": "v0.3.6",

213
config/horizon.php Normal file
View File

@ -0,0 +1,213 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Horizon Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Horizon will be accessible from. If this
| setting is null, Horizon will reside under the same domain as the
| application. Otherwise, this value will serve as the subdomain.
|
*/
'domain' => env('HORIZON_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Horizon Path
|--------------------------------------------------------------------------
|
| This is the URI path where Horizon will be accessible from. Feel free
| to change this path to anything you like. Note that the URI will not
| affect the paths of its internal API that aren't exposed to users.
|
*/
'path' => env('HORIZON_PATH', 'horizon'),
/*
|--------------------------------------------------------------------------
| Horizon Redis Connection
|--------------------------------------------------------------------------
|
| This is the name of the Redis connection where Horizon will store the
| meta information required for it to function. It includes the list
| of supervisors, failed jobs, job metrics, and other information.
|
*/
'use' => 'default',
/*
|--------------------------------------------------------------------------
| Horizon Redis Prefix
|--------------------------------------------------------------------------
|
| This prefix will be used when storing all Horizon data in Redis. You
| may modify the prefix when you are running multiple installations
| of Horizon on the same server so that they don't have problems.
|
*/
'prefix' => env(
'HORIZON_PREFIX',
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
),
/*
|--------------------------------------------------------------------------
| Horizon Route Middleware
|--------------------------------------------------------------------------
|
| These middleware will get attached onto each Horizon route, giving you
| the chance to add your own middleware to this list or change any of
| the existing middleware. Or, you can simply stick with this list.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Queue Wait Time Thresholds
|--------------------------------------------------------------------------
|
| This option allows you to configure when the LongWaitDetected event
| will be fired. Every connection / queue combination may have its
| own, unique threshold (in seconds) before this event is fired.
|
*/
'waits' => [
'redis:default' => 60,
],
/*
|--------------------------------------------------------------------------
| Job Trimming Times
|--------------------------------------------------------------------------
|
| Here you can configure for how long (in minutes) you desire Horizon to
| persist the recent and failed jobs. Typically, recent jobs are kept
| for one hour while all failed jobs are stored for an entire week.
|
*/
'trim' => [
'recent' => 60,
'pending' => 60,
'completed' => 60,
'recent_failed' => 10080,
'failed' => 10080,
'monitored' => 10080,
],
/*
|--------------------------------------------------------------------------
| Silenced Jobs
|--------------------------------------------------------------------------
|
| Silencing a job will instruct Horizon to not place the job in the list
| of completed jobs within the Horizon dashboard. This setting may be
| used to fully remove any noisy jobs from the completed jobs list.
|
*/
'silenced' => [
// App\Jobs\ExampleJob::class,
],
/*
|--------------------------------------------------------------------------
| Metrics
|--------------------------------------------------------------------------
|
| Here you can configure how many snapshots should be kept to display in
| the metrics graph. This will get used in combination with Horizon's
| `horizon:snapshot` schedule to define how long to retain metrics.
|
*/
'metrics' => [
'trim_snapshots' => [
'job' => 24,
'queue' => 24,
],
],
/*
|--------------------------------------------------------------------------
| Fast Termination
|--------------------------------------------------------------------------
|
| When this option is enabled, Horizon's "terminate" command will not
| wait on all of the workers to terminate unless the --wait option
| is provided. Fast termination can shorten deployment delay by
| allowing a new instance of Horizon to start while the last
| instance will continue to terminate each of its workers.
|
*/
'fast_termination' => false,
/*
|--------------------------------------------------------------------------
| Memory Limit (MB)
|--------------------------------------------------------------------------
|
| This value describes the maximum amount of memory the Horizon master
| supervisor may consume before it is terminated and restarted. For
| configuring these limits on your workers, see the next section.
|
*/
'memory_limit' => 64,
/*
|--------------------------------------------------------------------------
| Queue Worker Configuration
|--------------------------------------------------------------------------
|
| Here you may define the queue worker settings used by your application
| in all environments. These supervisors and settings handle all your
| queued jobs and will be provisioned by Horizon during deployment.
|
*/
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'simple',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 60,
'nice' => 0,
],
],
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
],
'local' => [
'supervisor-1' => [
'maxProcesses' => 3,
],
],
],
];

View File

@ -35,4 +35,19 @@ return [
], ],
], ],
/* custom geocoding service values */
'geocoding' => [
'provider' => env('GEOCODER', 'arcgis'),
'timeout' => (int) env('GEOCODER_TIMEOUT', 20),
'user_agent' => env('GEOCODER_USER_AGENT', 'Kithkin/LocalDev'),
'arcgis' => [
'api_key' => env('ARCGIS_API_KEY'),
'store' => (bool) env('ARCGIS_STORE_RESULTS', true),
'endpoint' => 'https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer',
// keep these compact and stable
'out_fields' => 'Match_addr,Addr_type,PlaceName,Place_addr,Address,City,Region,Postal,CountryCode,LongLabel',
'max_results' => 1,
],
],
]; ];

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('calendar_meta', function (Blueprint $table) {
$table->unsignedInteger('mirror_calendar_id')
->nullable()
->after('subscription_id');
});
}
public function down(): void
{
Schema::table('calendar_meta', function (Blueprint $table) {
$table->dropColumn('mirror_calendar_id');
});
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
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'
);
});
}
public function down(): void
{
Schema::table('calendarinstances', function (Blueprint $table) {
$table->dropUnique('uniq_user_feed_mirror');
});
}
};

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
// add composite + geo + optional fulltext indexes to locations
public function up(): void
{
Schema::table('locations', function (Blueprint $table) {
// composite btree index for common lookups
$table->index(
['display_name', 'city', 'state', 'postal', 'country'],
'locations_name_city_idx'
);
// simple btree index to speed bounding-box geo lookups
$table->index(['lat', 'lon'], 'locations_lat_lon_idx');
// optional: fulltext index for free-form text searching
// note: requires mysql/mariadb version with innodb fulltext support
$table->fullText('raw_address', 'locations_raw_address_fulltext');
});
}
// drop the indexes added in up()
public function down(): void
{
Schema::table('locations', function (Blueprint $table) {
$table->dropIndex('locations_name_city_idx');
$table->dropIndex('locations_lat_lon_idx');
$table->dropFullText('locations_raw_address_fulltext');
});
}
};

View File

@ -161,20 +161,23 @@ main {
@apply flex flex-row items-center justify-between w-full; @apply flex flex-row items-center justify-between w-full;
@apply bg-white sticky top-0; @apply bg-white sticky top-0;
/* if h1 exists it means there's no aside, so force the width from that */ /* app hedar; if h1 exists it means there's no aside, so force the width from that */
h1 { h1 {
@apply relative flex items-center pl-6 2xl:pl-8; @apply relative flex items-center pl-6 2xl:pl-8;
width: minmax(20rem, 20dvw); width: minmax(20rem, 20dvw);
} }
/* actual page header */
h2 { h2 {
@apply flex flex-row gap-1 items-center justify-start relative top-2px; @apply flex flex-row gap-1 items-center justify-start relative top-2px;
animation: title-drop 350ms ease-out both;
> span { > span {
@apply text-gray-700; @apply text-gray-700;
} }
} }
/* header menu */
menu { menu {
@apply flex flex-row items-center justify-end gap-4; @apply flex flex-row items-center justify-end gap-4;
} }
@ -223,3 +226,15 @@ main {
fill: var(--color-cyan-500); fill: var(--color-cyan-500);
} }
} }
/* animations */
@keyframes title-drop {
from {
opacity: 0;
transform: translateY(-1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -31,17 +31,23 @@
@apply bg-cyan-100; @apply bg-cyan-100;
} }
&:nth-child(-n+7) { /* need to handle this for various 4, 5, and 7 day configs */
&:nth-child(-n+7) { /* first 7 items */
@apply border-t-2; @apply border-t-2;
} }
&:nth-last-child(-n+7) { /* last 7 items */
&:last-child { @apply border-b-md;
@apply rounded-br-lg;
} }
/*&:last-child {
@apply rounded-br-lg;
}*/
/* events */
.event { .event {
@apply flex items-center text-xs gap-1 px-1 py-px font-medium truncate rounded-sm bg-transparent; @apply flex items-center text-xs gap-1 px-1 py-px font-medium truncate rounded-sm bg-transparent;
transition: background-color 125ms ease-in-out; transition: background-color 125ms ease-in-out;
animation: event-slide 350ms ease-in-out both;
.indicator { .indicator {
--indicator-bg: var(--event-color); --indicator-bg: var(--event-color);
@ -66,3 +72,16 @@
} }
} }
} }
/* animations */
@keyframes event-slide {
from {
opacity: 0;
transform: translateX(-1rem);
}
to {
opacity: 1;
transform: translateX(0);
}
}

View File

@ -6,7 +6,7 @@
</p> </p>
</div> </div>
<form method="post" <form method="post"
action="{{ route('calendar.settings.subscribe.store') }}" action="{{ route('subscriptions.store') }}"
class="form-grid-1 mt-8"> class="form-grid-1 mt-8">
@csrf @csrf
@ -23,7 +23,11 @@
placeholder="Phases of the moon..." placeholder="Phases of the moon..."
required="true" required="true"
description="If you leave this blank, we'll try to make a best guess for the name." /> description="If you leave this blank, we'll try to make a best guess for the name." />
<x-input.text-label name="color" type="color" label="{{ __('Calendar color') }}" /> <x-input.text-label
name="color"
type="color"
value="#06D2A1"
label="{{ __('Calendar color') }}" />
<div class="flex gap-4"> <div class="flex gap-4">
<x-button variant="primary" type="submit">{{ __('Subscribe') }}</x-button> <x-button variant="primary" type="submit">{{ __('Subscribe') }}</x-button>

View File

@ -18,9 +18,9 @@
@endif @endif
@if ($event->meta->description) @if ($event->meta->description)
<div> <p>
{!! nl2br(e($event->meta->description)) !!} {!! Str::markdown(nl2br(e($event->meta->description))) !!}
</div> </p>
@endif @endif
</x-modal.body> </x-modal.body>
</x-modal.content> </x-modal.content>