Commit 3cff1d76 authored by Anne Blankert's avatar Anne Blankert

mvt cache, previewer, uppercase tablename support

parent e94b2719
node_modules
config/dbconfig.json
public/files
\ No newline at end of file
public/files
cache
\ No newline at end of file
// based on https://raw.githubusercontent.com/tobinbradley/dirt-simple-postgis-http-api/master/routes/bbox.js
// route query
const sqlTableName = require('./utils/sqltablename.js');
const sql = (params, query) => {
return `
SELECT
ST_Extent(ST_Transform(${query.geom_column}, ${query.srid})) as bbox
FROM
${params.table}
${sqlTableName(params.table)}
-- Optional where filter
${query.filter ? `WHERE ${query.filter}` : '' }
......
// based on https://github.com/tobinbradley/dirt-simple-postgis-http-api/blob/master/routes/geobuf.js
// route query
const sqlTableName = require('./utils/sqltablename.js');
const sql = (params, query) => {
let bounds = query.bounds ? query.bounds.split(',').map(Number) : null
bounds && bounds.length === 3
......@@ -20,11 +21,11 @@ const sql = (params, query) => {
${query.columns ? `, ${query.columns}` : ''}
FROM
${params.table}
${sqlTableName(params.table)}
${
bounds
? `, (SELECT ST_SRID(${query.geom_column}) as srid FROM ${
params.table
sqlTableName(params.table)
} LIMIT 1) sq`
: ''
}
......
// based on https://raw.githubusercontent.com/tobinbradley/dirt-simple-postgis-http-api/master/routes/geojson.js
// route query
const sqlTableName = require('./utils/sqltablename.js');
const sql = (params, query) => {
let bounds = query.bounds ? query.bounds.split(',').map(Number) : null;
bounds && bounds.length === 3 ? bounds = merc.bbox(bounds[1], bounds[2], bounds[0]) : null;
......@@ -30,8 +31,8 @@ const sql = (params, query) => {
` : `'{}'::json AS properties`}
FROM
${params.table} AS lg
${bounds ? `, (SELECT ST_SRID(${query.geom_column}) as srid FROM ${params.table} LIMIT 1) sq` : ''}
${sqlTableName(params.table)} AS lg
${bounds ? `, (SELECT ST_SRID(${query.geom_column}) as srid FROM ${sqlTableName(params.table)} LIMIT 1) sq` : ''}
-- Optional Filter
......
// based on https://raw.githubusercontent.com/tobinbradley/dirt-simple-postgis-http-api/master/routes/mvt.js
const sqlTableName = require('./utils/sqltablename.js');
const DirCache = require('./utils/dircache.js')
const cache = new DirCache('./cache/mvt');
const sm = require('@mapbox/sphericalmercator');
const fs = require('fs');
const merc = new sm({
size: 256
})
// route query
let cacheMiddleWare = async(req, res, next) => {
const cacheDir = `${req.params.datasource}/${req.params.z}/${req.params.x}/${req.params.y}`;
const key = (req.query.geom_column?req.query.geom_column:'geom') + req.query.columns?','+req.query.columns:'';
const mvt = await cache.getCachedFile(cacheDir, key);
if (mvt) {
console.log(`cache hit for ${cacheDir}?${key}`);
if (mvt.length === 0) {
res.status(204)
}
res.header('Content-Type', 'application/x-protobuf').send(mvt);
return;
} else {
res.sendResponse = res.send;
res.send = (body) => {
cache.setCachedFile(cacheDir, key, body);
res.sendResponse(body);
}
next();
}
}
const sql = (params, query) => {
let bounds = merc.bbox(params.x, params.y, params.z, false, '900913')
return `
SELECT
ST_AsMVT(q, '${params.table}', 4096, 'geom')
FROM (
SELECT
${query.columns ? `${query.columns},` : ''}
......@@ -23,29 +46,25 @@ const sql = (params, query) => {
bounds[2]
}, ${bounds[3]}))
) geom
FROM (
SELECT
${query.columns ? `${query.columns},` : ''}
${query.geom_column},
srid
FROM
${params.table},
${sqlTableName(params.table)},
(SELECT ST_SRID(${query.geom_column}) AS srid FROM ${
params.table
} LIMIT 1) a
sqlTableName(params.table)
} LIMIT 1) a
WHERE
ST_transform(
ST_MakeEnvelope(${bounds.join()}, 3857),
srid
) &&
${query.geom_column}
-- Optional Filter
${query.filter ? `AND ${query.filter}` : ''}
) r
) q
`
} // TODO, use sql place holders $1, $2 etc. instead of inserting user-parameters into query
......@@ -101,7 +120,7 @@ module.exports = function(app, pool) {
* 422:
* description: invalid datasource or columnname
*/
app.get('/data/:datasource/mvt/:z/:x/:y', async (req, res)=>{
app.get('/data/:datasource/mvt/:z/:x/:y', cacheMiddleWare, async (req, res)=>{
if (!req.query.geom_column) {
req.query.geom_column = 'geom'; // default
}
......
......@@ -24,6 +24,7 @@ const geobuf = require('./geobuf.js')(app, readOnlyPool);
const list_layers = require('./list_layers.js')(app, readOnlyPool);
const layer_columns = require('./layer_columns.js')(app, readOnlyPool);
const bbox = require('./bbox.js')(app, readOnlyPool);
const query = require('./query.js')(app, readOnlyPool);
app.listen(pgserverconfig.port);
console.log(`pgserver listening on port ${pgserverconfig.port}`);
......
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Info</title>
<style>
.canvascontainer {width: 500px; height: auto; border: 1px solid lightblue}
#map {display: inline-block; width: 500px; height: 500px; border: 1 px solid lightblue;}
#info {display: inline-block;}
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.8.0"></script>
<script src='https://api.tiles.mapbox.com/mapbox-gl-js/v1.2.0/mapbox-gl.js'></script>
<link href='https://api.tiles.mapbox.com/mapbox-gl-js/v1.2.0/mapbox-gl.css' rel='stylesheet' />
<script>
"use strict";
let chart1 = null, chart2 = null;
function quantile(arr, p) {
let len = arr.length, id;
if ( p === 0.0 ) {
return arr[ 0 ];
}
if ( p === 1.0 ) {
return arr[ len-1 ];
}
id = ( len*p ) - 1;
// [2] Is the index an integer?
if ( id === Math.floor( id ) ) {
// Value is the average between the value at id and id+1:
return ( arr[ id ] + arr[ id+1 ] ) / 2.0;
}
// [3] Round up to the next index:
id = Math.ceil( id );
return arr[ id ];
}
function niceNumbers (range, round) {
const exponent = Math.floor(Math.log10(range));
const fraction = range / Math.pow(10, exponent);
let niceFraction;
if (round) {
if (fraction < 1.5) niceFraction = 1;
else if (fraction < 3) niceFraction = 2;
else if (fraction < 7) niceFraction = 5;
else niceFraction = 10;
} else {
if (fraction <= 1.0) niceFraction = 1;
else if (fraction <= 2) niceFraction = 2;
else if (fraction <= 5) niceFraction = 5;
else niceFraction = 10;
}
return niceFraction * Math.pow(10, exponent);
}
function getLinearTicks (min, max, maxTicks) {
const range = niceNumbers(max - min, false);
let tickSpacing;
if (range === 0) {
tickSpacing = 1;
} else {
tickSpacing = niceNumbers(range / (maxTicks), true);
}
return {
min: Math.floor(min / tickSpacing) * tickSpacing,
max: Math.ceil(max / tickSpacing) * tickSpacing,
tickWidth: tickSpacing
};
}
let map = null;
function initMap()
{
map = new mapboxgl.Map({
container: 'map',
"style": {
"version": 8,
"name": "DefaultBaseStyle",
"id": "defaultbasestyle",
"glyphs": `https://free.tilehosting.com/fonts/{fontstack}/{range}.pbf?key=`,
"sources": {
"osmgrijs": {
"type":"raster",
"tileSize":256,
"tiles":[
"https://tiles.edugis.nl/mapproxy/osm/tiles/osmgrayscale_EPSG900913/{z}/{x}/{y}.png?origin=nw"
],
"attribution":"&copy; <a href=\"https://www.openstreetmap.org/about\" target=\"copyright\">OpenStreetMap contributors</a>"
}
},
"layers": [
{
"id": "osmgrijs",
"type": "raster",
"source": "osmgrijs"
}
]
}
});
map.on('mousemove', function (e) {
var features = map.queryRenderedFeatures(e.point).map(function(feature){ return {layer: {id: feature.layer.id, type: feature.layer.type}, properties:(feature.properties)};});
document.getElementById('info').innerHTML = JSON.stringify(features.map(feature=>feature.properties), null, 2);
});
}
const colors1 = ["#686868", "#e60000", "#000000", "#faaa00", "#734c00", "#b2b2b2", "#267300", "#beffdc", "#ffff73",
"#a3ff73", "#e9ffbe", "#b4d79e", "#7ed2fc", "#ffffff"];
const colors = ["#14a4ab", "#a9a9a9", "#4e4e4e", "#67ae00", '#febe00', "#e2fe5f", "#ada4fe", '#e8beff', '#d7d79e',
'#0afeb3', '#a83800', '#73dfff','#69d5b4', '#bee8ff', '#97dbf2', '#feee00', '#e9ffbe', '#97dbf2',
'#b1d600', '#73b2ff', '#97dbf2', '#fefac2', '#fe8419', '#7ab6f5', '#000000', '#00e6a9', '#b39d3d',
'#97fe00', '#97fe00', '#ffeabe', '#ae974b', '#97dbf2', '#bed2ff', '#73dfff' ,'#97dbf2', '#ffbebe',
'#ac7a9d']
const greenScheme = [{red:247, green:252, blue:253}, {red:0, green:68, blue:27}];
function hex (i) {
let result = i.toString(16);
if (result.length < 2) {
result = '0' + result;
}
return result;
}
function createRangeColors(classLabels) {
const length = classLabels.length;
const result = [];
if (length > 1) {
const redStep = (greenScheme[0].red - greenScheme[1].red) / (length - 1);
const greenStep = (greenScheme[0].green - greenScheme[1].green) / (length - 1);
const blueStep = (greenScheme[0].blue - greenScheme[1].blue) / (length - 1);
for (let i = 0; i < length; i++) {
result.push('#' + hex(greenScheme[0].red - Math.round(redStep * i)) + hex(greenScheme[0].green - Math.round(greenStep * i)) + hex(greenScheme[0].blue - Math.round(blueStep * i)));
result.push(classLabels[i]);
}
result.push('rgba(0,0,0,1)');
}
return result;
}
function showMapLayer(fullTableName, geomType, attrName, attrType, classLabels) {
let layerType, paint;
const classLabelAndColors = classLabels.reduce((result, key, index)=>{
result.push(key);
result.push(colors[index % colors.length])
return result;
}, []);
const rangeColors = createRangeColors(classLabels);
let styledColors;
if (classLabels.length < 2) {
styledColors = colors[0];
} else {
switch (attrType) {
case 'varchar':
styledColors = [
"match",
["get", attrName],
...classLabelAndColors,
colors[0]
];
break;
case 'numeric':
styledColors = [
"step",
["to-number", ["get", attrName]],
...rangeColors
]
break;
default:
styledColors = [
"step",
["get", attrName],
...rangeColors
]
}
}
switch (geomType) {
case 'MULTIPOLYGON':
case 'POLYGON':
layerType = 'fill';
paint = {
"fill-color": styledColors,
"fill-outline-color": "black",
"fill-opacity": 0.8
}
break;
case 'MULTILINESTRING':
case 'LINESTRING':
layerType = 'line';
paint = {
"line-color": styledColors,
"line-width": 1
}
break;
case 'MULTIPOINT':
case 'POINT':
layerType = 'circle';
paint = {
"circle-radius": 5,
"circle-color": styledColors,
"circle-stroke-color": "black",
"circle-stroke-width" : 1
}
break;
default:
document.querySelector("#layerjson").innerHTML = `Field geom of type: '${geomType}' not supported<br>Supported types: (MULTI-) POINT/LINE/POLYGON<p>`
}
if (layerType){
const baseUrl = new URL(`/data`, window.location.href).href;
const url = `${baseUrl}/${fullTableName}/mvt/{z}/{x}/{y}?columns="${attrName}"`
console.log(`url: ${url}`);
const layer = {
"id": "attrlayer",
"type": layerType,
"source": {
"type": "vector",
"tiles": [url],
},
"source-layer": fullTableName,
"paint": paint,
"filter": ['has', attrName]
}
map.addLayer(layer);
document.querySelector("#layerjson").innerHTML = `<pre>${JSON.stringify(layer, null, 2)}</pre>`;
}
}
function init() {
initMap();
const urlParams = new URLSearchParams(window.location.search);
const fullTableName = urlParams.get('table');
const attrName = urlParams.get("column");
const attrType = urlParams.get("columntype");
const geomType = urlParams.get('geomtype');
document.querySelector('#tablename').innerHTML = fullTableName;
document.querySelector('#columnname').innerHTML = `${attrName} (${attrType})`;
document.querySelector('#back').innerHTML = `<a href="tableinfo.html?table=${fullTableName}">Terug naar layer informatie</a>`
const parts = fullTableName.split('.');
const tableName = (parts.length > 1) ? parts[1] : parts[0];
fetch(`data/query/${fullTableName}?columns=count("${attrName}"),count(distinct+"${attrName}")+as+distinct,min("${attrName}"),max("${attrName}")`).then(response=>{
if (response.ok) {
response.json().then(json=> {
const div = document.querySelector('#attrinfo');
div.innerHTML = `<b>aantal</b>:${json[0].count}<br>
<b>verschillend</b>:${json[0].distinct}<br>
<b>min</b>:${json[0].min}<br>
<b>max</b>:${json[0].max}<br>`;
})
}
})
fetch(`data/query/${fullTableName}?columns="${attrName}"`).then(response=>{
if (response.ok) {
response.json().then(json=> {
let histogram, verdeling;
let histLabels = [], verdLabels = [], classBorders = [];
let arr;
switch(attrType) {
case "varchar":
arr = json
.map(item=>item[attrName])
.filter(value=>value !== null)
.sort()
histogram = new Map();
for (let i = 0; i < arr.length; i++) {
const value = arr[i];
const count = histogram.get(value);
histogram.set(value, count?count+1:1);
}
histLabels = Array.from(histogram.keys());
classBorders = histLabels;
break;
case "int4":
case "int8":
case "float8":
case "numeric":
verdeling = new Map();
histogram = new Map();
arr = json
.map(item=>{
let result = item[attrName];
if (typeof result === "string") {
result = Number(result);
if (isNaN(result)) {
result = undefined;
}
}
return result;
})
.filter(value=>value !== null)
.filter(value=>value !== undefined)
.sort((value1,value2)=>value1-value2)
const sampleInterval = Math.ceil(arr.length / 100);
for (let i = 0; i * sampleInterval < arr.length; i++) {
verdeling.set(i, arr[i*sampleInterval]);
verdLabels.push(`${1 + i * sampleInterval}`);
}
const min = arr[0];
const max = arr[arr.length - 1];
const iqr = quantile(arr, 0.75) - quantile(arr, 0.25);
let bins;
if (max === min) {
bins = 1
} else {
bins = Math.round((max - min) / (2 * (iqr/Math.pow(arr.length, 1/3))));
}
if (bins > 100) {
bins = 100;
}
const linearTicks = getLinearTicks(min, max, bins);
const binwidth = linearTicks.tickWidth;
console.log(`count: ${arr.length}, min: ${min}, max: ${max}, iqr: ${iqr}, bins: ${bins}, binwidth: ${binwidth}
linearTicks.min: ${linearTicks.min}, linearTicks.max: ${linearTicks.max}, linearTicks.tickWidth: ${linearTicks.tickWidth}`)
bins = Math.ceil((linearTicks.max - linearTicks.min) / binwidth);
if (bins < 1) {
bins = 1;
}
for (let i = 0; i < bins; i++) {
histogram.set(i, 0);
const start = Math.round(10000 * (linearTicks.min + i * binwidth)) / 10000;
const end = Math.round(10000 * (linearTicks.min + (i+1) * binwidth)) / 10000;
if (linearTicks.max === linearTicks.min) {
histLabels.push(`${start}`)
classBorders.push(start);
} else {
histLabels.push(`${start} - ${end}`);
classBorders.push(end);
}
}
for (let i = 0; i < arr.length; i++) {
const value = arr[i];
const bin = Math.floor((value - linearTicks.min)/binwidth);
histogram.set(bin, histogram.get(bin) + 1);
}
break;
}
if (histogram) {
const values = Array.from(histogram.values());
const canvas = document.querySelector('#histogram');
const ctx = canvas.getContext('2d');
if (chart1) {
chart1.destroy();
}
chart1 = new Chart(ctx, {
type: 'bar',
data: {
labels: histLabels,
datasets: [{
label: `aantal ${tableName}`,
data: values,
backgroundColor: "blue",
borderColor: "white",
borderWidth: 1
}]
},
options: {
title: {
display: true,
position: 'bottom',
padding: 0,
text: attrName
},
scales: {
yAxes: [{
ticks: {
beginAtZero: true
}
}]
}
}
});
showMapLayer(fullTableName, geomType, attrName, attrType, classBorders);
}
if (verdeling) {
const values = Array.from(verdeling.values());
const canvas = document.querySelector('#verdeling');
const ctx = canvas.getContext('2d');
if (chart2) {
chart2.destroy();
}
chart2 = new Chart(ctx, {
type: 'line',
data: {
labels: verdLabels,
datasets: [{
label: `${attrName}`,
data: values,
backgroundColor: "blue",
borderColor: "white",
borderWidth: 1
}]
},
options: {
title: {
display: true,
position: 'bottom',
padding: 0,
text: tableName
},
scales: {
yAxes: [{
ticks: {
beginAtZero: true
}
}]
}
}
});
}
})
}
})
}
</script>
</head>
<body onload="init()">
<h1>Attribuut-informatie</h1>
<h2 id="tablename"></h2>
<h2 id="columnname"></h2>
<div id="attrinfo"></div>
<div class="canvascontainer"><canvas id="histogram" width=1000 height=500></canvas></div>
<div class="canvascontainer"><canvas id="verdeling" width=1000 height=500></canvas></div>
<div id="map"></div>
<div id="info"></div>
<div id="layerjson"></div>
<div id="back"></div>
</body>
</html>
\ No newline at end of file
......@@ -67,7 +67,7 @@
table.innerHTML = '<tr><th>schema</th><th>name</th><th>geom_column</th><th>srid</th><th>geom_type</th><th>dim</th><th>count</th></tr>' +
layerInfo.map(item=>`<tr>
<td>${item.f_table_schema}</td>
<td><a href="tableinfo.html?table=${item.f_table_schema}.${item.f_table_name}&geom_column=${item.f_geometry_column}&srid=${item.srid}&geom_type=${item.type}&dimensions=${item.coord_dimension}&estimated_rows=${item.estimated_rows}">${item.f_table_name}</a></td>
<td><a href="tableinfo.html?table=${item.f_table_schema}.${item.f_table_name}&geom_column=${item.f_geometry_column}&srid=${item.srid}&geomtype=${item.type}&dimensions=${item.coord_dimension}&estimated_rows=${item.estimated_rows}">${item.f_table_name}</a></td>
<td>${item.f_geometry_column}</td>
<td>${item.srid}</td>
<td>${item.type}</td>
......
const sqlTableName = require('./utils/sqltablename.js');
const sql = (params, query) => {
return `
SELECT
${query.columns}
FROM
${sqlTableName(params.table)}
-- Optional Filter
${query.filter ? `WHERE ${query.filter}` : '' }
-- Optional Group
${query.group ? `GROUP BY ${query.group}` : '' }
-- Optional sort
${query.sort ? `ORDER BY ${query.sort}` : '' }
-- Optional limit
${query.limit ? `LIMIT ${query.limit}` : '' }
`
}
module.exports = function(app, pool) {
/**
* @swagger
*
* /data/query/{table}:
* get:
* description: Query a table or view.
* tags: ['api']
* produces:
* - application/json
* parameters:
* - name: table
* description: The name of the table or view.
* in: path
* required: true
* type: string
* - name: columns
* description: columns to return (default '*')
* in: query
* required: false
* - name: filter
* description: Optional filter parameters for a SQL WHERE statement.
* in: query
* required: false
* type: string
* - name: sort
* description: Optional sort by column(s).
* in: query
* required: false
* type: string
* - name: limit
* description: Optional limit to the number of output features.
* in: query
* required: false
* type: integer
* - name: group
* description: Optional column(s) to group by.
* in: query
* required: false
* type: string
* responses:
* 200:
* description: query result
* 422:
* description: invalid table or column name
*/
app.get('/data/query/:table', async (req, res)=> {
const sqlString = sql(req.params, req.query);
try {
const result = await pool.query(sqlString);
res.json(result.rows);
} catch (err) {
res.status(422).json({error: err});
}
})
}
......@@ -23,7 +23,7 @@ const swaggerDefinition = {
const swaggerJSDocOptions = {
swaggerDefinition,
apis: ['./login.js', './mvt.js', './list_layers.js', './layer_columns.js', './bbox.js', './geojson.js', './geobuf.js']
apis: ['./login.js', './mvt.js', './list_layers.js', './layer_columns.js', './bbox.js', './geojson.js', './geobuf.js', './query.js']
}
const swaggerSpec = swaggerJSDoc(swaggerJSDocOptions);
......
const fs = require('fs')
const path = require('path');
module.exports = class DirCache {
constructor(cacheRoot) {
if (!cacheRoot) {
this.cacheRoot = path.resolve('./cache/mvt')
} else {
this.cacheRoot = cacheRoot;
}
}
getCachedFile(dir, key) {
const file = path.join(this.cacheRoot, dir, key);
return new Promise((resolve, reject)=>{
fs.readFile(file, (err, data) => {
if (err) {
resolve(null);
} else {
resolve(data)
}
})
})
}
setCachedFile(dir, key, data) {
const dirName = path.join(this.cacheRoot, dir);
const filePath = path.join(dirName, key);
console.log(`cache dirname: ${dirName}`);
console.log(`filePath: ${filePath}`);
fs.mkdirSync(dirName, {recursive: true});
return new Promise((resolve, reject) => {
fs.writeFile(filePath, data, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
})
})
}
}
function cacheDirName(params) {
return `${path.dirname(__dirname)}/cache/mvt/${params.table}/${params.z}/${params.x}/${params.y}`
}
function cacheFileName(query) {
if (query.columns) {
return query.columns;
}
return 'noquery';
}
function getCache(params, query) {
const dirname = cacheDirName(params);
const filename = cacheFileName(query);
console.log(`getCache: ${dirname}`);
return fsPromises.readFile(`${dirname}/${filename}`)
.then(data=>data)
.catch(error=>null);
}
function setCache(params, query, data) {
const dirname = cacheDirName(params);
const filename = cacheFileName(query);
console.log(`setCache: ${dirname}`);
return fsPromises.writeFile(`${dirname}/${filename}`, data)
.then(() => {return})
.catch(err=>err);
}
function lockCache(params, query) {
const dirname = cacheDirName(params);
const filename = cacheFileName(query);
fs.mkdirSync(dirname, {recursive: true});
return fsPromises.writeFile(`${dirname}/${filename}.lck`, 'lock', {flag: 'wx'})
.then(()=>{
return true
})
.catch(err=>{
return fsPromises.stat(`${dirname}/${filename}.lck`)
.then(st=>{
console.log(Date.now() - st.ctimeMs);
if (Date.now() - st.ctimeMs > 240000) {
return unlockCache(params,query).then(()=>lockCache(params,query));
} else {
return false;
}
})
.catch(err=>{
console.log(err);
return false;
});
});
}
function unlockCache(params, query){
const dirname = cacheDirName(params);
const filename = cacheFileName(query);
return fsPromises.unlink(`${dirname}/${filename}.lck`)
.then(()=>true)
.catch(err=>{
console.log(`unlockCache: error: ${err}`);
return false;
})
}
function wait(ms) {
return new Promise((r, j)=>setTimeout(r, ms));
}
async function waitForCache(params, query) {
const dirname = cacheDirName(params);
const filename = cacheFileName(query);
for (let i = 0; i < 180; i++) {
console.log(`waiting for cache.. ${i}`);
await wait(1000);
data = await getCache(params, query);
if (data) {
console.log(`cache wait done.. ${i}`)
return data;
}
}
console.log(`cache wait failed`);
return null;
}
\ No newline at end of file
module.exports = function(tableName) {
return tableName
.split('.')
.map(part=>{
if (part.search(/^[0-9]|[A-Z]|\s/) === -1) {
return part;
}
let newPart = part;
if (part.search('"') !== -1) {
newPart = part.replace(/"/g, '\"')
}
return `"${newPart}"`
})
.join('.')
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment