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:
parent
98fb10bc14
commit
80c368525a
22
.env.example
22
.env.example
@ -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
|
||||||
|
84
README.md
84
README.md
@ -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.
|
||||||
|
@ -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'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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!'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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')
|
||||||
|
145
app/Jobs/GeocodeEventLocations.php
Normal file
145
app/Jobs/GeocodeEventLocations.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
160
app/Jobs/SyncSubscription.php
Normal file
160
app/Jobs/SyncSubscription.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
23
app/Jobs/SyncSubscriptionsDispatcher.php
Normal file
23
app/Jobs/SyncSubscriptionsDispatcher.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
@ -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, …) */
|
||||||
|
@ -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
|
||||||
|
@ -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.
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 you’d 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 Sabre’s calendarobjects row */
|
/* back-reference to Sabre’s 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
29
app/Models/Location.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
36
app/Providers/HorizonServiceProvider.php
Normal file
36
app/Providers/HorizonServiceProvider.php
Normal 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, [
|
||||||
|
//
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
162
app/Services/Location/Geocoder.php
Normal file
162
app/Services/Location/Geocoder.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
@ -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,
|
||||||
];
|
];
|
||||||
|
@ -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
82
composer.lock
generated
@ -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
213
config/horizon.php
Normal 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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
@ -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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user