diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..e6c8977 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,25 @@ +// eslint-disable-next-line no-undef +module.exports = { + root: true, + extends: ['eslint:recommended', 'prettier'], + parserOptions: { + ecmaVersion: 2019, + sourceType: 'module' + }, + env: { + node: true, + es2019: true + }, + overrides: [ + { + files: ['**/*.ts'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'prettier' + ], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'] + } + ] +} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d860367..de315ee 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: - node-version: '16.x' + node-version: '18.x' registry-url: 'https://registry.npmjs.org' - run: npm publish --access public env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8514289..e70d512 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: - node-version: [10.x, 16.x] + node-version: [18.x] steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 2752eb9..b21c47a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ node_modules/ .DS_Store +plugin/ +package-lock.json +tsconfig.tsbuildinfo diff --git a/.npmignore b/.npmignore index fe9a459..87880ba 100644 --- a/.npmignore +++ b/.npmignore @@ -1,3 +1,11 @@ node_modules/ .DS_Store test/ +package-lock.json +src/ +tsconfig.json +.eslintrc.js +.prettierrc.json +.github/ +tsconfig.tsbuildinfo +*.tgz diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..8c158f2 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "none" +} \ No newline at end of file diff --git a/README.md b/README.md index 8189978..046e35a 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,47 @@ -# Signal K Node server Charts plugin +# Signal K server Charts plugin Signal K Node server plugin to provide chart metadata, such as name, description and location of the actual chart tile data. +Supports both v1 and v2 Signal K resources api paths. + +| Server Version | API | Path | +|--- |--- |--- | +| 1.x.x | v1 | `/signalk/v1/api/resources/charts` | +| 2.x.x | v2 | `/signalk/v2/api/resources/charts` | + + +_Note: v2 resource paths will only be made available on Signal K server >= v2._ + ### Usage 1. Install "Signal K Charts" plugin from Signal K Appstore -2. Add "Chart paths" in plugin configuration. Defaults to `${signalk-configuration-path}/charts` +2. Configure plugin in **Plugin Config** + +- Add "Chart paths" which are the paths to the folders where chart files are stored. Defaults to `${signalk-configuration-path}/charts` + + +3. Add "Chart paths" in plugin configuration. Defaults to `${signalk-configuration-path}/charts` Chart paths configuration -3. Put charts into selected paths -4. Add optional online chart providers +4. Put charts into selected paths + +5. Add optional online chart providers Online chart providers configuration + + + _WMS example:_ -![image](https://user-images.githubusercontent.com/38519157/102832518-90077100-443e-11eb-9a1d-d0806bb2b10b.png) -5. Activate plugin +server type configuration + +6. Activate plugin -6. Use one of the client apps supporting Signal K charts, for example: +7. Use one of the client apps supporting Signal K charts, for example: - [Freeboard SK](https://www.npmjs.com/package/@signalk/freeboard-sk) - [Tuktuk Chart Plotter](https://www.npmjs.com/package/tuktuk-chart-plotter) diff --git a/index.js b/index.js deleted file mode 100644 index fff232c..0000000 --- a/index.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('./src/plugin') diff --git a/package.json b/package.json index 1608c17..2460df3 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,20 @@ { "name": "@signalk/charts-plugin", - "version": "2.3.1", + "version": "2.4.0", "description": "Signal K plugin to provide chart support for Signal K server", - "main": "index.js", + "main": "plugin/index.js", "scripts": { + "build": "tsc", + "build-declaration": "tsc --declaration --allowJs false", + "watch": "npm run build -- -w", + "start": "npm run build -- -w", + "prepare": "tsc", + "prettier": "prettier -w src/", + "lint": "eslint -c .eslintrc.js --ext .ts --ext .js --fix src/", + "format": "npm run prettier && npm run lint", + "ci-lint": "eslint -c .eslintrc.js --ext .ts --ext .js src/ && prettier --check src/", "test": "mocha", - "deploy": "npm test && git tag -a v$npm_package_version -m 'Release v$npm_package_version'; git push --tags; npm publish --access public --scope @signalk ." + "deploy": "npm build && npm test && git tag -a v$npm_package_version -m 'Release v$npm_package_version'; git push --tags; npm publish --access public --scope @signalk ." }, "license": "Apache-2.0", "keywords": [ @@ -22,6 +31,7 @@ }, "dependencies": { "@mapbox/mbtiles": "^0.12.1", + "@signalk/server-api": "^2.0.0-beta.3", "baconjs": "1.0.1", "bluebird": "3.5.1", "lodash": "^4.17.11", @@ -32,10 +42,19 @@ "url": "https://github.com/SignalK/charts-plugin" }, "devDependencies": { + "@types/express": "^4.17.17", + "@types/lodash": "^4.14.191", + "@types/node": "^18.14.4", + "@typescript-eslint/eslint-plugin": "^5.52.0", + "@typescript-eslint/parser": "^5.52.0", "body-parser": "1.18.2", "chai": "4.1.2", "chai-http": "^4.2.1", + "eslint": "^8.34.0", + "eslint-config-prettier": "^8.6.0", "express": "4.16.2", - "mocha": "5.0.0" + "mocha": "5.0.0", + "prettier": "^2.8.4", + "typescript": "^4.5.4" } } diff --git a/src/@types/bluebird.d.ts b/src/@types/bluebird.d.ts new file mode 100644 index 0000000..18ce023 --- /dev/null +++ b/src/@types/bluebird.d.ts @@ -0,0 +1 @@ +declare module 'bluebird' diff --git a/src/@types/mapbox_mbtiles.d.ts b/src/@types/mapbox_mbtiles.d.ts new file mode 100644 index 0000000..0b52a9c --- /dev/null +++ b/src/@types/mapbox_mbtiles.d.ts @@ -0,0 +1 @@ +declare module '@mapbox/mbtiles' diff --git a/src/@types/xml2js.d.ts b/src/@types/xml2js.d.ts new file mode 100644 index 0000000..58c7edf --- /dev/null +++ b/src/@types/xml2js.d.ts @@ -0,0 +1 @@ +declare module 'xml2js' diff --git a/src/charts.js b/src/charts.js deleted file mode 100644 index 5ce2c35..0000000 --- a/src/charts.js +++ /dev/null @@ -1,175 +0,0 @@ -const debug = require('debug')('signalk-charts-plugin') -const Promise = require('bluebird') -const path = require('path') -const MBTiles = require('@mapbox/mbtiles') -const xml2js = require('xml2js') -const fs = Promise.promisifyAll(require('fs')) -const _ = require('lodash') -const {apiRoutePrefix} = require('./constants') - -function findCharts(chartBaseDir) { - return fs - .readdirAsync(chartBaseDir) - .then(files => { - return Promise.mapSeries(files, filename => { - const isMbtilesFile = filename.match(/\.mbtiles$/i) - const file = path.resolve(chartBaseDir, filename) - const isDirectory = fs.statSync(file).isDirectory() - if (isMbtilesFile) { - return openMbtilesFile(file, filename) - } else if (isDirectory) { - return directoryToMapInfo(file, filename) - } else { - return Promise.resolve(null) - } - }) - }) - .then(result => _.filter(result, _.identity)) - .then(charts => _.reduce(charts, (result, chart) => { - result[chart.identifier] = chart - return result - }, {})) - .catch(err => { - console.error(`Error reading charts directory ${chartBaseDir}:${err.message}`) - }) -} - -function openMbtilesFile(file, filename) { - return new Promise((resolve, reject) => { - new MBTiles(file, (err, mbtiles) => { - if (err) { - return reject(err) - } - mbtiles.getInfo((err, metadata) => { - if (err) { - return reject(err) - } - - return resolve({mbtiles, metadata}) - }) - }) - }).then(({mbtiles, metadata}) => { - if (_.isEmpty(metadata) || metadata.bounds === undefined) { - return null - } - const identifier = filename.replace(/\.mbtiles$/i, '') - return { - _fileFormat: 'mbtiles', - _mbtilesHandle: mbtiles, - _flipY: false, - identifier, - name: metadata.name || metadata.id, - description: metadata.description, - bounds: metadata.bounds, - minzoom: metadata.minzoom, - maxzoom: metadata.maxzoom, - format: metadata.format, - type: 'tilelayer', - tilemapUrl: `${apiRoutePrefix}/charts/${identifier}/{z}/{x}/{y}`, - scale: metadata.scale || '250000' - } - }).catch(e => { - console.error(`Error loading chart ${file}`, e.message) - return null - }) -} - -function parseTilemapResource(tilemapResource) { - return fs - .readFileAsync(tilemapResource) - .then(Promise.promisify(xml2js.parseString)) - .then(parsed => { - const result = parsed.TileMap - const name = _.get(result, 'Title.0') - const format = _.get(result, 'TileFormat.0.$.extension') - const scale = _.get(result, 'Metadata.0.$.scale') - const bbox = _.get(result, 'BoundingBox.0.$') - const zoomLevels = _.map(_.get(result, 'TileSets.0.TileSet')||[], set => parseInt(_.get(set, '$.href'))) - return { - _flipY: true, - name, - description: name, - bounds: bbox ? [parseFloat(bbox.minx), parseFloat(bbox.miny), parseFloat(bbox.maxx), parseFloat(bbox.maxy)] : undefined, - minzoom: !_.isEmpty(zoomLevels) ? _.min(zoomLevels) : undefined, - maxzoom: !_.isEmpty(zoomLevels) ? _.max(zoomLevels) : undefined, - format, - type: 'tilelayer', - scale: scale || '250000' - } - }) -} - -function parseMetadataJson(metadataJson) { - return fs - .readFileAsync(metadataJson) - .then(JSON.parse) - .then(metadata => { - function parseBounds(bounds) { - if (_.isString(bounds)) { - return _.map(bounds.split(','), bound => parseFloat(_.trim(bound))) - } else if (_.isArray(bounds) && bounds.length === 4) { - return bounds - } else { - return undefined - } - } - return { - _flipY: false, - name: metadata.name || metadata.id, - description: metadata.description, - bounds: parseBounds(metadata.bounds), - minzoom: parseIntIfNotUndefined(metadata.minzoom), - maxzoom: parseIntIfNotUndefined(metadata.maxzoom), - format: metadata.format, - type: 'tilelayer', - scale: metadata.scale || '250000' - } - }) -} - -function directoryToMapInfo(file, identifier) { - function loadInfo() { - const tilemapResource = path.join(file, 'tilemapresource.xml') - const metadataJson = path.join(file, 'metadata.json') - - const hasTilemapResource = fs.existsSync(tilemapResource) - const hasMetadataJson = fs.existsSync(metadataJson) - if (hasTilemapResource) { - return parseTilemapResource(tilemapResource) - } else if (hasMetadataJson) { - return parseMetadataJson(metadataJson) - } else { - return Promise.resolve(null) - } - } - - return loadInfo() - .then(info => { - if (info) { - if (!info.format) { - console.error(`Missing format metadata for chart ${identifier}`) - return null - } - return _.merge(info, { - identifier, - _fileFormat: 'directory', - _filePath: file, - tilemapUrl: `${apiRoutePrefix}/charts/${identifier}/{z}/{x}/{y}`, - }) - } - return null - }) - .catch(e => { - console.error(`Error getting charts from ${file}`, e.message) - return undefined - }) -} - -function parseIntIfNotUndefined(val) { - const parsed = parseInt(val) - return _.isFinite(parsed) ? parsed : undefined -} - -module.exports = { - findCharts -} diff --git a/src/charts.ts b/src/charts.ts new file mode 100644 index 0000000..1ae32c9 --- /dev/null +++ b/src/charts.ts @@ -0,0 +1,231 @@ +import * as bluebird from 'bluebird' +import path from 'path' +import MBTiles from '@mapbox/mbtiles' +import * as xml2js from 'xml2js' +import { Dirent, promises as fs } from 'fs' +import * as _ from 'lodash' +import { ChartProvider } from './types' + +export function findCharts(chartBaseDir: string) { + return fs + .readdir(chartBaseDir, { withFileTypes: true }) + .then((files) => { + return bluebird.mapSeries(files, (file: Dirent) => { + const isMbtilesFile = file.name.match(/\.mbtiles$/i) + const filePath = path.resolve(chartBaseDir, file.name) + const isDirectory = file.isDirectory() + if (isMbtilesFile) { + return openMbtilesFile(filePath, file.name) + } else if (isDirectory) { + return directoryToMapInfo(filePath, file.name) + } else { + return Promise.resolve(null) + } + }) + }) + .then((result: ChartProvider) => _.filter(result, _.identity)) + .then((charts: ChartProvider[]) => + _.reduce( + charts, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (result: any, chart: ChartProvider) => { + result[chart.identifier] = chart + return result + }, + {} + ) + ) + .catch((err: Error) => { + console.error( + `Error reading charts directory ${chartBaseDir}:${err.message}` + ) + }) +} + +function openMbtilesFile(file: string, filename: string) { + return ( + new Promise((resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new MBTiles(file, (err: Error, mbtiles: any) => { + if (err) { + return reject(err) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mbtiles.getInfo((err: Error, metadata: any) => { + if (err) { + return reject(err) + } + return resolve({ mbtiles, metadata }) + }) + }) + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .then((res: any) => { + if (_.isEmpty(res.metadata) || res.metadata.bounds === undefined) { + return null + } + const identifier = filename.replace(/\.mbtiles$/i, '') + const data: ChartProvider = { + _fileFormat: 'mbtiles', + _filePath: file, + _mbtilesHandle: res.mbtiles, + _flipY: false, + identifier, + name: res.metadata.name || res.metadata.id, + description: res.metadata.description, + bounds: res.metadata.bounds, + minzoom: res.metadata.minzoom, + maxzoom: res.metadata.maxzoom, + format: res.metadata.format, + type: 'tilelayer', + scale: parseInt(res.metadata.scale) || 250000, + v1: { + tilemapUrl: `~basePath~/charts/${identifier}/{z}/{x}/{y}`, + chartLayers: res.metadata.vector_layers + ? parseVectorLayers(res.metadata.vector_layers) + : [] + }, + v2: { + url: `~basePath~/charts/${identifier}/{z}/{x}/{y}`, + layers: res.metadata.vector_layers + ? parseVectorLayers(res.metadata.vector_layers) + : [] + } + } + return data + }) + .catch((e: Error) => { + console.error(`Error loading chart ${file}`, e.message) + return null + }) + ) +} + +function parseVectorLayers(layers: Array<{ id: string }>) { + return layers.map((l) => l.id) +} + +function directoryToMapInfo(file: string, identifier: string) { + async function loadInfo() { + const tilemapResource = path.join(file, 'tilemapresource.xml') + const metadataJson = path.join(file, 'metadata.json') + try { + await fs.stat(tilemapResource) + return parseTilemapResource(tilemapResource) + } catch { + try { + await fs.stat(metadataJson) + return parseMetadataJson(metadataJson) + } catch { + return null + } + } + } + + return loadInfo() + .then((info: ChartProvider | null) => { + if (info) { + if (!info.format) { + console.error(`Missing format metadata for chart ${identifier}`) + return null + } + info.identifier = identifier + ;(info._fileFormat = 'directory'), + (info._filePath = file), + (info.v1 = { + tilemapUrl: `~basePath~/charts/${identifier}/{z}/{x}/{y}`, + chartLayers: [] + }) + info.v2 = { + url: `~basePath~/charts/${identifier}/{z}/{x}/{y}`, + layers: [] + } + + return info + } + return null + }) + .catch((e) => { + console.error(`Error getting charts from ${file}`, e.message) + return undefined + }) +} + +function parseTilemapResource(tilemapResource: string) { + return ( + fs + .readFile(tilemapResource) + .then(bluebird.promisify(xml2js.parseString)) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .then((parsed: any) => { + const result = parsed.TileMap + const name = _.get(result, 'Title.0') + const format = _.get(result, 'TileFormat.0.$.extension') + const scale = _.get(result, 'Metadata.0.$.scale') + const bbox = _.get(result, 'BoundingBox.0.$') + const zoomLevels = _.map( + _.get(result, 'TileSets.0.TileSet') || [], + (set) => parseInt(_.get(set, '$.href')) + ) + const res: ChartProvider = { + _flipY: true, + name, + description: name, + bounds: bbox + ? [ + parseFloat(bbox.minx), + parseFloat(bbox.miny), + parseFloat(bbox.maxx), + parseFloat(bbox.maxy) + ] + : undefined, + minzoom: !_.isEmpty(zoomLevels) ? _.min(zoomLevels) : undefined, + maxzoom: !_.isEmpty(zoomLevels) ? _.max(zoomLevels) : undefined, + format, + type: 'tilelayer', + scale: parseInt(scale) || 250000, + identifier: '', + _filePath: '' + } + return res + }) + ) +} + +function parseMetadataJson(metadataJson: string) { + return fs + .readFile(metadataJson, { encoding: 'utf8' }) + .then((txt) => { + return JSON.parse(txt) + }) + .then((metadata) => { + function parseBounds(bounds: number[] | string) { + if (_.isString(bounds)) { + return _.map(bounds.split(','), (bound) => parseFloat(_.trim(bound))) + } else if (_.isArray(bounds) && bounds.length === 4) { + return bounds + } else { + return undefined + } + } + const res: ChartProvider = { + _flipY: false, + name: metadata.name || metadata.id, + description: metadata.description || '', + bounds: parseBounds(metadata.bounds), + minzoom: parseIntIfNotUndefined(metadata.minzoom), + maxzoom: parseIntIfNotUndefined(metadata.maxzoom), + format: metadata.format, + type: metadata.type, + scale: parseInt(metadata.scale) || 250000, + identifier: '', + _filePath: '' + } + return res + }) +} + +function parseIntIfNotUndefined(val: string) { + const parsed = parseInt(val) + return _.isFinite(parsed) ? parsed : undefined +} diff --git a/src/constants.js b/src/constants.js deleted file mode 100644 index 5635613..0000000 --- a/src/constants.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - apiRoutePrefix: '/signalk/v1/api/resources' -} \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..e3cd161 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,4 @@ +export const apiRoutePrefix = { + 1: '/signalk/v1/api/resources', + 2: '/signalk/v2/api/resources' +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e76d479 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,438 @@ +import * as bluebird from 'bluebird' +import path from 'path' +import fs from 'fs' +import * as _ from 'lodash' +import { findCharts } from './charts' +import { apiRoutePrefix } from './constants' +import { ChartProvider, OnlineChartProvider } from './types' +import { Request, Response, Application } from 'express' +import { OutgoingHttpHeaders } from 'http' +import { + Plugin, + PluginServerApp, + ResourceProviderRegistry +} from '@signalk/server-api' + +const MIN_ZOOM = 1 +const MAX_ZOOM = 24 + +interface Config { + chartPaths: string[] + onlineChartProviders: OnlineChartProvider[] +} + +interface ChartProviderApp + extends PluginServerApp, + ResourceProviderRegistry, + Application { + statusMessage?: () => string + error: (msg: string) => void + debug: (...msg: unknown[]) => void + setPluginStatus: (pluginId: string, status?: string) => void + setPluginError: (pluginId: string, status?: string) => void + config: { + ssl: boolean + configPath: string + version: string + getExternalPort: () => number + } +} + +module.exports = (app: ChartProviderApp): Plugin => { + let chartProviders: { [key: string]: ChartProvider } = {} + let pluginStarted = false + let props: Config = { + chartPaths: [], + onlineChartProviders: [] + } + const configBasePath = app.config.configPath + const defaultChartsPath = path.join(configBasePath, '/charts') + const serverMajorVersion = app.config.version ? parseInt(app.config.version.split('.')[0]) : '1' + ensureDirectoryExists(defaultChartsPath) + + // ******** REQUIRED PLUGIN DEFINITION ******* + const CONFIG_SCHEMA = { + title: 'Signal K Charts', + type: 'object', + properties: { + chartPaths: { + type: 'array', + title: 'Chart paths', + description: `Add one or more paths to find charts. Defaults to "${defaultChartsPath}"`, + items: { + type: 'string', + title: 'Path', + description: `Path for chart files, relative to "${configBasePath}"` + } + }, + onlineChartProviders: { + type: 'array', + title: 'Online chart providers', + items: { + type: 'object', + title: 'Provider', + required: ['name', 'minzoom', 'maxzoom', 'format', 'url'], + properties: { + name: { + type: 'string', + title: 'Name' + }, + description: { + type: 'string', + title: 'Description' + }, + minzoom: { + type: 'number', + title: `Minimum zoom level, between [${MIN_ZOOM}, ${MAX_ZOOM}]`, + maximum: MAX_ZOOM, + minimum: MIN_ZOOM, + default: MIN_ZOOM + }, + maxzoom: { + type: 'number', + title: `Maximum zoom level, between [${MIN_ZOOM}, ${MAX_ZOOM}]`, + maximum: MAX_ZOOM, + minimum: MIN_ZOOM, + default: 15 + }, + serverType: { + type: 'string', + title: 'Map source / server type', + default: 'tilelayer', + enum: ['tilelayer', 'S-57', 'WMS', 'WMTS', 'mapstyleJSON', 'tileJSON'], + description: + 'Map data source type served by the supplied url. (Use tilelayer for xyz / tms tile sources.)' + }, + format: { + type: 'string', + title: 'Format', + default: 'png', + enum: ['png', 'jpg', 'pbf'], + description: + 'Format of map tiles: raster (png, jpg, etc.) / vector (pbf).' + }, + url: { + type: 'string', + title: 'URL', + description: + 'Map URL (for tilelayer include {z}, {x} and {y} parameters, e.g. "http://example.org/{z}/{x}/{y}.png")' + }, + style: { + type: 'string', + title: 'Vector Map Style', + description: + 'Path to file containing map style definitions for Vector maps (e.g. "http://example.org/styles/mymapstyle.json")' + }, + layers: { + type: 'array', + title: 'Layers', + description: + 'List of map layer ids to display. (Use with WMS / WMTS types.)', + items: { + title: 'Layer Name', + description: 'Name of layer to display', + type: 'string' + } + } + } + } + } + } + } + + const CONFIG_UISCHEMA = {} + + const plugin: Plugin = { + id: 'charts', + name: 'Signal K Charts', + schema: () => CONFIG_SCHEMA, + uiSchema: () => CONFIG_UISCHEMA, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + start: (settings: any) => { + return doStartup(settings) // return required for tests + }, + stop: () => { + app.setPluginStatus('stopped') + } + } + + const doStartup = (config: Config) => { + app.debug('** loaded config: ', config) + props = { ...config } + + const chartPaths = _.isEmpty(props.chartPaths) + ? [defaultChartsPath] + : resolveUniqueChartPaths(props.chartPaths, configBasePath) + + const onlineProviders = _.reduce( + props.onlineChartProviders, + (result: { [key: string]: object }, data) => { + const provider = convertOnlineProviderConfig(data) + result[provider.identifier] = provider + return result + }, + {} + ) + app.debug( + `Start charts plugin. Chart paths: ${chartPaths.join( + ', ' + )}, online charts: ${Object.keys(onlineProviders).length}` + ) + + // Do not register routes if plugin has been started once already + pluginStarted === false && registerRoutes() + pluginStarted = true + const urlBase = `${app.config.ssl ? 'https' : 'http'}://localhost:${ + 'getExternalPort' in app.config ? app.config.getExternalPort() : 3000 + }` + app.debug('**urlBase**', urlBase) + app.setPluginStatus('Started') + + const loadProviders = bluebird + .mapSeries(chartPaths, (chartPath: string) => findCharts(chartPath)) + .then((list: ChartProvider[]) => + _.reduce(list, (result, charts) => _.merge({}, result, charts), {}) + ) + + return loadProviders + .then((charts: { [key: string]: ChartProvider }) => { + app.debug( + `Chart plugin: Found ${ + _.keys(charts).length + } charts from ${chartPaths.join(', ')}.` + ) + chartProviders = _.merge({}, charts, onlineProviders) + }) + .catch((e: Error) => { + console.error(`Error loading chart providers`, e.message) + chartProviders = {} + app.setPluginError(`Error loading chart providers`) + }) + } + + const registerRoutes = () => { + app.debug('** Registering API paths **') + + app.get( + `/signalk/:version(v[1-2])/api/resources/charts/:identifier/:z([0-9]*)/:x([0-9]*)/:y([0-9]*)`, + async (req: Request, res: Response) => { + const { identifier, z, x, y } = req.params + const provider = chartProviders[identifier] + if (!provider) { + return res.sendStatus(404) + } + switch (provider._fileFormat) { + case 'directory': + return serveTileFromFilesystem( + res, + provider, + parseInt(z), + parseInt(x), + parseInt(y) + ) + case 'mbtiles': + return serveTileFromMbtiles( + res, + provider, + parseInt(z), + parseInt(x), + parseInt(y) + ) + default: + console.log( + `Unknown chart provider fileformat ${provider._fileFormat}` + ) + res.status(500).send() + } + } + ) + + app.debug('** Registering v1 API paths **') + + app.get( + apiRoutePrefix[1] + '/charts/:identifier', + (req: Request, res: Response) => { + const { identifier } = req.params + const provider = chartProviders[identifier] + if (provider) { + return res.json(sanitizeProvider(provider)) + } else { + return res.status(404).send('Not found') + } + } + ) + + app.get(apiRoutePrefix[1] + '/charts', (req: Request, res: Response) => { + const sanitized = _.mapValues(chartProviders, (provider) => + sanitizeProvider(provider) + ) + res.json(sanitized) + }) + + // v2 routes + if (serverMajorVersion === 2) { + app.debug('** Registering v2 API paths **') + registerAsProvider() + } + } + + // Resources API provider registration + const registerAsProvider = () => { + app.debug('** Registering as Resource Provider for `charts` **') + try { + app.registerResourceProvider({ + type: 'charts', + methods: { + listResources: (params: { + [key: string]: number | string | object | null + }) => { + app.debug(`** listResources()`, params) + return Promise.resolve( + _.mapValues(chartProviders, (provider) => + sanitizeProvider(provider, 2) + ) + ) + }, + getResource: (id: string) => { + app.debug(`** getResource()`, id) + const provider = chartProviders[id] + if (provider) { + return Promise.resolve(sanitizeProvider(provider, 2)) + } else { + throw new Error('Chart not found!') + } + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setResource: (id: string, value: any) => { + throw new Error(`Not implemented!\n Cannot set ${id} to ${value}`) + }, + deleteResource: (id: string) => { + throw new Error(`Not implemented!\n Cannot delete ${id}`) + } + } + }) + } catch (error) { + app.debug('Failed Provider Registration!') + } + } + + return plugin +} + +const responseHttpOptions = { + headers: { + 'Cache-Control': 'public, max-age=7776000' // 90 days + } +} + +const resolveUniqueChartPaths = ( + chartPaths: string[], + configBasePath: string +) => { + const paths = _.map(chartPaths, (chartPath) => + path.resolve(configBasePath, chartPath) + ) + return _.uniq(paths) +} + +const convertOnlineProviderConfig = (provider: OnlineChartProvider) => { + const id = _.kebabCase(_.deburr(provider.name)) + const data = { + identifier: id, + name: provider.name, + description: provider.description, + bounds: [-180, -90, 180, 90], + minzoom: Math.min(Math.max(1, provider.minzoom), 19), + maxzoom: Math.min(Math.max(1, provider.maxzoom), 19), + format: provider.format, + scale: 250000, + type: provider.serverType ? provider.serverType : 'tilelayer', + style: provider.style ? provider.style : null, + v1: { + tilemapUrl: provider.url, + chartLayers: provider.layers ? provider.layers : null + }, + v2: { + url: provider.url, + layers: provider.layers ? provider.layers : null + } + } + return data +} + +const sanitizeProvider = (provider: ChartProvider, version = 1) => { + let v + if (version === 1) { + v = _.merge({}, provider.v1) + v.tilemapUrl = v.tilemapUrl.replace('~basePath~', apiRoutePrefix[1]) + } else if (version === 2) { + v = _.merge({}, provider.v2) + v.url = v.url ? v.url.replace('~basePath~', apiRoutePrefix[2]) : '' + } + provider = _.omit(provider, [ + '_filePath', + '_fileFormat', + '_mbtilesHandle', + '_flipY', + 'v1', + 'v2' + ]) as ChartProvider + return _.merge(provider, v) +} + +const ensureDirectoryExists = (path: string) => { + if (!fs.existsSync(path)) { + fs.mkdirSync(path) + } +} + +const serveTileFromFilesystem = ( + res: Response, + provider: ChartProvider, + z: number, + x: number, + y: number +) => { + const { format, _flipY, _filePath } = provider + const flippedY = Math.pow(2, z) - 1 - y + const file = _filePath + ? path.resolve(_filePath, `${z}/${x}/${_flipY ? flippedY : y}.${format}`) + : '' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + res.sendFile(file, responseHttpOptions, (err: any) => { + if (err && err.code === 'ENOENT') { + res.sendStatus(404) + } else if (err) { + throw err + } + }) +} + +const serveTileFromMbtiles = ( + res: Response, + provider: ChartProvider, + z: number, + x: number, + y: number +) => { + provider._mbtilesHandle.getTile( + z, + x, + y, + (err: Error, tile: Buffer, headers: OutgoingHttpHeaders) => { + if (err && err.message && err.message === 'Tile does not exist') { + res.sendStatus(404) + } else if (err) { + console.error( + `Error fetching tile ${provider.identifier}/${z}/${x}/${y}:`, + err + ) + res.sendStatus(500) + } else { + headers['Cache-Control'] = responseHttpOptions.headers['Cache-Control'] + res.writeHead(200, headers) + res.end(tile) + } + } + ) +} diff --git a/src/plugin.js b/src/plugin.js deleted file mode 100644 index 7a9b3a4..0000000 --- a/src/plugin.js +++ /dev/null @@ -1,231 +0,0 @@ -const Promise = require('bluebird') -const _ = require('lodash') -const path = require('path') -const fs = require('fs') -const Charts = require('./charts') -const {apiRoutePrefix} = require('./constants') - -const MIN_ZOOM = 1 -const MAX_ZOOM = 19 - -module.exports = function(app) { - let chartProviders = [] - let pluginStarted = false - const configBasePath = app.config.configPath - const defaultChartsPath = path.join(configBasePath, "/charts") - ensureDirectoryExists(defaultChartsPath) - - function start(props) { - const chartPaths = _.isEmpty(props.chartPaths) - ? [defaultChartsPath] - : resolveUniqueChartPaths(props.chartPaths, configBasePath) - const onlineProviders = _.reduce(props.onlineChartProviders, (result, data) => { - const provider = convertOnlineProviderConfig(data) - result[provider.identifier] = provider - return result - }, {}) - app.debug(`Start charts plugin. Chart paths: ${chartPaths.join(', ')}, online charts: ${onlineProviders.length}`) - - const loadProviders = Promise.mapSeries(chartPaths, chartPath => Charts.findCharts(chartPath)) - .then(list => _.reduce(list, (result, charts) => _.merge({}, result, charts), {})) - return loadProviders.then(charts => { - app.debug(`Chart plugin: Found ${_.keys(charts).length} charts from ${chartPaths.join(', ')}`) - chartProviders = _.merge({}, charts, onlineProviders) - // Do not register routes if plugin has been started once already - pluginStarted === false && registerRoutes() - pluginStarted = true - }).catch(e => { - console.error(`Error loading chart providers`, e.message) - chartProviders = {} - }) - } - - function stop() { - } - - function registerRoutes() { - app.get(apiRoutePrefix + '/charts/:identifier/:z([0-9]*)/:x([0-9]*)/:y([0-9]*)', (req, res) => { - const { identifier, z, x, y } = req.params - const provider = chartProviders[identifier] - if (!provider) { - res.sendStatus(404) - return - } - switch (provider._fileFormat) { - case 'directory': - return serveTileFromFilesystem(res, provider, z, x, y) - case 'mbtiles': - return serveTileFromMbtiles(res, provider, z, x, y) - default: - console.error(`Unknown chart provider fileformat ${provider._fileFormat}`) - res.status(500).send() - } - }) - - app.get(apiRoutePrefix + "/charts/:identifier", (req, res) => { - const { identifier } = req.params - const provider = chartProviders[identifier] - if (provider) { - return res.json(sanitizeProvider(provider)) - } else { - return res.status(404).send('Not found') - } - }) - - app.get(apiRoutePrefix + "/charts", (req, res) => { - const sanitized = _.mapValues(chartProviders, sanitizeProvider) - res.json(sanitized) - }) - } - - return { - id: 'charts', - name: 'Signal K Charts', - description: 'Singal K Charts resource', - schema: { - title: 'Signal K Charts', - description: `Add one or more paths to find charts. Defaults to "${defaultChartsPath}"`, - type: 'object', - properties: { - chartPaths: { - type: 'array', - title: 'Chart paths', - items: { - type: 'string', - title: "Path", - description: `Path for chart files, relative to "${configBasePath}"` - } - }, - onlineChartProviders: { - type: 'array', - title: 'Online chart providers', - items: { - type: 'object', - title: 'Provider', - required: ['name', 'minzoom', 'maxzoom', 'format', 'url'], - properties: { - name: { - type: 'string', - title: 'Name' - }, - description: { - type: 'string', - title: 'Description' - }, - minzoom: { - type: 'number', - title: `Minimum zoom level, between [${MIN_ZOOM}, ${MAX_ZOOM}]`, - maximum: MAX_ZOOM, - minimum: MIN_ZOOM, - default: MIN_ZOOM, - }, - maxzoom: { - type: 'number', - title: `Maximum zoom level, between [${MIN_ZOOM}, ${MAX_ZOOM}]`, - maximum: MAX_ZOOM, - minimum: MIN_ZOOM, - default: 15, - }, - serverType: { - type: 'string', - title: 'Server Type', - default: 'tilelayer', - enum: ['tilelayer', 'WMS'] - }, - format: { - type: 'string', - title: 'Format', - default: 'png', - enum: ['png', 'jpg'] - }, - url: { - type: 'string', - title: 'URL', - description: 'Map URL (for tilelayer include {z}, {x} and {y} parameters, e.g. "http://example.org/{z}/{x}/{y}.png")' - }, - layers: { - type: 'array', - title: 'Layers', - description: '(WMS only) ', - items: { - title: 'Layer Name', - description: 'Name of layer to display', - type: 'string' - } - } - } - } - } - } - }, - start, - stop - } -} - - -const responseHttpOptions = { - headers: { - 'Cache-Control': 'public, max-age=7776000' // 90 days - } -} - -function resolveUniqueChartPaths(chartPaths, configBasePath) { - const paths = _.map(chartPaths, chartPath => path.resolve(configBasePath, chartPath)) - return _.uniq(paths) -} - -function convertOnlineProviderConfig(provider) { - const id = _.kebabCase(_.deburr(provider.name)) - return { - name: provider.name, - description: provider.description, - bounds: [-180, -90, 180, 90], - minzoom: Math.min(Math.max(1, provider.minzoom), 19), - maxzoom: Math.min(Math.max(1, provider.maxzoom), 19), - format: provider.format, - scale: 'N/A', - identifier: id, - tilemapUrl: provider.url, - type: (provider.serverType) ? provider.serverType : 'tilelayer', - chartLayers: (provider.layers) ? provider.layers : null - } -} - -function sanitizeProvider(provider) { - return _.omit(provider, ['_filePath', '_fileFormat', '_mbtilesHandle', '_flipY']) -} - -function ensureDirectoryExists (path) { - if (!fs.existsSync(path)) { - fs.mkdirSync(path) - } -} - -function serveTileFromFilesystem(res, provider, z, x, y) { - const {identifier, format, _flipY, _filePath} = provider - const flippedY = Math.pow(2, z) - 1 - y - const file = path.resolve(_filePath, `${z}/${x}/${_flipY ? flippedY : y}.${format}`) - res.sendFile(file, responseHttpOptions, (err) => { - if (err && err.code === 'ENOENT') { - res.sendStatus(404) - } else if (err) { - throw err - } - }) -} - -function serveTileFromMbtiles(res, provider, z, x, y) { - provider._mbtilesHandle.getTile(z, x, y, (err, tile, headers) => { - if (err && err.message && err.message === 'Tile does not exist') { - res.sendStatus(404) - } else if (err) { - console.error(`Error fetching tile ${provider.identifier}/${z}/${x}/${y}:`, err) - res.sendStatus(500) - } else { - headers['Cache-Control'] = responseHttpOptions.headers['Cache-Control'] - res.writeHead(200, headers) - res.end(tile) - } - }) -} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..dd3e7da --- /dev/null +++ b/src/types.ts @@ -0,0 +1,40 @@ +type MapSourceType = 'tilelayer' | 'S-57' | 'WMS' | 'WMTS' | 'mapstyleJSON' | 'tileJSON' + +export interface ChartProvider { + _fileFormat?: 'mbtiles' | 'directory' + _filePath: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _mbtilesHandle?: any + _flipY?: boolean + identifier: string + name: string + description: string + type: MapSourceType + scale: number + v1?: { + tilemapUrl: string + chartLayers: string[] + } + v2?: { + url: string + layers: string[] + } + bounds?: number[] + minzoom?: number + maxzoom?: number + format?: string + style?: string + layers?: string[] +} + +export interface OnlineChartProvider { + name: string + description: string + minzoom: number + maxzoom: number + serverType: MapSourceType + format: 'png' | 'jpg' + url: string + style: string + layers: string[] +} diff --git a/test/charts/unpacked-tiles/metadata.json b/test/charts/unpacked-tiles/metadata.json index f5fd362..a7208c3 100644 --- a/test/charts/unpacked-tiles/metadata.json +++ b/test/charts/unpacked-tiles/metadata.json @@ -10,6 +10,6 @@ "maxzoom": "4", "tilejson": "2.0.0", "scheme": "xyz", - "type": "overlay", + "type": "tilelayer", "description": "CAROLINAS" } diff --git a/test/expected-charts.json b/test/expected-charts.json index 752683a..5c8b180 100644 --- a/test/expected-charts.json +++ b/test/expected-charts.json @@ -6,13 +6,14 @@ -61.875, 31.952162238 ], + "chartLayers": [], "description": "CAROLINAS", "format": "png", "identifier": "test", "maxzoom": 5, "minzoom": 3, "name": "MBTILES_19", - "scale": "250000", + "scale": 250000, "tilemapUrl": "/signalk/v1/api/resources/charts/test/{z}/{x}/{y}", "type": "tilelayer" }, @@ -23,13 +24,14 @@ 25.541777414896345, 66.0258911558707 ], + "chartLayers": [], "description": "Översikt Svenska Sjökort", "format": "png", "identifier": "tms-tiles", "maxzoom": 5, "minzoom": 4, "name": "Översikt Svenska Sjökort", - "scale": "4000000", + "scale": 4000000, "tilemapUrl": "/signalk/v1/api/resources/charts/tms-tiles/{z}/{x}/{y}", "type": "tilelayer" }, @@ -40,13 +42,14 @@ -61.875, 31.952162238 ], + "chartLayers": [], "description": "CAROLINAS", "format": "png", "identifier": "unpacked-tiles", "maxzoom": 4, "minzoom": 3, "name": "NOAA MBTiles test file", - "scale": "250000", + "scale": 250000, "tilemapUrl": "/signalk/v1/api/resources/charts/unpacked-tiles/{z}/{x}/{y}", "type": "tilelayer" } diff --git a/test/plugin-test.js b/test/plugin-test.js index f275407..f9bcfa5 100644 --- a/test/plugin-test.js +++ b/test/plugin-test.js @@ -8,7 +8,7 @@ const chaiHttp = require('chai-http') const Promise = require('bluebird') const express = require('express') const expect = chai.expect -const Plugin = require('../src/plugin') +const Plugin = require('../plugin/index') const expectedCharts = require('./expected-charts.json') chai.use(chaiHttp) @@ -79,7 +79,8 @@ describe('GET /resources/charts', () => { maxzoom: 15, minzoom: 2, name: 'Test Name', - scale: 'N/A', + scale: 250000, + "style": null, tilemapUrl: 'https://example.com', type: 'tilelayer', chartLayers: null @@ -105,6 +106,7 @@ describe('GET /resources/charts', () => { expect(result.status).to.equal(404) }) }) + }) describe('GET /resources/charts/:identifier/:z/:x/:y', () => { @@ -166,6 +168,7 @@ describe('GET /resources/charts/:identifier/:z/:x/:y', () => { }) }) + const expectTileResponse = (response, expectedTilePath, expectedFormat) => { const expectedTile = fs.readFileSync(path.resolve(__dirname, expectedTilePath)) expect(response.status).to.equal(200) @@ -179,6 +182,14 @@ const createDefaultApp = () => { app.use(require('body-parser').json()) app.debug = (x) => console.log(x) app.config = { configPath: path.resolve(__dirname) } + + app.statusMessage = () => 'started' + app.error = (msg) => undefined + app.debug = (...msg) => undefined + app.setPluginStatus = (pluginId, status) => undefined + app.setPluginError = (pluginId, status) => undefined + + return new Promise((resolve, reject) => { const server = http.createServer(app) server.listen(() => { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8987f83 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "outDir": "./plugin", + "esModuleInterop": true, + "strict": true, + "allowJs": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "composite": true, + "rootDir": "src" + }, + "include": ["./src/**/*"], + "exclude": ["node_modules"], + "typedocOptions": { + "mode": "modules", + "out": "tsdocs", + "exclude": ["test", "node_modules"], + "theme": "default", + "ignoreCompilerErrors": true, + "excludePrivate": true, + "excludeNotExported": true, + "target": "ES5", + "moduleResolution": "node", + "preserveConstEnums": true, + "stripInternal": true, + "suppressExcessPropertyErrors": true, + "suppressImplicitAnyIndexErrors": true, + "module": "commonjs" + } +} \ No newline at end of file