From f133696e3f29504361f3fb25b59899af8575410f Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 15 Jun 2026 21:20:19 -0700 Subject: [PATCH] =?UTF-8?q?date-wizard:=20Calendars=20panel=20=E2=80=94=20?= =?UTF-8?q?copy/rotate=20subscription=20link=20+=20connectors=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single 'Connect Google…' button with a 'Calendars…' panel: - Your AesthetiCal subscription link (GET ?calToken=1) with Copy + Rotate (POST {rotateToken}); the secret full-calendar ICS URL to paste into Apple/Google Calendar. - Connected calendars list with per-row Remove; 'Connect a Calendar…' adds another inbound feed (reopens the connect sheet after this one dismisses to avoid sheet-stacking). --- date-wizard/Sources/DateWizard/CalAPI.swift | 27 +++ .../Sources/DateWizard/WizardController.swift | 155 +++++++++++++++++- 2 files changed, 180 insertions(+), 2 deletions(-) diff --git a/date-wizard/Sources/DateWizard/CalAPI.swift b/date-wizard/Sources/DateWizard/CalAPI.swift index d9a68fb608..7e4c4f9dc9 100644 --- a/date-wizard/Sources/DateWizard/CalAPI.swift +++ b/date-wizard/Sources/DateWizard/CalAPI.swift @@ -69,6 +69,13 @@ private struct FeedEventsEnvelope: Codable { } private struct FeedsEnvelope: Codable { var feeds: [Feed] } private struct FeedEnvelope: Codable { var feed: Feed } + +// The caller's secret subscription link (full calendar, private included). +struct SubscriptionLink: Codable { + var token: String + var code: String? + var url: String? // ready-to-paste ICS subscription URL +} private struct OkEnvelope: Codable { var ok: Bool } private struct MessageEnvelope: Codable { var message: String? } @@ -209,6 +216,26 @@ final class CalAPI { } } + // Fetch (minting on first use) the caller's secret subscription link. + func fetchSubscriptionLink( + completion: @escaping (Result) -> Void) { + var comps = URLComponents(string: "\(Self.base)/api/cal")! + comps.queryItems = [URLQueryItem(name: "calToken", value: "1")] + request(url: comps.url!, method: "GET", body: nil) { result in + completion(result.flatMap { Self.decode(SubscriptionLink.self, $0) }) + } + } + + // Rotate the secret (invalidates all existing subscription URLs). + func rotateSubscriptionLink( + completion: @escaping (Result) -> Void) { + let body: [String: Any] = ["rotateToken": true] + request(url: URL(string: "\(Self.base)/api/cal")!, method: "POST", + body: Self.json(body)) { result in + completion(result.flatMap { Self.decode(SubscriptionLink.self, $0) }) + } + } + // ── plumbing ───────────────────────────────────────────────────── // Run an authed request; hops back to the main queue with raw Data. diff --git a/date-wizard/Sources/DateWizard/WizardController.swift b/date-wizard/Sources/DateWizard/WizardController.swift index 817d8881cf..840b02948a 100644 --- a/date-wizard/Sources/DateWizard/WizardController.swift +++ b/date-wizard/Sources/DateWizard/WizardController.swift @@ -41,6 +41,11 @@ final class WizardController: NSWindowController, NSWindowDelegate, WeekViewDele private var authOverlay: NSView? private var authStatusLabel: NSTextField? + // Calendars panel (subscription link + connectors), live-updated by async loads. + private var subLinkField: NSTextField? + private var connectorsBox: NSView? + private weak var calendarsWindow: NSWindow? + private var editor: EventEditor? // ── lifecycle ──────────────────────────────────────────────────── @@ -123,7 +128,7 @@ final class WizardController: NSWindowController, NSWindowDelegate, WeekViewDele todayButton = NSButton(title: "Today", target: self, action: #selector(goToday)) refreshButton = NSButton(title: "Refresh", target: self, action: #selector(refreshAction)) addButton = NSButton(title: "+ Event", target: self, action: #selector(addEventAction)) - connectButton = NSButton(title: "Connect Google…", target: self, action: #selector(connectFeedAction)) + connectButton = NSButton(title: "Calendars…", target: self, action: #selector(calendarsPanelAction)) modeButton = NSButton(title: dayCount == 1 ? "Week" : "Day", target: self, action: #selector(toggleModeAction)) @@ -136,7 +141,7 @@ final class WizardController: NSWindowController, NSWindowDelegate, WeekViewDele // Lay buttons out right-to-left across the top. var x: CGFloat = 840 - pad let widths: [(NSButton, CGFloat)] = [ - (connectButton, 130), (addButton, 78), (refreshButton, 76), + (connectButton, 96), (addButton, 78), (refreshButton, 76), (modeButton, 54), (todayButton, 58), (nextButton, 30), (prevButton, 30), ] for (b, w) in widths { @@ -385,6 +390,152 @@ final class WizardController: NSWindowController, NSWindowDelegate, WeekViewDele } } + // ── calendars panel: subscription link + connectors ────────────── + @objc private func calendarsPanelAction() { + guard let window else { return } + let W: CGFloat = 470 + let container = NSView(frame: NSRect(x: 0, y: 0, width: W, height: 248)) + + let linkTitle = NSTextField(labelWithString: "Your AesthetiCal subscription link") + linkTitle.font = .systemFont(ofSize: 12, weight: .semibold) + linkTitle.frame = NSRect(x: 0, y: 226, width: W, height: 18) + container.addSubview(linkTitle) + + let hint = NSTextField(labelWithString: "Subscribe in Apple/Google Calendar. Full calendar (private incl.) — keep it secret.") + hint.font = .systemFont(ofSize: 10); hint.textColor = .secondaryLabelColor + hint.frame = NSRect(x: 0, y: 210, width: W, height: 14) + container.addSubview(hint) + + let field = NSTextField(frame: NSRect(x: 0, y: 182, width: 352, height: 22)) + field.isEditable = false; field.isSelectable = true + field.font = .monospacedSystemFont(ofSize: 10, weight: .regular) + field.stringValue = "Loading…" + container.addSubview(field) + self.subLinkField = field + + let copyBtn = NSButton(title: "Copy", target: self, action: #selector(copySubLink)) + copyBtn.bezelStyle = .rounded + copyBtn.frame = NSRect(x: 356, y: 181, width: 48, height: 24) + container.addSubview(copyBtn) + let rotateBtn = NSButton(title: "Rotate", target: self, action: #selector(rotateSubLink)) + rotateBtn.bezelStyle = .rounded + rotateBtn.frame = NSRect(x: 406, y: 181, width: 60, height: 24) + container.addSubview(rotateBtn) + + let sep = NSBox(frame: NSRect(x: 0, y: 166, width: W, height: 1)) + sep.boxType = .separator + container.addSubview(sep) + + let connTitle = NSTextField(labelWithString: "Connected calendars") + connTitle.font = .systemFont(ofSize: 12, weight: .semibold) + connTitle.frame = NSRect(x: 0, y: 142, width: 300, height: 18) + container.addSubview(connTitle) + + let box = NSView(frame: NSRect(x: 0, y: 0, width: W, height: 134)) + container.addSubview(box) + self.connectorsBox = box + + let alert = NSAlert() + alert.messageText = "Calendars" + alert.accessoryView = container + alert.addButton(withTitle: "Done") + alert.addButton(withTitle: "Connect a Calendar…") + alert.beginSheetModal(for: window) { [weak self] resp in + guard let self else { return } + self.subLinkField = nil + self.connectorsBox = nil + // "Connect a Calendar…" — present its sheet now that this one is gone. + if resp == .alertSecondButtonReturn { self.connectFeedAction() } + } + + loadSubLink() + reloadConnectors() + } + + private func loadSubLink() { + api.fetchSubscriptionLink { [weak self] r in + guard let self else { return } + switch r { + case .success(let link): + self.subLinkField?.stringValue = link.url ?? "(no link — handle not set)" + case .failure(let e): + if case .unauthorized = e { self.handleUnauthorized() } + self.subLinkField?.stringValue = "Error: \(e.localizedDescription)" + } + } + } + + @objc private func copySubLink() { + guard let s = subLinkField?.stringValue, s.hasPrefix("http") else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(s, forType: .string) + statusLabel.stringValue = "Subscription link copied." + } + + @objc private func rotateSubLink() { + subLinkField?.stringValue = "Rotating…" + api.rotateSubscriptionLink { [weak self] r in + guard let self else { return } + switch r { + case .success(let link): + self.subLinkField?.stringValue = link.url ?? "(no link)" + self.statusLabel.stringValue = "Subscription link rotated — old links revoked." + case .failure(let e): + if case .unauthorized = e { self.handleUnauthorized() } + self.subLinkField?.stringValue = "Error: \(e.localizedDescription)" + } + } + } + + private func reloadConnectors() { + api.fetchFeeds { [weak self] r in + guard let self, let box = self.connectorsBox else { return } + box.subviews.forEach { $0.removeFromSuperview() } + switch r { + case .success(let feeds): + if feeds.isEmpty { + let l = NSTextField(labelWithString: "No calendars connected yet — use “Connect a Calendar…”.") + l.font = .systemFont(ofSize: 11); l.textColor = .secondaryLabelColor + l.frame = NSRect(x: 0, y: box.bounds.height - 22, width: box.bounds.width, height: 18) + box.addSubview(l) + return + } + var y = box.bounds.height - 26 + for feed in feeds { + let name = NSTextField(labelWithString: feed.label ?? "Calendar") + name.font = .systemFont(ofSize: 11) + name.frame = NSRect(x: 0, y: y + 3, width: 340, height: 18) + box.addSubview(name) + let rm = NSButton(title: "Remove", target: self, + action: #selector(self.removeConnector(_:))) + rm.bezelStyle = .rounded + rm.frame = NSRect(x: box.bounds.width - 84, y: y, width: 84, height: 24) + rm.identifier = NSUserInterfaceItemIdentifier(feed.id) + box.addSubview(rm) + y -= 30 + } + case .failure(let e): + if case .unauthorized = e { self.handleUnauthorized() } + } + } + } + + @objc private func removeConnector(_ sender: NSButton) { + guard let id = sender.identifier?.rawValue else { return } + sender.isEnabled = false + api.removeFeed(id: id) { [weak self] r in + guard let self else { return } + switch r { + case .success: + self.reloadConnectors() + self.refresh() + case .failure(let e): + sender.isEnabled = true + if case .unauthorized = e { self.handleUnauthorized() } + } + } + } + // ── connect a Google calendar ──────────────────────────────────── @objc private func connectFeedAction() { guard let window else { return } -- 2.51.2