Skip to main content

max / goingson

21.5 KB · 395 lines History Blame Raw
1 /**
2 * @fileoverview Tauri IPC abstraction layer.
3 *
4 * Wraps every Rust `#[tauri::command]` behind a thin JS method so the UI
5 * never calls `__TAURI__.core.invoke` directly. Methods are grouped by
6 * domain (projects, tasks, emails, …) and exposed on `GoingsOn.api`.
7 *
8 * Each method maps 1:1 to a Tauri command — the method name documents
9 * which command is invoked, and the arguments mirror the Rust serde input.
10 */
11
12 (function() {
13 'use strict';
14
15 // Wait for Tauri to be ready
16 let tauriInvoke = null;
17
18 // Initialize Tauri invoke function
19 async function initTauri() {
20 if (window.__TAURI__) {
21 tauriInvoke = window.__TAURI__.core.invoke;
22 return true;
23 }
24 return false;
25 }
26
27 // Try to init immediately
28 initTauri();
29
30 /**
31 * Invoke a Tauri command. Lazily initializes the `__TAURI__` reference on
32 * first call — this handles cases where the script loads before the Tauri
33 * runtime is injected (e.g. in dev mode with slow WebView init).
34 */
35 async function invoke(command, args = {}) {
36 if (!tauriInvoke) {
37 await initTauri();
38 }
39 if (!tauriInvoke) {
40 throw new Error('Tauri not available');
41 }
42 try {
43 const result = await tauriInvoke(command, args);
44 // Mark JS-initiated writes so the DB file-watcher can ignore its own echo
45 // (CHRONIC-E): a successful mutation already refreshed the UI, so the
46 // db:external-change it triggers is a redundant invalidate-everything storm.
47 // Reads never mark (so a background-sync write the JS didn't initiate still
48 // refreshes via db:external-change). Conservative prefix match: only commands
49 // we are confident write the DB mark.
50 if (MUTATION_COMMAND.test(command)) {
51 window.__goLastLocalWriteAt = Date.now();
52 }
53 return result;
54 } catch (err) {
55 console.error(`[api] invoke '${command}' failed:`, err, 'args:', JSON.stringify(args));
56 throw err;
57 }
58 }
59
60 /// Commands that write the local DB. Matching this marks a local write for the
61 /// file-watcher echo-suppression (CHRONIC-E). Read commands (list_/get_/search/
62 /// validate) deliberately do not match.
63 const MUTATION_COMMAND = /^(create|update|delete|bulk|complete|start|snooze|unsnooze|mark|clear|archive|unarchive|set|toggle|upsert|add|remove|move|promote|convert|reorder|save|restore|import|sync|log|schedule|reanchor|link|unlink)_/;
64
65 // ============ API Object ============
66
67 const api = {
68 // Projects
69 projects: {
70 list: () => invoke('list_projects'),
71 get: (id) => invoke('get_project', { id }),
72 create: (input) => invoke('create_project', { input }),
73 update: (id, input) => invoke('update_project', { id, input }),
74 delete: (id) => invoke('delete_project', { id }),
75 },
76
77 // Tasks — CRUD + lifecycle (start/complete) + snooze/waiting status
78 tasks: {
79 list: () => invoke('list_tasks'),
80 listFiltered: (filters) => invoke('list_tasks_filtered', { filters }), // Server-side filter + paginate
81 listByProject: (projectId) => invoke('list_tasks_for_project', { projectId }),
82 get: (id) => invoke('get_task', { id }),
83 getOverview: (id) => invoke('get_task_overview', { id }),
84 create: (input) => invoke('create_task', { input }),
85 quickAdd: (text) => invoke('quick_add_task', { input: { text } }), // Natural language: "Fix bug +work @tomorrow !high"
86 update: (id, input) => invoke('update_task', { id, input }),
87 delete: (id) => invoke('delete_task', { id }),
88 bulkSetProject: (ids, projectId) => invoke('bulk_set_task_project', { ids, projectId }),
89 bulkSetPriority: (ids, priority) => invoke('bulk_set_task_priority', { ids, priority }),
90 // Saved views (named task-filter presets)
91 savedViews: {
92 list: () => invoke('list_saved_views'),
93 listPinned: () => invoke('list_pinned_views'),
94 get: (id) => invoke('get_saved_view', { id }),
95 create: (input) => invoke('create_saved_view', { input }),
96 update: (id, input) => invoke('update_saved_view', { id, input }),
97 delete: (id) => invoke('delete_saved_view', { id }),
98 togglePinned: (id) => invoke('toggle_view_pinned', { id }),
99 },
100 start: (id) => invoke('start_task', { id }), // Pending → Started (sets started_at)
101 complete: (id) => invoke('complete_task', { id }), // Spawns next instance for recurring tasks
102 listSnoozed: () => invoke('list_snoozed_tasks'),
103 snooze: (id, until) => invoke('snooze_task', { id, input: { until } }),
104 unsnooze: (id) => invoke('unsnooze_task', { id }),
105 listWaiting: () => invoke('list_waiting_tasks'),
106 markWaiting: (id, expectedResponse) => invoke('mark_task_waiting', { id, input: { expectedResponseDate: expectedResponse } }),
107 clearWaiting: (id) => invoke('clear_task_waiting', { id }),
108 },
109
110 // Annotations
111 annotations: {
112 list: (taskId) => invoke('list_annotations', { taskId }),
113 add: (taskId, note) => invoke('add_annotation', { taskId, input: { note } }),
114 delete: (taskId, annotationId) => invoke('delete_annotation', { annotationId }),
115 },
116
117 // Subtasks
118 subtasks: {
119 list: (taskId) => invoke('list_subtasks', { taskId }),
120 add: (taskId, text) => invoke('add_subtask', { taskId, input: { text } }),
121 addLink: (taskId, linkedTaskId) => invoke('add_subtask_link', { taskId, linkedTaskId }),
122 toggle: (taskId, subtaskId) => invoke('toggle_subtask', { subtaskId }),
123 update: (taskId, subtaskId, text) => invoke('update_subtask', { subtaskId, input: { text } }),
124 delete: (taskId, subtaskId) => invoke('delete_subtask', { subtaskId }),
125 },
126
127 // Status tokens (typed at-a-glance markers, e.g. linked commits).
128 // `record` is idempotent in (kind, reference), so it also toggles state/primary.
129 statusTokens: {
130 list: (taskId) => invoke('list_task_status_tokens', { taskId }),
131 record: (taskId, input) => invoke('record_task_status_token', { taskId, input }),
132 delete: (tokenId) => invoke('delete_task_status_token', { tokenId }),
133 },
134
135 // Events
136 events: {
137 list: () => invoke('list_events'),
138 listByProject: (projectId) => invoke('list_events_for_project', { projectId }),
139 listUpcoming: () => invoke('list_upcoming_events'),
140 get: (id) => invoke('get_event', { id }),
141 create: (input) => invoke('create_event', { input }),
142 update: (id, input) => invoke('update_event', { id, input }),
143 delete: (id) => invoke('delete_event', { id }),
144 bulkDelete: (ids) => invoke('bulk_delete_events', { ids }),
145 listBetween: (start, end) => invoke('list_events_between', { start, end }),
146 getStatusIndicator: (leadMinutes) => invoke('get_event_status_indicator', { leadMinutes }),
147 listSnoozed: () => invoke('list_snoozed_events'),
148 snooze: (id, until) => invoke('snooze_event', { id, input: { until } }),
149 unsnooze: (id) => invoke('unsnooze_event', { id }),
150 },
151
152 // Emails — CRUD + threading + status (read/archive/snooze/waiting)
153 emails: {
154 list: (includeArchived = false) => invoke('list_emails', { includeArchived }),
155 listThreaded: (params = {}) => invoke('list_emails_threaded', { params }), // Groups by thread_id, paginated
156 listByProject: (projectId) => invoke('list_emails_for_project', { projectId }),
157 listUnlinked: () => invoke('list_unlinked_emails'), // Emails not linked to any project
158 get: (id) => invoke('get_email', { id }),
159 buildReplyPrefill: (id, replyAll) => invoke('build_reply_prefill', { id, replyAll }), // Rust builds reply recipients/subject/quote
160 buildForwardPrefill: (id) => invoke('build_forward_prefill', { id }), // Rust builds Fwd: subject + forwarded body
161 fetchFullBody: (id) => invoke('fetch_email_full_body', { id }), // Lazy-load a body truncated at sync (JMAP >100KB)
162 create: (input) => invoke('create_email', { input }),
163 send: (input) => invoke('send_email', { input }), // SMTP send + save copy to local DB
164 delete: (id) => invoke('delete_email', { id }),
165 markRead: (id) => invoke('mark_email_read', { id }),
166 markUnread: (id) => invoke('mark_email_unread', { id }),
167 archive: (id) => invoke('archive_email', { id }), // Also moves on IMAP server if available
168 unarchive: (id) => invoke('unarchive_email', { id }),
169 markAllRead: () => invoke('mark_all_emails_read'),
170 linkToProject: (id, projectId) => invoke('link_email_to_project', { id, input: { projectId } }),
171 getUnreadCount: () => invoke('get_unread_email_count'),
172 listSnoozed: () => invoke('list_snoozed_emails'),
173 snooze: (id, until) => invoke('snooze_email', { id, input: { until } }),
174 unsnooze: (id) => invoke('unsnooze_email', { id }),
175 listWaiting: () => invoke('list_waiting_emails'),
176 markWaiting: (id, expectedResponse) => invoke('mark_email_waiting', { id, input: { expectedResponseDate: expectedResponse } }),
177 clearWaiting: (id) => invoke('clear_email_waiting', { id }),
178 listByThread: (threadId) => invoke('list_emails_by_thread', { threadId }),
179 saveDraft: (input) => invoke('save_email_draft', { input }),
180 listDrafts: () => invoke('list_email_drafts'),
181 sendDraft: (id) => invoke('send_email_draft', { id }),
182 setLabels: (id, labels) => invoke('set_email_labels', { id, labels }),
183 listFolders: () => invoke('list_email_folders'),
184 listLabels: () => invoke('list_email_labels'),
185 moveToFolder: (id, folder) => invoke('move_email_to_folder', { id, folder }),
186 },
187
188 // Contacts — CRUD + multi-value fields (emails, phones, social, custom)
189 contacts: {
190 list: () => invoke('list_contacts'),
191 get: (id) => invoke('get_contact', { id }),
192 create: (input) => invoke('create_contact', { input }),
193 update: (id, input) => invoke('update_contact', { id, input }),
194 delete: (id) => invoke('delete_contact', { id }),
195 bulkDelete: (ids) => invoke('bulk_delete_contacts', { ids }),
196 bulkTag: (ids, tag) => invoke('bulk_tag_contacts', { ids, tag }),
197 addEmail: (contactId, input) => invoke('add_contact_email', { contactId, input }),
198 removeEmail: (emailId) => invoke('remove_contact_email', { emailId }),
199 updateEmail: (emailId, input) => invoke('update_contact_email', { emailId, input }),
200 addPhone: (contactId, input) => invoke('add_contact_phone', { contactId, input }),
201 removePhone: (phoneId) => invoke('remove_contact_phone', { phoneId }),
202 updatePhone: (phoneId, input) => invoke('update_contact_phone', { phoneId, input }),
203 addSocialHandle: (contactId, input) => invoke('add_contact_social_handle', { contactId, input }),
204 removeSocialHandle: (handleId) => invoke('remove_contact_social_handle', { handleId }),
205 updateSocialHandle: (handleId, input) => invoke('update_contact_social_handle', { handleId, input }),
206 addCustomField: (contactId, input) => invoke('add_contact_custom_field', { contactId, input }),
207 removeCustomField: (fieldId) => invoke('remove_contact_custom_field', { fieldId }),
208 updateCustomField: (fieldId, input) => invoke('update_contact_custom_field', { fieldId, input }),
209 findByEmail: (email) => invoke('find_contact_by_email', { email }), // Reverse lookup for email sender → contact
210 validateAddresses: (addresses) => invoke('validate_email_addresses', { addresses }),
211 promoteContact: (id) => invoke('promote_contact', { id }),
212 listTasksForContact: (contactId) => invoke('list_tasks_for_contact', { contactId }),
213 listEventsForContact: (contactId) => invoke('list_events_for_contact', { contactId }),
214 listEmailsForContact: (contactId) => invoke('list_emails_for_contact', { contactId }),
215 getActivity: (contactId, limit) => invoke('get_contact_activity', { contactId, limit }), // Pre-merged, pre-sorted, pre-formatted timeline + counts (Rust does the math). Omit limit to fetch the full feed.
216 listFiltered: (search, tag, includeImplicit) => invoke('list_contacts_filtered', { search: search || null, tag: tag || null, includeImplicit: includeImplicit ?? false }),
217 },
218
219 // Email Accounts — IMAP/JMAP account configuration and sync triggers
220 emailAccounts: {
221 list: () => invoke('list_email_accounts'),
222 get: (id) => invoke('get_email_account', { id }),
223 create: (input) => invoke('create_email_account', { input }),
224 update: (id, input) => invoke('update_email_account', { id, input }),
225 updateSyncInterval: (id, syncIntervalMinutes) => invoke('update_email_sync_interval', { id, input: { syncIntervalMinutes } }),
226 updateSignature: (id, emailSignature) => invoke('update_email_signature', { id, input: { emailSignature } }),
227 updateNotify: (id, enabled) => invoke('update_email_notify', { id, enabled }),
228 delete: (id) => invoke('delete_email_account', { id }),
229 test: (id) => invoke('test_email_account', { id }), // Verify IMAP/SMTP credentials
230 sync: (id, fullSync = false) => invoke('sync_email_account', { id, fullSync }), // fullSync ignores last_sync_at
231 },
232
233 // Stats
234 stats: {
235 getDashboard: () => invoke('get_dashboard_stats'),
236 },
237
238 // App metadata
239 app: {
240 getChangelog: () => invoke('get_changelog'),
241 parseNaturalDate: (input) => invoke('parse_natural_date', { input }), // "tomorrow", "friday 3pm" -> YYYY-MM-DDTHH:MM
242 },
243
244 // Day Planning
245 dayPlanning: {
246 getDay: (date) => invoke('get_day_planning', { date }),
247 scheduleTask: (id, input) => invoke('schedule_task', { id, input }),
248 unscheduleTask: (id) => invoke('unschedule_task', { id }),
249 },
250
251 // Snooze
252 snooze: {
253 getOptions: () => invoke('get_snooze_options'),
254 },
255
256 // OAuth — provider-based auth flow (Google, Microsoft, Yahoo, Fastmail)
257 oauth: {
258 listProviders: () => invoke('list_oauth_providers'),
259 start: (providerId) => invoke('start_oauth', { providerId }), // Opens browser for consent
260 pollResult: (port) => invoke('poll_oauth_result', { port }), // Polls the loopback callback over IPC
261 complete: (input) => invoke('complete_oauth', { input }), // Exchanges auth code for tokens
262 refreshTokens: (accountId) => invoke('refresh_oauth_tokens', { accountId }),
263 disconnect: (accountId) => invoke('disconnect_oauth', { accountId }),
264 reconnect: (accountId) => invoke('reconnect_oauth', { accountId }),
265 },
266
267 // Search — full-text search across all entity types
268 search: {
269 query: (input) => invoke('search', { input }),
270 },
271
272 // Export/Backup — data export (JSON/CSV/ICS) and automatic backup management
273 export: {
274 getSummary: () => invoke('get_export_summary'), // Counts per entity type for the export dialog
275 json: (filePath) => invoke('export_json', { filePath }),
276 tasksCSV: (filePath, projectId = null) => invoke('export_tasks_csv', { filePath, projectId }),
277 eventsICS: (filePath, includePast = true) => invoke('export_events_ics', { filePath, includePast }),
278 createBackup: () => invoke('create_backup'), // Full SQLite snapshot
279 listBackups: () => invoke('list_backups'),
280 restoreBackup: (filePath, options) => invoke('restore_backup', { filePath, options }),
281 deleteBackup: (filePath) => invoke('delete_backup', { filePath }),
282 getBackupSettings: () => invoke('get_backup_settings'),
283 saveBackupSettings: (settings) => invoke('save_backup_settings', { input: settings }),
284 },
285
286 // Daily Notes — per-day reflection (persisted to SQLite)
287 dailyNotes: {
288 get: (date) => invoke('get_daily_note', { date }),
289 upsert: (input) => invoke('upsert_daily_note', { input }),
290 },
291
292 // Weekly Review — GTD-style reflection: focus tasks, vacation, nudges
293 weeklyReview: {
294 get: (weekStart = null) => invoke('get_weekly_review', { input: { weekStart } }),
295 complete: (notes, weekStart = null) => invoke('complete_weekly_review', { input: { notes, weekStart } }),
296 setFocus: (id, isFocus) => invoke('set_task_focus', { id, input: { isFocus } }),
297 clearAllFocus: () => invoke('clear_all_focus'),
298 checkNudge: () => invoke('check_weekly_review_nudge'),
299 setVacationDays: (days, weekStart = null) => invoke('set_vacation_days', { input: { days, weekStart } }),
300 },
301
302 // Monthly Review — reflection, goal-setting, heat-map calendar
303 monthlyReview: {
304 get: (month = null) => invoke('get_monthly_review', { input: { month } }),
305 upsertGoal: (month, text, position) => invoke('upsert_monthly_goal', { input: { month, text, position } }),
306 updateGoalStatus: (id, status) => invoke('update_monthly_goal_status', { id, input: { status } }),
307 deleteGoal: (id) => invoke('delete_monthly_goal', { id }),
308 saveReflection: (month, highlight, change) => invoke('save_monthly_reflection', { input: { month, highlight, change } }),
309 },
310
311 // Milestones
312 milestones: {
313 list: (projectId) => invoke('list_milestones', { projectId }),
314 create: (input) => invoke('create_milestone', { input }),
315 update: (id, input) => invoke('update_milestone', { id, input }),
316 delete: (id) => invoke('delete_milestone', { id }),
317 reorder: (projectId, input) => invoke('reorder_milestones', { projectId, input }),
318 },
319
320 // Sync — cross-device sync with E2E encryption
321 sync: {
322 getTiers: () => invoke('sync_get_tiers'),
323 status: () => invoke('sync_status'),
324 startAuth: () => invoke('sync_start_auth'),
325 completeAuth: (input) => invoke('sync_complete_auth', { input }),
326 disconnect: () => invoke('sync_disconnect'),
327 syncNow: () => invoke('sync_now'),
328 setupEncryptionNew: (password) => invoke('sync_setup_encryption_new', { password }), // First device
329 setupEncryptionExisting: (password) => invoke('sync_setup_encryption_existing', { password }), // Join existing
330 updateSettings: (input) => invoke('sync_update_settings', { input }),
331 subscriptionStatus: () => invoke('sync_subscription_status'),
332 subscribe: (interval) => invoke('sync_subscribe', { interval }),
333 accountInfo: () => invoke('sync_account_info'),
334 },
335
336 // Preferences — small JSON file in the app config dir for settings read before the DB is up
337 preferences: {
338 get: () => invoke('get_preferences'),
339 setUpdateCheckOnLaunch: (enabled) => invoke('set_update_check_on_launch', { enabled }),
340 },
341
342 // Import — native CSV/TSV import (entity type auto-detected from headers)
343 // plus vCard/iCalendar import. Must be ONE object: a second `import:` key
344 // would shadow this one and silently break whichever block lost the merge
345 // (it previously broke the native CSV preview/execute path).
346 import: {
347 preview: (filePath, options = {}) => invoke('preview_import', { input: { filePath, options } }), // Dry-run parse
348 execute: (filePath, options = {}, selectedIndices = []) =>
349 invoke('execute_import', { input: { filePath, options, selectedIndices } }), // Create entities in DB
350 previewVcf: (filePath) => invoke('preview_vcf', { filePath }),
351 importVcf: (filePath) => invoke('import_vcf', { filePath }),
352 previewIcs: (filePath) => invoke('preview_ics', { filePath }),
353 importIcs: (filePath) => invoke('import_ics', { filePath }),
354 },
355
356 // Attachments — file attachments on tasks and projects
357 attachments: {
358 list: (taskId, projectId) => invoke('list_attachments', { taskId: taskId || null, projectId: projectId || null }),
359 add: (taskId, projectId, filePath) => invoke('add_attachment', { taskId: taskId || null, projectId: projectId || null, filePath }),
360 delete: (id) => invoke('delete_attachment', { id }),
361 open: (id) => invoke('open_attachment', { id }),
362 save: (id, destination) => invoke('save_attachment', { id, destination }),
363 convertFromEmail: (emailId, taskId) => invoke('convert_email_attachments', { emailId, taskId }),
364 openEmailBlob: (blobHash, filename) => invoke('open_email_blob', { blobHash, filename }),
365 saveEmailBlob: (blobHash, destination) => invoke('save_email_blob', { blobHash, destination }),
366 fileSize: (filePath) => invoke('get_file_size', { filePath }),
367 },
368
369 // Time Tracking — start/stop timer, sessions, summaries
370 timeTracking: {
371 startTimer: (taskId) => invoke('start_timer', { taskId }),
372 stopTimer: (taskId) => invoke('stop_timer', { taskId }),
373 discardTimer: (taskId) => invoke('discard_timer', { taskId }),
374 getActive: () => invoke('get_active_timer'),
375 listSessions: (taskId) => invoke('list_time_sessions', { taskId }),
376 logManual: (taskId, minutes, date) => invoke('log_manual_time', { input: { taskId, minutes, date } }),
377 getSummaryPanel: () => invoke('get_time_summary_panel'), // Pre-computed: today total + week per-project rollup (Rust does the math)
378 },
379
380 // Window — Tauri window management
381 window: {
382 setTitle: (title) => invoke('set_window_title', { title }),
383 openCompose: (context) => invoke('open_compose_window', { context: context || null }), // Separate compose window, optional reply context
384 openEmailInBrowser: (id) => invoke('open_email_in_browser', { id }), // Render HTML email to temp file → open
385 openExternal: (url) => invoke('open_external_url', { url }), // Validated http(s) opener; JS shell.open is not granted
386 },
387 };
388
389 // ============ Populate GoingsOn.api Namespace ============
390
391 GoingsOn.api = api;
392
393 })();
394
395