405 lines
13 KiB
PHP
405 lines
13 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers;
|
||
|
||
use Carbon\Carbon;
|
||
use Illuminate\Http\Request;
|
||
use App\Models\Calendar;
|
||
use App\Models\Event;
|
||
use App\Services\Calendar\CreateCalendar;
|
||
use App\Services\Calendar\CalendarRangeResolver;
|
||
use App\Services\Calendar\CalendarViewBuilder;
|
||
use App\Services\Calendar\CalendarSettingsPersister;
|
||
use App\Services\Event\EventRecurrence;
|
||
|
||
class CalendarController extends Controller
|
||
{
|
||
/**
|
||
* Consolidated calendar dashboard.
|
||
*
|
||
* Query params:
|
||
* view = month | week | four (default: month)
|
||
* date = Y-m-d anchor date (default: today, in user TZ)
|
||
*
|
||
* The view receives a `$payload` array:
|
||
* ├─ view current view name
|
||
* ├─ range ['start' => Carbon, 'end' => Carbon]
|
||
* ├─ calendars keyed by calendar id (for the left-hand toggle list)
|
||
* └─ events flat list of VEVENTs in that range
|
||
*/
|
||
public function index(
|
||
Request $request,
|
||
EventRecurrence $recurrence,
|
||
CalendarRangeResolver $rangeResolver,
|
||
CalendarViewBuilder $viewBuilder,
|
||
CalendarSettingsPersister $settingsPersister
|
||
)
|
||
{
|
||
/**
|
||
*
|
||
* manage parameters and core variables
|
||
*/
|
||
|
||
// set the calendar key
|
||
$principal = auth()->user()->principal_uri;
|
||
|
||
// user settings
|
||
$user = $request->user();
|
||
$tz = $user->timezone ?? config('app.timezone');
|
||
$timeFormat = $user->getSetting('app.time_format', '12');
|
||
|
||
// settings
|
||
$defaults = $settingsPersister->defaults($user, $tz);
|
||
$weekStart = $defaults['week_start'];
|
||
$weekEnd = $defaults['week_end'];
|
||
|
||
// get the view and time range
|
||
[$view, $range] = $rangeResolver->resolveRange(
|
||
$request,
|
||
$tz,
|
||
$weekStart,
|
||
$weekEnd,
|
||
$defaults['view'],
|
||
$defaults['date']
|
||
);
|
||
|
||
$density = $settingsPersister->resolveDensity($request, $defaults['density']);
|
||
$stepMinutes = $density['step'];
|
||
$labelEvery = $density['label_every'];
|
||
|
||
$daytimeHoursEnabled = $settingsPersister->resolveDaytimeHours($request, $defaults['daytime_hours']);
|
||
$daytimeHoursRange = $settingsPersister->daytimeHoursRange();
|
||
$daytimeHoursRows = $daytimeHoursEnabled
|
||
? intdiv((($daytimeHoursRange['end'] - $daytimeHoursRange['start']) * 60), 15)
|
||
: 96;
|
||
$daytimeHoursForView = ($daytimeHoursEnabled && in_array($view, ['day', 'week', 'four'], true))
|
||
? $daytimeHoursRange
|
||
: null;
|
||
|
||
// date range span and controls
|
||
$span = $rangeResolver->gridSpan($view, $range, $weekStart, $weekEnd);
|
||
$nav = $rangeResolver->navDates($view, $range['start'], $tz);
|
||
|
||
// get the user's visible calendars from the left bar
|
||
$visible = collect($request->query('c', []));
|
||
|
||
// keep a stable anchor date for forms that aren't the nav buttons
|
||
$anchorDate = $request->query('date', now($tz)->toDateString());
|
||
|
||
// persist settings
|
||
$settingsPersister->persist(
|
||
$user,
|
||
$request,
|
||
$view,
|
||
$range['start'],
|
||
$stepMinutes,
|
||
$daytimeHoursEnabled
|
||
);
|
||
|
||
/**
|
||
*
|
||
* calendars
|
||
*/
|
||
|
||
$calendars = Calendar::query()
|
||
->dashboardForPrincipal($principal)
|
||
->get();
|
||
|
||
$calendars = $calendars->map(function ($cal) use ($visible) {
|
||
$cal->visible = $visible->isEmpty() || $visible->contains($cal->slug);
|
||
return $cal;
|
||
});
|
||
|
||
$calendar_map = $calendars->keyBy('id');
|
||
|
||
/**
|
||
*
|
||
* get events for calendars in range
|
||
*/
|
||
|
||
// get all the events in one query
|
||
$events = Event::forCalendarsInRange(
|
||
$calendars->pluck('id'),
|
||
$span['start'],
|
||
$span['end']
|
||
);
|
||
|
||
// build event payload
|
||
$events = $viewBuilder->buildEventPayloads(
|
||
$events,
|
||
$calendar_map,
|
||
$timeFormat,
|
||
$view,
|
||
$range,
|
||
$tz,
|
||
$recurrence,
|
||
$span,
|
||
$daytimeHoursForView,
|
||
);
|
||
|
||
/**
|
||
*
|
||
* mini calendar
|
||
*/
|
||
|
||
// create the mini calendar grid based on the mini cal controls
|
||
$mini_anchor = $request->query('mini', $range['start']->toDateString());
|
||
$mini_anchor_date = $rangeResolver->safeDate($mini_anchor, $tz, $range['start']->toDateString());
|
||
|
||
$mini_start = $mini_anchor_date->copy()->startOfMonth();
|
||
|
||
$mini_nav = [
|
||
'prev' => $mini_start->copy()->subMonth()->toDateString(),
|
||
'next' => $mini_start->copy()->addMonth()->toDateString(),
|
||
'today' => Carbon::today($tz)->startOfMonth()->toDateString(),
|
||
'label' => $mini_start->format('F Y'),
|
||
];
|
||
$mini_headers = $viewBuilder->weekdayHeaders($tz, $weekStart);
|
||
|
||
// compute the mini's 42-day span (Mon..Sun, 6 rows)
|
||
$mini_grid_start = $mini_start->copy()->startOfWeek($weekStart);
|
||
$mini_grid_end = $mini_start->copy()->endOfMonth()->endOfWeek($weekEnd);
|
||
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
|
||
);
|
||
|
||
$mini_events = $viewBuilder->buildEventPayloads(
|
||
$mini_events,
|
||
$calendar_map,
|
||
$timeFormat,
|
||
$view,
|
||
['start' => $mini_grid_start, 'end' => $mini_grid_end],
|
||
$tz,
|
||
$recurrence,
|
||
['start' => $mini_grid_start, 'end' => $mini_grid_end],
|
||
null,
|
||
);
|
||
|
||
// now build the mini from mini_events (not from $events)
|
||
$mini = $viewBuilder->buildMiniGrid($mini_start, $mini_events, $tz, $weekStart, $weekEnd);
|
||
|
||
/**
|
||
*
|
||
* main calendar grid
|
||
*/
|
||
|
||
// create the calendar grid of days
|
||
$grid = $viewBuilder->buildCalendarGrid($view, $range, $events, $tz, $span);
|
||
|
||
// get the title
|
||
$header = $rangeResolver->headerTitle($view, $range['start'], $range['end']);
|
||
|
||
// format the data for the frontend, including separate arrays for events specifically and the big grid
|
||
$payload = [
|
||
'view' => $view,
|
||
'range' => $range,
|
||
'nav' => $nav,
|
||
'header' => $header,
|
||
'week_start' => $weekStart,
|
||
'hgroup' => $viewBuilder->viewHeaders($view, $range, $tz, $weekStart),
|
||
'events' => $events, // keyed by occurrence
|
||
'grid' => $grid, // day objects hold only ID-sets
|
||
'mini' => $mini, // mini calendar days with events for indicators
|
||
'mini_nav' => $mini_nav, // separate mini calendar navigation
|
||
'mini_headers' => $mini_headers,
|
||
'active' => [
|
||
'date' => $range['start']->toDateString(),
|
||
'year' => $range['start']->format('Y'),
|
||
'month' => $range['start']->format("F"),
|
||
'day' => $range['start']->format("d"),
|
||
],
|
||
'daytime_hours' => [
|
||
'enabled' => $daytimeHoursEnabled,
|
||
'start' => $daytimeHoursRange['start'],
|
||
'end' => $daytimeHoursRange['end'],
|
||
'rows' => $daytimeHoursRows,
|
||
],
|
||
'timezone' => $tz,
|
||
'calendars' => $calendars->mapWithKeys(function ($cal)
|
||
{
|
||
$color = $cal->meta_color
|
||
?? $cal->calendarcolor
|
||
?? default_calendar_color();
|
||
$colorFg = $cal->meta_color_fg
|
||
?? contrast_text_color($color);
|
||
|
||
return [
|
||
$cal->id => [
|
||
'id' => $cal->id,
|
||
'slug' => $cal->slug,
|
||
'name' => $cal->displayname,
|
||
'color' => $color,
|
||
'color_fg' => $colorFg,
|
||
'visible' => $cal->visible,
|
||
'is_remote' => $cal->is_remote,
|
||
],
|
||
];
|
||
}),
|
||
];
|
||
|
||
// time-based payload values
|
||
$timeBased = in_array($view, ['day', 'week', 'four'], true);
|
||
if ($timeBased) {
|
||
// create the time gutter if we're in a time-based view
|
||
$payload['slots'] = $viewBuilder->timeSlots(
|
||
$range['start'],
|
||
$tz,
|
||
$timeFormat,
|
||
$daytimeHoursEnabled ? $daytimeHoursRange : null
|
||
);
|
||
$payload['time_format'] = $timeFormat; // optional, if the blade cares
|
||
|
||
// add the now indicator
|
||
$payload['now'] = $viewBuilder->nowIndicator(
|
||
$view,
|
||
$range,
|
||
$tz,
|
||
15,
|
||
1,
|
||
$daytimeHoursEnabled ? $daytimeHoursRange : null
|
||
);
|
||
}
|
||
|
||
// send the density array always, even though it doesn't matter for month
|
||
$payload['density'] = [
|
||
'step' => $stepMinutes, // 15|30|60
|
||
'label_every' => $labelEvery, // 1|2|4
|
||
'anchor' => $anchorDate,
|
||
];
|
||
|
||
return view('calendar.index', $payload);
|
||
}
|
||
|
||
/**
|
||
* create sabre calendar + meta
|
||
*/
|
||
public function store(Request $request, CreateCalendar $creator)
|
||
{
|
||
$data = $request->validate([
|
||
'name' => 'required|string|max:100',
|
||
'description' => 'nullable|string|max:255',
|
||
'timezone' => 'required|string|max:64',
|
||
'color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
|
||
'redirect' => 'nullable|string', // where to go after creating
|
||
]);
|
||
|
||
$creator->create($request->user(), $data);
|
||
$redirect = $data['redirect'] ?? route('calendar.index');
|
||
|
||
return redirect($redirect)->with('toast', [
|
||
'message' => __('Calendar created!'),
|
||
'type' => 'success',
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* show calendar details
|
||
*/
|
||
public function show(Calendar $calendar)
|
||
{
|
||
$this->authorize('view', $calendar);
|
||
|
||
$calendar->load([
|
||
'meta',
|
||
'instances' => fn ($q) => $q->where('principaluri', auth()->user()->uri),
|
||
]);
|
||
|
||
/* grab the single instance for convenience in the view */
|
||
$instance = $calendar->instances->first();
|
||
$caldavUrl = $instance?->caldavUrl(); // null-safe
|
||
|
||
/* events + meta, newest first */
|
||
$events = $calendar->events()
|
||
->with('meta')
|
||
->orderByDesc('lastmodified')
|
||
->get();
|
||
|
||
return view(
|
||
'calendar.show',
|
||
compact('calendar', 'instance', 'events', 'caldavUrl')
|
||
);
|
||
}
|
||
|
||
/**
|
||
* edit calendar page
|
||
*/
|
||
public function edit(Calendar $calendar)
|
||
{
|
||
$this->authorize('update', $calendar);
|
||
|
||
$calendar->load([
|
||
'meta',
|
||
'instances' => fn ($q) =>
|
||
$q->where('principaluri', auth()->user()->uri),
|
||
]);
|
||
|
||
$instance = $calendar->instances->first(); // may be null but shouldn’t
|
||
|
||
return view('calendar.edit', compact('calendar', 'instance'));
|
||
}
|
||
|
||
/**
|
||
* update sabre + meta records
|
||
*/
|
||
public function update(Request $request, Calendar $calendar)
|
||
{
|
||
$this->authorize('update', $calendar);
|
||
|
||
$data = $request->validate([
|
||
'name' => 'required|string|max:100',
|
||
'description' => 'nullable|string|max:255',
|
||
'timezone' => 'required|string',
|
||
'color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
|
||
]);
|
||
|
||
// update the instance row
|
||
$calendar->instances()
|
||
->where('principaluri', auth()->user()->uri)
|
||
->update([
|
||
'displayname' => $data['name'],
|
||
'description' => $data['description'] ?? '',
|
||
'calendarcolor' => $data['color'] ?? null,
|
||
'timezone' => $data['timezone'],
|
||
]);
|
||
|
||
// bump synctoken on master calendar row
|
||
$calendar->increment('synctoken');
|
||
|
||
// update calendar meta (our table)
|
||
$color = calendar_color($data);
|
||
$calendar->meta()->updateOrCreate([], [
|
||
'color' => $color,
|
||
'color_fg' => contrast_text_color($color),
|
||
]);
|
||
|
||
return redirect()
|
||
->route('calendar.show', $calendar)
|
||
->with('toast', __('Calendar saved successfully!'));
|
||
}
|
||
|
||
/**
|
||
*
|
||
* Delete calendar
|
||
* @todo
|
||
*/
|
||
public function destroy(Calendar $calendar)
|
||
{
|
||
$this->authorize('delete', $calendar);
|
||
$calendar->delete(); // cascades to meta via FK
|
||
return redirect()->route('calendar.index');
|
||
}
|
||
|
||
/**
|
||
*
|
||
* Private helpers
|
||
*/
|
||
|
||
}
|