Skip to main content

max / goingson

11.6 KB · 310 lines History Blame Raw
1 /**
2 * GoingsOn - External File Import Module
3 * Handles importing contacts from vCard (.vcf) and events from iCalendar (.ics) files.
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9
10 // ============ Import Type Selection ============
11
12 /**
13 * Opens the external import dialog with type selection.
14 */
15 function openImportDialog() {
16 const content = `
17 <div class="import-external-types">
18 <button class="import-type-card" onclick="GoingsOn.importExternal.startContactImport()">
19 <span class="import-type-icon">&#128101;</span>
20 <span class="import-type-label">Import Contacts</span>
21 <span class="import-type-desc">Import from a vCard (.vcf) file</span>
22 </button>
23 <button class="import-type-card" onclick="GoingsOn.importExternal.startCalendarImport()">
24 <span class="import-type-icon">&#128197;</span>
25 <span class="import-type-label">Import Calendar</span>
26 <span class="import-type-desc">Import from an iCalendar (.ics) file</span>
27 </button>
28 </div>
29 <div class="form-actions">
30 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
31 </div>
32 `;
33
34 GoingsOn.ui.openModal('Import External Data', content);
35 }
36
37 // ============ Contact Import ============
38
39 /**
40 * Opens file picker for vCard import, then shows preview.
41 */
42 async function startContactImport() {
43 const filePath = await pickFile('vCard Files', ['vcf']);
44 if (!filePath) return;
45
46 GoingsOn.ui.openModal('Import Contacts', `
47 <div class="import-preview-container">
48 <div class="loading">Parsing ${esc(getFileName(filePath))}...</div>
49 </div>
50 `);
51
52 try {
53 const preview = await GoingsOn.api.import.previewVcf(filePath);
54
55 if (preview.length === 0) {
56 updatePreviewContent('<p class="import-empty">No contacts found in file.</p>', null);
57 return;
58 }
59
60 const maxPreview = 25;
61 const display = preview.slice(0, maxPreview);
62
63 const html = `
64 <p class="import-summary"><strong>${preview.length}</strong> contact${preview.length !== 1 ? 's' : ''} found</p>
65 <div class="import-preview-table-wrapper">
66 <table class="data-table import-preview-table">
67 <thead>
68 <tr><th>Name</th><th>Company</th><th>Emails</th><th>Phones</th></tr>
69 </thead>
70 <tbody>
71 ${display.map(c => `
72 <tr>
73 <td>${esc(c.displayName)}</td>
74 <td>${esc(c.company || '')}</td>
75 <td>${c.emailCount}</td>
76 <td>${c.phoneCount}</td>
77 </tr>
78 `).join('')}
79 </tbody>
80 </table>
81 </div>
82 ${preview.length > maxPreview ? `<p class="import-more">...and ${preview.length - maxPreview} more</p>` : ''}
83 `;
84
85 updatePreviewContent(html, async () => {
86 await executeContactImport(filePath, preview.length);
87 });
88 } catch (err) {
89 updatePreviewContent(`<p class="import-error">Failed to parse file: ${esc(GoingsOn.utils.getErrorMessage(err))}</p>`, null);
90 }
91 }
92
93 /**
94 * Executes the vCard import.
95 */
96 async function executeContactImport(filePath, expectedCount) {
97 const btn = document.getElementById('import-external-confirm');
98 if (btn) {
99 btn.disabled = true;
100 btn.textContent = 'Importing...';
101 }
102
103 try {
104 const result = await GoingsOn.api.import.importVcf(filePath);
105 GoingsOn.ui.closeModal();
106
107 const parts = [];
108 if (result.imported > 0) parts.push(`${result.imported} imported`);
109 if (result.skipped > 0) parts.push(`${result.skipped} skipped (duplicates)`);
110 if (result.errors.length > 0) parts.push(`${result.errors.length} failed`);
111
112 const hasErrors = result.errors.length > 0;
113 GoingsOn.ui.showToast(
114 `Contacts: ${parts.join(', ')}`,
115 hasErrors ? 'warning' : 'success'
116 );
117
118 if (GoingsOn.navigation && GoingsOn.navigation.reloadCurrentView) {
119 GoingsOn.navigation.reloadCurrentView();
120 }
121 } catch (err) {
122 GoingsOn.ui.showToast('Import failed: ' + GoingsOn.utils.getErrorMessage(err), 'error');
123 if (btn) {
124 btn.disabled = false;
125 btn.textContent = 'Import';
126 }
127 }
128 }
129
130 // ============ Calendar Import ============
131
132 /**
133 * Opens file picker for ICS import, then shows preview.
134 */
135 async function startCalendarImport() {
136 const filePath = await pickFile('iCalendar Files', ['ics']);
137 if (!filePath) return;
138
139 GoingsOn.ui.openModal('Import Calendar', `
140 <div class="import-preview-container">
141 <div class="loading">Parsing ${esc(getFileName(filePath))}...</div>
142 </div>
143 `);
144
145 try {
146 const preview = await GoingsOn.api.import.previewIcs(filePath);
147
148 if (preview.length === 0) {
149 updatePreviewContent('<p class="import-empty">No events found in file.</p>', null);
150 return;
151 }
152
153 const maxPreview = 25;
154 const display = preview.slice(0, maxPreview);
155
156 const html = `
157 <p class="import-summary"><strong>${preview.length}</strong> event${preview.length !== 1 ? 's' : ''} found</p>
158 <div class="import-preview-table-wrapper">
159 <table class="data-table import-preview-table">
160 <thead>
161 <tr><th>Title</th><th>Start</th><th>Location</th><th>Recurrence</th></tr>
162 </thead>
163 <tbody>
164 ${display.map(e => `
165 <tr>
166 <td>${esc(e.title)}</td>
167 <td>${esc(formatImportDate(e.startTime))}</td>
168 <td>${esc(e.location || '')}</td>
169 <td>${esc(e.recurrence)}</td>
170 </tr>
171 `).join('')}
172 </tbody>
173 </table>
174 </div>
175 ${preview.length > maxPreview ? `<p class="import-more">...and ${preview.length - maxPreview} more</p>` : ''}
176 `;
177
178 updatePreviewContent(html, async () => {
179 await executeCalendarImport(filePath, preview.length);
180 });
181 } catch (err) {
182 updatePreviewContent(`<p class="import-error">Failed to parse file: ${esc(GoingsOn.utils.getErrorMessage(err))}</p>`, null);
183 }
184 }
185
186 /**
187 * Executes the ICS import.
188 */
189 async function executeCalendarImport(filePath, expectedCount) {
190 const btn = document.getElementById('import-external-confirm');
191 if (btn) {
192 btn.disabled = true;
193 btn.textContent = 'Importing...';
194 }
195
196 try {
197 const result = await GoingsOn.api.import.importIcs(filePath);
198 GoingsOn.ui.closeModal();
199
200 const parts = [];
201 if (result.imported > 0) parts.push(`${result.imported} imported`);
202 if (result.skipped > 0) parts.push(`${result.skipped} skipped (duplicates)`);
203 if (result.errors.length > 0) parts.push(`${result.errors.length} failed`);
204
205 const hasErrors = result.errors.length > 0;
206 GoingsOn.ui.showToast(
207 `Events: ${parts.join(', ')}`,
208 hasErrors ? 'warning' : 'success'
209 );
210
211 if (GoingsOn.navigation && GoingsOn.navigation.reloadCurrentView) {
212 GoingsOn.navigation.reloadCurrentView();
213 }
214 } catch (err) {
215 GoingsOn.ui.showToast('Import failed: ' + GoingsOn.utils.getErrorMessage(err), 'error');
216 if (btn) {
217 btn.disabled = false;
218 btn.textContent = 'Import';
219 }
220 }
221 }
222
223 // ============ Helpers ============
224
225 /**
226 * Opens native file picker with given filter.
227 * @param {string} filterName - Display name for the file filter
228 * @param {string[]} extensions - Allowed file extensions
229 * @returns {Promise<string|null>} Selected file path, or null if cancelled
230 */
231 async function pickFile(filterName, extensions) {
232 try {
233 const { open } = window.__TAURI__.dialog;
234 return await open({
235 multiple: false,
236 filters: [{ name: filterName, extensions }],
237 });
238 } catch (err) {
239 GoingsOn.ui.showToast('Failed to open file picker: ' + GoingsOn.utils.getErrorMessage(err), 'error');
240 return null;
241 }
242 }
243
244 /**
245 * Gets the filename from a full path.
246 * @param {string} path - Full file path
247 * @returns {string} Filename component
248 */
249 function getFileName(path) {
250 return path.split(/[/\\]/).pop() || path;
251 }
252
253 /**
254 * Formats an ISO date string for display in preview table.
255 * @param {string} isoString - ISO 8601 date string
256 * @returns {string} Locale-formatted date string
257 */
258 function formatImportDate(isoString) {
259 if (!isoString) return '';
260 try {
261 const d = new Date(isoString);
262 return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
263 } catch {
264 return isoString;
265 }
266 }
267
268 /**
269 * Updates the modal content with preview HTML and optional confirm button.
270 * @param {string} previewHtml - HTML to insert into the preview container
271 * @param {Function|null} onConfirm - Callback for the Import button, or null for close-only
272 */
273 function updatePreviewContent(previewHtml, onConfirm) {
274 const container = document.querySelector('.import-preview-container');
275 if (!container) return;
276
277 container.innerHTML = previewHtml;
278
279 // Update or create form actions
280 const modal = container.closest('.modal-body') || container.parentElement;
281 let actions = modal.querySelector('.form-actions');
282 if (!actions) {
283 actions = document.createElement('div');
284 actions.className = 'form-actions';
285 modal.appendChild(actions);
286 }
287
288 if (onConfirm) {
289 actions.innerHTML = `
290 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
291 <button type="button" class="btn btn-primary" id="import-external-confirm">Import</button>
292 `;
293 document.getElementById('import-external-confirm').addEventListener('click', onConfirm);
294 } else {
295 actions.innerHTML = `
296 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button>
297 `;
298 }
299 }
300
301 // ============ Populate Namespace ============
302
303 GoingsOn.importExternal = {
304 openDialog: openImportDialog,
305 startContactImport,
306 startCalendarImport,
307 };
308
309 })();
310