joplin-plugin-my-calendar 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,518 @@
1
+ // src/ui/icsImport.js
2
+
3
+ (function () {
4
+ 'use strict';
5
+
6
+ // Single shared settings object across all UI scripts.
7
+ window.__mcUiSettings = window.__mcUiSettings || {
8
+ weekStart: 'monday',
9
+ debug: undefined,
10
+ // new multi-link format
11
+ icsExportLinks: [],
12
+ };
13
+ const uiSettings = window.__mcUiSettings;
14
+
15
+ const IDS = {
16
+ root: 'ics-root',
17
+ targetFolder: 'mc-target-folder',
18
+ fileInput: 'ics-file',
19
+ exportLinkBox: 'mc-ics-export-link',
20
+ logBox: 'mc-imp-log',
21
+ };
22
+
23
+ const LS = {
24
+ targetFolderId: 'mycalendar.targetFolderId',
25
+ preserveLocalColor: 'mycalendar_preserve_local_color',
26
+ importColorEnabled: 'mycalendar_import_color_enabled',
27
+ importColorValue: 'mycalendar_import_color_value',
28
+ };
29
+
30
+ function safeGetLS(key, fallback = '') {
31
+ try {
32
+ const v = localStorage.getItem(key);
33
+ return v == null ? fallback : v;
34
+ } catch {
35
+ return fallback;
36
+ }
37
+ }
38
+
39
+ function safeSetLS(key, value) {
40
+ try {
41
+ localStorage.setItem(key, value);
42
+ } catch {
43
+ // ignore
44
+ }
45
+ }
46
+
47
+ function sanitizeExternalUrl(input) {
48
+ const s = String(input ?? '').trim();
49
+ if (!s) return '';
50
+ try {
51
+ const u = new URL(s);
52
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return '';
53
+ return u.toString();
54
+ } catch {
55
+ return '';
56
+ }
57
+ }
58
+
59
+ function el(tag, attrs = {}, children = []) {
60
+ const n = document.createElement(tag);
61
+
62
+ for (const [k, v] of Object.entries(attrs)) {
63
+ if (v === undefined || v === null) continue;
64
+
65
+ if (k === 'style') {
66
+ n.setAttribute('style', String(v));
67
+ continue;
68
+ }
69
+ if (k === 'class') {
70
+ n.className = String(v);
71
+ continue;
72
+ }
73
+
74
+ // Better handling for common element properties (best practice for inputs)
75
+ if (k === 'checked') {
76
+ n.checked = Boolean(v);
77
+ if (v) n.setAttribute('checked', 'checked');
78
+ continue;
79
+ }
80
+ if (k === 'disabled') {
81
+ n.disabled = Boolean(v);
82
+ if (v) n.setAttribute('disabled', 'disabled');
83
+ continue;
84
+ }
85
+ if (k === 'value') {
86
+ n.value = String(v);
87
+ continue;
88
+ }
89
+
90
+ if (k.startsWith('on') && typeof v === 'function') {
91
+ n.addEventListener(k.slice(2), v);
92
+ continue;
93
+ }
94
+
95
+ n.setAttribute(k, String(v));
96
+ }
97
+
98
+ for (const c of children) {
99
+ if (c == null) continue;
100
+ if (typeof c === 'string') n.appendChild(document.createTextNode(c));
101
+ else n.appendChild(c);
102
+ }
103
+
104
+ return n;
105
+ }
106
+
107
+ function createUiLogger(prefix) {
108
+ let outputBox = null;
109
+
110
+ function setOutputBox(el) {
111
+ outputBox = el || null;
112
+ }
113
+
114
+ function isDebugEnabled() {
115
+ return uiSettings.debug === true;
116
+ }
117
+
118
+ function forwardToMain(level, args) {
119
+ try {
120
+ if (!isDebugEnabled()) return;
121
+
122
+ const pm = window.webviewApi?.postMessage;
123
+ if (typeof pm !== 'function') return;
124
+
125
+ const safeArgs = (args || []).map((a) => {
126
+ if (a && typeof a === 'object' && a.message && a.stack) {
127
+ return {__error: true, message: a.message, stack: a.stack};
128
+ }
129
+ if (typeof a === 'string') return a;
130
+ try {
131
+ return JSON.stringify(a);
132
+ } catch {
133
+ return String(a);
134
+ }
135
+ });
136
+
137
+ pm({name: 'uiLog', source: 'icsImport', level, args: safeArgs});
138
+ } catch {
139
+ // ignore
140
+ }
141
+ }
142
+
143
+ function appendToBox(args) {
144
+ if (!outputBox) return;
145
+ if (!isDebugEnabled()) return;
146
+
147
+ try {
148
+ const line = args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ');
149
+ const div = document.createElement('div');
150
+ div.textContent = line;
151
+ outputBox.appendChild(div);
152
+ outputBox.scrollTop = outputBox.scrollHeight;
153
+ } catch {
154
+ // ignore
155
+ }
156
+ }
157
+
158
+ function write(consoleFn, args) {
159
+ if (args.length > 0 && typeof args[0] === 'string') {
160
+ const [msg, ...rest] = args;
161
+ consoleFn(`${prefix} ${msg}`, ...rest);
162
+ } else {
163
+ consoleFn(prefix, ...args);
164
+ }
165
+
166
+ appendToBox(args);
167
+ }
168
+
169
+ return {
170
+ setOutputBox,
171
+ log: (...args) => {
172
+ write(console.log, args);
173
+ forwardToMain('log', args);
174
+ },
175
+ info: (...args) => {
176
+ write(console.log, args);
177
+ forwardToMain('info', args);
178
+ },
179
+ debug: (...args) => {
180
+ write(console.log, args);
181
+ forwardToMain('debug', args);
182
+ },
183
+ warn: (...args) => {
184
+ write(console.warn, args);
185
+ forwardToMain('warn', args);
186
+ },
187
+ error: (...args) => {
188
+ write(console.error, args);
189
+ forwardToMain('error', args);
190
+ },
191
+ };
192
+ }
193
+
194
+ // Expose for unit tests even if #ics-root is missing
195
+ const uiLogger =
196
+ window.__mcImportLogger || (window.__mcImportLogger = createUiLogger('[MyCalendar Import]'));
197
+
198
+ function mcRegisterOnMessage(handler) {
199
+ window.__mcMsgHandlers = window.__mcMsgHandlers || [];
200
+ window.__mcMsgHandlers.push(handler);
201
+
202
+ if (window.__mcMsgDispatcherInstalled) return;
203
+ window.__mcMsgDispatcherInstalled = true;
204
+
205
+ if (window.webviewApi?.onMessage) {
206
+ window.webviewApi.onMessage((ev) => {
207
+ const msg = ev && ev.message ? ev.message : ev;
208
+ for (const h of window.__mcMsgHandlers) {
209
+ try {
210
+ h(msg);
211
+ } catch (e) {
212
+ uiLogger.error('handler error', e);
213
+ }
214
+ }
215
+ });
216
+ }
217
+ }
218
+
219
+ function init() {
220
+ const root = document.getElementById(IDS.root);
221
+ if (!root) return;
222
+
223
+ const logBox = el('div', {id: IDS.logBox});
224
+ uiLogger.setOutputBox(logBox);
225
+
226
+ const debugHeader = el('div', {class: 'mc-import-section-header'}, ['Debug log']);
227
+ const exportLinkBox = el('div', {id: IDS.exportLinkBox});
228
+
229
+ function renderExportLinks() {
230
+ exportLinkBox.textContent = '';
231
+
232
+ const rawLinks = Array.isArray(uiSettings.icsExportLinks) ? uiSettings.icsExportLinks : [];
233
+ const links = [];
234
+
235
+ for (const it of rawLinks) {
236
+ if (!it) continue;
237
+ const url = sanitizeExternalUrl(it.url);
238
+ if (!url) continue;
239
+ const title = String(it.title ?? '').trim();
240
+ links.push({title, url});
241
+ }
242
+
243
+ if (!links.length) return;
244
+
245
+ const label = el('div', {class: 'mc-import-section-header'}, ['ICS export links']);
246
+ const btnRow = el('div', {class: 'mc-export-link-row'}, []);
247
+
248
+ links.forEach((l, idx) => {
249
+ const text = (l.title || '').trim() || `Link ${idx + 1}`;
250
+ const a = el(
251
+ 'a',
252
+ {
253
+ href: l.url,
254
+ target: '_blank',
255
+ rel: 'noopener noreferrer',
256
+ class: 'mc-setting-btn mc-export-link-btn',
257
+ },
258
+ [text],
259
+ );
260
+ btnRow.appendChild(a);
261
+ });
262
+
263
+ exportLinkBox.appendChild(label);
264
+ exportLinkBox.appendChild(btnRow);
265
+ }
266
+
267
+ function applyDebugUI() {
268
+ if (exportLinkBox.parentNode) root.removeChild(exportLinkBox);
269
+ renderExportLinks();
270
+ if (exportLinkBox.childNodes.length) root.appendChild(exportLinkBox);
271
+
272
+ if (debugHeader.parentNode) root.removeChild(debugHeader);
273
+ if (logBox.parentNode) root.removeChild(logBox);
274
+
275
+ if (uiSettings.debug === true) {
276
+ root.appendChild(debugHeader);
277
+ root.appendChild(logBox);
278
+ }
279
+ }
280
+
281
+ const folderSelect = el('select', {
282
+ id: IDS.targetFolder,
283
+ class: 'mc-setting-select-control mc-flex-1 mc-w-100',
284
+ });
285
+
286
+ root.innerHTML = '';
287
+
288
+ const importForm = el('form', {
289
+ id: 'mc-ics-import-form',
290
+ onsubmit: (e) => {
291
+ e.preventDefault();
292
+ e.stopPropagation();
293
+ },
294
+ });
295
+
296
+ const importLoader = el('div', {class: 'mc-grid-loader', 'aria-hidden': 'true'}, [
297
+ el('div', {class: 'mc-grid-spinner'}),
298
+ ]);
299
+ importForm.appendChild(importLoader);
300
+
301
+ const btnReloadFolders = el(
302
+ 'button',
303
+ {
304
+ type: 'button',
305
+ class: 'mc-setting-btn',
306
+ onclick: () => window.webviewApi?.postMessage?.({name: 'requestFolders'}),
307
+ },
308
+ ['Reload'],
309
+ );
310
+
311
+ const folderRow = el('div', {class: 'mc-import-row'}, [
312
+ el('div', {class: 'mc-import-row-label'}, ['Target notebook']),
313
+ folderSelect,
314
+ btnReloadFolders,
315
+ ]);
316
+
317
+ folderSelect.addEventListener('change', () => {
318
+ safeSetLS(LS.targetFolderId, folderSelect.value || '');
319
+ });
320
+
321
+ function populateFolders(list) {
322
+ const desired = safeGetLS(LS.targetFolderId, '');
323
+ folderSelect.innerHTML = '';
324
+ folderSelect.appendChild(el('option', {value: '', disabled: true}, ['Select a notebook…']));
325
+
326
+ for (const f of list || []) {
327
+ const prefix = f.depth ? '- '.repeat(Math.min(10, f.depth)) : '';
328
+ folderSelect.appendChild(el('option', {value: f.id}, [prefix + f.title]));
329
+ }
330
+
331
+ const hasDesired = desired && Array.from(folderSelect.options).some((o) => o.value === desired);
332
+ if (hasDesired) folderSelect.value = desired;
333
+ else if (folderSelect.options.length > 1) folderSelect.selectedIndex = 1;
334
+
335
+ if (!folderSelect.value && folderSelect.options.length > 1) folderSelect.selectedIndex = 1;
336
+ }
337
+
338
+ const fileInput = el('input', {
339
+ id: IDS.fileInput,
340
+ type: 'file',
341
+ accept: '.ics,text/calendar',
342
+ class: 'mc-flex-1',
343
+ });
344
+
345
+ let preserveLocalColor = safeGetLS(LS.preserveLocalColor, '1') !== '0';
346
+ let importColorEnabled = safeGetLS(LS.importColorEnabled, '0') === '1';
347
+ let importColorValue = safeGetLS(LS.importColorValue, '#1470d9');
348
+ if (!/^#[0-9a-fA-F]{6}$/.test(importColorValue)) importColorValue = '#1470d9';
349
+
350
+ const importColorPicker = el('input', {
351
+ type: 'color',
352
+ value: importColorValue,
353
+ disabled: !importColorEnabled,
354
+ onchange: () => {
355
+ importColorValue = String(importColorPicker.value || '').trim();
356
+ safeSetLS(LS.importColorValue, importColorValue);
357
+ },
358
+ });
359
+
360
+ let importInProgress = false;
361
+
362
+ function setImportLoading(isLoading) {
363
+ importForm.classList.toggle('mc-loading', !!isLoading);
364
+ importForm.setAttribute('aria-busy', isLoading ? 'true' : 'false');
365
+ }
366
+
367
+ function setImportState(isInProgress) {
368
+ importInProgress = !!isInProgress;
369
+ btnImportFile.disabled = importInProgress;
370
+ fileInput.disabled = importInProgress;
371
+ folderSelect.disabled = importInProgress;
372
+ setImportLoading(importInProgress);
373
+ }
374
+
375
+ async function doImportFromPicker() {
376
+ if (importInProgress) return;
377
+
378
+ const f = fileInput.files && fileInput.files[0];
379
+ if (!f) return uiLogger.log('No file selected.');
380
+
381
+ const targetFolderId = String(folderSelect.value || '').trim();
382
+ if (!targetFolderId) return uiLogger.log('Select a target notebook first.');
383
+
384
+ setImportState(true);
385
+
386
+ try {
387
+ const reader = new FileReader();
388
+
389
+ reader.onerror = () => {
390
+ uiLogger.error('FileReader error:', reader.error?.message || reader.error);
391
+ setImportState(false);
392
+ };
393
+
394
+ reader.onload = () => {
395
+ const text = String(reader.result || '');
396
+ try {
397
+ window.webviewApi?.postMessage?.({
398
+ name: 'icsImport',
399
+ mode: 'text',
400
+ ics: text,
401
+ source: `filepicker:${f.name}`,
402
+ targetFolderId,
403
+ preserveLocalColor,
404
+ importDefaultColor: importColorEnabled ? importColorValue : undefined,
405
+ });
406
+ // IMPORTANT: keep import state until importDone/importError
407
+ } catch (e) {
408
+ uiLogger.error('postMessage failed:', e);
409
+ setImportState(false);
410
+ }
411
+ };
412
+
413
+ reader.readAsText(f);
414
+ } catch (e) {
415
+ uiLogger.error('Import failed:', e);
416
+ setImportState(false);
417
+ }
418
+ }
419
+
420
+ const btnImportFile = el(
421
+ 'button',
422
+ {
423
+ type: 'button',
424
+ class: 'mc-setting-btn',
425
+ onclick: () => void doImportFromPicker(),
426
+ },
427
+ ['Import'],
428
+ );
429
+
430
+ const rowFile = el('div', {class: 'mc-import-row'}, [
431
+ el('div', {class: 'mc-import-row-label'}, ['.ics file']),
432
+ fileInput,
433
+ btnImportFile,
434
+ ]);
435
+
436
+ const preserveColorInput = el('input', {
437
+ type: 'checkbox',
438
+ checked: preserveLocalColor,
439
+ onchange: () => {
440
+ preserveLocalColor = !!preserveColorInput.checked;
441
+ safeSetLS(LS.preserveLocalColor, preserveLocalColor ? '1' : '0');
442
+ },
443
+ });
444
+
445
+ const importColorEnabledInput = el('input', {
446
+ type: 'checkbox',
447
+ checked: importColorEnabled,
448
+ onchange: () => {
449
+ importColorEnabled = !!importColorEnabledInput.checked;
450
+ importColorPicker.disabled = !importColorEnabled;
451
+ safeSetLS(LS.importColorEnabled, importColorEnabled ? '1' : '0');
452
+ },
453
+ });
454
+
455
+ const optionsRow = el('div', {class: 'mc-import-options'}, [
456
+ el('div', {class: 'mc-import-section-header'}, ['Options']),
457
+ el('label', {class: 'mc-import-option-label'}, [
458
+ preserveColorInput,
459
+ el('span', {}, ['Preserve local color on re-import']),
460
+ ]),
461
+ el('label', {class: 'mc-import-option-label'}, [
462
+ importColorEnabledInput,
463
+ el('span', {}, ['Set default color for imported events without color']),
464
+ ]),
465
+ el('div', {class: 'mc-import-color-picker-row'}, [
466
+ importColorPicker,
467
+ el('span', {class: 'mc-import-color-picker-label'}, ['Default import color']),
468
+ ]),
469
+ ]);
470
+
471
+ importForm.appendChild(folderRow);
472
+ importForm.appendChild(rowFile);
473
+ importForm.appendChild(optionsRow);
474
+ root.appendChild(importForm);
475
+
476
+ applyDebugUI();
477
+
478
+ mcRegisterOnMessage((msg) => {
479
+ if (!msg || !msg.name) return;
480
+
481
+ switch (msg.name) {
482
+ case 'uiSettings': {
483
+ if (typeof msg.debug === 'boolean') uiSettings.debug = msg.debug;
484
+ if (Array.isArray(msg.icsExportLinks)) uiSettings.icsExportLinks = msg.icsExportLinks;
485
+ applyDebugUI();
486
+ return;
487
+ }
488
+ case 'importStatus':
489
+ uiLogger.log('[STATUS]', msg.text);
490
+ return;
491
+ case 'importDone':
492
+ uiLogger.log(
493
+ '[DONE]',
494
+ `added=${msg.added} updated=${msg.updated} skipped=${msg.skipped} errors=${msg.errors}`,
495
+ );
496
+ setImportState(false);
497
+ return;
498
+ case 'importError':
499
+ uiLogger.log('[ERROR]', msg.error || 'unknown');
500
+ setImportState(false);
501
+ return;
502
+ case 'folders':
503
+ populateFolders(msg.folders);
504
+ return;
505
+ default:
506
+ return;
507
+ }
508
+ });
509
+
510
+ window.webviewApi?.postMessage?.({name: 'uiReady'});
511
+ window.webviewApi?.postMessage?.({name: 'requestFolders'});
512
+
513
+ uiLogger.debug('initialized');
514
+ }
515
+
516
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
517
+ else init();
518
+ })();