diff --git a/routes/cookbook_schedule_routes.py b/routes/cookbook_schedule_routes.py index b298311..a87b427 100644 --- a/routes/cookbook_schedule_routes.py +++ b/routes/cookbook_schedule_routes.py @@ -205,4 +205,62 @@ def setup_cookbook_schedule_routes() -> APIRouter: from src.cookbook_scheduler import _reconcile_once return await _reconcile_once() + @router.post("/ensure-calendar") + async def ensure_calendar(request: Request): + """Ensure a calendar named \"Cookbook\" exists for the current user + and is registered as the scheduler calendar in settings. Idempotent. + + Used by the Schedule button so the user never has to pick: the + first click creates the calendar and wires it up; subsequent + clicks return the existing href. + """ + require_admin(request) + from src.settings import get_setting, _save_settings, _load_settings + existing = get_setting("cookbook_schedule_calendar_href", "") or "" + # Verify the saved href still exists in /api/calendar/calendars + # (the user might have deleted the calendar manually) by hitting + # the list endpoint loopback. + import httpx + from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN + headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN} + live: list = [] + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get("http://localhost:7000/api/calendar/calendars", headers=headers) + live = (r.json() or {}).get("calendars", []) if r.status_code < 400 else [] + except Exception: + live = [] + if existing and any(c.get("href") == existing for c in live): + match = next(c for c in live if c.get("href") == existing) + return {"ok": True, "href": existing, "name": match.get("name"), "created": False} + # No usable cookbook calendar — see if one named "Cookbook" already + # exists (perhaps from a previous setup), else create a fresh one. + match = next((c for c in live if (c.get("name") or "").lower() == "cookbook"), None) + if match: + href = match.get("href") + else: + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.post( + "http://localhost:7000/api/calendar/calendars", + params={"name": "Cookbook", "color": "#3b82f6"}, + headers=headers, + ) + data = r.json() if r.content else {} + except Exception as exc: + raise HTTPException(500, f"create calendar failed: {exc}") + if not data.get("ok"): + raise HTTPException(500, f"create calendar failed: {data}") + href = data.get("id") or "" + if not href: + raise HTTPException(500, "no href returned from calendar create") + # Persist into settings (bypasses DEFAULT_SETTINGS guard by using + # _load/_save directly since we know this key is whitelisted). + s = _load_settings() + s["cookbook_schedule_calendar_href"] = href + if not s.get("cookbook_scheduler_enabled"): + s["cookbook_scheduler_enabled"] = True + _save_settings(s) + return {"ok": True, "href": href, "name": "Cookbook", "created": True} + return router diff --git a/static/js/calendar.js b/static/js/calendar.js index 31a4423..4088867 100644 --- a/static/js/calendar.js +++ b/static/js/calendar.js @@ -3346,3 +3346,42 @@ window.addEventListener('calendar-refresh', () => { const calendarModule = { openCalendar, closeCalendar, isCalendarOpen }; export { openCalendar, openCalendarTo, closeCalendar, isCalendarOpen }; export default calendarModule; + +// ── Cookbook scheduler hook ───────────────────────────────────────────── +// Lets the Cookbook tab's Schedule button open the standard calendar +// event-create form pre-filled with the model's name + cookbook YAML in +// the description, on the auto-created Cookbook calendar. Keeps the +// Schedule UX identical to creating any other calendar event — no +// custom modal, just the existing flow that users already know. +window.cookbookOpenScheduleForm = function (draft) { + // Open the calendar first so #cal-body exists for _showEventForm. + if (!_open) openCalendar(); + // Defer a tick so the modal DOM is mounted before we touch it. + setTimeout(() => { + _showEventForm(null, _today(), _today()); + setTimeout(() => { + try { + const sumEl = document.getElementById('cal-f-sum'); + if (sumEl && draft && draft.summary) sumEl.value = draft.summary; + const descEl = document.getElementById('cal-f-desc'); + if (descEl && draft && draft.description) descEl.value = draft.description; + const rrEl = document.getElementById('cal-f-rrule'); + if (rrEl && draft && draft.rrule) rrEl.value = draft.rrule; + // Calendar selector lives behind the "Add details" expand; force- + // expand so the user sees it's heading into the Cookbook calendar. + const form = document.querySelector('.cal-form-bespoke'); + if (form) form.classList.add('is-expanded'); + const calSel = document.getElementById('cal-f-cal'); + if (calSel && draft && draft.calendar_href) { + const opt = Array.from(calSel.options).find(o => o.value === draft.calendar_href); + if (opt) calSel.value = draft.calendar_href; + } + // Focus the title so the user can immediately type if they want + // to rename the event (rare, but cheap to enable). + if (sumEl) sumEl.focus(); + } catch (e) { + console.warn('cookbook schedule prefill failed:', e); + } + }, 60); + }, _open ? 0 : 80); +}; diff --git a/static/js/cookbookSchedule.js b/static/js/cookbookSchedule.js index c0f612a..21a7659 100644 --- a/static/js/cookbookSchedule.js +++ b/static/js/cookbookSchedule.js @@ -201,16 +201,18 @@ } // Click-binding: any .hwfit-serve-schedule button inside a serve - // panel opens the modal with the panel's current config. - document.addEventListener("click", (e) => { + // panel routes to the STANDARD calendar event-creation form, with the + // model's name pre-filled as the event title and a `cookbook:` YAML + // block in the description. The event lands on the auto-created + // "Cookbook" calendar so the reconciler picks it up. The custom + // openModal() above is kept as a fallback in case the calendar + // module hasn't loaded. + document.addEventListener("click", async (e) => { const btn = e.target.closest && e.target.closest(".hwfit-serve-schedule"); if (!btn) return; e.preventDefault(); e.stopPropagation(); - // Reach into the serve panel to read current config. cookbookServe.js - // stores the active config on the panel root via data attributes - // that we read here without coupling further. const panel = btn.closest("[data-cookbook-serve-panel]") || btn.closest(".doclib-card-expanded") || btn.closest(".doclib-card"); const ds = panel ? panel.dataset || {} : {}; const config = { @@ -221,7 +223,47 @@ host: ds.host || "", port: ds.port ? Number(ds.port) : undefined, }; - openModal(config); + + // Ensure the Cookbook calendar exists and is configured. Returns + // the href to feed into the event form. + btn.disabled = true; + let calHref = ""; + try { + const r = await fetch("/api/cookbook/schedule/ensure-calendar", { + method: "POST", credentials: "same-origin", + }); + if (r.ok) { + const data = await r.json(); + calHref = data.href || ""; + } + } catch (_) {} + btn.disabled = false; + + // Build the cookbook: YAML block that goes into the event description. + // The reconciler parses this to know HOW to launch when the window + // opens. If only the title is set, the reconciler title-matches + // against saved presets. + const yamlLines = ["cookbook:"]; + for (const k of ["preset", "repo_id", "cmd", "host", "port"]) { + if (config[k]) yamlLines.push(` ${k}: ${config[k]}`); + } + if (yamlLines.length === 1 && config.title) { + yamlLines.push(` preset: ${config.title}`); + } + const draft = { + summary: config.title, + description: yamlLines.join("\n"), + rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", // default: weekdays + calendar_href: calHref, + }; + + if (typeof window.cookbookOpenScheduleForm === "function") { + window.cookbookOpenScheduleForm(draft); + } else { + // Fallback to the legacy in-house modal if the calendar module + // hasn't loaded for some reason. + openModal(config); + } }); // Reveal Schedule buttons once we confirm the feature is enabled. diff --git a/static/js/cookbookServe.js b/static/js/cookbookServe.js index 160a140..945a353 100644 --- a/static/js/cookbookServe.js +++ b/static/js/cookbookServe.js @@ -744,12 +744,14 @@ function _rerenderCachedModels() { // pushes Cancel + Launch to the right. panelHtml += ``; panelHtml += ``; - // Schedule button — opens a modal that converts the current serve - // config into a calendar event. Hidden unless the scheduler feature - // flag is on (cookbookSchedule.js reads /api/cookbook/schedule/upcoming - // on init and toggles visibility). - panelHtml += ``; + // Launch + Schedule pair. The little ° button sits flush against + // Launch and opens the calendar event-creation form pre-filled + // with this model's config. Hidden until cookbookSchedule.js + // confirms the scheduler feature flag is on. + panelHtml += ``; panelHtml += ``; + panelHtml += ``; + panelHtml += ``; panelHtml += ``; panelHtml += ``; diff --git a/static/style.css b/static/style.css index aa58b43..75f5279 100644 --- a/static/style.css +++ b/static/style.css @@ -35829,3 +35829,29 @@ body.theme-frosted .modal { .cookbook-schedule-modal { width: 96vw; } .cookbook-schedule-day { padding: 8px 14px; font-size: 14px; } } + +/* Launch+Schedule paired button group. The ° button is a tiny icon + button glued to the right side of Launch with a thin divider. */ +.hwfit-serve-launch-group { + display: inline-flex; + align-items: stretch; + vertical-align: middle; +} +.hwfit-serve-launch-group .hwfit-serve-launch { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; + margin-right: 0 !important; +} +.hwfit-serve-launch-group .hwfit-serve-schedule { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + border-left: 1px solid rgba(255,255,255,.18) !important; + padding: 0 8px !important; + min-width: 26px; + display: inline-flex !important; + align-items: center; + justify-content: center; +} +.hwfit-serve-launch-group .hwfit-serve-schedule svg { + flex-shrink: 0; +}