signalk-polar-performance-plugin 1.3.3 → 2.0.0
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.
- package/CHANGELOG.md +25 -1
- package/README.md +41 -86
- package/docs/developer-reference.md +22 -130
- package/openApi.json +12 -798
- package/package.json +13 -7
- package/plugin/index.js +178 -521
- package/public/app.js +53 -638
- package/public/index.html +0 -2
- package/public/plotter.html +20 -106
- package/test/lifecycle.test.js +6 -1
- package/docs/import.png +0 -0
- package/docs/polar.png +0 -0
- package/docs/polar_management.png +0 -0
- package/plugin/PolarFileStore.js +0 -133
- package/plugin/PolarTable.js +0 -897
- package/plugin/SI.js +0 -21
- package/plugin/import/ExpeditionImporter.js +0 -152
- package/plugin/import/FormatRegistry.js +0 -21
- package/plugin/import/ImportService.js +0 -199
- package/plugin/import/JieterImporter.js +0 -18
- package/plugin/import/OrcSource.js +0 -396
- package/plugin/import/SourceRegistry.js +0 -17
- package/plugin/import/canonical.js +0 -73
- package/plugin/import/matrixText.js +0 -133
- package/plugin/index.js.bak +0 -1003
- package/test/PolarFileStore.test.js +0 -216
- package/test/PolarTable.test.js +0 -349
- package/test/api.test.js +0 -502
- package/test/migration.test.js +0 -164
package/public/app.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// app.js — Polar Performance Plugin webapp
|
|
2
|
-
// Pages: Overview | Inputs |
|
|
2
|
+
// Pages: Overview | Inputs | Outputs
|
|
3
|
+
// The active polar and performance factor are selected in a separate polar
|
|
4
|
+
// management webapp (e.g. signalk-polar-management) and read here via SK paths
|
|
5
|
+
// — this webapp only displays them, it does not manage polar storage/selection.
|
|
3
6
|
// No build step. Uses window.PolarCanvas from polar-canvas.js (loaded before this module).
|
|
4
7
|
// Live data is updated in-place every second — DOM is only rebuilt on page switch.
|
|
5
8
|
|
|
@@ -47,7 +50,7 @@ async function apiGet(path, opts = {}) {
|
|
|
47
50
|
try {
|
|
48
51
|
const res = await fetch(API + path, { credentials: 'same-origin' })
|
|
49
52
|
if (!res.ok) {
|
|
50
|
-
if (!opts.
|
|
53
|
+
if (!(opts.silentStatuses || []).includes(res.status)) showMessage('API error ' + res.status + ': ' + path)
|
|
51
54
|
return null
|
|
52
55
|
}
|
|
53
56
|
return res.json()
|
|
@@ -66,34 +69,6 @@ async function apiPut(path, body) {
|
|
|
66
69
|
} catch (e) { showMessage('Save failed: ' + e.message); return null }
|
|
67
70
|
}
|
|
68
71
|
|
|
69
|
-
async function apiPost(path, body) {
|
|
70
|
-
try {
|
|
71
|
-
const res = await fetch(API + path, {
|
|
72
|
-
method: 'POST', credentials: 'same-origin',
|
|
73
|
-
headers: { 'Content-Type': 'application/json' },
|
|
74
|
-
body: JSON.stringify(body)
|
|
75
|
-
})
|
|
76
|
-
if (!res.ok) {
|
|
77
|
-
let detail = ''
|
|
78
|
-
try {
|
|
79
|
-
const payload = await res.json()
|
|
80
|
-
if (payload?.error) detail = ': ' + payload.error
|
|
81
|
-
} catch (_) {}
|
|
82
|
-
showMessage('Import failed: ' + res.status + detail)
|
|
83
|
-
return null
|
|
84
|
-
}
|
|
85
|
-
return res.json()
|
|
86
|
-
} catch (e) { showMessage('Import failed: ' + e.message); return null }
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async function apiDelete(path) {
|
|
90
|
-
try {
|
|
91
|
-
const res = await fetch(API + path, { method: 'DELETE', credentials: 'same-origin' })
|
|
92
|
-
if (!res.ok) { showMessage('Delete failed: ' + res.status); return null }
|
|
93
|
-
return res.json()
|
|
94
|
-
} catch (e) { showMessage('Delete failed: ' + e.message); return null }
|
|
95
|
-
}
|
|
96
|
-
|
|
97
72
|
// Fetch a single scalar value from the SK REST API
|
|
98
73
|
// ── Message bar ───────────────────────────────────────────────────────────────
|
|
99
74
|
let _msgTimer = null
|
|
@@ -113,55 +88,6 @@ function sectionHeading(text) {
|
|
|
113
88
|
return h
|
|
114
89
|
}
|
|
115
90
|
|
|
116
|
-
function createCollapsibleCard(title, startOpen = false) {
|
|
117
|
-
const card = document.createElement('div')
|
|
118
|
-
card.className = 'card mb-3'
|
|
119
|
-
|
|
120
|
-
const header = document.createElement('div')
|
|
121
|
-
header.className = 'card-header p-0'
|
|
122
|
-
|
|
123
|
-
const button = document.createElement('button')
|
|
124
|
-
button.type = 'button'
|
|
125
|
-
button.className = 'btn btn-link btn-block text-start text-decoration-none px-3 py-2'
|
|
126
|
-
button.style.textAlign = 'left'
|
|
127
|
-
|
|
128
|
-
const body = document.createElement('div')
|
|
129
|
-
body.className = 'card-body'
|
|
130
|
-
body.style.display = startOpen ? '' : 'none'
|
|
131
|
-
|
|
132
|
-
const syncLabel = () => {
|
|
133
|
-
button.textContent = (body.style.display === 'none' ? '+ ' : '- ') + title
|
|
134
|
-
button.setAttribute('aria-expanded', body.style.display === 'none' ? 'false' : 'true')
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
button.addEventListener('click', () => {
|
|
138
|
-
body.style.display = body.style.display === 'none' ? '' : 'none'
|
|
139
|
-
syncLabel()
|
|
140
|
-
})
|
|
141
|
-
syncLabel()
|
|
142
|
-
|
|
143
|
-
header.appendChild(button)
|
|
144
|
-
card.appendChild(header)
|
|
145
|
-
card.appendChild(body)
|
|
146
|
-
return { card, body }
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function createPageCard(title) {
|
|
150
|
-
const card = document.createElement('div')
|
|
151
|
-
card.className = 'card mb-3'
|
|
152
|
-
|
|
153
|
-
const header = document.createElement('div')
|
|
154
|
-
header.className = 'card-header fw-bold text-uppercase'
|
|
155
|
-
header.textContent = title
|
|
156
|
-
|
|
157
|
-
const body = document.createElement('div')
|
|
158
|
-
body.className = 'card-body'
|
|
159
|
-
|
|
160
|
-
card.appendChild(header)
|
|
161
|
-
card.appendChild(body)
|
|
162
|
-
return { card, body }
|
|
163
|
-
}
|
|
164
|
-
|
|
165
91
|
// Set text of a span by id (fast in-place update, no DOM rebuild)
|
|
166
92
|
function setVal(id, text) {
|
|
167
93
|
const el = document.getElementById(id)
|
|
@@ -253,38 +179,6 @@ function createNumberInput(key, value, opts, showRevert, onSaved) {
|
|
|
253
179
|
return wrap
|
|
254
180
|
}
|
|
255
181
|
|
|
256
|
-
function createPercentInput(key, value, opts, onSaved) {
|
|
257
|
-
const wrap = document.createElement('span')
|
|
258
|
-
const inp = document.createElement('input')
|
|
259
|
-
inp.type = 'number'
|
|
260
|
-
inp.className = 'form-control form-control-sm d-inline-block'
|
|
261
|
-
inp.style.width = '90px'
|
|
262
|
-
inp.value = Number.isFinite(value) ? (value * 100).toFixed(0) : (opts.defaultPercent ?? 100)
|
|
263
|
-
if (opts.minPercent !== undefined) inp.min = opts.minPercent
|
|
264
|
-
if (opts.maxPercent !== undefined) inp.max = opts.maxPercent
|
|
265
|
-
if (opts.stepPercent !== undefined) inp.step = opts.stepPercent
|
|
266
|
-
|
|
267
|
-
const suffix = document.createElement('span')
|
|
268
|
-
suffix.className = 'ms-1 small text-muted'
|
|
269
|
-
suffix.textContent = '%'
|
|
270
|
-
|
|
271
|
-
inp.addEventListener('change', () => {
|
|
272
|
-
const percent = Number(inp.value)
|
|
273
|
-
if (!Number.isFinite(percent)) return
|
|
274
|
-
apiPut('/settings', { [key]: percent / 100 }).then(s => {
|
|
275
|
-
if (s) {
|
|
276
|
-
settings = s
|
|
277
|
-
inp.value = Number.isFinite(s[key]) ? (s[key] * 100).toFixed(0) : inp.value
|
|
278
|
-
if (typeof onSaved === 'function') onSaved(s)
|
|
279
|
-
}
|
|
280
|
-
})
|
|
281
|
-
})
|
|
282
|
-
|
|
283
|
-
wrap.appendChild(inp)
|
|
284
|
-
wrap.appendChild(suffix)
|
|
285
|
-
return wrap
|
|
286
|
-
}
|
|
287
|
-
|
|
288
182
|
// Update warnings container in-place. Skips DOM write when content unchanged.
|
|
289
183
|
function updateWarnings(el, items) {
|
|
290
184
|
if (!el) return
|
|
@@ -306,9 +200,6 @@ let statusData = null // from /status — raw inputs + computed outputs
|
|
|
306
200
|
let rawValues = {} // raw sensor values from /status, keyed by short name (tws/twa/bsp/hdg)
|
|
307
201
|
let outputValues = {} // computed output values from /status, keyed by SK path string
|
|
308
202
|
let settings = null
|
|
309
|
-
let polarsList = []
|
|
310
|
-
let importFormats = []
|
|
311
|
-
let internetOnline = false
|
|
312
203
|
let lifecycleWarnings = []
|
|
313
204
|
|
|
314
205
|
// Canvas state
|
|
@@ -385,48 +276,24 @@ async function refreshSettings() {
|
|
|
385
276
|
if (s) settings = s
|
|
386
277
|
}
|
|
387
278
|
|
|
388
|
-
async function refreshPolars() {
|
|
389
|
-
const list = await apiGet('/polars')
|
|
390
|
-
if (list) polarsList = list
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
async function refreshImportFormats() {
|
|
394
|
-
const formats = await apiGet('/imports/formats')
|
|
395
|
-
if (formats) importFormats = formats
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
async function refreshInternetStatus() {
|
|
399
|
-
const result = await apiGet('/internet')
|
|
400
|
-
internetOnline = !!(result && result.online)
|
|
401
|
-
}
|
|
402
|
-
|
|
403
279
|
async function refreshLibrary() {
|
|
404
|
-
const
|
|
405
|
-
if (!activeId) {
|
|
406
|
-
if (libraryVersion !== '') {
|
|
407
|
-
libraryVersion = ''; twsList = []; curves = {}
|
|
408
|
-
if (polar) polar.setLibraryData([], {}, null)
|
|
409
|
-
}
|
|
410
|
-
return
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
const newList = await apiGet('/polars/' + encodeURIComponent(activeId) + '/axes/tws', { silent503: true })
|
|
280
|
+
const newList = await apiGet('/polar/axes/tws', { silentStatuses: [404] })
|
|
414
281
|
if (!newList) {
|
|
415
|
-
// No polar
|
|
282
|
+
// No active polar — clear any previously displayed curves
|
|
416
283
|
if (libraryVersion !== '') {
|
|
417
284
|
libraryVersion = ''; twsList = []; curves = {}
|
|
418
285
|
if (polar) polar.setLibraryData([], {}, null)
|
|
419
286
|
}
|
|
420
287
|
return
|
|
421
288
|
}
|
|
422
|
-
const version =
|
|
289
|
+
const version = JSON.stringify(newList)
|
|
423
290
|
if (version === libraryVersion) return
|
|
424
291
|
libraryVersion = version; twsList = newList; curves = {}
|
|
425
292
|
await Promise.all(twsList.map(async tws => {
|
|
426
293
|
try {
|
|
427
294
|
const c = await apiGet(
|
|
428
|
-
'/
|
|
429
|
-
{
|
|
295
|
+
'/polar/queries/curve?tws=' + tws.toFixed(5) + '&step=' + STEP,
|
|
296
|
+
{ silentStatuses: [404] }
|
|
430
297
|
)
|
|
431
298
|
if (c) curves[tws] = c
|
|
432
299
|
} catch (_) {}
|
|
@@ -462,13 +329,10 @@ async function refreshLive() {
|
|
|
462
329
|
// 3. Update live TWS curve for canvas
|
|
463
330
|
if (liveData && polar) {
|
|
464
331
|
const tws = liveData.tws
|
|
465
|
-
const activeId = settings?.activePolar
|
|
466
332
|
if (tws !== null && (!liveTws || Math.abs(tws - liveTws) > 0.05)) {
|
|
467
333
|
liveTws = tws
|
|
468
334
|
try {
|
|
469
|
-
liveCurve =
|
|
470
|
-
? await apiGet('/polars/' + encodeURIComponent(activeId) + '/queries/curve?tws=' + tws.toFixed(5) + '&step=' + STEP)
|
|
471
|
-
: null
|
|
335
|
+
liveCurve = await apiGet('/polar/queries/curve?tws=' + tws.toFixed(5) + '&step=' + STEP, { silentStatuses: [404] })
|
|
472
336
|
}
|
|
473
337
|
catch (_) {}
|
|
474
338
|
}
|
|
@@ -517,6 +381,12 @@ function _buildOverviewPage() {
|
|
|
517
381
|
// Live numbers (right) — skeleton built once; values updated in-place via setVal()
|
|
518
382
|
const right = document.createElement('div')
|
|
519
383
|
right.className = 'col-md-6'
|
|
384
|
+
|
|
385
|
+
// Active polar — read-only; selected in a separate polar management webapp
|
|
386
|
+
right.appendChild(sectionHeading('Active Polar'))
|
|
387
|
+
const polarInfoDiv = document.createElement('div'); polarInfoDiv.id = 'ov-polar-info'
|
|
388
|
+
right.appendChild(polarInfoDiv)
|
|
389
|
+
|
|
520
390
|
right.appendChild(sectionHeading('Live Performance'))
|
|
521
391
|
right.appendChild(buildTable([
|
|
522
392
|
{ label: 'True Wind Speed', id: 'ov-tws' },
|
|
@@ -584,6 +454,36 @@ function _buildOverviewPage() {
|
|
|
584
454
|
|
|
585
455
|
function _tickOverview() {
|
|
586
456
|
const d = liveData
|
|
457
|
+
|
|
458
|
+
const infoEl = document.getElementById('ov-polar-info')
|
|
459
|
+
if (infoEl) {
|
|
460
|
+
const p = meta?.activePolar
|
|
461
|
+
if (!p) {
|
|
462
|
+
infoEl.innerHTML = ''
|
|
463
|
+
const none = document.createElement('p'); none.className = 'text-muted small mb-2'
|
|
464
|
+
none.textContent = 'No active polar — select one in the polar management webapp.'
|
|
465
|
+
infoEl.appendChild(none)
|
|
466
|
+
} else if (!document.getElementById('ov-polar-name')) {
|
|
467
|
+
infoEl.innerHTML = ''
|
|
468
|
+
infoEl.appendChild(buildTable([
|
|
469
|
+
{ label: 'Name', id: 'ov-polar-name' },
|
|
470
|
+
{ label: 'Boat type', id: 'ov-polar-boatType' },
|
|
471
|
+
{ label: 'Sail number', id: 'ov-polar-sailnumber' },
|
|
472
|
+
{ label: 'Year', id: 'ov-polar-year' },
|
|
473
|
+
{ label: 'Source', id: 'ov-polar-source' },
|
|
474
|
+
{ label: 'Performance factor', id: 'ov-polar-perf' },
|
|
475
|
+
]))
|
|
476
|
+
}
|
|
477
|
+
if (p) {
|
|
478
|
+
setVal('ov-polar-name', p.name || '—')
|
|
479
|
+
setVal('ov-polar-boatType', p.boatType || '—')
|
|
480
|
+
setVal('ov-polar-sailnumber', p.sailnumber || '—')
|
|
481
|
+
setVal('ov-polar-year', p.year ? String(p.year) : '—')
|
|
482
|
+
setVal('ov-polar-source', p.source || '—')
|
|
483
|
+
setVal('ov-polar-perf', fmtVal(meta?.performanceFactor, 'performance', RATIO_DEFAULT))
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
587
487
|
setVal('ov-tws', fmtVal(d?.tws, 'tws', SPEED_DEFAULT))
|
|
588
488
|
setVal('ov-twa', fmtVal(d?.twa != null ? Math.abs(d.twa) : null, 'twa', ANGLE_DEFAULT))
|
|
589
489
|
setVal('ov-bsp', fmtVal(d?.bsp, 'bsp', SPEED_DEFAULT))
|
|
@@ -612,7 +512,7 @@ function _tickOverview() {
|
|
|
612
512
|
if (d?.tws == null) warns.push('True wind speed — no data (environment.wind.speedTrue)')
|
|
613
513
|
if (d?.twa == null) warns.push('True wind angle — no data (environment.wind.angleTrueWater)')
|
|
614
514
|
if (d?.bsp == null) warns.push('Boat speed — no data')
|
|
615
|
-
if (d?.tws != null && d?.polarState == null) warns.push('No polar
|
|
515
|
+
if (d?.tws != null && d?.polarState == null) warns.push('No active polar — select one in the polar management webapp')
|
|
616
516
|
warns.push(...polarStateWarnings(d))
|
|
617
517
|
updateWarnings(document.getElementById('ov-warnings'), warns)
|
|
618
518
|
}
|
|
@@ -705,110 +605,6 @@ const SMOOTHER_PARAMS = {
|
|
|
705
605
|
Kalman: { key: 'smootherParamKalman', label: 'Steady-state gain (0–1)', min: 0.001, max: 1, step: 0.001, default: 0.1 },
|
|
706
606
|
}
|
|
707
607
|
|
|
708
|
-
function _buildSettingsPage() {
|
|
709
|
-
if (!settings) {
|
|
710
|
-
const p = document.createElement('p'); p.className = 'text-muted small mt-2'
|
|
711
|
-
p.textContent = 'Loading settings…'; return p
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
const row = document.createElement('div')
|
|
715
|
-
row.className = 'row g-3'
|
|
716
|
-
|
|
717
|
-
// ── Left: polar canvas ────────────────────────────────────────────────────
|
|
718
|
-
const left = document.createElement('div')
|
|
719
|
-
left.className = 'col-md-6'
|
|
720
|
-
const canvasEl = document.createElement('canvas')
|
|
721
|
-
canvasEl.id = 'settings-polar-canvas'
|
|
722
|
-
canvasEl.className = 'polar-canvas'
|
|
723
|
-
left.appendChild(canvasEl)
|
|
724
|
-
row.appendChild(left)
|
|
725
|
-
|
|
726
|
-
// ── Right: settings controls ──────────────────────────────────────────────
|
|
727
|
-
const right = document.createElement('div')
|
|
728
|
-
right.className = 'col-md-6'
|
|
729
|
-
|
|
730
|
-
// Polar
|
|
731
|
-
right.appendChild(sectionHeading('Polar'))
|
|
732
|
-
|
|
733
|
-
// Resolve metadata for the currently active polar
|
|
734
|
-
const activeMeta = (() => {
|
|
735
|
-
const a = settings?.activePolar
|
|
736
|
-
if (!a) return {}
|
|
737
|
-
const entry = polarsList.find(p => (typeof p === 'string' ? p : p.id) === a)
|
|
738
|
-
return (entry && typeof entry === 'object') ? entry : {}
|
|
739
|
-
})()
|
|
740
|
-
|
|
741
|
-
right.appendChild(_settingsTable([
|
|
742
|
-
{ label: 'Active polar',
|
|
743
|
-
control: _polarSelector() },
|
|
744
|
-
{ label: 'Performance adjust', desc: '100% = polar speed unchanged',
|
|
745
|
-
control: createPercentInput('perfAdjust', settings.perfAdjust, { minPercent: 10, maxPercent: 200, stepPercent: 5, defaultPercent: 100 }, async () => {
|
|
746
|
-
libraryVersion = '' // force full curve reload — perfAdjust changes speeds but not the TWS list
|
|
747
|
-
await refreshLibrary()
|
|
748
|
-
if (activePage === 'settings') switchPage('settings')
|
|
749
|
-
}) },
|
|
750
|
-
{ label: 'Boat name', control: _metaDisplay(activeMeta.name) },
|
|
751
|
-
{ label: 'Boat type', control: _metaDisplay(activeMeta.boatType) },
|
|
752
|
-
{ label: 'Sail number', control: _metaDisplay(activeMeta.sailnumber) },
|
|
753
|
-
{ label: 'Year', control: _metaDisplay(activeMeta.year ? String(activeMeta.year) : '') },
|
|
754
|
-
{ label: 'Source', control: _metaDisplay(activeMeta.source) },
|
|
755
|
-
]))
|
|
756
|
-
|
|
757
|
-
// Smoother
|
|
758
|
-
// (moved to Inputs page)
|
|
759
|
-
|
|
760
|
-
// Speed source
|
|
761
|
-
// (moved to Inputs page)
|
|
762
|
-
|
|
763
|
-
row.appendChild(right)
|
|
764
|
-
|
|
765
|
-
// ── Canvas init + ResizeObserver ──────────────────────────────────────────
|
|
766
|
-
let settingsPolar = null
|
|
767
|
-
|
|
768
|
-
// Wrap canvas in a relative container so we can overlay a message
|
|
769
|
-
const canvasWrap = document.createElement('div')
|
|
770
|
-
canvasWrap.style.position = 'relative'
|
|
771
|
-
canvasWrap.appendChild(canvasEl)
|
|
772
|
-
left.innerHTML = ''
|
|
773
|
-
left.appendChild(canvasWrap)
|
|
774
|
-
|
|
775
|
-
const noPolarMsg = document.createElement('div')
|
|
776
|
-
noPolarMsg.textContent = 'No polar loaded — select one above'
|
|
777
|
-
noPolarMsg.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:0.85rem;color:#888;pointer-events:none;'
|
|
778
|
-
noPolarMsg.style.display = twsList.length ? 'none' : 'flex'
|
|
779
|
-
canvasWrap.appendChild(noPolarMsg)
|
|
780
|
-
|
|
781
|
-
if (window.PolarCanvas) {
|
|
782
|
-
settingsPolar = new window.PolarCanvas(canvasEl, { showLibrary: true, showLiveCurve: false })
|
|
783
|
-
if (Object.keys(meta).length) settingsPolar.setMeta(meta)
|
|
784
|
-
if (twsList.length) settingsPolar.setLibraryData(twsList, curves, null)
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
if (window.ResizeObserver) {
|
|
788
|
-
let _rafPending = false
|
|
789
|
-
const obs = new ResizeObserver(() => {
|
|
790
|
-
if (_rafPending) return
|
|
791
|
-
_rafPending = true
|
|
792
|
-
requestAnimationFrame(() => {
|
|
793
|
-
_rafPending = false
|
|
794
|
-
if (canvasEl.offsetWidth > 0 && settingsPolar) {
|
|
795
|
-
settingsPolar.resize()
|
|
796
|
-
settingsPolar.draw()
|
|
797
|
-
}
|
|
798
|
-
})
|
|
799
|
-
})
|
|
800
|
-
obs.observe(canvasEl)
|
|
801
|
-
row._cleanup = () => { obs.disconnect(); settingsPolar = null }
|
|
802
|
-
} else {
|
|
803
|
-
requestAnimationFrame(() => {
|
|
804
|
-
if (settingsPolar) { settingsPolar.resize(); settingsPolar.draw() }
|
|
805
|
-
})
|
|
806
|
-
row._cleanup = () => { settingsPolar = null }
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
return row
|
|
810
|
-
}
|
|
811
|
-
|
|
812
608
|
function _settingsTable(rows) {
|
|
813
609
|
const tbl = document.createElement('table')
|
|
814
610
|
tbl.className = 'table table-sm table-borderless mb-0'
|
|
@@ -828,50 +624,6 @@ function _settingsTable(rows) {
|
|
|
828
624
|
tbl.appendChild(tbody); return tbl
|
|
829
625
|
}
|
|
830
626
|
|
|
831
|
-
/** Read-only display span for polar metadata fields. */
|
|
832
|
-
function _metaDisplay(value) {
|
|
833
|
-
const span = document.createElement('span')
|
|
834
|
-
span.className = value ? 'small' : 'small text-muted'
|
|
835
|
-
span.textContent = value || '—'
|
|
836
|
-
return span
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
function readOptionalIntegerInput(input) {
|
|
840
|
-
const text = String(input?.value || '').trim()
|
|
841
|
-
if (!text) return undefined
|
|
842
|
-
const value = Number(text)
|
|
843
|
-
return Number.isInteger(value) ? value : undefined
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
function _polarSelector() {
|
|
847
|
-
const sel = document.createElement('select')
|
|
848
|
-
sel.className = 'form-select form-select-sm'; sel.style.width = '100%'
|
|
849
|
-
const none = document.createElement('option'); none.value = ''; none.textContent = '— none —'
|
|
850
|
-
sel.appendChild(none)
|
|
851
|
-
polarsList.forEach(raw => {
|
|
852
|
-
const p = typeof raw === 'string' ? { id: raw, name: raw } : raw
|
|
853
|
-
const o = document.createElement('option'); o.value = p.id
|
|
854
|
-
o.textContent = p.name && p.name !== p.id ? `${p.name} (${p.id})` : p.id
|
|
855
|
-
sel.appendChild(o)
|
|
856
|
-
})
|
|
857
|
-
sel.value = settings?.activePolar || ''
|
|
858
|
-
sel.addEventListener('change', () => {
|
|
859
|
-
const request = sel.value
|
|
860
|
-
? apiPut('/polars/active', { id: sel.value })
|
|
861
|
-
: apiDelete('/polars/active')
|
|
862
|
-
|
|
863
|
-
request.then(async s => {
|
|
864
|
-
if (!s) return
|
|
865
|
-
settings = { ...(settings || {}), activePolar: s.id || '' }
|
|
866
|
-
libraryVersion = '' // force full reload — new polar may share the same TWS list
|
|
867
|
-
await refreshLibrary()
|
|
868
|
-
await refreshPolars()
|
|
869
|
-
if (activePage === 'settings') switchPage('settings')
|
|
870
|
-
})
|
|
871
|
-
})
|
|
872
|
-
return sel
|
|
873
|
-
}
|
|
874
|
-
|
|
875
627
|
function _smootherSelector() {
|
|
876
628
|
const sel = document.createElement('select')
|
|
877
629
|
sel.className = 'form-select form-select-sm'; sel.style.width = '100%'
|
|
@@ -937,345 +689,11 @@ function _tickOutputs() {
|
|
|
937
689
|
updateWarnings(document.getElementById('out-warnings'), polarStateWarnings(liveData))
|
|
938
690
|
}
|
|
939
691
|
|
|
940
|
-
// ── PAGE: Polars ───────────────────────────────────────────────────────────────
|
|
941
|
-
function _buildPolarsPage() {
|
|
942
|
-
const wrap = document.createDocumentFragment()
|
|
943
|
-
|
|
944
|
-
const storedCard = createPageCard('Stored Polars')
|
|
945
|
-
wrap.appendChild(storedCard.card)
|
|
946
|
-
const listDiv = document.createElement('div'); listDiv.id = 'polars-list'
|
|
947
|
-
_renderPolarsList(listDiv)
|
|
948
|
-
storedCard.body.appendChild(listDiv)
|
|
949
|
-
|
|
950
|
-
const textCard = createCollapsibleCard('Import Text Polar', false)
|
|
951
|
-
wrap.appendChild(textCard.card)
|
|
952
|
-
const textWrap = textCard.body
|
|
953
|
-
|
|
954
|
-
const importMetaTable = document.createElement('table')
|
|
955
|
-
importMetaTable.className = 'table table-sm table-borderless mb-2'
|
|
956
|
-
const importMetaBody = document.createElement('tbody')
|
|
957
|
-
|
|
958
|
-
const formatSel = document.createElement('select')
|
|
959
|
-
formatSel.className = 'form-select form-select-sm'
|
|
960
|
-
formatSel.style.width = '220px'
|
|
961
|
-
if (!importFormats.length) {
|
|
962
|
-
const opt = document.createElement('option')
|
|
963
|
-
opt.value = ''
|
|
964
|
-
opt.textContent = 'No formats available'
|
|
965
|
-
formatSel.appendChild(opt)
|
|
966
|
-
formatSel.disabled = true
|
|
967
|
-
} else {
|
|
968
|
-
importFormats.forEach(format => {
|
|
969
|
-
const opt = document.createElement('option')
|
|
970
|
-
opt.value = format.id
|
|
971
|
-
opt.textContent = format.name
|
|
972
|
-
formatSel.appendChild(opt)
|
|
973
|
-
})
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
const makeTextInput = (placeholder, width) => {
|
|
977
|
-
const input = document.createElement('input')
|
|
978
|
-
input.type = 'text'
|
|
979
|
-
input.className = 'form-control form-control-sm'
|
|
980
|
-
if (width) input.style.width = width
|
|
981
|
-
if (placeholder) input.placeholder = placeholder
|
|
982
|
-
return input
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
const nameInp = makeTextInput('Optional display name')
|
|
986
|
-
const sailInp = makeTextInput('Optional sail number')
|
|
987
|
-
const typeInp = makeTextInput('Optional boat type')
|
|
988
|
-
const yearInp = document.createElement('input')
|
|
989
|
-
yearInp.type = 'number'
|
|
990
|
-
yearInp.className = 'form-control form-control-sm'
|
|
991
|
-
yearInp.style.width = '120px'
|
|
992
|
-
yearInp.placeholder = 'Optional year'
|
|
993
|
-
const sourceInp = makeTextInput('Defaults to format id')
|
|
994
|
-
|
|
995
|
-
const addFormRow = (label, control, desc) => {
|
|
996
|
-
const tr = document.createElement('tr')
|
|
997
|
-
const tdL = document.createElement('td')
|
|
998
|
-
tdL.textContent = label
|
|
999
|
-
if (desc) {
|
|
1000
|
-
const sm = document.createElement('small')
|
|
1001
|
-
sm.className = 'text-muted d-block'
|
|
1002
|
-
sm.textContent = desc
|
|
1003
|
-
tdL.appendChild(sm)
|
|
1004
|
-
}
|
|
1005
|
-
const tdV = document.createElement('td')
|
|
1006
|
-
tdV.appendChild(control)
|
|
1007
|
-
tr.appendChild(tdL)
|
|
1008
|
-
tr.appendChild(tdV)
|
|
1009
|
-
importMetaBody.appendChild(tr)
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
addFormRow('Format', formatSel, 'Text formats currently supported by the plugin.')
|
|
1013
|
-
addFormRow('Name', nameInp)
|
|
1014
|
-
addFormRow('Sail number', sailInp)
|
|
1015
|
-
addFormRow('Boat type', typeInp)
|
|
1016
|
-
addFormRow('Year', yearInp)
|
|
1017
|
-
addFormRow('Source label', sourceInp, 'Optional metadata override stored with the canonical polar.')
|
|
1018
|
-
importMetaTable.appendChild(importMetaBody)
|
|
1019
|
-
textWrap.appendChild(importMetaTable)
|
|
1020
|
-
|
|
1021
|
-
const importTextArea = document.createElement('textarea')
|
|
1022
|
-
importTextArea.className = 'form-control form-control-sm mb-2'
|
|
1023
|
-
importTextArea.rows = 12
|
|
1024
|
-
importTextArea.placeholder = 'Paste Jieter or Expedition polar text here'
|
|
1025
|
-
importTextArea.style.fontFamily = 'monospace'
|
|
1026
|
-
importTextArea.style.fontSize = '0.8rem'
|
|
1027
|
-
textWrap.appendChild(importTextArea)
|
|
1028
|
-
|
|
1029
|
-
const notesArea = document.createElement('textarea')
|
|
1030
|
-
notesArea.className = 'form-control form-control-sm mb-2'
|
|
1031
|
-
notesArea.rows = 3
|
|
1032
|
-
notesArea.placeholder = 'Optional notes stored with the imported polar'
|
|
1033
|
-
textWrap.appendChild(notesArea)
|
|
1034
|
-
|
|
1035
|
-
const importBtn = document.createElement('button')
|
|
1036
|
-
importBtn.className = 'btn btn-sm btn-primary mb-3'
|
|
1037
|
-
importBtn.textContent = 'Import'
|
|
1038
|
-
importBtn.disabled = !importFormats.length
|
|
1039
|
-
importBtn.addEventListener('click', async () => {
|
|
1040
|
-
const format = formatSel.value
|
|
1041
|
-
const content = importTextArea.value.trim()
|
|
1042
|
-
if (!format) { showMessage('Select an import format'); return }
|
|
1043
|
-
if (!content) { showMessage('Paste polar text first'); return }
|
|
1044
|
-
|
|
1045
|
-
const body = {
|
|
1046
|
-
content,
|
|
1047
|
-
...(nameInp.value.trim() ? { name: nameInp.value.trim() } : {}),
|
|
1048
|
-
...(sailInp.value.trim() ? { sailnumber: sailInp.value.trim() } : {}),
|
|
1049
|
-
...(typeInp.value.trim() ? { boatType: typeInp.value.trim() } : {}),
|
|
1050
|
-
...(readOptionalIntegerInput(yearInp) !== undefined ? { year: readOptionalIntegerInput(yearInp) } : {}),
|
|
1051
|
-
...(sourceInp.value.trim() ? { source: sourceInp.value.trim() } : {}),
|
|
1052
|
-
...(notesArea.value.trim() ? { notes: notesArea.value.trim() } : {})
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
const result = await apiPost('/imports/text/' + encodeURIComponent(format), body)
|
|
1056
|
-
if (result) {
|
|
1057
|
-
showMessage('Imported "' + result.id + '"')
|
|
1058
|
-
nameInp.value = ''
|
|
1059
|
-
sailInp.value = ''
|
|
1060
|
-
typeInp.value = ''
|
|
1061
|
-
yearInp.value = ''
|
|
1062
|
-
sourceInp.value = ''
|
|
1063
|
-
importTextArea.value = ''
|
|
1064
|
-
notesArea.value = ''
|
|
1065
|
-
await refreshPolars()
|
|
1066
|
-
_renderPolarsList(document.getElementById('polars-list'))
|
|
1067
|
-
}
|
|
1068
|
-
})
|
|
1069
|
-
textWrap.appendChild(importBtn)
|
|
1070
|
-
|
|
1071
|
-
const orcCard = createCollapsibleCard('Import ORC Certificate', false)
|
|
1072
|
-
wrap.appendChild(orcCard.card)
|
|
1073
|
-
const orcWrap = orcCard.body
|
|
1074
|
-
|
|
1075
|
-
if (!internetOnline) {
|
|
1076
|
-
const unavailable = document.createElement('div')
|
|
1077
|
-
unavailable.className = 'text-muted small mb-3'
|
|
1078
|
-
unavailable.textContent = 'No internet connection — ORC import is unavailable.'
|
|
1079
|
-
orcWrap.appendChild(unavailable)
|
|
1080
|
-
} else {
|
|
1081
|
-
const sourceBlurb = document.createElement('p')
|
|
1082
|
-
sourceBlurb.className = 'text-muted small mb-2'
|
|
1083
|
-
sourceBlurb.textContent = 'Search the official ORC active certificate index by RefNo, boat name, sail number, or class.'
|
|
1084
|
-
orcWrap.appendChild(sourceBlurb)
|
|
1085
|
-
|
|
1086
|
-
const searchRow = document.createElement('div')
|
|
1087
|
-
searchRow.className = 'd-flex flex-wrap gap-2 align-items-center mb-2'
|
|
1088
|
-
|
|
1089
|
-
const orcSearchInp = makeTextInput('RefNo, boat name, sail number, or class', '320px')
|
|
1090
|
-
const orcSearchBtn = document.createElement('button')
|
|
1091
|
-
orcSearchBtn.className = 'btn btn-sm btn-secondary'
|
|
1092
|
-
orcSearchBtn.textContent = 'Search'
|
|
1093
|
-
searchRow.appendChild(orcSearchInp)
|
|
1094
|
-
searchRow.appendChild(orcSearchBtn)
|
|
1095
|
-
orcWrap.appendChild(searchRow)
|
|
1096
|
-
|
|
1097
|
-
const orcResults = document.createElement('div')
|
|
1098
|
-
orcResults.id = 'orc-results'
|
|
1099
|
-
orcResults.className = 'mb-3'
|
|
1100
|
-
orcWrap.appendChild(orcResults)
|
|
1101
|
-
|
|
1102
|
-
const renderOrcResults = (items) => {
|
|
1103
|
-
orcResults.innerHTML = ''
|
|
1104
|
-
|
|
1105
|
-
if (!items.length) {
|
|
1106
|
-
const empty = document.createElement('div')
|
|
1107
|
-
empty.className = 'text-muted small'
|
|
1108
|
-
empty.textContent = 'No ORC certificates matched the current search.'
|
|
1109
|
-
orcResults.appendChild(empty)
|
|
1110
|
-
return
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
const table = document.createElement('table')
|
|
1114
|
-
table.className = 'table table-sm table-hover mb-0'
|
|
1115
|
-
const tbody = document.createElement('tbody')
|
|
1116
|
-
|
|
1117
|
-
items.forEach(item => {
|
|
1118
|
-
const tr = document.createElement('tr')
|
|
1119
|
-
|
|
1120
|
-
const tdInfo = document.createElement('td')
|
|
1121
|
-
const title = document.createElement('div')
|
|
1122
|
-
title.className = 'fw-semibold'
|
|
1123
|
-
title.textContent = item.name || item.externalId
|
|
1124
|
-
tdInfo.appendChild(title)
|
|
1125
|
-
|
|
1126
|
-
const meta = document.createElement('small')
|
|
1127
|
-
meta.className = 'text-muted d-block'
|
|
1128
|
-
const metaParts = [
|
|
1129
|
-
item.externalId,
|
|
1130
|
-
item.sailnumber,
|
|
1131
|
-
item.boatType,
|
|
1132
|
-
item.certificateName,
|
|
1133
|
-
item.familyName,
|
|
1134
|
-
item.countryId,
|
|
1135
|
-
Number.isInteger(item.year) ? String(item.year) : ''
|
|
1136
|
-
].filter(Boolean)
|
|
1137
|
-
meta.textContent = metaParts.join(' | ')
|
|
1138
|
-
tdInfo.appendChild(meta)
|
|
1139
|
-
|
|
1140
|
-
const tdAction = document.createElement('td')
|
|
1141
|
-
tdAction.className = 'polar-actions'
|
|
1142
|
-
const btn = document.createElement('button')
|
|
1143
|
-
btn.className = 'btn btn-sm btn-primary'
|
|
1144
|
-
btn.textContent = 'Import'
|
|
1145
|
-
btn.addEventListener('click', async () => {
|
|
1146
|
-
const result = await apiPost(
|
|
1147
|
-
'/imports/sources/orc/items/' + encodeURIComponent(item.externalId)
|
|
1148
|
-
)
|
|
1149
|
-
if (result) {
|
|
1150
|
-
showMessage('Imported "' + result.id + '" from ORC')
|
|
1151
|
-
await refreshPolars()
|
|
1152
|
-
_renderPolarsList(document.getElementById('polars-list'))
|
|
1153
|
-
}
|
|
1154
|
-
})
|
|
1155
|
-
tdAction.appendChild(btn)
|
|
1156
|
-
|
|
1157
|
-
tr.appendChild(tdInfo)
|
|
1158
|
-
tr.appendChild(tdAction)
|
|
1159
|
-
tbody.appendChild(tr)
|
|
1160
|
-
})
|
|
1161
|
-
|
|
1162
|
-
table.appendChild(tbody)
|
|
1163
|
-
orcResults.appendChild(table)
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
const runOrcSearch = async () => {
|
|
1167
|
-
const q = orcSearchInp.value.trim()
|
|
1168
|
-
if (!q) {
|
|
1169
|
-
showMessage('Enter an ORC search term first')
|
|
1170
|
-
return
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
const query = new URLSearchParams({ q })
|
|
1174
|
-
const results = await apiGet('/imports/sources/orc/search?' + query.toString())
|
|
1175
|
-
if (results) renderOrcResults(results)
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
orcSearchBtn.addEventListener('click', runOrcSearch)
|
|
1179
|
-
orcSearchInp.addEventListener('keydown', (event) => {
|
|
1180
|
-
if (event.key === 'Enter') {
|
|
1181
|
-
event.preventDefault()
|
|
1182
|
-
runOrcSearch()
|
|
1183
|
-
}
|
|
1184
|
-
})
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
return wrap
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
function _renderPolarsList(el) {
|
|
1191
|
-
el.innerHTML = ''
|
|
1192
|
-
if (!polarsList.length) {
|
|
1193
|
-
const p = document.createElement('p'); p.className = 'text-muted small mb-3'
|
|
1194
|
-
p.textContent = 'No canonical polars stored yet.'
|
|
1195
|
-
el.appendChild(p)
|
|
1196
|
-
return
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
const tbl = document.createElement('table')
|
|
1200
|
-
tbl.className = 'table table-sm table-borderless mb-2'
|
|
1201
|
-
const tbody = document.createElement('tbody')
|
|
1202
|
-
|
|
1203
|
-
polarsList.forEach(raw => {
|
|
1204
|
-
const entry = typeof raw === 'string' ? { id: raw, name: raw } : raw
|
|
1205
|
-
const id = entry.id
|
|
1206
|
-
const isActive = id === settings?.activePolar
|
|
1207
|
-
const tr = document.createElement('tr')
|
|
1208
|
-
const tdN = document.createElement('td')
|
|
1209
|
-
const labelSpan = document.createElement('span')
|
|
1210
|
-
labelSpan.textContent = entry.name || id
|
|
1211
|
-
tdN.appendChild(labelSpan)
|
|
1212
|
-
if (isActive) {
|
|
1213
|
-
const badge = document.createElement('span')
|
|
1214
|
-
badge.className = 'badge bg-success ms-2'
|
|
1215
|
-
badge.textContent = 'active'
|
|
1216
|
-
tdN.appendChild(badge)
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
const sub = []
|
|
1220
|
-
if (entry.boatType) sub.push(entry.boatType)
|
|
1221
|
-
if (entry.sailnumber) sub.push(entry.sailnumber)
|
|
1222
|
-
if (sub.length) {
|
|
1223
|
-
const sm = document.createElement('small')
|
|
1224
|
-
sm.className = 'text-muted d-block'
|
|
1225
|
-
sm.textContent = sub.join(' \u00b7 ')
|
|
1226
|
-
tdN.appendChild(sm)
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
const tdA = document.createElement('td')
|
|
1230
|
-
tdA.className = 'polar-actions'
|
|
1231
|
-
if (!isActive) {
|
|
1232
|
-
const actBtn = document.createElement('button')
|
|
1233
|
-
actBtn.className = 'btn btn-sm btn-outline-primary me-1'
|
|
1234
|
-
actBtn.textContent = 'Activate'
|
|
1235
|
-
actBtn.addEventListener('click', async () => {
|
|
1236
|
-
const result = await apiPut('/polars/active', { id })
|
|
1237
|
-
if (result) {
|
|
1238
|
-
settings = { ...(settings || {}), activePolar: result.id }
|
|
1239
|
-
libraryVersion = ''
|
|
1240
|
-
await refreshLibrary()
|
|
1241
|
-
await refreshPolars()
|
|
1242
|
-
_renderPolarsList(el)
|
|
1243
|
-
if (activePage === 'settings') switchPage('settings')
|
|
1244
|
-
}
|
|
1245
|
-
})
|
|
1246
|
-
tdA.appendChild(actBtn)
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
const delBtn = document.createElement('button')
|
|
1250
|
-
delBtn.className = 'btn btn-sm btn-outline-danger'
|
|
1251
|
-
delBtn.textContent = 'Delete'
|
|
1252
|
-
delBtn.addEventListener('click', async () => {
|
|
1253
|
-
if (!confirm('Delete polar "' + id + '"?')) return
|
|
1254
|
-
await apiDelete('/polars/' + encodeURIComponent(id))
|
|
1255
|
-
await refreshSettings()
|
|
1256
|
-
await refreshPolars()
|
|
1257
|
-
libraryVersion = ''
|
|
1258
|
-
await refreshLibrary()
|
|
1259
|
-
_renderPolarsList(el)
|
|
1260
|
-
})
|
|
1261
|
-
tdA.appendChild(delBtn)
|
|
1262
|
-
|
|
1263
|
-
tr.appendChild(tdN)
|
|
1264
|
-
tr.appendChild(tdA)
|
|
1265
|
-
tbody.appendChild(tr)
|
|
1266
|
-
})
|
|
1267
|
-
|
|
1268
|
-
tbl.appendChild(tbody)
|
|
1269
|
-
el.appendChild(tbl)
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
692
|
// ── Navigation ────────────────────────────────────────────────────────────────
|
|
1273
693
|
const PAGES = {
|
|
1274
|
-
overview: { title: 'Overview',
|
|
1275
|
-
inputs: { title: 'Inputs',
|
|
1276
|
-
|
|
1277
|
-
outputs: { title: 'Outputs', build: _buildOutputsPage },
|
|
1278
|
-
polars: { title: 'Polar management', build: _buildPolarsPage },
|
|
694
|
+
overview: { title: 'Overview', build: _buildOverviewPage },
|
|
695
|
+
inputs: { title: 'Inputs', build: _buildInputsPage },
|
|
696
|
+
outputs: { title: 'Outputs', build: _buildOutputsPage },
|
|
1279
697
|
}
|
|
1280
698
|
|
|
1281
699
|
let _currentPageEl = null
|
|
@@ -1288,14 +706,11 @@ function switchPage(page) {
|
|
|
1288
706
|
document.querySelectorAll('#main-nav .nav-link').forEach(l =>
|
|
1289
707
|
l.classList.toggle('active', l.dataset.page === page)
|
|
1290
708
|
)
|
|
1291
|
-
const shell = document.getElementById('page-shell')
|
|
1292
709
|
const title = document.getElementById('card-title')
|
|
1293
710
|
const body = document.getElementById('card-body')
|
|
1294
711
|
title.textContent = PAGES[page].title
|
|
1295
712
|
body.innerHTML = ''
|
|
1296
|
-
|
|
1297
|
-
body.classList.toggle('page-shellless-body', page === 'polars')
|
|
1298
|
-
body.classList.toggle('polar-layout', page === 'overview' || page === 'settings')
|
|
713
|
+
body.classList.toggle('polar-layout', page === 'overview')
|
|
1299
714
|
|
|
1300
715
|
_currentPageEl = PAGES[page].build()
|
|
1301
716
|
body.appendChild(_currentPageEl)
|
|
@@ -1332,7 +747,7 @@ async function init() {
|
|
|
1332
747
|
}
|
|
1333
748
|
})
|
|
1334
749
|
|
|
1335
|
-
await
|
|
750
|
+
await refreshSettings()
|
|
1336
751
|
await loadMeta()
|
|
1337
752
|
switchPage('overview')
|
|
1338
753
|
await refreshLibrary()
|