Skip to main content

max / goingson

9.0 KB · 253 lines History Blame Raw
1 /**
2 * GoingsOn - Import Module
3 *
4 * Imports tasks, projects, or events from a CSV/TSV file. The entity type is
5 * auto-detected from the header columns by the native importer (Rust). CSV is
6 * GoingsOn's import interchange format; convert other tools' exports to CSV
7 * with a separate converter, then bring them in here.
8 */
9
10 (function() {
11 'use strict';
12 const esc = GoingsOn.utils.escapeHtml;
13 const escAttrVal = GoingsOn.utils.escapeAttrValue;
14
15 // ============ State ============
16
17 let selectedFilePath = null;
18 let previewData = null;
19
20 // ============ Import Wizard ============
21
22 /**
23 * Opens the import modal (choose file, then preview and confirm).
24 */
25 function openImportModal() {
26 selectedFilePath = null;
27 previewData = null;
28
29 const content = `
30 <div class="import-wizard">
31 <div class="import-step" id="import-step-file">
32 <h3>1. Choose a CSV or TSV file</h3>
33 <p class="import-hint">Columns are matched by name (description, due, priority, project, tags for tasks; start/end for events; name/type for projects).</p>
34 <div class="file-selector">
35 <button class="btn btn-primary" id="select-file-btn" data-act="import.selectFile">
36 Choose File...
37 </button>
38 <span id="selected-file-name" class="selected-file-name"></span>
39 </div>
40 </div>
41 <div class="import-step hidden" id="import-step-preview">
42 <h3>2. Preview</h3>
43 <div id="import-preview-container" class="import-preview-container">
44 <div class="loading">Loading preview...</div>
45 </div>
46 </div>
47 </div>
48 <div class="form-actions">
49 <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Cancel</button>
50 <button type="button" class="btn btn-primary" id="import-confirm-btn" disabled data-act="import.executeImport">
51 Import
52 </button>
53 </div>
54 `;
55
56 GoingsOn.ui.openModal('Import from CSV', content);
57 }
58
59 /**
60 * Opens the file dialog, then loads a preview.
61 */
62 async function selectFile() {
63 try {
64 const { open } = window.__TAURI__.dialog;
65 const filePath = await open({
66 multiple: false,
67 filters: [{ name: 'CSV / TSV', extensions: ['csv', 'tsv'] }],
68 });
69
70 if (!filePath) return; // User cancelled
71
72 selectedFilePath = filePath;
73 document.getElementById('selected-file-name').textContent = getFileName(filePath);
74
75 document.getElementById('import-step-preview').classList.remove('hidden');
76 await loadPreview();
77 } catch (err) {
78 GoingsOn.ui.showToast('Failed to select file: ' + GoingsOn.utils.getErrorMessage(err), 'error');
79 }
80 }
81
82 /**
83 * Gets the filename from a full path.
84 */
85 function getFileName(path) {
86 return path.split(/[/\\]/).pop() || path;
87 }
88
89 /**
90 * Loads and displays the preview of the import data.
91 */
92 async function loadPreview() {
93 const container = document.getElementById('import-preview-container');
94 container.innerHTML = '<div class="loading">Parsing file...</div>';
95
96 try {
97 previewData = await GoingsOn.api.import.preview(selectedFilePath);
98
99 if (!previewData.items || previewData.items.length === 0) {
100 const warnings = previewData.warnings || [];
101 container.innerHTML = `<p class="import-empty">No items found in file.</p>`;
102 if (warnings.length > 0) {
103 container.insertAdjacentHTML('beforeend', renderWarnings(warnings));
104 }
105 document.getElementById('import-confirm-btn').disabled = true;
106 return;
107 }
108
109 container.innerHTML = renderPreviewTable(previewData);
110 document.getElementById('import-confirm-btn').disabled = false;
111
112 if (previewData.warnings && previewData.warnings.length > 0) {
113 container.insertAdjacentHTML('beforeend', renderWarnings(previewData.warnings));
114 }
115 } catch (err) {
116 container.innerHTML = `<p class="import-error">Failed to parse file: ${esc(GoingsOn.utils.getErrorMessage(err))}</p>`;
117 document.getElementById('import-confirm-btn').disabled = true;
118 }
119 }
120
121 function renderWarnings(warnings) {
122 return `
123 <div class="import-warnings">
124 <strong>Warnings:</strong>
125 <ul>${warnings.map(w => `<li>${esc(w)}</li>`).join('')}</ul>
126 </div>
127 `;
128 }
129
130 /**
131 * Renders the preview table.
132 */
133 function renderPreviewTable(data) {
134 const entityType = data.entityType || 'item';
135 const items = data.items || [];
136 const maxPreview = 25;
137 const displayItems = items.slice(0, maxPreview);
138
139 const columns = getColumnsForEntityType(entityType);
140
141 return `
142 <p class="import-summary">
143 <strong>${items.length}</strong> ${esc(entityType)}${items.length !== 1 ? 's' : ''} found
144 </p>
145 <div class="import-preview-table-wrapper">
146 <table class="data-table import-preview-table">
147 <thead>
148 <tr>${columns.map(c => `<th>${esc(c.label)}</th>`).join('')}</tr>
149 </thead>
150 <tbody>
151 ${displayItems.map(item => renderPreviewRow(item, columns)).join('')}
152 </tbody>
153 </table>
154 </div>
155 ${items.length > maxPreview ? `<p class="import-more">...and ${items.length - maxPreview} more</p>` : ''}
156 `;
157 }
158
159 /**
160 * Columns to display per entity type. Keys are the camelCase fields the
161 * native importer emits on each item's `data` object.
162 */
163 function getColumnsForEntityType(entityType) {
164 switch (entityType) {
165 case 'task':
166 return [
167 { key: 'description', label: 'Description' },
168 { key: 'projectName', label: 'Project' },
169 { key: 'priority', label: 'Priority' },
170 { key: 'due', label: 'Due' },
171 ];
172 case 'project':
173 return [
174 { key: 'name', label: 'Name' },
175 { key: 'description', label: 'Description' },
176 { key: 'projectType', label: 'Type' },
177 { key: 'status', label: 'Status' },
178 ];
179 case 'event':
180 return [
181 { key: 'title', label: 'Title' },
182 { key: 'start', label: 'Start' },
183 { key: 'end', label: 'End' },
184 { key: 'location', label: 'Location' },
185 ];
186 default:
187 return [{ key: 'description', label: 'Description' }];
188 }
189 }
190
191 /**
192 * Renders a preview table row.
193 */
194 function renderPreviewRow(item, columns) {
195 const data = item.data || {};
196 return `
197 <tr>
198 ${columns.map(c => {
199 const value = data[c.key] || '';
200 return `<td title="${escAttrVal(value)}">${esc(truncate(value, 50))}</td>`;
201 }).join('')}
202 </tr>
203 `;
204 }
205
206 function truncate(str, maxLen) {
207 if (!str) return '';
208 str = String(str);
209 return str.length > maxLen ? str.substring(0, maxLen) + '...' : str;
210 }
211
212 /**
213 * Executes the import.
214 */
215 async function executeImport() {
216 if (!selectedFilePath || !previewData) return;
217
218 const btn = document.getElementById('import-confirm-btn');
219 btn.disabled = true;
220 btn.textContent = 'Importing...';
221
222 try {
223 const result = await GoingsOn.api.import.execute(selectedFilePath);
224
225 GoingsOn.ui.closeModal();
226
227 const entityType = previewData.entityType || 'item';
228 if (result.failedCount > 0) {
229 GoingsOn.ui.showToast(`Imported ${result.importedCount} ${entityType}(s), ${result.failedCount} failed`, 'warning');
230 } else {
231 GoingsOn.ui.showToast(`Successfully imported ${result.importedCount} ${entityType}(s)!`, 'success');
232 }
233
234 if (typeof GoingsOn.navigation !== 'undefined' && GoingsOn.navigation.reloadCurrentView) {
235 GoingsOn.navigation.reloadCurrentView();
236 }
237 } catch (err) {
238 GoingsOn.ui.showToast('Import failed: ' + GoingsOn.utils.getErrorMessage(err), 'error');
239 btn.disabled = false;
240 btn.textContent = 'Import';
241 }
242 }
243
244 // ============ Populate Namespace ============
245
246 GoingsOn.import = {
247 openModal: openImportModal,
248 selectFile,
249 executeImport,
250 };
251
252 })();
253