Skip to main content

max / goingson

11.8 KB · 329 lines History Blame Raw
1 /**
2 * GoingsOn - Import Module
3 * Handles importing data from files using plugins.
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9 const escAttr = GoingsOn.utils.escapeAttr;
10
11 // ============ State ============
12
13 let availablePlugins = [];
14 let selectedPlugin = null;
15 let selectedFilePath = null;
16 let previewData = null;
17
18 // ============ Import Wizard ============
19
20 /**
21 * Opens the import wizard modal.
22 */
23 async function openImportModal() {
24 // Reset state
25 selectedPlugin = null;
26 selectedFilePath = null;
27 previewData = null;
28
29 // Load available plugins
30 try {
31 availablePlugins = await GoingsOn.api.plugins.listEnabled();
32 } catch (err) {
33 GoingsOn.ui.showToast('Failed to load import plugins: ' + GoingsOn.utils.getErrorMessage(err), 'error');
34 return;
35 }
36
37 if (availablePlugins.length === 0) {
38 GoingsOn.ui.showToast('No import plugins available. Enable plugins in Settings.', 'info');
39 return;
40 }
41
42 const content = `
43 <div class="import-wizard">
44 <div class="import-step" id="import-step-1">
45 <h3>1. Select Import Source</h3>
46 <div class="plugin-selector">
47 ${availablePlugins.map(p => renderPluginOption(p)).join('')}
48 </div>
49 </div>
50 <div class="import-step hidden" id="import-step-2">
51 <h3>2. Select File</h3>
52 <div class="file-selector">
53 <button class="btn btn-primary" id="select-file-btn" onclick="GoingsOn.import.selectFile()">
54 Choose File...
55 </button>
56 <span id="selected-file-name" class="selected-file-name"></span>
57 </div>
58 </div>
59 <div class="import-step hidden" id="import-step-3">
60 <h3>3. Preview</h3>
61 <div id="import-preview-container" class="import-preview-container">
62 <div class="loading">Loading preview...</div>
63 </div>
64 </div>
65 </div>
66 <div class="form-actions">
67 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
68 <button type="button" class="btn btn-primary" id="import-confirm-btn" disabled onclick="GoingsOn.import.executeImport()">
69 Import
70 </button>
71 </div>
72 `;
73
74 GoingsOn.ui.openModal('Import Data', content);
75 }
76
77 /**
78 * Render a plugin option button for the import wizard.
79 * @param {Object} plugin - Plugin object with id, name, description, plugin_type
80 * @returns {string} HTML string for the plugin option
81 */
82 function renderPluginOption(plugin) {
83 const config = plugin.plugin_type?.import || {};
84 const extensions = config.file_extensions || [];
85 const entityTypes = config.entity_types || [];
86
87 return `
88 <button class="plugin-option" data-plugin-id="${escAttr(plugin.id)}" onclick="GoingsOn.import.selectPlugin('${escAttr(plugin.id)}')">
89 <span class="plugin-name">${esc(plugin.name)}</span>
90 <span class="plugin-meta">
91 ${extensions.length > 0 ? `<span class="plugin-extensions">.${extensions.join(', .')}</span>` : ''}
92 ${entityTypes.length > 0 ? `<span class="plugin-types">${entityTypes.join(', ')}</span>` : ''}
93 </span>
94 <span class="plugin-description">${esc(plugin.description)}</span>
95 </button>
96 `;
97 }
98
99 /**
100 * Select a plugin in the import wizard and advance to file selection.
101 * @param {string} pluginId - Plugin ID to select
102 */
103 function selectPlugin(pluginId) {
104 selectedPlugin = availablePlugins.find(p => p.id === pluginId);
105 if (!selectedPlugin) return;
106
107 // Update UI - highlight selected plugin
108 document.querySelectorAll('.plugin-option').forEach(el => {
109 el.classList.remove('selected');
110 });
111 document.querySelector(`.plugin-option[data-plugin-id="${pluginId}"]`)?.classList.add('selected');
112
113 // Show step 2
114 document.getElementById('import-step-2').classList.remove('hidden');
115
116 // Reset file selection
117 selectedFilePath = null;
118 previewData = null;
119 document.getElementById('selected-file-name').textContent = '';
120 document.getElementById('import-step-3').classList.add('hidden');
121 document.getElementById('import-confirm-btn').disabled = true;
122 }
123
124 /**
125 * Opens the file dialog to select a file.
126 */
127 async function selectFile() {
128 if (!selectedPlugin) return;
129
130 const config = selectedPlugin.plugin_type?.import || {};
131 const extensions = config.file_extensions || ['*'];
132
133 try {
134 // Use Tauri's file dialog
135 const { open } = window.__TAURI__.dialog;
136 const filePath = await open({
137 multiple: false,
138 filters: [{
139 name: selectedPlugin.name,
140 extensions: extensions,
141 }],
142 });
143
144 if (!filePath) return; // User cancelled
145
146 selectedFilePath = filePath;
147 document.getElementById('selected-file-name').textContent = getFileName(filePath);
148
149 // Show step 3 and load preview
150 document.getElementById('import-step-3').classList.remove('hidden');
151 await loadPreview();
152 } catch (err) {
153 GoingsOn.ui.showToast('Failed to select file: ' + GoingsOn.utils.getErrorMessage(err), 'error');
154 }
155 }
156
157 /**
158 * Gets the filename from a full path.
159 */
160 function getFileName(path) {
161 return path.split(/[/\\]/).pop() || path;
162 }
163
164 /**
165 * Loads and displays the preview of the import data.
166 */
167 async function loadPreview() {
168 const container = document.getElementById('import-preview-container');
169 container.innerHTML = '<div class="loading">Parsing file...</div>';
170
171 try {
172 previewData = await GoingsOn.api.plugins.preview(selectedPlugin.id, selectedFilePath, {});
173
174 if (!previewData.items || previewData.items.length === 0) {
175 container.innerHTML = '<p class="import-empty">No items found in file.</p>';
176 document.getElementById('import-confirm-btn').disabled = true;
177 return;
178 }
179
180 container.innerHTML = renderPreviewTable(previewData);
181 document.getElementById('import-confirm-btn').disabled = false;
182
183 // Show warnings if any
184 if (previewData.warnings && previewData.warnings.length > 0) {
185 const warningsHtml = `
186 <div class="import-warnings">
187 <strong>Warnings:</strong>
188 <ul>${previewData.warnings.map(w => `<li>${esc(w)}</li>`).join('')}</ul>
189 </div>
190 `;
191 container.insertAdjacentHTML('beforeend', warningsHtml);
192 }
193 } catch (err) {
194 container.innerHTML = `<p class="import-error">Failed to parse file: ${esc(GoingsOn.utils.getErrorMessage(err))}</p>`;
195 document.getElementById('import-confirm-btn').disabled = true;
196 }
197 }
198
199 /**
200 * Renders the preview table.
201 */
202 function renderPreviewTable(data) {
203 const entityType = data.entity_type || 'item';
204 const items = data.items || [];
205 const maxPreview = 25;
206 const displayItems = items.slice(0, maxPreview);
207
208 // Determine columns based on entity type
209 const columns = getColumnsForEntityType(entityType);
210
211 return `
212 <p class="import-summary">
213 <strong>${items.length}</strong> ${entityType}${items.length !== 1 ? 's' : ''} found
214 </p>
215 <div class="import-preview-table-wrapper">
216 <table class="data-table import-preview-table">
217 <thead>
218 <tr>${columns.map(c => `<th>${c.label}</th>`).join('')}</tr>
219 </thead>
220 <tbody>
221 ${displayItems.map((item, idx) => renderPreviewRow(item, columns, idx)).join('')}
222 </tbody>
223 </table>
224 </div>
225 ${items.length > maxPreview ? `<p class="import-more">...and ${items.length - maxPreview} more</p>` : ''}
226 `;
227 }
228
229 /**
230 * Gets the columns to display based on entity type.
231 */
232 function getColumnsForEntityType(entityType) {
233 switch (entityType) {
234 case 'task':
235 return [
236 { key: 'description', label: 'Description' },
237 { key: 'project_name', label: 'Project' },
238 { key: 'priority', label: 'Priority' },
239 { key: 'due', label: 'Due' },
240 ];
241 case 'project':
242 return [
243 { key: 'name', label: 'Name' },
244 { key: 'description', label: 'Description' },
245 { key: 'project_type', label: 'Type' },
246 { key: 'status', label: 'Status' },
247 ];
248 case 'event':
249 return [
250 { key: 'title', label: 'Title' },
251 { key: 'start', label: 'Start' },
252 { key: 'end', label: 'End' },
253 { key: 'location', label: 'Location' },
254 ];
255 default:
256 return [
257 { key: 'description', label: 'Description' },
258 ];
259 }
260 }
261
262 /**
263 * Renders a preview table row.
264 */
265 function renderPreviewRow(item, columns, index) {
266 const data = item.data || {};
267 return `
268 <tr>
269 ${columns.map(c => {
270 const value = data[c.key] || '';
271 return `<td title="${escAttr(value)}">${esc(truncate(value, 50))}</td>`;
272 }).join('')}
273 </tr>
274 `;
275 }
276
277 /**
278 * Truncates a string to a maximum length.
279 */
280 function truncate(str, maxLen) {
281 if (!str) return '';
282 str = String(str);
283 return str.length > maxLen ? str.substring(0, maxLen) + '...' : str;
284 }
285
286 /**
287 * Executes the import.
288 */
289 async function executeImport() {
290 if (!selectedPlugin || !selectedFilePath || !previewData) return;
291
292 const btn = document.getElementById('import-confirm-btn');
293 btn.disabled = true;
294 btn.textContent = 'Importing...';
295
296 try {
297 const result = await GoingsOn.api.plugins.execute(selectedPlugin.id, selectedFilePath, {});
298
299 GoingsOn.ui.closeModal();
300
301 const entityType = previewData.entity_type || 'item';
302 if (result.failed_count > 0) {
303 GoingsOn.ui.showToast(`Imported ${result.imported_count} ${entityType}(s), ${result.failed_count} failed`, 'warning');
304 } else {
305 GoingsOn.ui.showToast(`Successfully imported ${result.imported_count} ${entityType}(s)!`, 'success');
306 }
307
308 // Refresh the current view
309 if (typeof GoingsOn.navigation !== 'undefined' && GoingsOn.navigation.reloadCurrentView) {
310 GoingsOn.navigation.reloadCurrentView();
311 }
312 } catch (err) {
313 GoingsOn.ui.showToast('Import failed: ' + GoingsOn.utils.getErrorMessage(err), 'error');
314 btn.disabled = false;
315 btn.textContent = 'Import';
316 }
317 }
318
319 // ============ Populate Namespace ============
320
321 GoingsOn.import = {
322 openModal: openImportModal,
323 selectPlugin,
324 selectFile,
325 executeImport,
326 };
327
328 })();
329