diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e73f37b..bb50df5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,8 @@ on: jobs: test: - # macos-14 valida que el proyecto compila y el dominio corre en Sonoma - # (el target mínimo declarado); macos-15 es el entorno principal. + # macos-14 verifies the project builds and the domain runs on Sonoma + # (the declared minimum target); macos-15 is the main environment. strategy: fail-fast: false matrix: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index adc5101..0365fff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ name: Release -# Al pushear un tag vX.Y.Z: compila, empaqueta y publica el release. -# Los releases dejan de depender de la máquina de nadie. +# On pushing a vX.Y.Z tag: build, package and publish the release. +# Releases stop depending on anyone's machine. on: push: tags: ["v*"] diff --git a/Corner/App/AppDelegate.swift b/Corner/App/AppDelegate.swift index 0c409d2..1447194 100644 --- a/Corner/App/AppDelegate.swift +++ b/Corner/App/AppDelegate.swift @@ -2,7 +2,7 @@ import AppKit import SwiftData import Carbon.HIToolbox -/// Composition root: único lugar donde se cablean las piezas (ARCHITECTURE §2). +/// Composition root: the only place where the pieces are wired together (ARCHITECTURE §2). @main @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { @@ -29,14 +29,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidFinishLaunching(_ notification: Notification) { - // Como test host no se cablea nada: los tests instancian sus propias - // piezas, y el guard de instancia única mataría al test runner si la - // app real está corriendo a la vez. + // As a test host nothing is wired: tests build their own pieces, and + // the single-instance guard would kill the test runner while the real + // app is running. guard ProcessInfo.processInfo.environment["XCTestSessionIdentifier"] == nil else { return } terminateIfAlreadyRunning() - // Tooltips (.help) más ágiles: el default del sistema (~2 s) hace que - // el texto completo de una tarea truncada tarde en aparecer (SPEC §6.8). + // Snappier tooltips (.help): the system default (~2 s) makes the full + // text of a truncated task take too long to appear (SPEC §6.8). UserDefaults.standard.register(defaults: ["NSInitialToolTipDelay": 1000]) let clock = SystemDayClock() @@ -66,15 +66,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { onToggleSync: { [weak self] in self?.toggleReminderSync() }, isSyncEnabled: { [weak self] in self?.reminderSync.isEnabled ?? false } ) - // Alarmas por tarea (SPEC §4.1.6): Corner solo notifica cuando el sync - // está apagado; con sync activo las entrega Recordatorios. + // Per-task alarms (SPEC §4.1.6): Corner only notifies when sync is off; + // with sync on, Reminders delivers them. notifications = NotificationScheduler(store: store) notifications.isDeliveryDelegated = { [weak self] in self?.reminderSync.isEnabled ?? false } notifications.onOpenPanel = { [weak self] in self?.togglePanelAtCursor() } - // Fan-out de los guardados locales a los dos interesados. + // Fan-out of local saves to the two interested parties. store.onDidSave = { [weak self] in self?.reminderSync.scheduleSync() self?.notifications.scheduleRefresh() @@ -83,7 +83,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { reminderSync.start() notifications.scheduleRefresh() - // Cableado hot corner <-> panel (no se conocen entre sí, ARCHITECTURE §3.4). + // Hot corner <-> panel wiring (they don't know each other, ARCHITECTURE §3.4). hotCorner.onTrigger = { [weak self] screen in self?.panelController.show(on: screen) self?.hotCorner.suspend() @@ -92,7 +92,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.hotCorner.resumeAfterCooldown() } - // Atajos globales (SPEC §2): ⌥Espacio panel, ⇧⌥Espacio captura rápida. + // Global shortcuts (SPEC §2): ⌥Space panel, ⇧⌥Space quick capture. hotKeys = HotKeyCenter() hotKeys.onHotKey = { [weak self] id in switch id { @@ -105,7 +105,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { hotKeys.register(id: 1, keyCode: UInt32(kVK_Space), modifiers: UInt32(optionKey)) hotKeys.register(id: 2, keyCode: UInt32(kVK_Space), modifiers: UInt32(optionKey | shiftKey)) - // Triggers del rollover (SPEC §4): arranque, medianoche, despertar de reposo. + // Rollover triggers (SPEC §4): launch, midnight, wake from sleep. rollover.performRolloverIfNeeded() dayChangeObserver = NotificationCenter.default.addObserver( forName: .NSCalendarDayChanged, object: nil, queue: .main @@ -130,8 +130,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if usedFallback { showStorageAlert() } - // Primera ejecución (SPEC §2): abrir el panel una única vez para que - // el usuario descubra qué instaló; el tip interno hace el resto. + // First run (SPEC §2): open the panel once so the user discovers what + // they installed; the in-panel tip does the rest. if !UserDefaults.standard.bool(forKey: "didLaunchBefore") { UserDefaults.standard.set(true, forKey: "didLaunchBefore") if let screen = cursorScreen { @@ -147,8 +147,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { }) ?? NSScreen.main } - /// Toggle del panel por menú o atajo: abrir suspende el hot corner - /// igual que abrir por esquina (SPEC §2). + /// Panel toggle from the menu or a shortcut: opening suspends the hot + /// corner just like opening from the corner itself (SPEC §2). private func togglePanelAtCursor() { guard let screen = cursorScreen else { return } let wasVisible = panelController.isVisible @@ -161,7 +161,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { quickCapture.toggle(on: screen) } - /// Alterna el sync con Recordatorios (SPEC §8); permiso denegado → alerta. + /// Toggles Reminders sync (SPEC §8); permission denied → alert. private func toggleReminderSync() { Task { @MainActor [weak self] in guard let self else { return } @@ -176,9 +176,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } - // MARK: - Persistencia (ARCHITECTURE §4) + // MARK: - Persistence (ARCHITECTURE §4) - /// Store en Application Support; si falla, arranca en memoria con alerta única (SPEC §6.10). + /// Store in Application Support; on failure it starts in memory with a single alert (SPEC §6.10). private static func makeContainer() -> (ModelContainer, usedFallback: Bool) { let schema = Schema([TaskItem.self, TaskCategory.self]) do { @@ -192,7 +192,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } catch { NSLog("Corner: no se pudo abrir el store en disco, usando memoria: \(error)") let config = ModelConfiguration(isStoredInMemoryOnly: true) - // Si ni el store en memoria puede crearse, no hay app posible. + // If not even the in-memory store can be created, there is no app to run. let container = try! ModelContainer(for: schema, configurations: config) return (container, true) } @@ -206,8 +206,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { alert.runModal() } - /// Un guardado o consulta falló en caliente (SPEC §6.10): avisar una sola - /// vez por sesión, nunca fallar en silencio. + /// A save or fetch failed at runtime (SPEC §6.10): warn once per session, + /// never fail silently. private var didShowPersistenceAlert = false private func showPersistenceAlertOnce(_ error: Error) { @@ -229,8 +229,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } -/// Referencia débil para que los closures de notificaciones (no aislados) -/// reentren al MainActor sin capturar self. +/// Weak reference so the notification closures (non-isolated) can re-enter +/// the MainActor without capturing self. @MainActor private enum AppAccess { static weak var shared: AppDelegate? diff --git a/Corner/Domain/DayClock.swift b/Corner/Domain/DayClock.swift index 7e134b1..44278f4 100644 --- a/Corner/Domain/DayClock.swift +++ b/Corner/Domain/DayClock.swift @@ -1,30 +1,30 @@ import Foundation -/// Única fuente de "¿qué día es hoy?". Nadie llama Date() para lógica de fechas -/// fuera de este archivo (ARCHITECTURE §3.3); eso hace testeable el rollover -/// y la vista semanal con fechas fijas. +/// Single source of "what day is today?". Nobody calls Date() for date logic +/// outside this file (ARCHITECTURE §3.3); that is what makes the rollover +/// and the week view testable with fixed dates. protocol DayClock { var now: Date { get } var calendar: Calendar { get } } extension DayClock { - /// Hoy, normalizado a startOfDay. + /// Today, normalized to startOfDay. var today: Date { calendar.startOfDay(for: now) } func startOfDay(_ date: Date) -> Date { calendar.startOfDay(for: date) } - /// Los 7 días (startOfDay) de la semana actual, lunes a domingo. + /// The 7 days (startOfDay) of the current week, Monday to Sunday. func currentWeek() -> [Date] { var cal = calendar - cal.firstWeekday = 2 // lunes + cal.firstWeekday = 2 // Monday guard let interval = cal.dateInterval(of: .weekOfYear, for: now) else { return [] } return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: interval.start) } } - /// Destinos de "Mover a…" (SPEC §4.1.5): mañana + días restantes de la - /// semana + lunes próximo en fin de semana. Ordenados; el primero es - /// siempre mañana. + /// "Move to…" destinations (SPEC §4.1.5): tomorrow + the remaining days of + /// the week + next Monday on weekends. Sorted; the first one is always + /// tomorrow. func moveDestinations() -> [Date] { let today = self.today var days = Set(currentWeek().filter { $0 > today }) @@ -45,7 +45,7 @@ struct SystemDayClock: DayClock { var calendar: Calendar { Calendar.current } } -/// Reloj fijo para tests. +/// Fixed clock for tests. struct FixedDayClock: DayClock { var now: Date var calendar: Calendar = Calendar(identifier: .gregorian) diff --git a/Corner/Domain/HotCornerLogic.swift b/Corner/Domain/HotCornerLogic.swift index e8b640d..e0845b0 100644 --- a/Corner/Domain/HotCornerLogic.swift +++ b/Corner/Domain/HotCornerLogic.swift @@ -1,10 +1,10 @@ import Foundation import CoreGraphics -/// Máquina de estados pura del hot corner (SPEC §2): permanencia, cooldown y -/// suspensión, sin timers ni AppKit — el crash de reordenar demostró que la -/// capa sin tests es donde viven los bugs, así que la decisión de "cuándo -/// disparar" se saca del monitor y se testea aquí. +/// Pure state machine for the hot corner (SPEC §2): dwell, cooldown and +/// suspension, with no timers or AppKit — the reorder crash proved that the +/// untested layer is where bugs live, so the "when to fire" decision was +/// pulled out of the monitor and is tested here. struct HotCornerLogic { var zoneSize: CGFloat = 12 var dwell: TimeInterval = 0.25 @@ -19,8 +19,8 @@ struct HotCornerLogic { case trigger } - /// Un tick de polling: posición del cursor, frame de la pantalla que lo - /// contiene (nil si ninguna) y el instante actual. + /// One polling tick: cursor position, frame of the screen containing it + /// (nil if none) and the current instant. mutating func poll(location: CGPoint, screenFrame: CGRect?, at now: Date) -> Verdict { guard !suspended, let frame = screenFrame else { dwellStart = nil @@ -45,13 +45,13 @@ struct HotCornerLogic { return .none } - /// El panel está visible: no re-disparar. + /// The panel is visible: do not fire again. mutating func suspend() { suspended = true dwellStart = nil } - /// El panel se cerró: reanudar con cooldown (SPEC §2). + /// The panel closed: resume with a cooldown (SPEC §2). mutating func resumeAfterCooldown(at now: Date) { suspended = false dwellStart = nil diff --git a/Corner/Domain/ReminderSchedule.swift b/Corner/Domain/ReminderSchedule.swift index 4087be1..a5b3ecb 100644 --- a/Corner/Domain/ReminderSchedule.swift +++ b/Corner/Domain/ReminderSchedule.swift @@ -1,36 +1,36 @@ import Foundation -/// Alarma pendiente de programar (SPEC §4.1.6). Struct neutral: el dominio -/// no conoce UserNotifications ni EventKit. +/// An alarm pending scheduling (SPEC §4.1.6). Neutral struct: the domain +/// knows nothing about UserNotifications or EventKit. struct PendingReminder: Equatable, Sendable { var taskID: UUID var title: String var fireDate: Date } -/// Qué alarmas deben estar programadas y a qué hora exacta. Función pura: -/// toda la política (pendientes, futuras, solo nivel superior) se testea aquí -/// en vez de dentro del scheduler del sistema. +/// Which alarms must be scheduled and at what exact time. Pure function: +/// the whole policy (pending, future, top-level only) is tested here +/// instead of inside the system scheduler. enum ReminderSchedule { - /// Vista mínima de una tarea para decidir su alarma. + /// Minimal view of a task for deciding its alarm. struct Candidate: Equatable, Sendable { var id: UUID var title: String - /// startOfDay del día asignado. + /// startOfDay of the assigned day. var day: Date var isDone: Bool var remindMinutes: Int? var isTopLevel: Bool } - /// Instante exacto del aviso: día asignado + minutos. + /// Exact instant of the alert: assigned day + minutes. static func fireDate(day: Date, minutes: Int, calendar: Calendar) -> Date { calendar.date(byAdding: .minute, value: minutes, to: calendar.startOfDay(for: day)) ?? day } - /// Alarmas a programar: nivel superior, pendientes, con hora, y cuyo - /// instante aún no pasó. Ordenadas por cercanía. + /// Alarms to schedule: top-level, pending, with a time, and whose instant + /// has not passed yet. Sorted by proximity. static func pending(from candidates: [Candidate], now: Date, calendar: Calendar = .current) -> [PendingReminder] { candidates.compactMap { candidate -> PendingReminder? in @@ -44,15 +44,15 @@ enum ReminderSchedule { .sorted { $0.fireDate < $1.fireDate } } - // MARK: - Opciones del menú "Recordarme" (SPEC §4.1.6) + // MARK: - "Remind me" menu options (SPEC §4.1.6) - /// Horas absolutas ofrecidas, en minutos desde medianoche. + /// Absolute times offered, in minutes from midnight. static let presetMinutes = [9 * 60, 12 * 60, 15 * 60, 18 * 60, 21 * 60] - /// Desplazamientos relativos ofrecidos, en minutos (solo para hoy). + /// Relative offsets offered, in minutes (today only). static let relativeOffsets = [5, 30, 60, 180] - /// Presets válidos para un día: en el día de hoy solo las horas que aún - /// no pasaron; en días futuros, todas. + /// Valid presets for a day: on today, only the times that have not passed + /// yet; on future days, all of them. static func availablePresets(for day: Date, now: Date, calendar: Calendar = .current) -> [Int] { let isToday = calendar.isDate(day, inSameDayAs: now) @@ -62,8 +62,8 @@ enum ReminderSchedule { return presetMinutes.filter { $0 > currentMinutes } } - /// Minutos del día correspondientes a "dentro de N minutos"; nil si eso - /// cruzaría a mañana (la alarma vive dentro de su día). + /// Minutes of the day for "in N minutes"; nil if that would cross into + /// tomorrow (an alarm lives within its own day). static func minutesFromNow(_ offset: Int, now: Date, calendar: Calendar = .current) -> Int? { let target = now.addingTimeInterval(TimeInterval(offset * 60)) @@ -72,7 +72,7 @@ enum ReminderSchedule { + calendar.component(.minute, from: target) } - /// "14:30" a partir de los minutos del día. + /// "14:30" from the minutes of the day. static func label(forMinutes minutes: Int) -> String { String(format: "%02d:%02d", minutes / 60, minutes % 60) } diff --git a/Corner/Domain/RolloverService.swift b/Corner/Domain/RolloverService.swift index df1445f..bd7ba88 100644 --- a/Corner/Domain/RolloverService.swift +++ b/Corner/Domain/RolloverService.swift @@ -2,12 +2,12 @@ import Foundation import SwiftData import os -/// Reasigna a hoy toda tarea pendiente con fecha pasada (SPEC §4, "rollover"). -/// Idempotente: correr dos veces el mismo día no hace nada la segunda vez. +/// Reassigns to today every pending task with a past date (SPEC §4, "rollover"). +/// Idempotent: running it twice on the same day does nothing the second time. /// -/// Este servicio NO se suscribe a notificaciones de sistema: el AppDelegate -/// (capa Sistema) escucha NSCalendarDayChanged y didWakeNotification y llama -/// a performRolloverIfNeeded() — así el dominio queda libre de AppKit +/// This service does NOT subscribe to system notifications: the AppDelegate +/// (System layer) listens to NSCalendarDayChanged and didWakeNotification and +/// calls performRolloverIfNeeded() — that keeps the domain free of AppKit /// (ARCHITECTURE §3.1). @MainActor final class RolloverService { @@ -25,9 +25,9 @@ final class RolloverService { func performRolloverIfNeeded() { let today = clock.today - // Consultas con predicado en base de datos: no se carga el historial - // completo en memoria (ARCHITECTURE §4). Solo tareas de nivel superior: - // las subtareas siguen a su padre (SPEC §4.2). + // Queries with a database predicate: the full history is never loaded into + // memory (ARCHITECTURE §4). Top-level tasks only: subtasks follow their + // parent (SPEC §4.2). let overdueDescriptor = FetchDescriptor( predicate: #Predicate { !$0.isDone && $0.assignedDay < today && $0.parent == nil }, sortBy: [SortDescriptor(\.assignedDay), SortDescriptor(\.sortOrder)] @@ -43,17 +43,17 @@ final class RolloverService { overdue = try context.fetch(overdueDescriptor) existingToday = try context.fetch(todayDescriptor) } catch { - log.error("fetch falló, rollover omitido: \(error, privacy: .public)") + log.error("fetch failed, rollover skipped: \(error, privacy: .public)") return } - // El indicador "arrastrada" solo aplica al día en que migró. + // The "rolled over" marker only applies to the day it migrated. guard !overdue.isEmpty else { return } for (index, task) in overdue.enumerated() { task.assignedDay = today task.rolledOver = true task.sortOrder = index - task.updatedAt = Date() // el rollover gana sobre un remoto viejo (SPEC §8) + task.updatedAt = Date() // the rollover wins over a stale remote version (SPEC §8) for subtask in task.subtasks ?? [] { subtask.assignedDay = today } } for (index, task) in existingToday.enumerated() { @@ -63,7 +63,7 @@ final class RolloverService { do { try context.save() } catch { - log.error("save falló tras rollover: \(error, privacy: .public)") + log.error("save failed after rollover: \(error, privacy: .public)") } onDidRollover() } diff --git a/Corner/Domain/SyncPlanner.swift b/Corner/Domain/SyncPlanner.swift index 0ffc942..7348d4c 100644 --- a/Corner/Domain/SyncPlanner.swift +++ b/Corner/Domain/SyncPlanner.swift @@ -1,21 +1,21 @@ import Foundation -/// Snapshot neutral de un recordatorio remoto (sin tipos de EventKit: -/// el dominio queda puro y testeable; el adaptador convierte). +/// Neutral snapshot of a remote reminder (no EventKit types: the domain +/// stays pure and testable; the adapter converts). struct RemoteReminder: Equatable, Sendable { var id: String var title: String - /// startOfDay; nil = sin fecha de vencimiento. + /// startOfDay; nil = no due date. var day: Date? var isDone: Bool var modifiedAt: Date - /// Lista de Recordatorios donde vive (SPEC §8 v2.1: lista = categoría). + /// Reminders list it lives in (SPEC §8 v2.1: list = category). var listID: String = "" - /// Hora del aviso en minutos del día; nil = sin alarma (SPEC §4.1.6). + /// Alert time in minutes of the day; nil = no alarm (SPEC §4.1.6). var remindMinutes: Int? = nil } -/// Vista mínima de una tarea local para planear el sync. +/// Minimal view of a local task for planning the sync. struct LocalTaskView: Equatable, Sendable { var id: UUID var reminderID: String? @@ -27,25 +27,25 @@ struct LocalTaskView: Equatable, Sendable { var remindMinutes: Int? = nil } -/// Qué hacer para converger local y remoto (SPEC §8). Función pura: -/// toda la política de conflictos vive aquí, con tests. +/// What to do to converge local and remote (SPEC §8). Pure function: +/// the entire conflict policy lives here, with tests. enum SyncAction: Equatable, Sendable { - /// Tarea local sin recordatorio → crearlo. + /// Local task with no reminder → create it. case pushCreate(taskID: UUID) - /// La versión local es más nueva → actualizar el recordatorio. + /// The local version is newer → update the reminder. case pushUpdate(taskID: UUID, reminderID: String) - /// La versión remota es más nueva → actualizar la tarea. + /// The remote version is newer → update the task. case pullUpdate(taskID: UUID, remote: RemoteReminder) - /// Recordatorio nuevo en la lista (iPhone/Siri) → crear tarea. + /// New reminder in the list (iPhone/Siri) → create the task. case pullCreate(RemoteReminder) - /// El recordatorio vinculado ya no existe → borrar la tarea local. + /// The linked reminder no longer exists → delete the local task. case deleteLocal(taskID: UUID) } enum SyncPlanner { - /// Última escritura gana; sin diferencias de contenido no hay acción. - /// `listForCategory` mapea categoría (nil = sin categoría) → lista de - /// Recordatorios esperada; una tarea en la lista equivocada difiere. + /// Last write wins; with no content differences there is no action. + /// `listForCategory` maps category (nil = uncategorized) → expected + /// Reminders list; a task in the wrong list counts as different. static func plan(local: [LocalTaskView], remote: [RemoteReminder], listForCategory: [UUID?: String] = [:]) -> [SyncAction] { var actions: [SyncAction] = [] @@ -61,9 +61,9 @@ enum SyncPlanner { continue } guard differs(task, match, listForCategory) else { continue } - // Empate de timestamps → gana lo local: garantiza convergencia - // (p. ej. un recordatorio sin fecha creado por Siri recibe la - // fecha que Corner le asignó, en vez de re-pull perpetuo). + // Timestamp tie → local wins: this guarantees convergence (e.g. a + // reminder created by Siri with no date takes the date Corner assigned + // instead of being pulled back forever). if task.updatedAt >= match.modifiedAt { actions.append(.pushUpdate(taskID: task.id, reminderID: reminderID)) } else { @@ -71,7 +71,7 @@ enum SyncPlanner { } } - // Lo que quedó en remoto sin pareja local es alta desde el iPhone/Siri. + // Whatever is left on the remote side with no local match is an addition from iPhone/Siri. for reminder in remoteByID.values.sorted(by: { $0.modifiedAt < $1.modifiedAt }) { actions.append(.pullCreate(reminder)) } @@ -86,7 +86,7 @@ enum SyncPlanner { || task.remindMinutes != reminder.remindMinutes { return true } - // Lista equivocada = categoría cambió en algún lado (SPEC §8 v2.1). + // Wrong list = the category changed on one side (SPEC §8 v2.1). if let expected = listForCategory[task.categoryID], expected != reminder.listID { return true } diff --git a/Corner/Domain/TaskCategory.swift b/Corner/Domain/TaskCategory.swift index f2c1373..ea79537 100644 --- a/Corner/Domain/TaskCategory.swift +++ b/Corner/Domain/TaskCategory.swift @@ -1,20 +1,20 @@ import Foundation import SwiftData -/// Categoría de tareas (SPEC §4.1): una tarea pertenece a lo sumo a una. -/// Mismas restricciones CloudKit que TaskItem (ARCHITECTURE §4): defaults en -/// todo, sin uniques, y la relación es opcional con inversa declarada. +/// Task category (SPEC §4.1): a task belongs to at most one. +/// Same CloudKit constraints as TaskItem (ARCHITECTURE §4): defaults on +/// everything, no uniques, and the relationship is optional with a declared inverse. @Model final class TaskCategory { var id: UUID = UUID() var name: String = "" - /// Color en hex ("#RRGGBB"); el dominio no conoce tipos de UI. + /// Hex color ("#RRGGBB"); the domain knows no UI types. var colorHex: String = "#8E8E93" var sortOrder: Int = 0 - /// Identificador de su lista espejo en Recordatorios (SPEC §8, v2.1). + /// Identifier of its mirror list in Reminders (SPEC §8, v2.1). var reminderListID: String? = nil - /// Inversa de TaskItem.category; al borrar la categoría, las tareas - /// quedan sin categoría (regla nullify por defecto). + /// Inverse of TaskItem.category; deleting the category leaves its tasks + /// uncategorized (default nullify rule). @Relationship(inverse: \TaskItem.category) var tasks: [TaskItem]? = nil diff --git a/Corner/Domain/TaskItem.swift b/Corner/Domain/TaskItem.swift index 8511784..ee7fcb3 100644 --- a/Corner/Domain/TaskItem.swift +++ b/Corner/Domain/TaskItem.swift @@ -1,36 +1,36 @@ import Foundation import SwiftData -/// Una tarea. Restricciones por compatibilidad CloudKit futura (ARCHITECTURE §4): -/// todos los atributos con default, sin @Attribute(.unique), sin relaciones. +/// A task. Constraints for future CloudKit compatibility (ARCHITECTURE §4): +/// every attribute has a default, no @Attribute(.unique), no relationships. @Model final class TaskItem { var id: UUID = UUID() var text: String = "" - /// Siempre normalizado a startOfDay; la comparación de días es por igualdad de este valor. + /// Always normalized to startOfDay; days are compared by equality of this value. var assignedDay: Date = Date() var isDone: Bool = false var completedAt: Date? = nil var createdAt: Date = Date() - /// Orden manual dentro del día. + /// Manual order within the day. var sortOrder: Int = 0 - /// true si migró desde un día anterior (se muestra con reloj ámbar durante hoy). + /// true if it migrated from an earlier day (shown with an amber clock during today). var rolledOver: Bool = false - /// Categoría opcional (SPEC §4.1). Relación opcional: requisito CloudKit. + /// Optional category (SPEC §4.1). Optional relationship: CloudKit requirement. var category: TaskCategory? = nil - /// Padre si esta tarea es una subtarea (SPEC §4.2). Un solo nivel: - /// las consultas de nivel superior filtran parent == nil. + /// Parent if this task is a subtask (SPEC §4.2). One level only: + /// top-level queries filter parent == nil. var parent: TaskItem? = nil - /// Subtareas; borrar el padre las borra (cascade). + /// Subtasks; deleting the parent deletes them (cascade). @Relationship(deleteRule: .cascade, inverse: \TaskItem.parent) var subtasks: [TaskItem]? = nil - /// Hora de aviso como minutos desde medianoche del día asignado - /// (SPEC §4.1.6); nil = sin alarma. No es fecha absoluta a propósito: - /// así la hora sobrevive al rollover y a "Mover a…". + /// Alert time as minutes from midnight of the assigned day (SPEC §4.1.6); + /// nil = no alarm. Deliberately not an absolute date: that way the time + /// survives the rollover and "Move to…". var remindMinutes: Int? = nil - /// Última modificación local; resuelve conflictos del sync (SPEC §8). + /// Last local modification; resolves sync conflicts (SPEC §8). var updatedAt: Date = Date() - /// calendarItemIdentifier del recordatorio vinculado; nil = sin sincronizar. + /// calendarItemIdentifier of the linked reminder; nil = not synced. var reminderID: String? = nil init(text: String, assignedDay: Date, sortOrder: Int = 0) { diff --git a/Corner/Domain/TaskStore.swift b/Corner/Domain/TaskStore.swift index b91ed7b..556d2a5 100644 --- a/Corner/Domain/TaskStore.swift +++ b/Corner/Domain/TaskStore.swift @@ -3,27 +3,27 @@ import SwiftData import Observation import os -/// Única puerta de lectura/escritura de tareas (ARCHITECTURE §3.2). -/// Las vistas jamás tocan SwiftData directo; eso permite activar CloudKit -/// o cambiar de motor sin tocar UI. +/// The single read/write gate for tasks (ARCHITECTURE §3.2). +/// Views never touch SwiftData directly; that is what allows enabling +/// CloudKit or swapping engines without touching the UI. @MainActor @Observable final class TaskStore { private let context: ModelContext private let clock: DayClock - /// Undo propio del panel (borrar tareas, SPEC §4). + /// The panel's own undo (deleting tasks, SPEC §4). @ObservationIgnored let undoManager = UndoManager() - /// Avisa a la capa Sistema que la persistencia falló (el AppDelegate - /// muestra alerta una sola vez, SPEC §6.10). Nunca fallar en silencio. + /// Tells the System layer that persistence failed (the AppDelegate shows + /// a single alert, SPEC §6.10). Never fail silently. @ObservationIgnored var onPersistenceError: ((Error) -> Void)? - /// Filtro de categoría activo; nil = "Todas". Persiste entre aperturas - /// del panel y entre sesiones (SPEC §4.1) — el panel se destruye al - /// cerrarse, así que este estado no puede vivir en la vista. + /// Active category filter; nil = "All". Persists across panel openings and + /// across sessions (SPEC §4.1) — the panel is destroyed when it closes, so + /// this state cannot live in the view. var filterID: UUID? { didSet { defaults.set(filterID?.uuidString, forKey: Self.filterKey) } } @@ -33,8 +33,8 @@ final class TaskStore { private static let filterKey = "selectedCategoryFilter" private static let welcomeKey = "welcomeTipDismissed" - /// Tip de bienvenida (SPEC §2, primera ejecución): visible hasta que el - /// usuario lo descarta; nunca vuelve. + /// Welcome tip (SPEC §2, first run): visible until the user dismisses it; + /// never comes back. private(set) var showsWelcomeTip = false func dismissWelcomeTip() { @@ -45,7 +45,7 @@ final class TaskStore { @ObservationIgnored private let log = Logger(subsystem: "com.andreetorres.corner", category: "TaskStore") - /// Snapshot observable; las vistas leen de aquí. Se refresca tras cada mutación. + /// Observable snapshot; views read from here. Refreshed after every mutation. private(set) var tasks: [TaskItem] = [] private(set) var categories: [TaskCategory] = [] @@ -54,7 +54,7 @@ final class TaskStore { self.clock = clock self.defaults = defaults refresh() - // Restaurar el filtro solo si la categoría aún existe. + // Restore the filter only if the category still exists. if let saved = defaults.string(forKey: Self.filterKey), let id = UUID(uuidString: saved), categories.contains(where: { $0.id == id }) { @@ -63,15 +63,15 @@ final class TaskStore { showsWelcomeTip = !defaults.bool(forKey: Self.welcomeKey) } - // MARK: - Lectura + // MARK: - Reading - /// Si un fetch falla, se conserva el último snapshot (la UI no se vacía) - /// y se reporta el error; nunca se degrada a lista vacía en silencio. + /// If a fetch fails the last snapshot is kept (the UI does not empty out) + /// and the error is reported; it never silently degrades to an empty list. /// - /// El snapshot se acota a la ventana visible (semana actual en adelante): - /// el historial de semanas pasadas permanece en disco pero no en memoria, - /// así el consumo queda constante sin importar los meses de uso - /// (ARCHITECTURE §4). El rollover hace su propia consulta de vencidas. + /// The snapshot is bounded to the visible window (current week onward): + /// the history of past weeks stays on disk but not in memory, so usage + /// stays constant no matter how many months of use (ARCHITECTURE §4). + /// The rollover runs its own query for overdue tasks. func refresh() { let weekStart = clock.currentWeek().first ?? clock.today var descriptor = FetchDescriptor( @@ -86,14 +86,14 @@ final class TaskStore { tasks = try context.fetch(descriptor) categories = try context.fetch(categoryDescriptor) } catch { - log.error("refresh falló: \(error, privacy: .public)") + log.error("refresh failed: \(error, privacy: .public)") onPersistenceError?(error) } - log.info("refresh: \(self.tasks.count) tareas en ventana, \(self.categories.count) categorías") + log.info("refresh: \(self.tasks.count) tasks in window, \(self.categories.count) categories") } - /// Tareas de nivel superior de un día: pendientes primero (orden manual), - /// hechas al final (SPEC §4). Las subtareas no aparecen aquí (SPEC §4.2). + /// Top-level tasks for a day: pending first (manual order), done at the + /// end (SPEC §4). Subtasks do not appear here (SPEC §4.2). func tasks(for day: Date) -> [TaskItem] { let d = clock.startOfDay(day) let ofDay = tasks.filter { $0.assignedDay == d && $0.parent == nil } @@ -104,21 +104,21 @@ final class TaskStore { return pending + done } - /// Subtareas en orden de creación; las hechas no se reordenan (SPEC §4.2). + /// Subtasks in creation order; done ones are not reordered (SPEC §4.2). func subtasks(of task: TaskItem) -> [TaskItem] { (task.subtasks ?? []).sorted { $0.sortOrder < $1.sortOrder } } func todayTasks() -> [TaskItem] { tasks(for: clock.today) } - /// Semana actual (lunes-domingo) como pares (día, tareas). + /// Current week (Monday-Sunday) as (day, tasks) pairs. func weekTasks() -> [(day: Date, tasks: [TaskItem])] { clock.currentWeek().map { ($0, tasks(for: $0)) } } var today: Date { clock.today } - // MARK: - Mutaciones + // MARK: - Mutations @discardableResult func add(_ text: String, to day: Date, category: TaskCategory? = nil) -> TaskItem? { @@ -138,8 +138,8 @@ final class TaskStore { save() } - /// Pone o quita la hora de aviso de una tarea de nivel superior - /// (SPEC §4.1.6). Los minutos son desde medianoche del día asignado. + /// Sets or clears the alert time of a top-level task (SPEC §4.1.6). + /// Minutes are counted from midnight of the assigned day. func setReminder(_ task: TaskItem, minutes: Int?) { guard task.parent == nil else { return } task.remindMinutes = minutes @@ -147,7 +147,7 @@ final class TaskStore { save() } - /// Candidatas para programar alarmas (la política vive en ReminderSchedule). + /// Candidates for scheduling alarms (the policy lives in ReminderSchedule). func reminderCandidates() -> [ReminderSchedule.Candidate] { tasks.map { ReminderSchedule.Candidate(id: $0.id, title: $0.text, day: $0.assignedDay, @@ -156,14 +156,14 @@ final class TaskStore { } } - /// Destinos disponibles para "Mover a…" (SPEC §4.1.5). + /// Available destinations for "Move to…" (SPEC §4.1.5). func moveDestinations() -> [Date] { clock.moveDestinations() } - /// Mueve una tarea pendiente de nivel superior a otro día; sus subtareas - /// viajan con ella. Llega al final de las pendientes del destino y deja - /// de contar como "arrastrada" (SPEC §4.1.5). + /// Moves a pending top-level task to another day; its subtasks travel with + /// it. It lands at the end of the destination's pending tasks and stops + /// counting as "rolled over" (SPEC §4.1.5). func move(_ task: TaskItem, to day: Date) { guard task.parent == nil, !task.isDone else { return } let destination = clock.startOfDay(day) @@ -179,7 +179,7 @@ final class TaskStore { save() } - // MARK: - Categorías (SPEC §4.1) + // MARK: - Categories (SPEC §4.1) @discardableResult func addCategory(_ name: String, colorHex: String) -> TaskCategory? { @@ -199,15 +199,15 @@ final class TaskStore { save() } - /// Borra la categoría; sus tareas quedan sin categoría (nullify). + /// Deletes the category; its tasks are left uncategorized (nullify). func deleteCategory(_ category: TaskCategory) { if filterID == category.id { filterID = nil } context.delete(category) save() } - /// Completado bidireccional (SPEC §4.2): el padre arrastra a sus hijas; - /// la última hija completa (o reabre) al padre. + /// Bidirectional completion (SPEC §4.2): the parent drags its children; + /// the last child completes (or reopens) the parent. func toggle(_ task: TaskItem) { let newState = !task.isDone setDone(task, newState) @@ -222,13 +222,13 @@ final class TaskStore { @discardableResult func addSubtask(_ text: String, to parent: TaskItem) -> TaskItem? { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, parent.parent == nil else { return nil } // un solo nivel + guard !trimmed.isEmpty, parent.parent == nil else { return nil } // one level only let nextOrder = ((parent.subtasks ?? []).map(\.sortOrder).max() ?? -1) + 1 let subtask = TaskItem(text: trimmed, assignedDay: parent.assignedDay, sortOrder: nextOrder) subtask.parent = parent context.insert(subtask) - reconcile(parent) // un paso nuevo pendiente reabre a un padre hecho + reconcile(parent) // a new pending step reopens a completed parent save() return subtask } @@ -239,10 +239,10 @@ final class TaskStore { task.updatedAt = Date() } - /// Restaura el invariante "nunca padre hecho con hijas pendientes" y su - /// recíproco tras cualquier cambio en las hijas. `excluding` permite - /// ignorar una subtarea recién borrada: la relación aún la contiene - /// hasta que el contexto guarda. + /// Restores the "never a done parent with pending children" invariant and + /// its converse after any change to the children. `excluding` allows + /// ignoring a just-deleted subtask: the relationship still contains it + /// until the context saves. private func reconcile(_ parent: TaskItem, excluding excludedID: UUID? = nil) { let subtasks = (parent.subtasks ?? []).filter { $0.id != excludedID } guard !subtasks.isEmpty else { return } @@ -258,8 +258,8 @@ final class TaskStore { save() } - /// Borra con undo disponible mientras el panel esté abierto (SPEC §4). - /// Borrar un padre borra sus subtareas (cascade); ⌘Z restaura todo (SPEC §4.2). + /// Deletes with undo available while the panel is open (SPEC §4). + /// Deleting a parent deletes its subtasks (cascade); ⌘Z restores it all (SPEC §4.2). func delete(_ task: TaskItem) { let snapshot = (task.text, task.assignedDay, task.isDone, task.completedAt, task.sortOrder, task.rolledOver) @@ -300,22 +300,22 @@ final class TaskStore { if let reminderID = task.reminderID, !isApplyingRemoteChanges { onDidDeleteSyncedTask?(reminderID) } - // Cascada manual: el deleteRule .cascade de SwiftData no cascadea en - // macOS 14 (lo cazó la matriz de CI) y las subtareas quedarían - // huérfanas como tareas de nivel superior. Inofensivo donde sí funciona. + // Manual cascade: SwiftData's .cascade deleteRule does not cascade on + // macOS 14 (caught by the CI matrix) and subtasks would be left orphaned + // as top-level tasks. Harmless where the rule does work. for subtask in task.subtasks ?? [] { context.delete(subtask) } context.delete(task) - // Quitar la última subtarea pendiente puede dejar al padre "todo hecho". + // Removing the last pending subtask can leave the parent "all done". if let parent { reconcile(parent, excluding: deletedID) } save() } - /// Reordena pendientes dentro de un día (para .onMove de SwiftUI). + /// Reorders pending tasks within a day (for SwiftUI's .onMove). func movePending(in day: Date, from source: IndexSet, to destination: Int) { var pending = tasks(for: day).filter { !$0.isDone } - // Los índices llegan del ForEach completo (pendientes + hechas al - // final): soltar debajo de las hechas trae un destino fuera del rango - // de pendientes — sin acotar, move(fromOffsets:) crashea. + // The indices come from the full ForEach (pending + done at the end): + // dropping below the done ones yields a destination outside the pending + // range — without clamping, move(fromOffsets:) crashes. let clampedSource = IndexSet(source.filter { $0 < pending.count }) let clampedDestination = min(destination, pending.count) guard !clampedSource.isEmpty else { return } @@ -324,23 +324,23 @@ final class TaskStore { save() } - // MARK: - Soporte de sincronización (SPEC §8) + // MARK: - Sync support (SPEC §8) - /// Notifica un guardado local (la capa Sistema lo usa para empujar el - /// sync). Silenciado mientras se aplican cambios remotos (anti-eco). + /// Signals a local save (the System layer uses it to push the sync). + /// Silenced while remote changes are being applied (anti-echo). @ObservationIgnored var onDidSave: (() -> Void)? - /// true mientras el adaptador de sync aplica cambios remotos. + /// true while the sync adapter is applying remote changes. @ObservationIgnored var isApplyingRemoteChanges = false - /// Borrado local de una tarea vinculada: avisa para eliminar su - /// recordatorio (SPEC §8). Silenciado durante cambios remotos. + /// Local deletion of a linked task: signals to remove its reminder + /// (SPEC §8). Silenced during remote changes. @ObservationIgnored var onDidDeleteSyncedTask: ((String) -> Void)? - /// Vista neutral de las tareas sincronizables (nivel superior, ventana actual). + /// Neutral view of the syncable tasks (top-level, current window). func syncSnapshot() -> [LocalTaskView] { tasks.filter { $0.parent == nil }.map { LocalTaskView(id: $0.id, reminderID: $0.reminderID, title: $0.text, @@ -350,7 +350,7 @@ final class TaskStore { } } - /// Categoría cuya lista espejo es `listID`; nil = lista general (SPEC §8 v2.1). + /// Category whose mirror list is `listID`; nil = general list (SPEC §8 v2.1). func category(forReminderList listID: String) -> TaskCategory? { categories.first { $0.reminderListID == listID } } @@ -369,9 +369,9 @@ final class TaskStore { save() } - /// Aplica una versión remota más nueva (SPEC §8). El `updatedAt` local - /// toma la fecha remota para no re-ganar el conflicto; el completado - /// arrastra a las subtareas (regla bidireccional de §4.2). + /// Applies a newer remote version (SPEC §8). The local `updatedAt` takes + /// the remote date so it does not win the conflict again; completion + /// drags the subtasks along (bidirectional rule from §4.2). func applyRemote(_ remote: RemoteReminder, to task: TaskItem) { task.text = remote.title let weekStart = clock.currentWeek().first ?? clock.today @@ -384,17 +384,17 @@ final class TaskStore { setDone(task, remote.isDone) for subtask in task.subtasks ?? [] { setDone(subtask, remote.isDone) } } - // La lista remota manda la categoría (SPEC §8 v2.1): mover un - // recordatorio a "Corner — X" desde el iPhone asigna esa categoría. + // The remote list dictates the category (SPEC §8 v2.1): moving a reminder + // to "Corner — X" from the iPhone assigns that category. task.category = category(forReminderList: remote.listID) - // La hora del recordatorio también es bidireccional (SPEC §4.1.6). + // The reminder's time is bidirectional too (SPEC §4.1.6). task.remindMinutes = remote.remindMinutes task.updatedAt = remote.modifiedAt save() } - /// Alta remota (iPhone/Siri): su fecha, o Hoy si no tiene o quedó fuera - /// de la ventana (SPEC §8). + /// Remote addition (iPhone/Siri): its own date, or Today if it has none or + /// fell outside the window (SPEC §8). @discardableResult func createFromRemote(_ remote: RemoteReminder) -> TaskItem? { let weekStart = clock.currentWeek().first ?? clock.today @@ -411,13 +411,13 @@ final class TaskStore { return task } - // MARK: - Persistencia + // MARK: - Persistence private func save() { do { try context.save() } catch { - log.error("save falló: \(error, privacy: .public)") + log.error("save failed: \(error, privacy: .public)") onPersistenceError?(error) } refresh() diff --git a/Corner/Resources/InfoPlist.xcstrings b/Corner/Resources/InfoPlist.xcstrings new file mode 100644 index 0000000..d8cbe5f --- /dev/null +++ b/Corner/Resources/InfoPlist.xcstrings @@ -0,0 +1,24 @@ +{ + "sourceLanguage": "es", + "strings": { + "NSRemindersFullAccessUsageDescription": { + "comment": "Shown by macOS when the user enables Reminders sync.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Corner only uses Reminders to sync your tasks with its own “Corner” lists, and only if you turn sync on from the menu." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Corner solo usa Recordatorios para sincronizar tus tareas con la lista 'Corner', y únicamente si activas la sincronización en el menú." + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/Corner/System/FloatingPanel.swift b/Corner/System/FloatingPanel.swift index 9ca592e..8cde629 100644 --- a/Corner/System/FloatingPanel.swift +++ b/Corner/System/FloatingPanel.swift @@ -1,8 +1,8 @@ import AppKit -/// Panel flotante base de Corner. Las ventanas .borderless devuelven -/// canBecomeKey == false y nunca reciben teclado; sin este override es -/// imposible escribir en los campos de texto. +/// Corner's base floating panel. `.borderless` windows return +/// canBecomeKey == false and never receive keyboard input; without this +/// override it is impossible to type in the text fields. final class FloatingPanel: NSPanel { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { false } diff --git a/Corner/System/HotCornerMonitor.swift b/Corner/System/HotCornerMonitor.swift index 42cd0c4..70be0ef 100644 --- a/Corner/System/HotCornerMonitor.swift +++ b/Corner/System/HotCornerMonitor.swift @@ -1,16 +1,16 @@ import AppKit -/// Detecta la permanencia del cursor en la esquina inferior izquierda (SPEC §2). -/// No sabe qué es un panel: solo emite onTrigger(screen) (ARCHITECTURE §3.4). +/// Detects the cursor dwelling in the bottom-left corner (SPEC §2). +/// It does not know what a panel is: it only emits onTrigger(screen) (ARCHITECTURE §3.4). /// -/// Implementado con polling de NSEvent.mouseLocation (10 Hz): no requiere -/// permisos de accesibilidad y funciona igual con nuestra app activa o no. +/// Implemented by polling NSEvent.mouseLocation (10 Hz): it needs no +/// accessibility permissions and works whether our app is active or not. @MainActor final class HotCornerMonitor { var onTrigger: ((NSScreen) -> Void)? - /// Toda la decisión de "cuándo disparar" vive en HotCornerLogic - /// (dominio puro, testeado); aquí solo queda el timer y AppKit. + /// The whole "when to fire" decision lives in HotCornerLogic + /// (pure domain, tested); only the timer and AppKit remain here. private var logic = HotCornerLogic() private var pollTimer: Timer? @@ -29,15 +29,15 @@ final class HotCornerMonitor { pollTimer = nil } - /// El panel está visible: no re-disparar. + /// The panel is visible: do not fire again. func suspend() { logic.suspend() } - /// El panel se cerró: reanudar con cooldown. + /// The panel closed: resume with a cooldown. func resumeAfterCooldown() { logic.resumeAfterCooldown(at: Date()) } private func poll() { let location = NSEvent.mouseLocation - // La esquina activa es la del monitor donde está el cursor (SPEC §6.1). + // The active corner belongs to the screen where the cursor is (SPEC §6.1). let screen = NSScreen.screens.first { NSMouseInRect(location, $0.frame, false) } if logic.poll(location: location, screenFrame: screen?.frame, at: Date()) == .trigger, let screen { @@ -46,8 +46,8 @@ final class HotCornerMonitor { } } -/// Referencia débil para que el closure del Timer (no aislado) reentre al MainActor -/// sin capturar self de forma que impida la desinicialización. +/// Weak reference so the Timer closure (non-isolated) re-enters the MainActor +/// without capturing self in a way that would prevent deinitialization. @MainActor private enum HotCornerAccess { static weak var shared: HotCornerMonitor? diff --git a/Corner/System/HotKeyCenter.swift b/Corner/System/HotKeyCenter.swift index 0b7b72c..b44345d 100644 --- a/Corner/System/HotKeyCenter.swift +++ b/Corner/System/HotKeyCenter.swift @@ -1,12 +1,12 @@ import AppKit import Carbon.HIToolbox -/// Atajos de teclado globales (SPEC §2). Usa Carbon RegisterEventHotKey: -/// funciona sin permisos de accesibilidad, a diferencia de los monitores -/// globales de keyDown de NSEvent. +/// Global keyboard shortcuts (SPEC §2). Uses Carbon RegisterEventHotKey: +/// it works without accessibility permissions, unlike NSEvent's global +/// keyDown monitors. @MainActor final class HotKeyCenter { - /// Recibe el id del atajo registrado. + /// Receives the id of the registered shortcut. var onHotKey: ((UInt32) -> Void)? private var hotKeyRefs: [EventHotKeyRef?] = [] @@ -17,8 +17,8 @@ final class HotKeyCenter { guard handlerRef == nil else { return } var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) - // Callback C: no puede capturar; reentra al MainActor vía HotKeyAccess. - // Los eventos Carbon del dispatcher llegan en el hilo principal. + // C callback: it cannot capture; it re-enters the MainActor via HotKeyAccess. + // Carbon dispatcher events arrive on the main thread. InstallEventHandler(GetApplicationEventTarget(), { _, event, _ in var hotKeyID = EventHotKeyID() GetEventParameter(event, EventParamName(kEventParamDirectObject), @@ -31,7 +31,7 @@ final class HotKeyCenter { HotKeyAccess.shared = self } - /// keyCode de Carbon (kVK_*) y modificadores Carbon (optionKey, shiftKey…). + /// Carbon keyCode (kVK_*) and Carbon modifiers (optionKey, shiftKey…). func register(id: UInt32, keyCode: UInt32, modifiers: UInt32) { var ref: EventHotKeyRef? let hotKeyID = EventHotKeyID(signature: Self.signature, id: id) @@ -41,7 +41,7 @@ final class HotKeyCenter { } } -/// Referencia débil para que el callback C (no aislado) reentre al MainActor. +/// Weak reference so the C callback (non-isolated) re-enters the MainActor. @MainActor private enum HotKeyAccess { static weak var shared: HotKeyCenter? diff --git a/Corner/System/MenuBarController.swift b/Corner/System/MenuBarController.swift index 7f7424c..f2874b7 100644 --- a/Corner/System/MenuBarController.swift +++ b/Corner/System/MenuBarController.swift @@ -2,8 +2,8 @@ import AppKit import ServiceManagement import Observation -/// Ícono y menú de la barra de menú (SPEC §5). Muestra la siguiente tarea -/// pendiente de hoy junto al ícono (SPEC §5.1). +/// Menu bar icon and menu (SPEC §5). Shows today's next pending task +/// next to the icon (SPEC §5.1). @MainActor final class MenuBarController: NSObject, NSMenuDelegate { private var statusItem: NSStatusItem? @@ -11,10 +11,10 @@ final class MenuBarController: NSObject, NSMenuDelegate { private let onQuickCapture: () -> Void private let store: TaskStore - /// Ítem dinámico "Completar: X" (SPEC §5); se refresca al abrir el menú. + /// Dynamic "Complete: X" item (SPEC §5); refreshed when the menu opens. private var completeItem: NSMenuItem? - /// Máximo de caracteres de la tarea en la barra (SPEC §5.1). + /// Maximum number of characters of the task shown in the bar (SPEC §5.1). private let titleLimit = 24 private let onCheckUpdates: () -> Void @@ -96,7 +96,7 @@ final class MenuBarController: NSObject, NSMenuDelegate { observeNextTask() } - /// Refresca el ítem "Completar: X" justo antes de mostrar el menú. + /// Refreshes the "Complete: X" item right before showing the menu. nonisolated func menuNeedsUpdate(_ menu: NSMenu) { MainActor.assumeIsolated { syncItem?.state = isSyncEnabled() ? .on : .off @@ -110,23 +110,23 @@ final class MenuBarController: NSObject, NSMenuDelegate { } } - // MARK: - Ícono de barra (SPEC §5) + // MARK: - Menu bar icon (SPEC §5) - /// La marca de Corner (L de esquina + palomita) como template monocromo, - /// dibujada como vector: nítida en cualquier retina y el sistema la tiñe - /// según modo claro/oscuro. Los íconos de barra deben ser plantillas (HIG). + /// Corner's mark (corner L + checkmark) as a monochrome template, drawn + /// as a vector: crisp on any retina display and tinted by the system for + /// light/dark mode. Menu bar icons must be templates (HIG). private static func makeCornerIcon() -> NSImage { let image = NSImage(size: NSSize(width: 18, height: 18), flipped: false) { _ in NSColor.black.setFill() NSColor.black.setStroke() - // Trazo en L anclado a la esquina inferior izquierda, como el logo. + // L-shaped stroke anchored to the bottom-left corner, like the logo. NSBezierPath(roundedRect: NSRect(x: 3, y: 3, width: 4.5, height: 12), xRadius: 2.25, yRadius: 2.25).fill() NSBezierPath(roundedRect: NSRect(x: 3, y: 3, width: 12.5, height: 4.5), xRadius: 2.25, yRadius: 2.25).fill() - // Palomita flotando en el hueco superior derecho. + // Checkmark floating in the top-right gap. let check = NSBezierPath() check.lineWidth = 2 check.lineCapStyle = .round @@ -142,10 +142,10 @@ final class MenuBarController: NSObject, NSMenuDelegate { return image } - // MARK: - Siguiente tarea (SPEC §5.1) + // MARK: - Next task (SPEC §5.1) - /// Observa el store y refresca el título; withObservationTracking es de un - /// solo disparo, así que se re-arma en cada cambio. + /// Observes the store and refreshes the title; withObservationTracking is + /// one-shot, so it is re-armed on every change. private func observeNextTask() { withObservationTracking { let pending = store.todayTasks().filter { !$0.isDone } @@ -155,7 +155,7 @@ final class MenuBarController: NSObject, NSMenuDelegate { } } - /// Formato "3 · Comprar café…" (SPEC §5.1); sin pendientes, solo el ícono. + /// Format "3 · Buy coffee…" (SPEC §5.1); with nothing pending, just the icon. private func applyTitle(count: Int, next: String?) { guard let button = statusItem?.button else { return } guard count > 0, let next, !next.isEmpty else { @@ -171,7 +171,7 @@ final class MenuBarController: NSObject, NSMenuDelegate { : text } - // MARK: - Acciones del menú + // MARK: - Menu actions @objc private func openPanel() { onOpenPanel() } diff --git a/Corner/System/NotificationScheduler.swift b/Corner/System/NotificationScheduler.swift index 8668fdb..956a13e 100644 --- a/Corner/System/NotificationScheduler.swift +++ b/Corner/System/NotificationScheduler.swift @@ -2,27 +2,27 @@ import AppKit import UserNotifications import os -/// Identificadores compartidos entre el scheduler (MainActor) y el delegado -/// (nonisolated): fuera de ambos para que los dos puedan leerlos. +/// Identifiers shared between the scheduler (MainActor) and the delegate +/// (nonisolated): outside both so each of them can read them. private enum NotificationIDs { static let category = "TASK_REMINDER" static let completeAction = "COMPLETE_TASK" } -/// Entrega local de las alarmas por tarea (SPEC §4.1.6). Solo actúa cuando el -/// sync con Recordatorios está apagado: con el sync activo, la alarma viaja en -/// el recordatorio y la entrega Apple (Mac + iPhone + Watch), y notificar aquí -/// duplicaría el aviso. +/// Local delivery of per-task alarms (SPEC §4.1.6). It only acts when +/// Reminders sync is off: with sync on, the alarm travels inside the +/// reminder and Apple delivers it (Mac + iPhone + Watch), so notifying +/// here would duplicate the alert. /// -/// Todas las llamadas a UNUserNotificationCenter usan las variantes con -/// completion handler `@Sendable`, no las `async`: los tipos de -/// UserNotifications no son Sendable en SDKs anteriores y las variantes async -/// no compilan en Xcode 16 (ver DEVELOPMENT.md, compatibilidad de SDK). +/// Every UNUserNotificationCenter call uses the `@Sendable` completion +/// handler variants, not the `async` ones: UserNotifications types are not +/// Sendable on earlier SDKs and the async variants do not compile on +/// Xcode 16 (see DEVELOPMENT.md, SDK compatibility). @MainActor final class NotificationScheduler { - /// true cuando Recordatorios se encarga de avisar. + /// true when Reminders is the one alerting. var isDeliveryDelegated: () -> Bool = { false } - /// Tocar la notificación abre el panel. + /// Tapping the notification opens the panel. var onOpenPanel: (() -> Void)? private let store: TaskStore @@ -42,8 +42,8 @@ final class NotificationScheduler { intentIdentifiers: [], options: []) ]) - // El delegado vive aparte y es Sendable: el center lo invoca desde - // contexto nonisolated y aquí solo se reentra al MainActor. + // The delegate lives apart and is Sendable: the center invokes it from a + // non-isolated context and here we only re-enter the MainActor. let delegate = NotificationDelegate { [weak self] taskID, isCompleteAction in Task { @MainActor in self?.handleResponse(taskID: taskID, isCompleteAction: isCompleteAction) @@ -53,7 +53,7 @@ final class NotificationScheduler { center.delegate = delegate } - /// Reprograma tras cambios; agrupa ráfagas de guardados. + /// Reschedules after changes; coalesces bursts of saves. func scheduleRefresh() { guard !pendingRefresh else { return } pendingRefresh = true @@ -64,8 +64,8 @@ final class NotificationScheduler { } } - /// Cancela todo y reprograma el conjunto vigente: idempotente y sin - /// avisos zombis de tareas ya completadas, borradas o movidas. + /// Cancels everything and reschedules the current set: idempotent and with + /// no zombie alerts from tasks already completed, deleted or moved. func refresh() async { center.removeAllPendingNotificationRequests() guard !isDeliveryDelegated() else { return } @@ -74,7 +74,7 @@ final class NotificationScheduler { now: Date()) guard !pending.isEmpty else { return } guard await ensureAuthorization() else { - log.info("alarmas no programadas: sin permiso de notificaciones") + log.info("alarms not scheduled: no notification permission") return } @@ -96,14 +96,14 @@ final class NotificationScheduler { trigger: UNCalendarNotificationTrigger(dateMatching: components, repeats: false)) center.add(request) { @Sendable error in if let error { - log.error("no se pudo programar la alarma: \(error, privacy: .public)") + log.error("could not schedule the alarm: \(error, privacy: .public)") } } } - log.info("alarmas programadas: \(pending.count)") + log.info("alarms scheduled: \(pending.count)") } - // MARK: - Permiso (solo cuando hay algo que notificar) + // MARK: - Permission (only when there is something to notify) private func ensureAuthorization() async -> Bool { switch await currentStatus() { @@ -116,8 +116,8 @@ final class NotificationScheduler { } } - /// Se extrae solo el estado (enum Sendable): UNNotificationSettings no - /// puede cruzar la frontera de actor. + /// Only the status is extracted (a Sendable enum): UNNotificationSettings + /// cannot cross the actor boundary. private func currentStatus() async -> UNAuthorizationStatus { await withCheckedContinuation { continuation in center.getNotificationSettings { @Sendable settings in @@ -134,7 +134,7 @@ final class NotificationScheduler { } } - // MARK: - Respuesta del usuario + // MARK: - User response private func handleResponse(taskID: UUID, isCompleteAction: Bool) { if isCompleteAction { @@ -145,8 +145,8 @@ final class NotificationScheduler { } } -/// Delegado nonisolated y Sendable: recibe los callbacks del sistema y -/// entrega solo valores Sendable (el UUID de la tarea y qué acción fue). +/// Non-isolated, Sendable delegate: it receives the system callbacks and +/// hands over only Sendable values (the task UUID and which action it was). private final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, @unchecked Sendable { private let onResponse: @Sendable (UUID, Bool) -> Void @@ -167,7 +167,7 @@ private final class NotificationDelegate: NSObject, UNUserNotificationCenterDele completionHandler() } - /// Mostrar el aviso aunque Corner esté al frente. + /// Show the alert even when Corner is frontmost. func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: diff --git a/Corner/System/PanelController.swift b/Corner/System/PanelController.swift index fce39d4..8b16ae2 100644 --- a/Corner/System/PanelController.swift +++ b/Corner/System/PanelController.swift @@ -1,11 +1,11 @@ import AppKit import SwiftUI -/// Dueño del ciclo de vida del panel flotante (SPEC §3). -/// No conoce al HotCornerMonitor; el AppDelegate los conecta (ARCHITECTURE §3.4). +/// Owner of the floating panel's lifecycle (SPEC §3). +/// It does not know HotCornerMonitor; the AppDelegate connects them (ARCHITECTURE §3.4). @MainActor final class PanelController { - /// Notifica el cierre (el AppDelegate lo usa para el cooldown del hot corner). + /// Signals the close (the AppDelegate uses it for the hot corner cooldown). var onHide: (() -> Void)? private let store: TaskStore @@ -14,7 +14,7 @@ final class PanelController { private let panelSize = NSSize(width: 320, height: 460) private let margin: CGFloat = 8 private let slideDuration: TimeInterval = 0.18 - /// El cursor debe estar fuera del panel 800 ms para autocierre (SPEC §2). + /// The cursor must stay outside the panel for 800 ms to auto-close (SPEC §2). private let mouseExitInterval: TimeInterval = 0.8 private var clickOutsideMonitors: [Any] = [] @@ -30,7 +30,7 @@ final class PanelController { self.store = store } - // MARK: - Mostrar / ocultar + // MARK: - Show / hide func show(on screen: NSScreen) { guard !isVisible else { return } @@ -40,7 +40,7 @@ final class PanelController { let finalFrame = NSRect(x: screen.frame.minX + margin, y: screen.frame.minY + margin, width: panelSize.width, height: panelSize.height) - // Desliza desde fuera de pantalla por la izquierda (SPEC §3). + // Slides in from off-screen on the left (SPEC §3). var startFrame = finalFrame startFrame.origin.x = screen.frame.minX - panelSize.width @@ -79,22 +79,22 @@ final class PanelController { isVisible ? hide() : show(on: screen) } - // MARK: - Construcción + // MARK: - Construction private func makePanel() -> NSPanel { let panel = FloatingPanel(contentRect: NSRect(origin: .zero, size: panelSize), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) panel.level = .floating - // Visible en todos los Spaces y sobre apps fullscreen (SPEC §3, §6.2). + // Visible on every Space and over fullscreen apps (SPEC §3, §6.2). panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.isOpaque = false panel.backgroundColor = .clear panel.hasShadow = true panel.hidesOnDeactivate = false panel.isMovable = false - // Regla de foco (SPEC §3): solo toma foco cuando el usuario clickea - // el campo de texto; abrir el panel no desactiva la app frontal. + // Focus rule (SPEC §3): it only takes focus when the user clicks the text + // field; opening the panel does not deactivate the frontmost app. panel.becomesKeyOnlyIfNeeded = true let host = NSHostingView(rootView: PanelView(store: store)) @@ -103,24 +103,24 @@ final class PanelController { return panel } - // MARK: - Cierre automático (SPEC §2) + // MARK: - Automatic dismissal (SPEC §2) private func installDismissMonitors() { - // Clic fuera de nuestra app (otras apps, escritorio): cerrar. + // A click outside our app (other apps, the desktop): close. let global = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { _ in MainActor.assumeIsolated { PanelAccess.shared?.hide() } } - // Clics dentro de nuestra app: NUNCA cierran. Las únicas ventanas - // propias además del panel son UI colgada de él (popovers del editor - // de categorías, menús contextuales) — tratarlas como "fuera" derribaba - // el panel a media edición. Aquí solo se gestiona el foco. + // Clicks inside our app NEVER close it. The only windows of our own + // besides the panel are UI hanging off it (category editor popovers, + // context menus) — treating those as "outside" tore the panel down + // mid-edit. Only focus is handled here. let local = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { event in MainActor.assumeIsolated { guard let controller = PanelAccess.shared else { return } if event.window === controller.panel, controller.panel?.isKeyWindow == false { - // SwiftUI dentro de un panel no-activante no siempre pide - // el foco solo: clic dentro del panel = foco de teclado - // (sin activar nuestra app; la app de abajo sigue frontal). + // SwiftUI inside a non-activating panel does not always ask for focus + // on its own: a click inside the panel means keyboard focus + // (without activating our app; the app below stays frontmost). controller.panel?.makeKey() } } @@ -128,10 +128,10 @@ final class PanelController { } clickOutsideMonitors = [global as Any, local as Any].compactMap { $0 } - // Esc: cierra el panel solo si el evento es del panel; si es de un - // popover, AppKit cierra el popover y el panel queda en paz. Con un - // campo de texto activo, Esc es para el campo (cancelar edición / - // limpiar) — la segunda Esc, ya sin foco, cierra el panel (SPEC §2). + // Esc: closes the panel only if the event belongs to the panel; if it + // comes from a popover, AppKit closes the popover and the panel is left + // alone. With a text field active, Esc belongs to the field (cancel edit, + // clear) — the second Esc, with no focus, closes the panel (SPEC §2). escMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in let handled = MainActor.assumeIsolated { () -> Bool in guard let controller = PanelAccess.shared, @@ -144,14 +144,14 @@ final class PanelController { return handled ? nil : event } - // Cursor fuera del panel 800 ms, solo si no se está escribiendo. + // Cursor outside the panel for 800 ms, only when not typing. let timer = Timer(timeInterval: 0.2, repeats: true) { _ in MainActor.assumeIsolated { PanelAccess.shared?.checkMouseExit() } } RunLoop.main.add(timer, forMode: .common) mouseExitTimer = timer - // Cambio de Space (SPEC §6.7). + // Space change (SPEC §6.7). spaceObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.activeSpaceDidChangeNotification, object: nil, queue: .main @@ -159,7 +159,7 @@ final class PanelController { MainActor.assumeIsolated { PanelAccess.shared?.hide() } } - // Bloqueo de pantalla (SPEC §2). + // Screen lock (SPEC §2). lockObserver = DistributedNotificationCenter.default().addObserver( forName: Notification.Name("com.apple.screenIsLocked"), object: nil, queue: .main @@ -190,11 +190,11 @@ final class PanelController { private func checkMouseExit() { guard let panel, panel.isVisible else { return } - // Si el usuario está escribiendo (panel es key), no cerrar solo (SPEC §2). + // If the user is typing (the panel is key), do not auto-close (SPEC §2). guard !panel.isKeyWindow else { mouseOutsideSince = nil; return } - // Con un popover o menú del panel abierto (ventana hija nuestra con - // foco, o hijas directas), el cursor "fuera del panel" es interacción - // legítima: no autocerrar. + // With a popover or menu of the panel open (a child window of ours with + // focus, or direct children), the cursor being "outside the panel" is + // legitimate interaction: do not auto-close. if let key = NSApp.keyWindow, key !== panel { mouseOutsideSince = nil; return } if !(panel.childWindows?.isEmpty ?? true) { mouseOutsideSince = nil; return } @@ -208,8 +208,8 @@ final class PanelController { } } -/// Referencia débil para que los closures de monitores/timers (no aislados) -/// reentren al MainActor sin capturar self. +/// Weak reference so the monitor/timer closures (non-isolated) re-enter +/// the MainActor without capturing self. @MainActor private enum PanelAccess { static weak var shared: PanelController? diff --git a/Corner/System/QuickCaptureController.swift b/Corner/System/QuickCaptureController.swift index 2ffa124..c8b9669 100644 --- a/Corner/System/QuickCaptureController.swift +++ b/Corner/System/QuickCaptureController.swift @@ -1,16 +1,16 @@ import AppKit import SwiftUI -/// Captura rápida (SPEC §2): campo flotante para soltar una tarea a Hoy -/// sin abrir el panel. Enter agrega y cierra; Esc o clic fuera descarta. +/// Quick capture (SPEC §2): a floating field to drop a task into Today +/// without opening the panel. Enter adds and closes; Esc or a click outside discards. @MainActor final class QuickCaptureController { private let store: TaskStore private var panel: NSPanel? private var clickMonitors: [Any] = [] - /// Alto con margen para 3 líneas; la tarjeta SwiftUI se ancla arriba y el - /// resto del frame es transparente (el fondo del panel es clear). + /// Height with room for 3 lines; the SwiftUI card is anchored at the top + /// and the rest of the frame is transparent (the panel background is clear). private let size = NSSize(width: 400, height: 130) var isVisible: Bool { panel?.isVisible ?? false } @@ -42,20 +42,20 @@ final class QuickCaptureController { }, onCancel: { [weak self] in self?.hide() } ) - // Tarjeta anclada arriba; el crecimiento (1→3 líneas) ocupa el - // espacio transparente inferior sin mover la ventana. + // Card anchored at the top; growing (1→3 lines) uses the transparent + // space below without moving the window. let anchored = VStack { view; Spacer(minLength: 0) }.frame(width: size.width) let host = NSHostingView(rootView: AnyView(anchored)) host.frame = NSRect(origin: .zero, size: size) panel.contentView = host - // Centrado horizontal, en el tercio superior (SPEC §2). + // Horizontally centered, in the upper third (SPEC §2). let frame = screen.frame panel.setFrameOrigin(NSPoint(x: frame.midX - size.width / 2, y: frame.minY + frame.height * 0.65)) self.panel = panel panel.orderFrontRegardless() - panel.makeKey() // el campo necesita teclado de inmediato + panel.makeKey() // the field needs the keyboard immediately installClickOutsideMonitors() } @@ -84,8 +84,8 @@ final class QuickCaptureController { } } -/// Referencia débil para que los closures de monitores (no aislados) -/// reentren al MainActor sin capturar self. +/// Weak reference so the monitor closures (non-isolated) re-enter the +/// MainActor without capturing self. @MainActor private enum QuickCaptureAccess { static weak var shared: QuickCaptureController? diff --git a/Corner/System/ReminderSync.swift b/Corner/System/ReminderSync.swift index 44dfcbf..5be8835 100644 --- a/Corner/System/ReminderSync.swift +++ b/Corner/System/ReminderSync.swift @@ -2,9 +2,9 @@ import AppKit import EventKit import os -/// Sincronización bidireccional con la lista "Corner" de Recordatorios -/// (SPEC §8). Opt-in; la política de convergencia vive en SyncPlanner -/// (dominio puro); aquí solo se traduce entre EventKit y el store. +/// Two-way sync with the "Corner" list in Reminders (SPEC §8). Opt-in; +/// the convergence policy lives in SyncPlanner (pure domain); here we only +/// translate between EventKit and the store. @MainActor final class ReminderSync { enum ToggleResult { @@ -26,18 +26,18 @@ final class ReminderSync { init(store: TaskStore, defaults: UserDefaults = .standard) { self.store = store self.defaults = defaults - // El fan-out de onDidSave lo hace el AppDelegate (composition root): - // el sync no es el único interesado en los guardados. + // The onDidSave fan-out is done by the AppDelegate (composition root): + // sync is not the only party interested in saves. } - /// Al arrancar con el sync ya activo. + /// When starting up with sync already enabled. func start() { guard isEnabled else { return } observeChanges() scheduleSync() } - /// Alterna el sync desde el menú. Pedir permiso solo al activar. + /// Toggles sync from the menu. Ask for permission only when enabling. func toggle() async -> ToggleResult { if isEnabled { defaults.set(false, forKey: Self.enabledKey) @@ -53,7 +53,7 @@ final class ReminderSync { return .enabled } - // MARK: - Disparadores + // MARK: - Triggers private func observeChanges() { guard changeObserver == nil else { return } @@ -65,7 +65,7 @@ final class ReminderSync { ReminderSyncAccess.shared = self } - /// Debounce de 1 s: agrupa ráfagas de guardados/notificaciones. + /// 1 s debounce: coalesces bursts of saves/notifications. func scheduleSync() { guard isEnabled, !pendingSync else { return } pendingSync = true @@ -78,11 +78,11 @@ final class ReminderSync { // MARK: - Sync - /// Listas gestionadas: la general "Corner" + una por categoría (SPEC §8 v2.1). + /// Managed lists: the general "Corner" one + one per category (SPEC §8 v2.1). private struct ManagedLists { var general: EKCalendar var byCategory: [UUID: EKCalendar] - /// categoría (nil = sin categoría) → calendarIdentifier, para el planner. + /// category (nil = uncategorized) → calendarIdentifier, for the planner. var mapping: [UUID?: String] var all: [EKCalendar] } @@ -90,17 +90,17 @@ final class ReminderSync { func syncNow() async { guard isEnabled else { return } guard await ensureAccess() else { - log.error("sync omitido: sin permiso de Recordatorios (estado \(EKEventStore.authorizationStatus(for: .reminder).rawValue))") + log.error("sync skipped: no Reminders permission (status \(EKEventStore.authorizationStatus(for: .reminder).rawValue))") return } - // Todo el ciclo con el candado anti-eco: ensureLists también guarda - // (ids de lista en categorías) y no debe re-disparar el sync. + // The whole cycle runs under the anti-echo lock: ensureLists also saves + // (list ids on categories) and must not re-trigger the sync. store.isApplyingRemoteChanges = true defer { store.isApplyingRemoteChanges = false } do { let lists = try ensureLists() - // Huérfanas incluidas en el fetch: sus recordatorios migran a la - // general (no están en el mapping) y la lista vacía se limpia. + // Orphans included in the fetch: their reminders migrate to the general + // list (they are not in the mapping) and the empty list is cleaned up. let orphans = orphanLists(excluding: lists) let remote = await fetchSnapshots(in: lists.all + orphans) let actions = SyncPlanner.plan(local: store.syncSnapshot(), @@ -111,9 +111,9 @@ final class ReminderSync { } if !actions.isEmpty { try eventStore.commit() } try cleanUpEmptyOrphans(orphans, snapshots: remote) - if !actions.isEmpty { log.info("sync: \(actions.count) acciones aplicadas") } + if !actions.isEmpty { log.info("sync: \(actions.count) actions applied") } } catch { - log.error("sync falló: \(error, privacy: .public)") + log.error("sync failed: \(error, privacy: .public)") } } @@ -146,7 +146,7 @@ final class ReminderSync { } } - /// Borra el recordatorio vinculado al borrar la tarea en Corner (SPEC §8). + /// Deletes the linked reminder when the task is deleted in Corner (SPEC §8). func deleteRemote(reminderID: String) { guard isEnabled, let reminder = eventStore.calendarItem(withIdentifier: reminderID) as? EKReminder @@ -154,20 +154,20 @@ final class ReminderSync { try? eventStore.remove(reminder, commit: true) } - // MARK: - Traducción EventKit ↔ dominio + // MARK: - EventKit ↔ domain translation private func write(_ task: TaskItem, into reminder: EKReminder, lists: ManagedLists) { reminder.title = task.text let calendar = Calendar.current var due = calendar.dateComponents([.year, .month, .day], from: task.assignedDay) - // La hora del aviso va en la fecha de vencimiento + una alarma, que es - // como la propia app Recordatorios modela "avísame a las X" (SPEC §4.1.6). + // The alert time goes in the due date + an alarm, which is how the + // Reminders app itself models "remind me at X" (SPEC §4.1.6). if let minutes = task.remindMinutes { due.hour = minutes / 60 due.minute = minutes % 60 } reminder.dueDateComponents = due - // Reemplazo total: quitar la hora en Corner también la quita en el iPhone. + // Full replacement: clearing the time in Corner also clears it on the iPhone. for alarm in reminder.alarms ?? [] { reminder.removeAlarm(alarm) } if let minutes = task.remindMinutes { let fire = ReminderSchedule.fireDate(day: task.assignedDay, @@ -176,13 +176,13 @@ final class ReminderSync { } reminder.isCompleted = task.isDone if task.isDone { reminder.completionDate = task.completedAt ?? Date() } - // Lista según la categoría; reasignar el calendar mueve el recordatorio. + // List according to the category; reassigning the calendar moves the reminder. reminder.calendar = task.category.flatMap { lists.byCategory[$0.id] } ?? lists.general } - /// Con firma ad-hoc, cada build es una identidad nueva para TCC: el - /// permiso puede volver a "no determinado" tras recompilar. Se re-pide - /// cuando haga falta en vez de fallar en silencio. + /// With ad-hoc signing every build is a new identity for TCC: the + /// permission can go back to "not determined" after recompiling. It is + /// re-requested when needed instead of failing silently. private func ensureAccess() async -> Bool { switch EKEventStore.authorizationStatus(for: .reminder) { case .fullAccess: return true @@ -191,16 +191,16 @@ final class ReminderSync { } } - /// API de permiso vía callback + continuation: la variante async "envía" - /// el EKEventStore no-Sendable entre actores (error de compilación en - /// Swift 6 estricto y trap en runtime). + /// Permission API via callback + continuation: the async variant "sends" + /// the non-Sendable EKEventStore across actors (a compile error under + /// strict Swift 6 and a trap at runtime). private func requestAccess() async -> Bool { await withCheckedContinuation { continuation in - // @Sendable explícito: el completion llega en cola de fondo; sin - // la anotación, compiladores como el de Xcode 16.2 emiten la - // verificación de MainActor y crashea (SIGTRAP) — mismo patrón - // que fetchSnapshots. El binario local (Xcode 26) no lo emitía: - // por eso solo crasheaba la build de CI. + // Explicit @Sendable: the completion arrives on a background queue; + // without the annotation, compilers such as Xcode 16.2's emit the + // MainActor check and it crashes (SIGTRAP) — same pattern as + // fetchSnapshots. The local binary (Xcode 26) did not emit it: + // that is why only the CI build crashed. eventStore.requestFullAccessToReminders { @Sendable granted, _ in continuation.resume(returning: granted) } @@ -210,11 +210,11 @@ final class ReminderSync { private func fetchSnapshots(in calendars: [EKCalendar]) async -> [RemoteReminder] { let predicate = eventStore.predicateForReminders(in: calendars) return await withCheckedContinuation { continuation in - // @Sendable explícito: EventKit invoca este closure en una cola de - // fondo; sin la anotación se infiere aislado al MainActor y la - // verificación dinámica de aislamiento crashea (SIGTRAP). + // Explicit @Sendable: EventKit invokes this closure on a background + // queue; without the annotation it is inferred as MainActor-isolated and + // the dynamic isolation check crashes (SIGTRAP). eventStore.fetchReminders(matching: predicate) { @Sendable reminders in - // Convertir a structs Sendable aquí mismo: EKReminder no cruza actores. + // Convert to Sendable structs right here: EKReminder does not cross actors. let snapshots = (reminders ?? []).map { reminder in RemoteReminder( id: reminder.calendarItemIdentifier, @@ -226,7 +226,7 @@ final class ReminderSync { modifiedAt: reminder.lastModifiedDate ?? reminder.creationDate ?? .distantPast, listID: reminder.calendar?.calendarIdentifier ?? "", - // Hora presente en el vencimiento = alarma (SPEC §4.1.6). + // A time present in the due date = an alarm (SPEC §4.1.6). remindMinutes: reminder.dueDateComponents.flatMap { comps in guard let hour = comps.hour, let minute = comps.minute else { return nil } @@ -239,15 +239,15 @@ final class ReminderSync { } } - // MARK: - Gestión de listas (SPEC §8 v2.1) + // MARK: - List management (SPEC §8 v2.1) private static let generalTitle = "Corner" private static let categoryPrefix = "Corner — " - /// Garantiza la lista general + una por categoría; renombra si la - /// categoría cambió de nombre; registra los ids en las categorías. + /// Guarantees the general list + one per category; renames when the + /// category was renamed; records the ids on the categories. private func ensureLists() throws -> ManagedLists { - // Un EKEventStore recién creado puede tener las fuentes sin cargar. + // A freshly created EKEventStore may still have its sources unloaded. eventStore.refreshSourcesIfNecessary() let source = try resolveSource() @@ -263,7 +263,7 @@ final class ReminderSync { let list = try ensureList(titled: title, knownID: category.reminderListID, source: source) - if list.title != title { // la categoría fue renombrada en Corner + if list.title != title { // the category was renamed in Corner list.title = title try eventStore.saveCalendar(list, commit: true) } @@ -294,9 +294,9 @@ final class ReminderSync { return calendar } - /// Fuente válida o nada: la lista debe pertenecer a una cuenta real - /// (iCloud/CalDAV primero, local después). sources.first a secas puede - /// ser una fuente sin cuenta ("El calendario no está asignado…"). + /// A valid source or nothing: the list must belong to a real account + /// (iCloud/CalDAV first, local afterwards). A bare sources.first can be + /// an account-less source ("The calendar is not assigned to an account…"). private func resolveSource() throws -> EKSource { if let source = eventStore.defaultCalendarForNewReminders()?.source ?? eventStore.sources.first(where: { $0.sourceType == .calDAV }) @@ -307,13 +307,13 @@ final class ReminderSync { let sources = eventStore.sources .map { "\($0.title)(\($0.sourceType.rawValue))" } .joined(separator: ", ") - log.error("sin fuente: auth=\(status.rawValue) fuentes=[\(sources, privacy: .public)]") + log.error("no source: auth=\(status.rawValue) sources=[\(sources, privacy: .public)]") throw NSError(domain: "com.andreetorres.corner", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Sin fuente de Recordatorios disponible (¿hay una cuenta de iCloud o local activa?)" ]) } - /// Listas "Corner — X" de categorías que ya no existen. + /// "Corner — X" lists of categories that no longer exist. private func orphanLists(excluding lists: ManagedLists) -> [EKCalendar] { let managed = Set(lists.all.map(\.calendarIdentifier)) return eventStore.calendars(for: .reminder).filter { @@ -321,20 +321,20 @@ final class ReminderSync { } } - /// Borra listas huérfanas ya vacías (sus recordatorios migraron a la - /// general en syncs anteriores al no estar en el mapping). + /// Deletes orphan lists that are already empty (their reminders migrated to + /// the general list in earlier syncs by not being in the mapping). private func cleanUpEmptyOrphans(_ orphans: [EKCalendar], snapshots: [RemoteReminder]) throws { for orphan in orphans where !snapshots.contains(where: { $0.listID == orphan.calendarIdentifier }) { try eventStore.removeCalendar(orphan, commit: true) - log.info("lista huérfana eliminada: \(orphan.title, privacy: .public)") + log.info("orphan list deleted: \(orphan.title, privacy: .public)") } } } -/// Referencia débil para que el closure de notificación (no aislado) -/// reentre al MainActor sin capturar self. +/// Weak reference so the notification closure (non-isolated) re-enters +/// the MainActor without capturing self. @MainActor private enum ReminderSyncAccess { static weak var shared: ReminderSync? diff --git a/Corner/System/UpdateChecker.swift b/Corner/System/UpdateChecker.swift index c4b6e44..4052bc3 100644 --- a/Corner/System/UpdateChecker.swift +++ b/Corner/System/UpdateChecker.swift @@ -1,8 +1,8 @@ import AppKit -/// "Buscar actualizaciones…" (SPEC §5): consulta manual al último release de -/// GitHub. Solo toca la red cuando el usuario lo pide — sin checks -/// automáticos ni telemetría (README: privacidad). +/// "Check for updates…" (SPEC §5): a manual query for GitHub's latest +/// release. It only touches the network when the user asks — no automatic +/// checks and no telemetry (README: privacy). @MainActor final class UpdateChecker { private static let releasesAPI = diff --git a/Corner/UI/CategoryChips.swift b/Corner/UI/CategoryChips.swift index ab0baff..f24cd2a 100644 --- a/Corner/UI/CategoryChips.swift +++ b/Corner/UI/CategoryChips.swift @@ -1,14 +1,14 @@ import SwiftUI -/// Fila de chips de filtro por categoría (SPEC §4.1): "Todas", una chip por -/// categoría y "+" para crear. Clic derecho en una chip: editar o eliminar. +/// Row of category filter chips (SPEC §4.1): "All", one chip per category +/// and "+" to create one. Right-click a chip to edit or delete it. struct CategoryChips: View { @Bindable var store: TaskStore - /// nil = "Todas". + /// nil = "All". @Binding var selectedID: UUID? @State private var showingCreate = false - /// Categoría en edición; su popover se ancla a la chip que lo abrió (SPEC §4.1). + /// Category being edited; its popover anchors to the chip that opened it (SPEC §4.1). @State private var editingCategory: TaskCategory? @State private var draftName = "" @State private var draftColor = CategoryColor.palette[4] @@ -27,8 +27,8 @@ struct CategoryChips: View { selected: selectedID == category.id) { selectedID = category.id } - // Doble clic también abre el editor (SPEC §4.1); el primer - // clic selecciona la chip, lo cual es inofensivo. + // A double click also opens the editor (SPEC §4.1); the first click + // selects the chip, which is harmless. .simultaneousGesture(TapGesture(count: 2).onEnded { beginEdit(category) }) @@ -60,8 +60,8 @@ struct CategoryChips: View { // MARK: - Chip - /// La chip seleccionada se tiñe del color de SU categoría ("Todas" usa - /// el acento de marca). + /// The selected chip is tinted with ITS category color ("All" uses the + /// brand accent). private func chip(name: String, colorHex: String?, selected: Bool, action: @escaping () -> Void) -> some View { let tint = colorHex.map { Color(hex: $0) } ?? .cornerAccent @@ -86,7 +86,7 @@ struct CategoryChips: View { .buttonStyle(.plain) } - // MARK: - Formulario crear/editar + // MARK: - Create/edit form private func beginCreate() { editingCategory = nil @@ -101,8 +101,8 @@ struct CategoryChips: View { editingCategory = category } - /// Binding no-nil solo para la chip cuya categoría está en edición, - /// de modo que el popover se ancle a ESA chip. + /// Non-nil binding only for the chip whose category is being edited, so + /// that the popover anchors to THAT chip. private func editBinding(for category: TaskCategory) -> Binding { Binding( get: { editingCategory?.id == category.id ? editingCategory : nil }, diff --git a/Corner/UI/CategoryColor.swift b/Corner/UI/CategoryColor.swift index 0136c71..f4fbeab 100644 --- a/Corner/UI/CategoryColor.swift +++ b/Corner/UI/CategoryColor.swift @@ -1,7 +1,7 @@ import SwiftUI -/// Paleta fija de colores para categorías y conversión hex → Color. -/// El dominio guarda solo el string hex; los tipos de UI viven aquí. +/// Fixed color palette for categories and hex → Color conversion. +/// The domain stores only the hex string; UI types live here. enum CategoryColor { static let palette = [ "#FF3B30", "#FF9500", "#FFCC00", "#34C759", @@ -10,12 +10,12 @@ enum CategoryColor { } extension Color { - /// Acento de marca de Corner (ámbar del ícono): chip "Todas", día actual, - /// indicador de arrastrada. NO se usa para "completado". + /// Corner brand accent (the icon amber): "All" chip, current day, + /// rolled-over indicator. NOT used for "done". static let cornerAccent = Color(hex: "#FF9500") - /// Verde semántico de "hecho" (checkbox y progreso): el significado - /// universal de completado (SPEC §3). + /// Semantic green for "done" (checkbox and progress): the universal + /// meaning of completion (SPEC §3). static let cornerDone = Color(hex: "#34C759") init(hex: String) { diff --git a/Corner/UI/NewTaskField.swift b/Corner/UI/NewTaskField.swift index 33ad498..01fdca6 100644 --- a/Corner/UI/NewTaskField.swift +++ b/Corner/UI/NewTaskField.swift @@ -1,14 +1,14 @@ import SwiftUI -/// Campo "Agregar tarea…". El borrador se descarta si el panel se cierra -/// sin confirmar (SPEC §6.9): el estado vive aquí y muere con la vista. +/// "Add task…" field. The draft is discarded if the panel closes without +/// confirming (SPEC §6.9): the state lives here and dies with the view. struct NewTaskField: View { - /// LocalizedStringKey: un String variable pasaría verbatim sin traducirse. + /// LocalizedStringKey: a String variable would pass through verbatim, untranslated. var placeholder: LocalizedStringKey = "Agregar tarea…" - /// Tomar el foco al aparecer (campo de subtareas: se expande para escribir). + /// Take focus on appear (subtask field: it expands ready to type). var autofocus: Bool = false - /// Esc: además de limpiar y soltar el foco, avisa a quien lo muestre - /// (para que un grupo de subtareas vacío pueda cerrarse). + /// Esc: besides clearing and releasing focus, it notifies whoever shows it + /// (so an empty subtask group can close itself). var onCancel: (() -> Void)? = nil let onSubmit: (String) -> Void @@ -19,8 +19,8 @@ struct NewTaskField: View { HStack(spacing: 6) { Image(systemName: "plus.circle.fill") .foregroundStyle(.secondary) - // axis .vertical: el texto largo envuelve (hasta 3 líneas visibles) - // en vez de desplazarse fuera de vista mientras se escribe. + // axis .vertical: long text wraps (up to 3 visible lines) instead of + // scrolling out of sight while typing. TextField(placeholder, text: $text, axis: .vertical) .lineLimit(1...3) .textFieldStyle(.plain) @@ -30,15 +30,15 @@ struct NewTaskField: View { text = "" } .onExitCommand { - // Esc limpia y suelta el foco; la segunda Esc cierra el panel. + // Esc clears and releases focus; the second Esc closes the panel. text = "" focused = false onCancel?() } .onChange(of: focused) { wasFocused, isFocused in - // Perder el foco con el campo vacío = el usuario se - // arrepintió: avisar para que quien lo muestre lo retire - // (un campo vacío colgando no debe quedarse, SPEC §4.2). + // Losing focus with an empty field means the user changed their mind: + // notify so whoever shows it can take it away (an empty field left + // hanging must not stay, SPEC §4.2). if wasFocused, !isFocused, text.isEmpty { onCancel?() } } .onAppear { diff --git a/Corner/UI/PanelView.swift b/Corner/UI/PanelView.swift index 1b7ceae..61e6de4 100644 --- a/Corner/UI/PanelView.swift +++ b/Corner/UI/PanelView.swift @@ -1,13 +1,13 @@ import SwiftUI -/// Contenedor del panel (SPEC §3): toggle Hoy/Semana, campo nueva tarea, -/// vista activa y pie con contador. No sabe que vive en un NSPanel. +/// Panel container (SPEC §3): Today/Week toggle, new task field, the active +/// view and a footer with the counter. It does not know it lives in an NSPanel. struct PanelView: View { enum Tab: CaseIterable, Identifiable { case today, week var id: Self { self } - /// LocalizedStringKey para que el picker se localice (un rawValue - /// String pasaría verbatim, sin traducción). + /// LocalizedStringKey so the picker gets localized (a String rawValue + /// would pass through verbatim, untranslated). var label: LocalizedStringKey { switch self { case .today: "Hoy" @@ -19,8 +19,8 @@ struct PanelView: View { @Bindable var store: TaskStore @State private var tab: Tab = .today - /// El filtro vive en el store (persiste entre aperturas del panel y - /// entre sesiones); la vista solo lo refleja (SPEC §4.1). + /// The filter lives in the store (it persists across panel openings and + /// across sessions); the view only reflects it (SPEC §4.1). private var filterCategory: TaskCategory? { store.categories.first { $0.id == store.filterID } } @@ -45,7 +45,7 @@ struct PanelView: View { CategoryChips(store: store, selectedID: $store.filterID) NewTaskField { text in - // La tarea nueva hereda el filtro activo. + // A new task inherits the active filter. store.add(text, to: store.today, category: filterCategory) } @@ -69,7 +69,7 @@ struct PanelView: View { ) } - /// Tip de primera ejecución (SPEC §2): persiste hasta descartarse. + /// First-run tip (SPEC §2): stays until dismissed. private var welcomeTip: some View { HStack(spacing: 8) { Image(systemName: "inset.filled.bottomleft.rectangle") @@ -97,8 +97,8 @@ struct PanelView: View { let done = todays.filter(\.isDone).count return VStack(spacing: 5) { if !todays.isEmpty { - // Barra propia: la nativa solo se tiñe con la ventana activa, - // y este panel es no-activante por diseño (siempre saldría gris). + // Custom bar: the native one is only tinted while the window is active, + // and this panel is non-activating by design (it would always look gray). GeometryReader { geo in ZStack(alignment: .leading) { Capsule().fill(.quaternary) @@ -125,7 +125,7 @@ struct PanelView: View { }() private static func todayLabel(_ day: Date) -> String { - // Solo la primera letra: "Miércoles, 15 de julio" (no "15 De Julio"). + // Only the first letter: "Miércoles, 15 de julio" (not "15 De Julio"). let raw = todayFormatter.string(from: day) return raw.prefix(1).localizedUppercase + raw.dropFirst() } diff --git a/Corner/UI/QuickCaptureView.swift b/Corner/UI/QuickCaptureView.swift index 61e05e7..9312465 100644 --- a/Corner/UI/QuickCaptureView.swift +++ b/Corner/UI/QuickCaptureView.swift @@ -1,6 +1,6 @@ import SwiftUI -/// Campo de captura rápida (SPEC §2): una línea, Enter agrega, Esc descarta. +/// Quick capture field (SPEC §2): one line, Enter adds, Esc discards. struct QuickCaptureView: View { let onSubmit: (String) -> Void let onCancel: () -> Void @@ -13,8 +13,8 @@ struct QuickCaptureView: View { Image(systemName: "plus.circle.fill") .font(.title3) .foregroundStyle(Color.cornerAccent) - // axis .vertical: el texto largo envuelve (hasta 3 líneas) en vez - // de desplazarse fuera de vista (SPEC §6.8). + // axis .vertical: long text wraps (up to 3 lines) instead of scrolling + // out of sight (SPEC §6.8). TextField("Nueva tarea para hoy…", text: $text, axis: .vertical) .lineLimit(1...3) .textFieldStyle(.plain) diff --git a/Corner/UI/TaskGroupRows.swift b/Corner/UI/TaskGroupRows.swift index 9e80176..5aaa66d 100644 --- a/Corner/UI/TaskGroupRows.swift +++ b/Corner/UI/TaskGroupRows.swift @@ -1,8 +1,8 @@ import SwiftUI -/// Fila de tarea de nivel superior + sus subtareas expandidas (SPEC §4.2). -/// Compartido por TodayView y WeekView; el estado expandido vive en la vista -/// dueña y es efímero (se colapsa al reabrir el panel). +/// A top-level task row + its expanded subtasks (SPEC §4.2). +/// Shared by TodayView and WeekView; the expanded state lives in the owning +/// view and is ephemeral (it collapses when the panel reopens). struct TaskGroupRows: View { @Bindable var store: TaskStore let task: TaskItem @@ -25,13 +25,13 @@ struct TaskGroupRows: View { ? nil : (subtasks.filter(\.isDone).count, subtasks.count), isExpanded: isExpanded, - // El chevron también aparece en un grupo expandido pero aún vacío: - // si no, tocar "+" y no escribir nada dejaba el campo abierto sin - // manera de cerrarlo (SPEC §4.2). + // The chevron also shows in a group that is expanded but still empty: + // otherwise tapping "+" and typing nothing left the field open with no + // way to close it (SPEC §4.2). onToggleExpand: (subtasks.isEmpty && !isExpanded) ? nil : { toggleExpanded() }, onAddSubtask: { withAnimation(.snappy) { - // Segundo toque en un grupo abierto y vacío = cerrarlo. + // A second tap on an open, empty group closes it. if isExpanded, subtasks.isEmpty { expanded.remove(task.id) } else { @@ -63,8 +63,8 @@ struct TaskGroupRows: View { .moveDisabled(true) } NewTaskField(placeholder: "Agregar paso…", autofocus: true, onCancel: { - // Esc en un grupo que sigue vacío lo colapsa; con subtareas - // ya creadas, Esc solo suelta el campo (SPEC §4.2). + // Esc on a group that is still empty collapses it; with subtasks already + // created, Esc only releases the field (SPEC §4.2). if store.subtasks(of: task).isEmpty { withAnimation(.snappy) { expanded.remove(task.id) } } @@ -87,9 +87,9 @@ struct TaskGroupRows: View { } } - // MARK: - Opciones de "Recordarme" (SPEC §4.1.6) + // MARK: - "Remind me" options (SPEC §4.1.6) - /// Relativas (solo si la tarea es de hoy) + horas fijas que aún no pasaron. + /// Relative ones (only if the task is today) + fixed times not yet passed. private static func reminderOptions(for day: Date, now: Date = Date()) -> [(label: String, minutes: Int)] { var options: [(label: String, minutes: Int)] = [] @@ -114,7 +114,7 @@ struct TaskGroupRows: View { } } - // MARK: - Etiquetas de "Mover a…" (SPEC §4.1.5) + // MARK: - "Move to…" labels (SPEC §4.1.5) private static let destinationFormatter: DateFormatter = { let formatter = DateFormatter() @@ -123,8 +123,8 @@ struct TaskGroupRows: View { return formatter }() - /// El destino más próximo siempre es mañana → "Mañana"; el resto por - /// nombre ("Viernes 18"). Se excluye el día donde la tarea ya vive. + /// The closest destination is always tomorrow → "Mañana"; the rest by name + /// ("Viernes 18"). The day the task already lives on is excluded. private static func labeledDestinations(_ days: [Date], excluding current: Date) -> [(label: String, day: Date)] { days.enumerated().map { index, day in diff --git a/Corner/UI/TaskRow.swift b/Corner/UI/TaskRow.swift index 3228995..6178943 100644 --- a/Corner/UI/TaskRow.swift +++ b/Corner/UI/TaskRow.swift @@ -1,8 +1,8 @@ import SwiftUI -/// Una fila de tarea (SPEC §4): checkbox, texto (editable con doble clic), -/// reloj ámbar si fue arrastrada, ✕ al hacer hover. Componente puro: -/// recibe datos y closures, sin lógica de fechas ni persistencia. +/// A task row (SPEC §4): checkbox, text (editable with a double click), +/// amber clock if it rolled over, ✕ on hover. Pure component: it receives +/// data and closures, with no date logic or persistence of its own. struct TaskRow: View { let task: TaskItem let showsRolloverDot: Bool @@ -11,16 +11,16 @@ struct TaskRow: View { let onRename: (String) -> Void let onDelete: () -> Void let onSetCategory: (TaskCategory?) -> Void - /// (hechas, total) de subtareas; nil si no tiene (SPEC §4.2). + /// (done, total) subtasks; nil when there are none (SPEC §4.2). var subtaskProgress: (done: Int, total: Int)? = nil var isExpanded: Bool = false var onToggleExpand: (() -> Void)? = nil - /// Presente solo en tareas de nivel superior: habilita "Agregar subtarea". + /// Present only on top-level tasks: enables "Add subtask". var onAddSubtask: (() -> Void)? = nil - /// Destinos de "Mover a…" con su etiqueta; vacío = sin menú (SPEC §4.1.5). + /// "Move to…" destinations with their label; empty = no menu (SPEC §4.1.5). var moveDestinations: [(label: String, day: Date)] = [] var onMoveToDay: ((Date) -> Void)? = nil - /// Opciones del submenú "Recordarme"; vacío = sin menú (SPEC §4.1.6). + /// "Remind me" submenu options; empty = no menu (SPEC §4.1.6). var reminderOptions: [(label: String, minutes: Int)] = [] var onSetReminder: ((Int?) -> Void)? = nil @@ -53,7 +53,7 @@ struct TaskRow: View { .accessibilityValue(task.text) if showsRolloverDot { - // Ícono en vez de punto: no compite con los puntos de categoría. + // An icon instead of a dot: it does not compete with the category dots. Image(systemName: "clock.arrow.circlepath") .font(.caption2) .foregroundStyle(Color.cornerAccent) @@ -76,8 +76,8 @@ struct TaskRow: View { .focused($editFocused) .onSubmit { commitEdit() } .onExitCommand { - // Esc cancela la edición descartando el borrador; - // isEditing=false primero anula el commit por pérdida de foco. + // Esc cancels the edit discarding the draft; setting isEditing=false + // first cancels the commit that focus loss would trigger. isEditing = false editFocused = false } @@ -90,7 +90,7 @@ struct TaskRow: View { .truncationMode(.tail) .strikethrough(task.isDone, color: .secondary) .foregroundStyle(task.isDone ? .secondary : .primary) - .help(task.text) // texto completo como tooltip al hacer hover + .help(task.text) // full text as a tooltip on hover .onTapGesture(count: 2) { draft = task.text isEditing = true @@ -191,7 +191,7 @@ struct TaskRow: View { @ViewBuilder private func markableLabel(_ name: String, isCurrent: Bool) -> some View { - // Los menús de macOS no renderizan vistas arbitrarias; el check va como símbolo. + // macOS menus do not render arbitrary views; the check goes as a symbol. if isCurrent { Label(name, systemImage: "checkmark") } else { diff --git a/Corner/UI/TodayView.swift b/Corner/UI/TodayView.swift index fd915fd..79aacbc 100644 --- a/Corner/UI/TodayView.swift +++ b/Corner/UI/TodayView.swift @@ -1,13 +1,13 @@ import SwiftUI -/// Lista de tareas de hoy (SPEC §4): pendientes con orden manual arriba, -/// hechas tachadas al final. ⌘Z deshace el último borrado. +/// Today's task list (SPEC §4): pending in manual order at the top, done +/// struck through at the bottom. ⌘Z undoes the last deletion. struct TodayView: View { @Bindable var store: TaskStore - /// Filtro de categoría activo; nil = todas (SPEC §4.1). + /// Active category filter; nil = all (SPEC §4.1). let filter: TaskCategory? - /// Tareas con subtareas expandidas; efímero por diseño (SPEC §4.2). + /// Tasks with expanded subtasks; ephemeral by design (SPEC §4.2). @State private var expanded: Set = [] var body: some View { @@ -37,7 +37,7 @@ struct TodayView: View { .moveDisabled(task.isDone || filter != nil) } .onMove { source, destination in - // Reordenar solo sin filtro: los índices filtrados no mapean al orden real. + // Reordering only without a filter: filtered indices do not map to the real order. guard filter == nil else { return } store.movePending(in: store.today, from: source, to: destination) } diff --git a/Corner/UI/WeekView.swift b/Corner/UI/WeekView.swift index 9f0b774..33440ca 100644 --- a/Corner/UI/WeekView.swift +++ b/Corner/UI/WeekView.swift @@ -1,14 +1,14 @@ import SwiftUI -/// Semana actual, lunes a domingo, agrupada por día (SPEC §4). -/// Días pasados solo muestran hechas (las pendientes ya migraron). -/// Se puede agregar tarea a hoy y a días futuros. +/// The current week, Monday to Sunday, grouped by day (SPEC §4). +/// Past days only show completed tasks (pending ones already migrated). +/// Tasks can be added to today and to future days. struct WeekView: View { @Bindable var store: TaskStore - /// Filtro de categoría activo; nil = todas (SPEC §4.1). + /// Active category filter; nil = all (SPEC §4.1). let filter: TaskCategory? - /// Tareas con subtareas expandidas; efímero por diseño (SPEC §4.2). + /// Tasks with expanded subtasks; ephemeral by design (SPEC §4.2). @State private var expanded: Set = [] var body: some View { diff --git a/CornerTests/DayClockTests.swift b/CornerTests/DayClockTests.swift index d8e2c7f..cd61d5b 100644 --- a/CornerTests/DayClockTests.swift +++ b/CornerTests/DayClockTests.swift @@ -3,7 +3,7 @@ import Foundation @testable import Corner struct DayClockTests { - // Miércoles 15 de enero de 2025, 14:30. + // Wednesday 15 January 2025, 14:30. private var wednesday: FixedDayClock { var components = DateComponents() components.year = 2025; components.month = 1; components.day = 15 @@ -39,7 +39,7 @@ struct DayClockTests { } } - // MARK: - Destinos de "Mover a…" (SPEC §4.1.5) + // MARK: - "Move to…" destinations (SPEC §4.1.5) private func clock(day: Int) -> FixedDayClock { let cal = Calendar(identifier: .gregorian) @@ -48,21 +48,21 @@ struct DayClockTests { } @Test func midweekDestinationsAreRestOfWeek() { - // Miércoles 15 → jueves a domingo. + // Wednesday 15 → Thursday to Sunday. let destinations = clock(day: 15).moveDestinations() let days = destinations.map { Calendar(identifier: .gregorian).component(.day, from: $0) } #expect(days == [16, 17, 18, 19]) } @Test func saturdayIncludesNextMonday() { - // Sábado 18 → domingo 19 y lunes 20. + // Saturday 18 → Sunday 19 and Monday 20. let days = clock(day: 18).moveDestinations() .map { Calendar(identifier: .gregorian).component(.day, from: $0) } #expect(days == [19, 20]) } @Test func sundayOffersOnlyNextMonday() { - // Domingo 19 → lunes 20 (mañana y lunes próximo coinciden, sin duplicar). + // Sunday 19 → Monday 20 (tomorrow and next Monday coincide, no duplicates). let days = clock(day: 19).moveDestinations() .map { Calendar(identifier: .gregorian).component(.day, from: $0) } #expect(days == [20]) diff --git a/CornerTests/HotCornerLogicTests.swift b/CornerTests/HotCornerLogicTests.swift index 60b4f5b..6e65056 100644 --- a/CornerTests/HotCornerLogicTests.swift +++ b/CornerTests/HotCornerLogicTests.swift @@ -2,8 +2,8 @@ import Testing import Foundation @testable import Corner -/// La máquina de estados del hot corner (SPEC §2): permanencia de 250 ms, -/// cooldown de 1 s, suspensión y multi-monitor. +/// The hot corner state machine (SPEC §2): 250 ms dwell, 1 s cooldown, +/// suspension and multi-monitor. struct HotCornerLogicTests { private let screen = CGRect(x: 0, y: 0, width: 1440, height: 900) private let corner = CGPoint(x: 5, y: 5) @@ -22,8 +22,8 @@ struct HotCornerLogicTests { @Test func leavingZoneResetsDwell() { var logic = HotCornerLogic() _ = logic.poll(location: corner, screenFrame: screen, at: at(0)) - _ = logic.poll(location: center, screenFrame: screen, at: at(0.2)) // salió - // Vuelve a la esquina: la permanencia empieza de cero. + _ = logic.poll(location: center, screenFrame: screen, at: at(0.2)) // left the zone + // Back to the corner: the dwell starts from zero. #expect(logic.poll(location: corner, screenFrame: screen, at: at(0.3)) == .none) #expect(logic.poll(location: corner, screenFrame: screen, at: at(0.4)) == .none) #expect(logic.poll(location: corner, screenFrame: screen, at: at(0.6)) == .trigger) @@ -31,11 +31,11 @@ struct HotCornerLogicTests { @Test func cooldownBlocksRetrigger() { var logic = HotCornerLogic() - logic.resumeAfterCooldown(at: at(0)) // panel recién cerrado - // Dentro del cooldown (1 s): ni acumula permanencia. + logic.resumeAfterCooldown(at: at(0)) // panel just closed + // Within the cooldown (1 s): it does not even accumulate dwell. #expect(logic.poll(location: corner, screenFrame: screen, at: at(0.5)) == .none) #expect(logic.poll(location: corner, screenFrame: screen, at: at(0.9)) == .none) - // Pasado el cooldown, la permanencia arranca de nuevo. + // Past the cooldown, the dwell starts again. #expect(logic.poll(location: corner, screenFrame: screen, at: at(1.1)) == .none) #expect(logic.poll(location: corner, screenFrame: screen, at: at(1.4)) == .trigger) } @@ -48,8 +48,8 @@ struct HotCornerLogicTests { } @Test func secondaryScreenCornerCounts() { - // Monitor secundario a la izquierda: su esquina inferior izquierda - // es la del frame de ESA pantalla (SPEC §6.1). + // Secondary monitor on the left: its bottom-left corner is the one of + // THAT screen's frame (SPEC §6.1). var logic = HotCornerLogic() let secondary = CGRect(x: -1920, y: 0, width: 1920, height: 1080) let itsCorner = CGPoint(x: -1915, y: 4) diff --git a/CornerTests/ReminderScheduleTests.swift b/CornerTests/ReminderScheduleTests.swift index 95e3ea8..61316f4 100644 --- a/CornerTests/ReminderScheduleTests.swift +++ b/CornerTests/ReminderScheduleTests.swift @@ -2,7 +2,7 @@ import Testing import Foundation @testable import Corner -/// Política de alarmas por tarea (SPEC §4.1.6). +/// Per-task alarm policy (SPEC §4.1.6). struct ReminderScheduleTests { private let calendar = Calendar(identifier: .gregorian) @@ -33,10 +33,10 @@ struct ReminderScheduleTests { let now = moment(15, 10) let candidates = [ candidate(minutes: 15 * 60, title: "Futura"), // ✓ - candidate(minutes: 9 * 60, title: "Ya pasó"), // ✗ pasada - candidate(minutes: nil, title: "Sin hora"), // ✗ sin alarma - candidate(minutes: 16 * 60, isDone: true, title: "Hecha"), // ✗ completada - candidate(minutes: 17 * 60, isTopLevel: false, title: "Subtarea"), // ✗ subtarea + candidate(minutes: 9 * 60, title: "Ya pasó"), // ✗ already passed + candidate(minutes: nil, title: "Sin hora"), // ✗ no alarm + candidate(minutes: 16 * 60, isDone: true, title: "Hecha"), // ✗ completed + candidate(minutes: 17 * 60, isTopLevel: false, title: "Subtarea"), // ✗ subtask ] let pending = ReminderSchedule.pending(from: candidates, now: now, calendar: calendar) #expect(pending.map(\.title) == ["Futura"]) @@ -55,7 +55,7 @@ struct ReminderScheduleTests { } @Test func presetsForTodayDropPastHours() { - // 13:00 → solo quedan 15:00, 18:00 y 21:00. + // 13:00 → only 15:00, 18:00 and 21:00 remain. let presets = ReminderSchedule.availablePresets(for: day(15), now: moment(15, 13), calendar: calendar) #expect(presets == [15 * 60, 18 * 60, 21 * 60]) @@ -68,10 +68,10 @@ struct ReminderScheduleTests { } @Test func relativeOffsetStaysWithinTheDay() { - // 22:00 + 1 h = 23:00, válido. + // 22:00 + 1 h = 23:00, valid. #expect(ReminderSchedule.minutesFromNow(60, now: moment(15, 22), calendar: calendar) == 23 * 60) - // 23:40 + 30 min cruzaría a mañana → sin opción. + // 23:40 + 30 min would cross into tomorrow → no option. #expect(ReminderSchedule.minutesFromNow(30, now: moment(15, 23, 40), calendar: calendar) == nil) } diff --git a/CornerTests/RolloverServiceTests.swift b/CornerTests/RolloverServiceTests.swift index 1b058db..9ff038a 100644 --- a/CornerTests/RolloverServiceTests.swift +++ b/CornerTests/RolloverServiceTests.swift @@ -3,9 +3,9 @@ import Foundation import SwiftData @testable import Corner -/// El contenedor vive como propiedad del struct (una instancia por @Test): -/// si solo vive dentro de una función auxiliar, SwiftData lo libera y el -/// contexto crashea (signal trap) al insertar. +/// The container lives as a property of the struct (one instance per @Test): +/// if it only lives inside a helper function, SwiftData releases it and the +/// context crashes (signal trap) on insert. @MainActor struct RolloverServiceTests { private let calendar = Calendar(identifier: .gregorian) @@ -52,7 +52,7 @@ struct RolloverServiceTests { @Test func rolledTasksGoFirstPreservingRelativeOrder() { let today = day(2025, 1, 15) - // Dos viejas de días distintos y una ya existente en hoy. + // Two old ones from different days and one already on today. context.insert(TaskItem(text: "Lunes", assignedDay: day(2025, 1, 13), sortOrder: 0)) context.insert(TaskItem(text: "Martes", assignedDay: day(2025, 1, 14), sortOrder: 0)) context.insert(TaskItem(text: "DeHoy", assignedDay: today, sortOrder: 0)) diff --git a/CornerTests/SyncPlannerTests.swift b/CornerTests/SyncPlannerTests.swift index c9ebdc4..943eba8 100644 --- a/CornerTests/SyncPlannerTests.swift +++ b/CornerTests/SyncPlannerTests.swift @@ -2,7 +2,7 @@ import Testing import Foundation @testable import Corner -/// Política de convergencia del sync (SPEC §8): última escritura gana. +/// Sync convergence policy (SPEC §8): last write wins. struct SyncPlannerTests { private let day = Calendar(identifier: .gregorian) .startOfDay(for: Date(timeIntervalSince1970: 1_700_000_000)) @@ -64,11 +64,11 @@ struct SyncPlannerTests { #expect(SyncPlanner.plan(local: [task], remote: []) == [.deleteLocal(taskID: task.id)]) } - // MARK: - Listas por categoría (SPEC §8 v2.1) + // MARK: - Per-category lists (SPEC §8 v2.1) @Test func wrongListMeansCategoryChangedLocally() { - // La tarea tiene categoría (lista esperada L-trabajo) pero el - // recordatorio vive en la general → mover (gana lo local, más nuevo). + // The task has a category (expected list L-trabajo) but the reminder + // lives in the general one → push (local is newer and wins). let categoryID = UUID() var task = local(reminderID: "r1", updatedAt: newer) task.categoryID = categoryID @@ -81,7 +81,7 @@ struct SyncPlannerTests { } @Test func remoteMovedToCategoryListWinsWhenNewer() { - // En el iPhone movieron el recordatorio a "Corner — Trabajo" → pull. + // On the iPhone the reminder was moved to "Corner — Trabajo" → pull. let categoryID = UUID() let task = local(reminderID: "r1", updatedAt: older) var reminder = remote("r1", modifiedAt: newer) @@ -92,9 +92,9 @@ struct SyncPlannerTests { == [.pullUpdate(taskID: task.id, remote: reminder)]) } - /// Invariante de convergencia: aplicar el plan una vez deja ambos lados - /// iguales — re-planear no produce acciones. Protege contra bucles de - /// sync (la falla más peligrosa de un sistema bidireccional). + /// Convergence invariant: applying the plan once leaves both sides equal — + /// re-planning produces no actions. This guards against sync loops (the + /// most dangerous failure of a two-way system). @Test func planConvergesAfterOneApplication() { let categoryID = UUID() let mapping: [UUID?: String] = [nil: "L-general", categoryID: "L-trabajo"] @@ -111,7 +111,7 @@ struct SyncPlannerTests { { var r = remote("r-nueva", title: "Alta desde Siri", modifiedAt: newer); r.listID = "L-trabajo"; return r }(), ] - // Simulación pura de aplicar cada acción a ambos lados. + // Pure simulation of applying each action to both sides. let actions = SyncPlanner.plan(local: localSide, remote: remoteSide, listForCategory: mapping) for action in actions { switch action { @@ -145,7 +145,7 @@ struct SyncPlannerTests { } } - // Convergencia: el segundo plan debe estar vacío. + // Convergence: the second plan must be empty. #expect(SyncPlanner.plan(local: localSide, remote: remoteSide, listForCategory: mapping).isEmpty) } diff --git a/CornerTests/TaskStoreTests.swift b/CornerTests/TaskStoreTests.swift index 86914e4..d1ed8e0 100644 --- a/CornerTests/TaskStoreTests.swift +++ b/CornerTests/TaskStoreTests.swift @@ -3,10 +3,10 @@ import Foundation import SwiftData @testable import Corner -/// Swift Testing crea una instancia nueva del struct por cada @Test, así que -/// cada test recibe un contenedor en memoria limpio. El contenedor vive como -/// propiedad: si solo vive dentro de una función auxiliar, SwiftData lo -/// libera y el contexto crashea (signal trap) al insertar. +/// Swift Testing creates a new instance of the struct for each @Test, so +/// every test gets a clean in-memory container. The container lives as a +/// property: if it only lives inside a helper function, SwiftData releases +/// it and the context crashes (signal trap) on insert. @MainActor struct TaskStoreTests { private let container: ModelContainer @@ -25,7 +25,7 @@ struct TaskStoreTests { } @Test func addNormalizesDayAndTrimsText() { - let task = store.add(" Comprar café ", to: clock.now) // now trae hora 10:00 + let task = store.add(" Comprar café ", to: clock.now) // now carries the 10:00 time #expect(task?.text == "Comprar café") #expect(task?.assignedDay == clock.today) } @@ -74,14 +74,14 @@ struct TaskStoreTests { #expect(store.todayTasks().map(\.text) == ["C", "A", "B"]) } - /// Los índices del drag vienen de la lista completa (pendientes + hechas): - /// soltar debajo de las hechas no debe crashear (SPEC §6, regresión). + /// The drag indices come from the full list (pending + done): dropping + /// below the done ones must not crash (SPEC §6, regression). @Test func movePendingBelowDoneTasksClampsInsteadOfCrashing() { store.add("A", to: clock.today) store.add("B", to: clock.today) let done = store.add("Hecha", to: clock.today)! store.toggle(done) - // Lista visible: [A, B, Hecha]; arrastrar A (índice 0) al final (3). + // Visible list: [A, B, Hecha]; drag A (index 0) to the end (3). store.movePending(in: clock.today, from: IndexSet(integer: 0), to: 3) #expect(store.todayTasks().map(\.text) == ["B", "A", "Hecha"]) } @@ -92,7 +92,7 @@ struct TaskStoreTests { #expect(week.map(\.day).contains(clock.today)) } - // MARK: - Categorías (SPEC §4.1) + // MARK: - Categories (SPEC §4.1) @Test func addCategoryAndAssignToTask() { let category = store.addCategory("Trabajo", colorHex: "#007AFF")! @@ -132,7 +132,7 @@ struct TaskStoreTests { #expect(store.todayTasks().first?.category?.id == category.id) } - // MARK: - Subtareas (SPEC §4.2) + // MARK: - Subtasks (SPEC §4.2) @Test func addSubtaskInheritsDayAndOrder() { let parent = store.add("Proyecto", to: clock.today)! @@ -213,7 +213,7 @@ struct TaskStoreTests { #expect(parent.isDone) } - // MARK: - Mover a otro día (SPEC §4.1.5) + // MARK: - Move to another day (SPEC §4.1.5) @Test func moveSendsTaskToEndOfTargetDayWithSubtasks() { let tomorrow = clock.calendar.date(byAdding: .day, value: 1, to: clock.today)! @@ -255,7 +255,7 @@ struct TaskStoreTests { #expect(parent.sortOrder == order) } - // MARK: - Alarmas por tarea (SPEC §4.1.6) + // MARK: - Per-task alarms (SPEC §4.1.6) @Test func setReminderStoresAndClearsTime() { let task = store.add("Con hora", to: clock.today)! @@ -273,8 +273,8 @@ struct TaskStoreTests { } @Test func reminderTimeSurvivesMovingToAnotherDay() { - // La hora se guarda como minutos del día, no como fecha absoluta: - // mover la tarea la conserva (SPEC §4.1.6). + // The time is stored as minutes of the day, not as an absolute date: + // moving the task preserves it (SPEC §4.1.6). let task = store.add("Reunión", to: clock.today)! store.setReminder(task, minutes: 9 * 60) let tomorrow = clock.calendar.date(byAdding: .day, value: 1, to: clock.today)! @@ -295,7 +295,7 @@ struct TaskStoreTests { #expect(task.remindMinutes == 18 * 60) } - // MARK: - Soporte de sync (SPEC §8) + // MARK: - Sync support (SPEC §8) @Test func mutationsTouchUpdatedAt() { let task = store.add("Tarea", to: clock.today)! @@ -331,13 +331,13 @@ struct TaskStoreTests { let work = store.addCategory("Trabajo", colorHex: "#007AFF")! store.setReminderListID("L-trabajo", for: work) - // Alta remota en la lista de Trabajo → nace con esa categoría. + // Remote addition in the Work list → it is born with that category. let created = store.createFromRemote(RemoteReminder( id: "r-cat", title: "Desde iPhone", day: clock.today, isDone: false, modifiedAt: Date(), listID: "L-trabajo")) #expect(created?.category?.id == work.id) - // Movida remotamente a la general → pierde la categoría. + // Moved remotely to the general list → it loses the category. store.isApplyingRemoteChanges = true defer { store.isApplyingRemoteChanges = false } store.applyRemote(RemoteReminder(id: "r-cat", title: "Desde iPhone", @@ -356,7 +356,7 @@ struct TaskStoreTests { #expect(task?.reminderID == "r2") } - // MARK: - Ventana de memoria (ARCHITECTURE §4) + // MARK: - Memory window (ARCHITECTURE §4) @Test func snapshotExcludesPastWeeksButKeepsThemOnDisk() throws { let twoWeeksAgo = clock.calendar.date(byAdding: .day, value: -14, to: clock.today)! @@ -368,14 +368,14 @@ struct TaskStoreTests { try container.mainContext.save() store.refresh() - // Fuera del snapshot en memoria… + // Outside the in-memory snapshot… #expect(!store.tasks.contains { $0.text == "Hecha hace dos semanas" }) - // …pero intacta en disco. + // …but intact on disk. let all = try container.mainContext.fetch(FetchDescriptor()) #expect(all.contains { $0.text == "Hecha hace dos semanas" }) } - // MARK: - Persistencia del filtro (SPEC §4.1) + // MARK: - Filter persistence (SPEC §4.1) @Test func filterPersistsAcrossStoreInstances() throws { let suite = "test-filter-\(UUID().uuidString)" @@ -389,7 +389,7 @@ struct TaskStoreTests { let category = first.addCategory("Trabajo", colorHex: "#007AFF")! first.filterID = category.id - // "Reinicio": nueva instancia de store sobre el mismo contexto y defaults. + // "Restart": a new store instance over the same context and defaults. let second = TaskStore(context: container.mainContext, clock: clock, defaults: defaults) #expect(second.filterID == category.id) } @@ -405,7 +405,7 @@ struct TaskStoreTests { let suite = "test-filter-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } - // UUID guardado de una categoría que ya no existe. + // Saved UUID of a category that no longer exists. defaults.set(UUID().uuidString, forKey: "selectedCategoryFilter") let config = ModelConfiguration(isStoredInMemoryOnly: true) diff --git a/Scripts/generate-icon.sh b/Scripts/generate-icon.sh index 3924712..d16a367 100755 --- a/Scripts/generate-icon.sh +++ b/Scripts/generate-icon.sh @@ -1,5 +1,5 @@ #!/bin/zsh -# Regenera el AppIcon.appiconset desde el vector en código. +# Regenerates AppIcon.appiconset from the vector drawn in code. set -e cd "$(dirname "$0")/.." TMP=$(mktemp -d) diff --git a/Scripts/generate-icon.swift b/Scripts/generate-icon.swift index a7ed79c..4b033d6 100644 --- a/Scripts/generate-icon.swift +++ b/Scripts/generate-icon.swift @@ -1,10 +1,10 @@ import AppKit -// Genera el ícono de la app como vector dibujado en código — arte 100% propio, -// sin dependencias de herramientas de terceros. Misma geometría que la marca -// de la barra de menú (MenuBarController.makeCornerIcon), a color. +// Generates the app icon as a vector drawn in code — artwork that is 100% ours, +// with no third-party tooling. Same geometry as the menu bar mark +// (MenuBarController.makeCornerIcon), in color. // -// Uso: ./Scripts/generate-icon.sh (regenera el AppIcon.appiconset) +// Usage: ./Scripts/generate-icon.sh (regenerates AppIcon.appiconset) let sizes = [16, 32, 64, 128, 256, 512, 1024] let outDir = CommandLine.arguments.count > 1 @@ -15,11 +15,11 @@ func drawIcon(size: CGFloat) { let s = size / 18.0 let rect = NSRect(x: 0, y: 0, width: size, height: size) - // Fondo blanco con esquinas suaves (macOS moderno enmascara en squircle). + // White background with soft corners (modern macOS masks it into a squircle). NSColor.white.setFill() NSBezierPath(roundedRect: rect, xRadius: size * 0.18, yRadius: size * 0.18).fill() - // Trazo en L (esquina inferior izquierda) con gradiente ámbar de marca. + // L-shaped stroke (bottom-left corner) with the brand amber gradient. let bracket = NSBezierPath(roundedRect: NSRect(x: 3.4 * s, y: 3.4 * s, width: 4.4 * s, height: 11.6 * s), xRadius: 2.2 * s, yRadius: 2.2 * s) @@ -31,7 +31,7 @@ func drawIcon(size: CGFloat) { ending: NSColor(calibratedRed: 1.00, green: 0.55, blue: 0.10, alpha: 1))! gradient.draw(in: bracket, angle: -70) - // Palomita flotando en el hueco superior derecho. + // Checkmark floating in the top-right gap. let check = NSBezierPath() check.lineWidth = 1.9 * s check.lineCapStyle = .round diff --git a/Scripts/make-demo-gif.sh b/Scripts/make-demo-gif.sh index 77846f2..5a182f5 100755 --- a/Scripts/make-demo-gif.sh +++ b/Scripts/make-demo-gif.sh @@ -1,16 +1,16 @@ #!/bin/zsh -# Convierte una grabación de pantalla (.mov de ⇧⌘5) en el GIF del README. +# Turns a screen recording (.mov from ⇧⌘5) into the README GIF. # -# Uso: -# ./Scripts/make-demo-gif.sh grabacion.mov # todo el video -# ./Scripts/make-demo-gif.sh grabacion.mov 2 14 # desde s2, 14s de duración -# ANCHO=900 FPS=18 ./Scripts/make-demo-gif.sh grabacion.mov +# Usage: +# ./Scripts/make-demo-gif.sh recording.mov # the whole video +# ./Scripts/make-demo-gif.sh recording.mov 2 14 # from 2s, 14s long +# ANCHO=900 FPS=18 ./Scripts/make-demo-gif.sh recording.mov # -# Salida: docs/img/corner-demo.gif (objetivo: < 8 MB para que GitHub lo cargue rápido) +# Output: docs/img/corner-demo.gif (target: < 8 MB so GitHub loads it fast) set -e cd "$(dirname "$0")/.." -SRC="${1:?falta el archivo .mov de entrada}" +SRC="${1:?missing input .mov file}" INICIO="${2:-0}" DURACION="${3:-}" ANCHO="${ANCHO:-800}" @@ -23,8 +23,8 @@ RECORTE="-ss $INICIO" TMP=$(mktemp -d) FILTROS="fps=$FPS,scale=$ANCHO:-1:flags=lanczos" -# Dos pasadas: paleta propia = colores fieles y archivo más chico que el GIF -# directo (que usaría una paleta genérica de 256 colores). +# Two passes: a custom palette means faithful colors and a smaller file than a +# direct GIF (which would use a generic 256-color palette). ffmpeg -loglevel error ${=RECORTE} -i "$SRC" \ -vf "$FILTROS,palettegen=stats_mode=diff" -y "$TMP/palette.png" ffmpeg -loglevel error ${=RECORTE} -i "$SRC" -i "$TMP/palette.png" \ @@ -32,4 +32,4 @@ ffmpeg -loglevel error ${=RECORTE} -i "$SRC" -i "$TMP/palette.png" \ TAMANO=$(du -h "$OUT" | cut -f1) echo "OK: $OUT ($TAMANO, ${ANCHO}px, ${FPS}fps)" -echo "Si pesa más de 8 MB: baja ANCHO (700) o FPS (12), o recorta con INICIO/DURACION." +echo "If it is over 8 MB: lower ANCHO (700) or FPS (12), or trim with INICIO/DURACION." diff --git a/Scripts/render-preview.sh b/Scripts/render-preview.sh index 7d043c7..1feb3c2 100755 --- a/Scripts/render-preview.sh +++ b/Scripts/render-preview.sh @@ -1,21 +1,21 @@ #!/bin/zsh -# Compila y ejecuta el renderizador de verificación visual. -# Uso: ./Scripts/render-preview.sh [directorio-salida] [idioma] -# ./Scripts/render-preview.sh # docs/img, idioma del sistema -# ./Scripts/render-preview.sh /tmp/out en # inglés (usa el catálogo de la app compilada) +# Builds and runs the visual verification renderer. +# Usage: ./Scripts/render-preview.sh [output-directory] [language] +# ./Scripts/render-preview.sh # docs/img, system language +# ./Scripts/render-preview.sh /tmp/out en # English (uses the built app's catalog) set -e cd "$(dirname "$0")/.." OUT="${1:-docs/img}" LANG_CODE="${2:-}" TMP=$(mktemp -d) -# swiftc solo permite código top-level en un archivo llamado main.swift +# swiftc only allows top-level code in a file named main.swift cp Scripts/render-preview.swift "$TMP/main.swift" xcrun swiftc -o "$TMP/render" "$TMP/main.swift" Corner/Domain/*.swift Corner/UI/*.swift if [[ -n "$LANG_CODE" ]]; then - # El binario suelto no lleva el catálogo compilado: copiar los .lproj de la - # app ya compilada junto al ejecutable para que Foundation los encuentre. + # The standalone binary has no compiled catalog: copy the .lproj folders from + # the already-built app next to the executable so Foundation finds them. APP=$(ls -d ~/Library/Developer/Xcode/DerivedData/Corner-*/Build/Products/Release/Corner.app 2>/dev/null | head -1) - [[ -z "$APP" ]] && { echo "Compila la app Release primero (xcodebuild -scheme Corner -configuration Release build)"; exit 1 } + [[ -z "$APP" ]] && { echo "Build the Release app first (xcodebuild -scheme Corner -configuration Release build)"; exit 1 } cp -R "$APP/Contents/Resources/"*.lproj "$TMP/" "$TMP/render" "$OUT" -AppleLanguages "($LANG_CODE)" else diff --git a/Scripts/render-preview.swift b/Scripts/render-preview.swift index 976f6f4..5ff9117 100644 --- a/Scripts/render-preview.swift +++ b/Scripts/render-preview.swift @@ -2,10 +2,10 @@ import AppKit import SwiftUI import SwiftData -// Herramienta de verificación visual: renderiza PanelView con datos de -// muestra en modo claro y oscuro y guarda PNGs (docs/img o el dir dado). +// Visual verification tool: renders PanelView with sample data in light and +// dark mode and writes PNGs (docs/img, or the given directory). // -// Uso: ./Scripts/render-preview.sh [directorio-salida] +// Usage: ./Scripts/render-preview.sh [output-directory] @MainActor func run() throws { @@ -20,17 +20,17 @@ func run() throws { let store = TaskStore(context: container.mainContext, clock: clock, defaults: suite) store.dismissWelcomeTip() - let work = store.addCategory("Trabajo", colorHex: "#007AFF")! + let work = store.addCategory("Work", colorHex: "#007AFF")! store.addCategory("Personal", colorHex: "#34C759") - let t1 = store.add("Implementación de tolerancia", to: clock.today, category: work)! - store.addSubtask("Analizar requerimientos", to: t1) - let s2 = store.addSubtask("Escribir pruebas", to: t1)! + let t1 = store.add("Prepare release notes", to: clock.today, category: work)! + store.addSubtask("Draft changelog", to: t1) + let s2 = store.addSubtask("Tag version", to: t1)! store.toggle(s2) - let t2 = store.add("Verificar operación EUR/USD", to: clock.today, + let t2 = store.add("Buy coffee", to: clock.today, category: store.categories.last)! store.toggle(t2) - store.add("Comprar café", to: clock.today) - let rolled = store.add("Informe semanal", to: clock.today, category: work)! + store.add("Call the bank", to: clock.today) + let rolled = store.add("Water the plants", to: clock.today, category: work)! rolled.rolledOver = true let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "docs/img" diff --git a/docs/img/corner-dark.png b/docs/img/corner-dark.png index 78a1c52..37cdb05 100644 Binary files a/docs/img/corner-dark.png and b/docs/img/corner-dark.png differ diff --git a/docs/img/corner-light.png b/docs/img/corner-light.png index 5544b3f..a84cfa8 100644 Binary files a/docs/img/corner-light.png and b/docs/img/corner-light.png differ diff --git a/project.yml b/project.yml index 07054e5..c2806ea 100644 --- a/project.yml +++ b/project.yml @@ -1,6 +1,6 @@ -# Definición del proyecto Xcode. El .xcodeproj se genera con: +# Xcode project definition. The .xcodeproj is generated with: # xcodegen generate -# y NO se versiona (ver .gitignore). Cambios de configuración van aquí. +# and is NOT versioned (see .gitignore). Configuration changes go here. name: Corner options: bundleIdPrefix: com.andreetorres @@ -16,7 +16,7 @@ settings: CURRENT_PROJECT_VERSION: "1" MACOSX_DEPLOYMENT_TARGET: "14.0" CODE_SIGN_STYLE: Automatic - CODE_SIGN_IDENTITY: "-" # firma ad-hoc: uso personal, sin cuenta de developer + CODE_SIGN_IDENTITY: "-" # ad-hoc signing: no paid Apple Developer account yet targets: Corner: @@ -29,12 +29,12 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: com.andreetorres.corner GENERATE_INFOPLIST_FILE: YES ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - # App de fondo: sin ícono en el Dock (SPEC / CLAUDE.md). + # Background app: no Dock icon (SPEC / docs/DECISIONS.md). INFOPLIST_KEY_LSUIElement: YES INFOPLIST_KEY_NSRemindersFullAccessUsageDescription: "Corner solo usa Recordatorios para sincronizar tus tareas con la lista 'Corner', y únicamente si activas la sincronización en el menú." INFOPLIST_KEY_NSHumanReadableCopyright: "© 2026 Andree Torres" - ENABLE_HARDENED_RUNTIME: YES # postura de seguridad (SECURITY.md) y prerrequisito de notarización - ENABLE_APP_SANDBOX: NO # pendiente: requiere migrar el store al contenedor (ver SECURITY.md) + ENABLE_HARDENED_RUNTIME: YES # security posture (SECURITY.md) and a notarization prerequisite + ENABLE_APP_SANDBOX: NO # pending: requires migrating the store into the container (see SECURITY.md) SWIFT_EMIT_LOC_STRINGS: YES CornerTests: