signalk-charts-provider-simple 2.5.0 → 2.6.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/README.md CHANGED
@@ -5,6 +5,7 @@ A lightweight Signal K server plugin for managing local nautical charts, written
5
5
  ## Features
6
6
 
7
7
  - **Local Chart Management**: MBTiles (raster and vector), with folder organization and enable/disable toggles
8
+ - **Folder Groups**: Enable/disable whole chart folders at once (OpenCPN-style groups, including nested folders); clients like Freeboard-SK see the change live via `resources.charts` deltas
8
9
  - **Download Manager**: Built-in download queue with progress tracking and ZIP extraction
9
10
  - **Chart Catalog**: Browse and download charts from [chartcatalogs.github.io](https://chartcatalogs.github.io/) with automatic update notifications
10
11
  - **NOAA Charts**: Draw a coverage region on a map from NOAA band-4 chart footprints; the plugin bundles the overlapping band-3/4/5 ENCs and converts them into a single vector MBTiles named after your chart set
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAQ,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAuHxD,OAAO,KAAK,EACV,aAAa,EAEb,iBAAiB,EAIjB,QAAQ,EAET,MAAM,YAAY,CAAC;AAwBpB,QAAA,MAAM,iBAAiB,GAAI,KAAK,iBAAiB,KAAG,MAg7HnD,CAAC;AA4GF,eAAO,MAAM,oBAAoB,GAC/B,KAAK,QAAQ,EACb,UAAU,aAAa,EACvB,GAAG,MAAM,EACT,GAAG,MAAM,EACT,GAAG,MAAM,KACR,IAiDF,CAAC;AAEF,eAAe,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAQ,MAAM,EAAE,MAAM,qBAAqB,CAAC;AA8HxD,OAAO,KAAK,EACV,aAAa,EAEb,iBAAiB,EAIjB,QAAQ,EAET,MAAM,YAAY,CAAC;AAwBpB,QAAA,MAAM,iBAAiB,GAAI,KAAK,iBAAiB,KAAG,MAujInD,CAAC;AA4GF,eAAO,MAAM,oBAAoB,GAC/B,KAAK,QAAQ,EACb,UAAU,aAAa,EACvB,GAAG,MAAM,EACT,GAAG,MAAM,EACT,GAAG,MAAM,KACR,IAiDF,CAAC;AAEF,eAAe,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { findCharts, findRepairableCharts } from './charts-loader.js';
9
9
  import { checkForUpdates, classifyUrl, fetchCatalog, fetchCatalogRegistry, getCachedCatalog, getCatalogRegistry, getCatalogsWithInstalledCharts, getConvertingCharts, getConvertingCount, getInstalledCatalogCharts, getRegistryStatus, initCatalogManager, pruneStaleInstalls, removeInstall, removeInstallByFilename, renameInstallFilename, rollbackInstall, setConvertingState, setInstallFilename, trackInstall } from './utils/catalog-manager.js';
10
10
  import { cleanCatalogTitle } from './utils/catalog-title.js';
11
11
  import { initChartState, isChartEnabled, setChartEnabled } from './utils/chart-state.js';
12
+ import { initFolderState, isFolderEnabled, isFolderPathEnabled, setFolderEnabled, removeFolderState } from './utils/folder-state.js';
12
13
  import { getCpuBudget, setCpuBudget } from './utils/concurrency.js';
13
14
  import { PLUGIN_OWNER_ID } from './utils/container-jobs.js';
14
15
  import { getContainerManager, waitForContainerManager } from './utils/container-manager.js';
@@ -147,6 +148,11 @@ const pluginConstructor = (app) => {
147
148
  clearInterval(catalogUpdateInterval);
148
149
  catalogUpdateInterval = null;
149
150
  }
151
+ // Release sqlite handles: on Windows an open handle keeps the
152
+ // .mbtiles file locked, and Signal K restarts the plugin on every
153
+ // config save, which would otherwise leak the whole map each time.
154
+ closeProviderHandles(Object.values(chartProviders));
155
+ chartProviders = {};
150
156
  app.setPluginStatus('stopped');
151
157
  },
152
158
  registerWithRouter: (router) => {
@@ -265,6 +271,7 @@ const pluginConstructor = (app) => {
265
271
  console.log(`[charts-provider] chartPath resolved to ${chartPath} (marker write failed)`);
266
272
  }
267
273
  initChartState(pluginDataDir);
274
+ initFolderState(pluginDataDir);
268
275
  const dataDir = pluginDataDir;
269
276
  initCatalogManager(dataDir, app.debug.bind(app));
270
277
  initNoaaEncFootprints(dataDir, app.debug.bind(app));
@@ -442,6 +449,43 @@ const pluginConstructor = (app) => {
442
449
  // a stale snapshot would delete them as "invalid".
443
450
  void cleanupChartDirectory(chartPath);
444
451
  };
452
+ // A chart is served only when its own flag AND every ancestor folder's
453
+ // flag are enabled; folder state is a separate layer that never rewrites
454
+ // per-chart state.
455
+ const isChartVisible = (chartPath, filePath) => {
456
+ const relativePath = path.relative(chartPath, filePath);
457
+ return isChartEnabled(relativePath) && isFolderPathEnabled(path.dirname(relativePath));
458
+ };
459
+ // findCharts opens a sqlite handle for every chart it finds; any provider
460
+ // that doesn't make it into (or drops out of) `chartProviders` must be
461
+ // closed explicitly or the handle leaks — and on Windows keeps the
462
+ // .mbtiles file locked. Safe to call on live objects being replaced:
463
+ // tile serving is synchronous from map lookup to handle use, so no
464
+ // in-flight request can hold a closed handle across an await.
465
+ const closeProviderHandles = (providers) => {
466
+ for (const provider of providers) {
467
+ try {
468
+ provider._mbtilesHandle?.close();
469
+ }
470
+ catch {
471
+ // Already closed — nothing to release.
472
+ }
473
+ }
474
+ };
475
+ const partitionVisibleCharts = (chartPath, charts) => {
476
+ const visible = {};
477
+ const dropped = [];
478
+ for (const [identifier, chart] of Object.entries(charts)) {
479
+ if (isChartVisible(chartPath, chart._filePath || '')) {
480
+ visible[identifier] = chart;
481
+ }
482
+ else {
483
+ dropped.push(chart);
484
+ }
485
+ }
486
+ closeProviderHandles(dropped);
487
+ return visible;
488
+ };
445
489
  // Load enabled charts into `chartProviders` and prune stale install
446
490
  // records. Display-critical: must run (and resolve) before the resource
447
491
  // provider is registered so `listResources` has charts to return. Returns
@@ -449,10 +493,7 @@ const pluginConstructor = (app) => {
449
493
  const loadChartProviders = async (chartPath) => {
450
494
  try {
451
495
  const charts = await findCharts(chartPath);
452
- const enabledCharts = Object.fromEntries(Object.entries(charts).filter(([, chart]) => {
453
- const relativePath = path.relative(chartPath, chart._filePath || '');
454
- return isChartEnabled(relativePath);
455
- }));
496
+ const enabledCharts = partitionVisibleCharts(chartPath, charts);
456
497
  app.debug(`Chart provider: Found ${Object.keys(charts).length} charts (${Object.keys(enabledCharts).length} enabled) from ${chartPath}.`);
457
498
  chartProviders = enabledCharts;
458
499
  pruneStaleInstalls(Object.keys(charts));
@@ -615,6 +656,12 @@ const pluginConstructor = (app) => {
615
656
  const ChartToggleBody = Type.Object({
616
657
  enabled: Type.Boolean()
617
658
  });
659
+ // folderPath travels in the body (not a URL param): nested folders contain
660
+ // '/' and Express 5 rejects encoded %2F in path segments.
661
+ const FolderToggleBody = Type.Object({
662
+ folderPath: Type.String({ minLength: 1 }),
663
+ enabled: Type.Boolean()
664
+ });
618
665
  const MoveChartBody = Type.Object({
619
666
  chartPath: Type.String({ minLength: 1 }),
620
667
  targetFolder: Type.String({ minLength: 1 })
@@ -915,13 +962,22 @@ const pluginConstructor = (app) => {
915
962
  return {
916
963
  ...chart,
917
964
  enabled: isChartEnabled(chart.relativePath),
965
+ folderEnabled: isFolderPathEnabled(chart.folder),
918
966
  downloading: downloadingFiles.has(chart.name),
919
967
  converting
920
968
  };
921
969
  });
970
+ const folderStates = Object.fromEntries(folders.map((folder) => [
971
+ folder,
972
+ {
973
+ enabled: isFolderEnabled(folder),
974
+ effectiveEnabled: isFolderPathEnabled(folder)
975
+ }
976
+ ]));
922
977
  res.json({
923
978
  charts: chartsWithState,
924
979
  folders: folders,
980
+ folderStates,
925
981
  basePath: chartPath
926
982
  });
927
983
  }
@@ -1051,6 +1107,7 @@ const pluginConstructor = (app) => {
1051
1107
  return;
1052
1108
  }
1053
1109
  await fs.promises.rmdir(fullPath);
1110
+ removeFolderState(folderPathParam);
1054
1111
  res.status(200).json({ success: true, message: 'Folder deleted successfully' });
1055
1112
  }
1056
1113
  catch (error) {
@@ -1058,6 +1115,64 @@ const pluginConstructor = (app) => {
1058
1115
  res.status(500).send('Error deleting folder');
1059
1116
  }
1060
1117
  });
1118
+ router.post('/folders/toggle', async (req, res) => {
1119
+ const body = parseBody(FolderToggleBody, req, res);
1120
+ if (!body) {
1121
+ return;
1122
+ }
1123
+ const { folderPath, enabled } = body;
1124
+ try {
1125
+ const basePath = props.chartPath || defaultChartsPath;
1126
+ const fullPath = path.join(basePath, folderPath);
1127
+ if (!isWithinBase(fullPath, basePath)) {
1128
+ res.status(403).send('Access denied: Invalid path');
1129
+ return;
1130
+ }
1131
+ if (path.normalize(fullPath) === path.normalize(basePath)) {
1132
+ res.status(400).send('Cannot toggle the root folder');
1133
+ return;
1134
+ }
1135
+ if (!fs.existsSync(fullPath)) {
1136
+ res.status(404).send('Folder not found');
1137
+ return;
1138
+ }
1139
+ if (!fs.statSync(fullPath).isDirectory()) {
1140
+ res.status(400).send('Not a folder');
1141
+ return;
1142
+ }
1143
+ // Diff providers before/after the refresh: a folder toggle can affect
1144
+ // many charts (including nested folders), while charts that are
1145
+ // individually disabled stay absent on both sides and emit no delta.
1146
+ const before = chartProviders;
1147
+ setFolderEnabled(folderPath, enabled);
1148
+ await refreshChartProviders();
1149
+ let chartsAffected = 0;
1150
+ for (const id of Object.keys(before)) {
1151
+ if (!chartProviders[id]) {
1152
+ emitChartDelta(id, null);
1153
+ chartsAffected++;
1154
+ }
1155
+ }
1156
+ for (const id of Object.keys(chartProviders)) {
1157
+ if (!before[id]) {
1158
+ emitChartDelta(id, sanitizeProvider(chartProviders[id], 2));
1159
+ chartsAffected++;
1160
+ }
1161
+ }
1162
+ app.debug(`Folder ${folderPath} set to ${enabled ? 'enabled' : 'disabled'} (${chartsAffected} chart delta(s))`);
1163
+ res.status(200).json({
1164
+ success: true,
1165
+ message: `Folder ${enabled ? 'enabled' : 'disabled'}`,
1166
+ folderPath,
1167
+ enabled,
1168
+ chartsAffected
1169
+ });
1170
+ }
1171
+ catch (error) {
1172
+ console.error('Error toggling folder state:', error);
1173
+ res.status(500).send('Error toggling folder state');
1174
+ }
1175
+ });
1061
1176
  router.post('/charts/:chartPath/toggle', async (req, res) => {
1062
1177
  const chartPathParam = decodeURIComponent(req.params.chartPath);
1063
1178
  const body = parseBody(ChartToggleBody, req, res);
@@ -1141,6 +1256,12 @@ const pluginConstructor = (app) => {
1141
1256
  const chartData = sanitizeProvider(chartProviders[chartId], 2);
1142
1257
  emitChartDelta(chartId, chartData);
1143
1258
  }
1259
+ else {
1260
+ // The move can hide the chart from serving (target folder disabled)
1261
+ // — retract it so v2 clients don't keep a stale entry whose tiles
1262
+ // now 404.
1263
+ emitChartDelta(chartId, null);
1264
+ }
1144
1265
  res.status(200).json({ success: true, message: 'Chart moved successfully' });
1145
1266
  }
1146
1267
  catch (error) {
@@ -3300,10 +3421,11 @@ const pluginConstructor = (app) => {
3300
3421
  try {
3301
3422
  const chartPath = props.chartPath || defaultChartsPath;
3302
3423
  const charts = await findCharts(chartPath);
3303
- chartProviders = Object.fromEntries(Object.entries(charts).filter(([, chart]) => {
3304
- const relativePath = path.relative(chartPath, chart._filePath || '');
3305
- return isChartEnabled(relativePath);
3306
- }));
3424
+ // Close the handles of the map being replaced — findCharts reopened
3425
+ // every chart, so the old provider objects would otherwise leak.
3426
+ const previous = chartProviders;
3427
+ chartProviders = partitionVisibleCharts(chartPath, charts);
3428
+ closeProviderHandles(Object.values(previous));
3307
3429
  app.debug(`Chart providers refreshed: ${Object.keys(chartProviders).length} enabled charts`);
3308
3430
  }
3309
3431
  catch (error) {