agmission/Development/server/controllers/export.js

1304 lines
50 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
module.exports = function (app) {
const
debug = require('debug')('agm:export'),
ObjectId = require('mongodb').ObjectId,
moment = require('moment'),
Job = require('../model/job'),
JobAssign = require('../model/job_assign'),
jobUtil = require('../helpers/job_util'),
Obstacle = require('../model/obstacles'),
async = require('async'),
path = require('path'),
fs = require('fs-extra'),
shpWrite = require('shp-write'),
util = require('util'),
archiver = require('archiver'),
xml2js = require('xml2js'),
uniqid = require('uniqid'),
turf = require('@turf/turf'),
fileHelper = require('../helpers/file_helper'),
urlHelper = require('../helpers/url_helper'),
utils = require('../helpers/utils'),
bufUtil = require('../helpers/line_buffer'),
polyUtil = require('../helpers/poly_util'),
geoUtil = require('../helpers/geo_util'),
webUtil = require('../helpers/web_util'),
{ Errors, UserTypes, AssignStatus, RateUnits } = require('../helpers/constants'),
{ AppParamError, AppError } = require('../helpers/app_error'),
lineUtil = require('../helpers/gridline_util'),
{ version } = require('../package.json'),
env = require('../helpers/env'),
cloneDeep = require('clone-deep'),
_ = require('lodash');
require('string-format-js');
const parserXMLAsync = util.promisify(xml2js.Parser().parseString);
const writeWorldFileAync = util.promisify(writeWorldFile);
const makeDownloadItemsAsync = util.promisify(makeDownloadItems);
const sendArchiveAsync = util.promisify(sendArchive);
const removeTempFilesAsync = util.promisify(removeTempFiles);
// Line identification number for NO1 file
const NO1_TITLE = 0; // title
const NO1_CENTRAL_MERIDIAN = 1; // central meridian
const NO1_CORNER = 2; // area corner points
const NO1_WAYPOINT = 3; // way points line
const NO1_NUMBER_OF_LINES = 4; // number of lines
const NO1_MAX_CROSS_TRACK = 8; // max cross track
const NO1_DELTA_X_Y_Z = 9; // delta x, y, z (LL to UTM conversion)
const NO1_K0_XY_SHIFT = 11; // K0, x/y shift (LL to UTM conversion)
// const NO1_1ST_LINE = 16; // 1st line number
const NO1_MASTER_POINT_HEADING = 17; // master point, heading
const NO1_ELLIPSOID = 20; // ellipsoid
const NO1_EQUATORIAL_CROSSING = 21; // equatorial crossing
// const NO1_DIR_RAMDISK_SPRAYSIDE = 35; // project directory, ramdisk, and spray side
const NO1_SWATHWIDTH_AC_INSIDE_AREA = 36; // swath width, number of A/C, inside area
const NO1_HALF_SWATH_OFFFSET_1ST_TIME = 37; // half swath offset on 1st line(1=YES, 0=NO)
const NO1_DISPLAY_UNIT = 38; // display units (1=US, 2=metric)
// const NO1_RACE_SKIP = 39; // race and skip track
const NO1_MAGNETIC_VARIATION = 40; // magnetic variation (degrees)
const NO1_SYSETEM_LAG = 41; // system lag (seconds)
const NO1_RELAY_ON_OFF = 42; // Relay ON/OFF (seconds), turn Relay ON/OFF early
const NO1_PATTERN = 43; // flight pattern
const NO1_EXPAND_LEFT_RIGHT = 44; // for expand spray pattern, indicates expand direction (1:left, 0: right)
const PRJ_ZONE_NAME = 62; // Zone name
const AGN_APPLICATION_RATE = 63; // Application rate for flow controller
const NO1_SWATHWIDTH_SEGMENTS = 202; // 4 swath widths for each segment
const NO1_AREATYPE = 203; // Area Type 1: ABLINE, 65536: SPLIT BDY
const MAX_MAP_DIMEN = 4 * 1024; // Maximum map dimention (in pixels)
async function anyJob_post(req, res) {
const userId = req.uid;
if (!utils.isObjectId(userId)) return res.json({ total: 0 });
const data = await JobAssign.countDocuments({ user: userId, status: 0 });
res.json({ total: data || 0 });
}
async function newJobs_post(req, res) {
const userId = req.uid;
if (!utils.isObjectId(userId)) return res.json([]);
const data = await JobAssign.find({ user: ObjectId(userId), status: 0 })
.populate('job', 'name startDate endDate')
.select('-_id date')
.lean();
res.json(data ? data : []);
}
async function downloadJob_post(req, res) {
if (!req.body.jobId) AppParamError.throw();
// Get job's download options to go
const job = await Job.findById(req.body.jobId, 'dlOp', { lean: true });
if (!job) AppError.throw(Errors.JOB_NOT_FOUND);
if (job.dlOp && job.dlOp.mapOp) {
const newReq = cloneDeep(req);
newReq.body['mapOps'] = job.dlOp.mapOp;
await downloadJobwMap(newReq, res);
} else {
await downloadJob(req, res);
}
}
async function downloadJob(req, res) {
const params = req.body;
await jobUtil.isJobAssignedToVehicle(req.uid, req.ut, params.jobId);
const items = await makeDownloadItemsAsync(req);
await sendArchiveAsync(items, res);
await writeJobLog(params.jobId, 2, req.uid, req.ut);
}
function sendArchive(items, res, cb) {
// Add items to archive then pipe to response, end response when done
const archive = archiver('zip', { zlib: { level: 9 } });
archive.on('end', () => {
res.end();
cb && cb();
});
archive.on('error', function (err) {
debug(err.stack);
cb && cb(err);
});
archive.pipe(res);
if (!utils.isEmptyArray(items)) {
for (const item of items) {
if (item.content)
archive.append(item.content, item.meta);
if (item.file)
archive.file(item.file, item.meta);
}
}
archive.finalize();
}
function makeDownloadItems(req, cb) {
const params = req.body;
// params = { jobId: 15, type: 1 }; // 1:'AGNAV', 2: AgNav Prj, 3:'SHP' (,4:'KML', 5:'KMZ' not yet)
// debug('Request params: ', util.inspect(job, false, null));
let dlType = params.type || 1; // Default is AGNAV type
let outFileName = '', sprayAreas = [], xclAreas = [], xclItems = [], _job, xyzFile, xclContent, archiveItems = [], numOfLines = -1;
async.series([
function (callback) {
Job.findById(params.jobId)
.populate({ path: 'client', model: UserTypes.CLIENT, select: { '_id': 0, 'name': 1 } })
.populate({ path: 'operator', model: UserTypes.PILOT, select: { '_id': 0, 'name': 1 } })
.populate({ path: 'vehicle', model: UserTypes.DEVICE, select: { '_id': 0, 'name': 1 } })
.populate('products.product', 'name')
.then(job => {
if (!job) {
callback(AppError.create(Errors.JOB_NOT_FOUND));
return;
} else {
return Job.populate(job, { path: 'crop', select: 'name', skipInvalidIds: true });
}
})
.then(job => {
_job = job.toObject();
// Override with selected download option
if (req.ut === UserTypes.DEVICE) {
dlType = _job.dlOp.type || 1;
}
outFileName = _job.name;
callback();
})
.catch(err => {
callback(err);
});
},
function (callback) {
const pipeline = [
{
"$match": { _id: params.jobId }
},
{ $unwind: "$sprayAreas" },
{
$lookup: {
from: "area_lines",
localField: "sprayAreas._id",
foreignField: "areaId",
as: "area_lines"
}
},
{ $unwind: { "path": "$area_lines", "preserveNullAndEmptyArrays": true } },
{
$project: {
"_id": "$_id",
"sprayAreas": {
"_id": "$sprayAreas._id", "geometry": "$sprayAreas.geometry", "properties": "$sprayAreas.properties",
'masterPoint': '$area_lines.masterPoint',
"latlngHeading": '$area_lines.latlngHeading', "heading": '$area_lines.heading',
"lines": {
$cond: { if: '$area_lines.lines', then: "$area_lines.lines", else: [] }
},
"mems": {
$cond: { if: '$area_lines.mems', then: "$area_lines.mems", else: [] }
},
}
}
},
{
"$group": {
"_id": "$_id", "sprayAreas": { $push: "$sprayAreas" }
}
}
];
Job.aggregate(pipeline, (err, result) => {
if (err) return callback(err);
if (result && result.length)
_job.sprayAreas = result[0].sprayAreas; // Replace the sprayAreas with the ones combines with grid lines related info
callback();
});
},
function (callback) {
sprayAreas = _job.sprayAreas;
if (utils.isEmptyArray(sprayAreas)) return callback(AppError.create(Errors.NO_SPRAY_AREA));
// Use the reusable helper function to process buffers to XCL areas
xclAreas = jobUtil.processBuffersToXclAreas(_job);
// Make sure spray areas always in clockwise order
for (const area of sprayAreas) {
let coors = area.geometry.coordinates[0];
if (!polyUtil.isClockwise(coors))
area.geometry.coordinates[0] = coors.reverse();
}
callback();
},
function (callback) {
if (dlType > 1) { // Generate xyz file for all combined areas options: PRJ or SHAPE
numOfLines = 0;
const rootArea = findRootArea(sprayAreas, true);
if (!rootArea) return callback();
// Heading to generate is in UTM, write to file in LL
const heading = utils.isNumber(rootArea.heading) ? rootArea.heading : 0.0;
lineUtil.getLinesLatLng(_job, null, true, 0, 0, heading, null, { useGroup: false, regenerate: true })
.then(linesRes => {
if (!utils.isEmptyArray(linesRes.lines) && !utils.isEmptyArray(linesRes.lines[0].lines)) {
numOfLines = linesRes.lines[0].lines.length;
const xyzContent = linesToXYZ(linesRes.lines[0].lines);
if (xyzContent)
archiveItems.push({ content: xyzContent, meta: { name: outFileName + '.xyz' } });
}
callback();
})
.catch(err => callback(err));
}
else callback();
},
function (callback) {
if (dlType === 1) { // AgNav
if (sprayAreas.length === 1) {
if (sprayAreas[0].properties && sprayAreas[0].properties.name)
outFileName = sprayAreas[0].properties.name;
const no1Content = writeAGNorN01orPRJ(_job, outFileName, sprayAreas, numOfLines, 1);
archiveItems.push({ content: no1Content, meta: { name: outFileName + '.no1' } });
xclContent = writeDSPorXCL(xclAreas);
if (xclContent)
archiveItems.push({ content: xclContent, meta: { name: outFileName + '.xcl' } });
xyzFile = writeAreasXYZ(sprayAreas, outFileName + '.xyz');
if (!utils.isEmptyArray(xyzFile))
archiveItems = archiveItems.concat(xyzFile);
}
else {
// Export No1 files for each spray zone, xcl not releated to any spray zone will be added to the first no1's xcl
// split spray area into seperated items a long with its xcls, then write these as no1+dsp+xcl files
const processXclAreas = xclAreas.slice(0);
const entries = [];
let sprArea, xclArea;
for (let i = 0; i < sprayAreas.length; i++) {
const xcls = [];
sprArea = sprayAreas[i];
let idx = processXclAreas.length - 1;
while (processXclAreas.length && idx >= 0) {
xclArea = processXclAreas[idx];
if (polyUtil.polygonWithin(xclArea.geometry.coordinates, sprArea.geometry.coordinates)
|| polyUtil.polygonIntersect(sprArea.geometry.coordinates, xclArea.geometry.coordinates)) {
xcls.push(xclArea);
processXclAreas.splice(idx, 1);
}
idx--;
}
entries.push({ area: sprArea, xcls: xcls });
}
// The rest of the unrelated xcls (if any) assign to the first spray zone
if (entries.length && processXclAreas && processXclAreas.length) {
if (entries[0].xcls)
entries[0].xcls = entries[0].xcls.concat(processXclAreas);
else
entries[0].xcls = processXclAreas;
}
// Now, create no1 file archive entries
const entriesByName = _.groupBy(entries, it => (it.area.properties && it.area.properties.name) ? it.area.properties.name.toLowerCase() : '');
let nameIdx = 1;
for (let entryName in entriesByName) {
xclItems.length = 0;
const groupEntry = entriesByName[entryName];
outFileName = groupEntry[0].area.properties.name;
if (!outFileName)
outFileName = `Spray_${nameIdx++}`;
if (groupEntry.length === 1) { // No1
const no1Content = writeAGNorN01orPRJ(_job, outFileName, [groupEntry[0].area], numOfLines, 1);
if (no1Content) {
archiveItems.push({ content: no1Content, meta: { name: outFileName + '.no1' } });
if (!utils.isEmptyArray(groupEntry[0].xcls))
xclItems = xclItems.concat(groupEntry[0].xcls);
xyzFile = writeAreasXYZ([groupEntry[0].area], outFileName + '.xyz');
if (!utils.isEmptyArray(xyzFile))
archiveItems = archiveItems.concat(xyzFile);
}
} else { // Prj
let sprayItems = []; xclItems = [];
for (const it of groupEntry) {
sprayItems.push(it.area);
if (!utils.isEmptyArray(it.xcls))
xclItems = xclItems.concat(it.xcls);
}
const prjContent = writeAGNorN01orPRJ(_job, outFileName, sprayItems, numOfLines, 2, true);
const dspContent = writeDSPorXCL(sprayItems);
const vfrContent = writeVFR(sprayItems, _job.appRate);
archiveItems = archiveItems.concat([
{ content: prjContent, meta: { name: outFileName + '.prj' } },
{ content: dspContent, meta: { name: outFileName + '.dsp' } },
{ content: vfrContent, meta: { name: outFileName + '.vfr' } }]);
xyzFile = writeAreasXYZ(sprayItems, outFileName + '.xyz');
if (!utils.isEmptyArray(xyzFile))
archiveItems = archiveItems.concat(xyzFile);
}
const xclContent = writeDSPorXCL(xclItems);
if (xclContent)
archiveItems.push({ content: xclContent, meta: { name: outFileName + '.xcl' } });
}
}
callback();
}
else if (dlType === 2) { // AgNav Prj
const prjContent = writeAGNorN01orPRJ(_job, outFileName, sprayAreas, numOfLines, 2, true, true);
const dspContent = writeDSPorXCL(sprayAreas);
const vfrContent = writeVFR(sprayAreas, _job.appRate);
archiveItems = archiveItems.concat([
{ content: prjContent, meta: { name: outFileName + '.prj' } },
{ content: dspContent, meta: { name: outFileName + '.dsp' } },
{ content: vfrContent, meta: { name: outFileName + '.vfr' } }]);
// XCL for the case of not combined, No1 or Prj
xclContent = writeDSPorXCL(xclAreas);
if (xclContent)
archiveItems.push({ content: xclContent, meta: { name: outFileName + '.xcl' } });
callback();
}
else if (dlType === 3) {
const items = [];
items.push(sprayAreas);
if (xclAreas.length > 0)
items.push(xclAreas);
const agnContent = writeAGNorN01orPRJ(_job, outFileName, sprayAreas, numOfLines, 3);
if (agnContent)
archiveItems.push({ content: agnContent, meta: { name: outFileName + '.agn' } });
writeShapeFile(_job, items, 1, outFileName, (err, items) => {
if (err) return callback(err);
if (!utils.isEmptyArray(items))
archiveItems = archiveItems.concat(items);
callback();
});
}
else callback();
},
function (callback) {
if (dlType === 3 && _job.waypoints.length > 0) {
writeShapeFile(_job, _job.waypoints, 2, outFileName + 'wpt', (err, items) => {
if (err) return callback(err);
if (!utils.isEmptyArray(items))
archiveItems = archiveItems.concat(items);
callback();
});
}
else
callback();
},
function (callback) {
const info = writeJobInfo(_job);
if (info)
archiveItems.push({ content: info, meta: { name: 'job.json' } });
callback();
}
], function (err) {
if (err) {
debug(params);
return cb(err);
}
return cb(null, archiveItems);
});
}
/**
* Write Job items to a shape file and return a list of archive entries contain the shp file items
*
* @param {*} job the job object for additional info only (must not be modified)
* @param {*} items items list
* @param {*} type 1: polygons, 2: waypoints
* @param {*} outFileName The base file name
* @param {*} cb Callback function
*/
function writeShapeFile(job, items, type, outFileName, cb) {
if (utils.isEmptyArray(items)) return cb();
const points = [];
const featureData = [];
let archiveItems = [];
if (type === 1) { // Area shape file
let sprayAreas = [], xclAreas = [], coors = [], name;
if (items.length > 1)
xclAreas = items[1] || [];
sprayAreas = items[0] || [];
for (let i = 0; i < sprayAreas.length; i++) {
const sprArea = sprayAreas[i];
const sprayWithchildCoors = [];
let rate = Number(sprArea.properties.appRate) || 0;
if (!rate) rate = job.appRate;
name = sprArea.properties.name || `${i + 1}`;
coors = sprArea.geometry.coordinates[0];
sprayWithchildCoors.push(coors);
featureData.push({ BLOCKID: name, XCL: 'N', RATE: rate.toFixed(2), Version: version }); // Dbf record
if (!utils.isEmptyArray(xclAreas)) {
let j = xclAreas.length - 1;
while (xclAreas.length && j >= 0) {
const xclArea = xclAreas[j];
if (polyUtil.polygonWithin(xclArea.geometry.coordinates, sprArea.geometry.coordinates)
|| polyUtil.polygonIntersect(sprArea.geometry.coordinates, xclArea.geometry.coordinates)) {
coors = xclArea.geometry.coordinates[0];
if (polyUtil.isClockwise(coors))
coors = coors.reverse();
sprayWithchildCoors.push(coors);
xclAreas.splice(j, 1);
}
j--;
}
}
// Same-name Spray areas and related xcls (holes be written to the same shape record (Multipolygon))
points.push([sprayWithchildCoors]);
}
// Process the rest of standalone xcl items as poly with XCL = 'Y'
if (!utils.isEmptyArray(xclAreas)) {
for (let m = 0; m < xclAreas.length; m++) {
const xclArea = xclAreas[m];
featureData.push({ BLOCKID: xclArea.properties.name || '', XCL: 'Y', RATE: 0, VERSION: version, });
coors = xclArea.geometry.coordinates[0];
if (!polyUtil.isClockwise(coors)) {
coors = coors.reverse();
}
points.push([coors]);
}
}
}
else if (type === 2) { // WayPoint shape file
// Process points for waypoint items
for (const item of items) {
featureData.push({ NAME: item.properties.name || '', VERSION: version });
if (item.geometry && item.geometry.coordinates)
points.push(item.geometry.coordinates);
}
}
if (utils.isEmptyArray(points)) return cb();
shpWrite.write(
// feature data
featureData.length ? featureData : [{ Version: version, Name: '' }],
// geometry type
type === 1 ? 'POLYGON' : 'POINT',
// geometries
points,
function (err, files) {
if (err)
return cb(err);
archiveItems = [
{ content: utils.ArrayBuffertoBuffer(files.shp.buffer), meta: { name: outFileName + '.shp' } },
{ content: utils.ArrayBuffertoBuffer(files.shx.buffer), meta: { name: outFileName + '.shx' } },
{ content: utils.ArrayBuffertoBuffer(files.dbf.buffer), meta: { name: outFileName + '.dbf' } },
{ content: writeShapePRJ(), meta: { name: outFileName + '.prj' } }];
return cb(null, archiveItems);
});
}
function writeShapePRJ() {
return 'GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]]';
}
function writeJobInfo(job) {
if (!job) return null;
const _job = {
_id: job._id, name: job.name, orderNumber: job.orderNumber, measureUnit: job.measureUnit, swathWidth: job.swathWidth,
appRate: job.appRate, appRateUnit: job.appRateUnit, startDate: job.startDate, endDate: job.endDate, flightNumber: job.flightNumber,
crop: (job.crop && job.crop['_id']) ? job.crop['name'] : job.crop, farm: job.farm, remark: job.remark
};
if (!utils.isEmptyArray(job.products)) {
_job.product = { name: job.products.map(p => p.product.name).join("/") };
}
if (job.operator) _job.operator = { name: job.operator.name };
if (job.client) _job.client = { name: job.client.name, address: job.client.address };
if (job.vehicle) _job.vehicle = { name: job.vehicle.name };
try {
return JSON.stringify(_job, null, 4);
} catch (error) {
debug(error);
return null;
}
}
function writeAreasXYZ(sprayAreas, fileName) {
if (utils.isEmptyArray(sprayAreas)) return null;
const items = [];
if (sprayAreas.length === 1) {
if (sprayAreas[0].lines.length)
items.push({ content: linesToXYZ(sprayAreas[0].lines), meta: { name: fileName } });
}
else {
const selLines = sprayAreas.map(a => a.lines);
const allLines = [].concat.apply([], selLines);
items.push({ content: linesToXYZ(allLines), meta: { name: fileName } });
}
return items;
}
function linesToXYZ(lines) {
const content = [];
let llUtm = new app.locals.LatLonUTM(0, 0), line, utm;
for (let i = 0; i < lines.length; i++) {
line = lines[i];
if (!line.length) continue;
content.push("LINE " + ((i + 1) * 10).toString());
for (let m = 1; m < line.length; m += 2) {
// Validate coordinates before processing
if (!line[m - 1] || line[m - 1].length < 2 || !line[m] || line[m].length < 2) {
continue;
}
const lat1 = Number(line[m - 1][0]);
const lon1 = Number(line[m - 1][1]);
const lat2 = Number(line[m][0]);
const lon2 = Number(line[m][1]);
if (!isFinite(lat1) || !isFinite(lon1) || !isFinite(lat2) || !isFinite(lon2)) {
continue;
}
llUtm.lat = lat1; llUtm.lon = lon1;
utm = llUtm.toUtm();
const easting1 = utils.fixedTo(utm.easting, 3);
const northing1 = utils.fixedTo(utm.northing, 3);
if (!isFinite(easting1) || !isFinite(northing1)) {
continue;
}
content.push('%-.3f %-.3f 1 0'.format(easting1, northing1));
llUtm.lat = lat2; llUtm.lon = lon2;
utm = llUtm.toUtm();
const easting2 = utils.fixedTo(utm.easting, 3);
const northing2 = utils.fixedTo(utm.northing, 3);
if (!isFinite(easting2) || !isFinite(northing2)) {
continue;
}
content.push('%-.3f %-.3f 2 0'.format(easting2, northing2));
}
}
return content.join('\r\n');
}
function writeDSPorXCL(areas) {
const content = [];
if (utils.isEmptyArray(areas))
return null;
let areaNum = 1;
for (const area of areas) {
if (area.geometry.coordinates && area.geometry.coordinates[0].length >= 3) {
let coors = area.geometry.coordinates[0];
if (!polyUtil.isClockwise(coors))
coors = coors.reverse();
for (const vertex of coors) {
// Validate vertex coordinates
const lat = Number(vertex[1]);
const lon = Number(vertex[0]);
if (!isFinite(lat) || !isFinite(lon)) {
continue; // Skip invalid coordinates
}
content.push('%-3d %.6f %.6f'.format(areaNum, lat, lon));
}
areaNum = areaNum + 1;
}
}
return content.join('\r\n');
}
function writeVFR(areas, jobAppRate) {
let content = [];
if (utils.isEmptyArray(areas))
return null;
let areaNum = 1;
for (const area of areas) {
if (area.geometry.coordinates && area.geometry.coordinates[0].length >= 3) {
for (const vertex of area.geometry.coordinates[0]) {
if (vertex && vertex.length >= 2) {
// Validate vertex coordinates
const lat = Number(vertex[1]);
const lon = Number(vertex[0]);
if (!isFinite(lat) || !isFinite(lon)) {
continue; // Skip invalid coordinates
}
// Validate application rate
const areaAppRate = (area.properties && area.properties.appRate) ? area.properties.appRate : jobAppRate;
const appRate = Number(areaAppRate) || 0;
if (!isFinite(appRate)) {
continue; // Skip invalid app rate
}
const fixedAppRate = utils.fixedTo(appRate, 2);
if (!isFinite(fixedAppRate)) {
continue; // Skip if fixedTo returns invalid value
}
content.push('%-3d %.6f %.6f %.2f'.format(areaNum, lat, lon, fixedAppRate));
}
}
areaNum = areaNum + 1;
}
}
return content.join('\r\n');
}
/**
* Write job items to an AGNAV file (No1, Prj, AGN)
*
* @param {*} job the job object
* @param {*} title title for line 0 (NO1_TITLE)
* @param {*} sprayAreas area list to write to the file
* @param {*} type: output file formats. 1: No1, 2: Prj, 3: AGN
*/
function writeAGNorN01orPRJ(job, name, sprayAreas, numOfLines, type, findRoot = false, firstRoot = false) {
let content = [];
if (!job || utils.isEmptyArray(sprayAreas))
return null;
let itemNum = 0;
// First vertex of the first area [long, lat]
const firstVertex = sprayAreas[0].geometry.coordinates[0][0];
// Validate firstVertex before computing areaCM
if (!firstVertex || firstVertex.length < 2 || !isFinite(Number(firstVertex[0]))) {
return null; // Cannot proceed without valid coordinates
}
const areaCM = geoUtil.computeCMfromLon(firstVertex[0]);
// Validate areaCM before using it
if (!isFinite(areaCM)) {
return null; // Cannot proceed without valid central meridian
}
const title = name ? name : 'Untitled Area';
content.push('%-2d NEW AREA FILE %-61s'.format(NO1_TITLE, title));
content.push('%-2d %-2s %d'.format(NO1_CENTRAL_MERIDIAN, 'L', areaCM));
let zoneNames = [];
if (type === 1) {
const no1Area = sprayAreas[0];
zoneNames.push(no1Area.properties.name);
itemNum = 1;
for (const vertex of no1Area.geometry.coordinates[0]) {
// Validate vertex coordinates
const lat = Number(vertex[1]);
const lon = Number(vertex[0]);
if (!isFinite(lat) || !isFinite(lon)) {
continue; // Skip invalid coordinates
}
content.push('%-2d %-11.6f %-11.6f AREA CORNER %d'.format(NO1_CORNER, lat, lon, itemNum));
itemNum = itemNum + 1;
}
}
else if (type === 2) {
let features = [];
for (const area of sprayAreas) {
area['type'] = 'Feature'; // Add the required property for GeoJSON object
// area = turf.truncate(area, { precision: 6});
features.push(area);
}
const featureCollection = {
'type': 'FeatureCollection',
'features': features
};
let areasBbox;
try {
areasBbox = turf.bbox(featureCollection); // bbox extent in [ minX, minY, maxX, maxY ] order
} catch (error) { }
if (areasBbox) {
// Validate bbox coordinates before using them
const validBbox = areasBbox.every(coord => isFinite(Number(coord)));
if (!validBbox) {
// Skip bbox processing if any coordinate is invalid
} else {
// Corners of the bounds
content.push('%-2d %-11.6f %-11.6f AREA CORNER %d'.format(NO1_CORNER, areasBbox[1], areasBbox[0], 1));
content.push('%-2d %-11.6f %-11.6f AREA CORNER %d'.format(NO1_CORNER, areasBbox[3], areasBbox[0], 2));
content.push('%-2d %-11.6f %-11.6f AREA CORNER %d'.format(NO1_CORNER, areasBbox[3], areasBbox[2], 3));
content.push('%-2d %-11.6f %-11.6f AREA CORNER %d'.format(NO1_CORNER, areasBbox[1], areasBbox[2], 4));
}
}
}
if (type <= 2 && job.waypoints) {
itemNum = 1;
for (const wpt of job.waypoints) {
// Validate waypoint coordinates
if (!wpt.geometry || !wpt.geometry.coordinates || wpt.geometry.coordinates.length < 2) {
continue;
}
const lat = Number(wpt.geometry.coordinates[1]);
const lon = Number(wpt.geometry.coordinates[0]);
if (!isFinite(lat) || !isFinite(lon)) {
continue; // Skip invalid coordinates
}
content.push('%-2d %-11.6f %-11.6f%-5.5s WAYPOINT %d'.format(NO1_WAYPOINT, lat, lon,
(!utils.isBlank(wpt.properties.name) ? wpt.properties.name.replace(' ', '_') : ''), itemNum));
itemNum = itemNum + 1;
}
}
let _numOfLines = 0;
if (numOfLines < 0) {
for (let i = 0; i < sprayAreas.length; i++) {
if (!utils.isEmptyArray(sprayAreas[i].lines))
_numOfLines += sprayAreas[i].lines.length;
}
}
else {
_numOfLines = numOfLines;
}
if (_numOfLines)
content.push('%-2d %11d NUMBER OF LINES'.format(NO1_NUMBER_OF_LINES, _numOfLines));
content.push('%-2d %11d MAX CROSS TRACK'.format(NO1_MAX_CROSS_TRACK, 200));
let wgs84Default = {
delx: 0,
dely: 0,
delz: 0,
k0: 0.9996000000, // UTM scale on the central meridian, fixed constant
xshift: 0.0,
yShift: 0.0
};
content.push('%-2d %5d %5d %5d DELTA X/Y/Z'.format(NO1_DELTA_X_Y_Z, wgs84Default.delx, wgs84Default.dely, wgs84Default.delz));
content.push('%-2d %12f %9.1f %9.1f K0, X/Y SHIFT'.format(NO1_K0_XY_SHIFT, wgs84Default.k0, wgs84Default.xshift, wgs84Default.yShift));
let masterPoint, heading = 0, rootArea = sprayAreas[0];
if (findRoot && sprayAreas.length > 1) {
rootArea = findRootArea(sprayAreas, firstRoot);
}
if (!utils.isEmptyArray(rootArea.masterPoint) && rootArea.masterPoint.length >= 2)
masterPoint = rootArea.masterPoint;
else
masterPoint = [firstVertex[1], firstVertex[0]];
heading = geoUtil.to180Range(rootArea.latlngHeading);
// Validate master point and heading before using them
const validMasterPoint = masterPoint && masterPoint.length >= 2 &&
isFinite(Number(masterPoint[0])) && isFinite(Number(masterPoint[1]));
const validHeading = isFinite(Number(heading));
const validFixedHeading = validHeading ? utils.fixedTo(heading) : 0;
if (validMasterPoint && isFinite(validFixedHeading)) {
content.push('%-2d %-11.6f %11.6f %6.2f MASTER POINT HEADING'.format(NO1_MASTER_POINT_HEADING, Number(masterPoint[0]), Number(masterPoint[1]), validFixedHeading, 1));
}
content.push('%-2d %-11s %11.1f %13f %8d ELLIPSOID'.format(NO1_ELLIPSOID, 'WGS-84', 6378137.0, 298.257223123, 22));
// Validate firstVertex for equatorial crossing calculation
const validLatForCrossing = isFinite(Number(firstVertex[1])) ? Number(firstVertex[1]) : 0;
let equatorialCrossing = validLatForCrossing > 0 ? 1 : 2;
content.push('%-2d %10d NO EQUATORIAL CROSSING, %s HEMISPHERE'.format(NO1_EQUATORIAL_CROSSING, equatorialCrossing,
equatorialCrossing === 1 ? 'N' : 'S'));
// Validate job properties before formatting
const validSwathWidth = isFinite(Number(job.swathWidth)) ? Number(job.swathWidth) : 0;
content.push('%-2d %10.1f %9d %2d SWATH WIDTH, NO A/C, SPRAY ON CLOSURE OF CONTACT'.format(NO1_SWATHWIDTH_AC_INSIDE_AREA, validSwathWidth, 1, 1));
content.push('%-2d %10d HALF-SWATH OFFSET OF MASTER LINE'.format(NO1_HALF_SWATH_OFFFSET_1ST_TIME, 1));
content.push('%-2d %10d %s'.format(NO1_DISPLAY_UNIT, job.measureUnit ? 1 : 2, job.measureUnit ? 'U.S. SYSTEM' : 'MET SYSTEM'));
// content.push('%-2d %5d %7d RACE TRACK, SKIP TRACK'.format(NO1_RACE_SKIP, 5, 1));
content.push('%-2d %10d MAGNETIC VARIATION, Deg.'.format(NO1_MAGNETIC_VARIATION, 0));
// System params, default for now
let systemLag = 0.80;
let relayOn = 0.65, relayOff = 0.5;
content.push('%-2d %-10.2f SYSTEM LAG, sec.'.format(NO1_SYSETEM_LAG, systemLag));
content.push('%-2d %-5.2f %7.2f RELAY ON/OFF, sec.'.format(NO1_RELAY_ON_OFF, relayOn, relayOff));
// Nov.08, 2021: Task#298 - Not writing these 2 => Let the console unit decide later on.
// content.push('%-2d %-27d SPRAY PATTERN 0-B&F, 1-RT 2-SQZ 3-SKP 4-Split 5-Expand'.format(NO1_PATTERN, 0));
// content.push('%-2d %-27d EXPAND PATTERN SIDE 0-Right, 1-Left'.format(NO1_EXPAND_LEFT_RIGHT, 1));
if (type === 2) { // only PRJ write area names
for (let i = 0; i < sprayAreas.length; i++) {
const area = sprayAreas[i];
const areaName = (area.properties && area.properties.name) ? area.properties.name.replace(' ', '_') : '';
content.push('%-2d %s'.format(PRJ_ZONE_NAME, areaName));
}
}
for (let i = 0; i < sprayAreas.length; i++) {
const area = sprayAreas[i];
const appRate = (area.properties && area.properties.appRate) ? area.properties.appRate : job.appRate;
// Validate application rate
const validAppRate = isFinite(Number(appRate)) ? Number(appRate) : 0;
content.push('%-2d %-27.2f AGNAV FLOW RATE %s'.format(AGN_APPLICATION_RATE, validAppRate, codeToAppRateUnit(job.appRateUnit, job.measureUnit)));
}
// Validate swath width for segment
const validSegmentSwathWidth = isFinite(Number(job.swathWidth)) ? Number(job.swathWidth) : 0;
content.push('%-3d %-6.1f %-6.1f %-6.1f %-6.1f SEGMENT SWATH'.format(NO1_SWATHWIDTH_SEGMENTS, validSegmentSwathWidth, 0.0, 0.0, 0.0));
content.push('%-3d %-26d AREA TYPE'.format(NO1_AREATYPE, 0));
// POLYGON FILL COLOR
// content.push('%-3d %-25s POLYGON FILL COLOR'.format(NO1_POLYGON_FILL_COLOR, 'colorforwhich??');
return content.join('\r\n');
}
function codeToAppRateUnit(unitCode, isUS) {
let rateUnit = isUS ? "LPH" : "GPA";
switch (unitCode) {
case RateUnits.OZ_PER_ACRE:
rateUnit = "OZPA";
break;
case RateUnits.GAL_PER_ACRE:
rateUnit = "GPA";
break;
case RateUnits.LBS_PER_ACRE:
rateUnit = "LBPA";
break;
case RateUnits.LIT_PER_HA:
rateUnit = "LPH";
break;
case RateUnits.KG_PER_HA:
rateUnit = "KGPH";
break;
}
return rateUnit;
}
async function downloadMap_post(req, res) {
await downloadJobwMap(req, res);
}
async function downloadJobwMap(req, res) {
const dlReq = req.body;
// debug('Request params: ', util.inspect(JSON.stringify(_dlReq), false, null));
const _mapOps = dlReq.mapOps;
let targetFileName, outFileName;
if (!dlReq.jobId || !_mapOps.width || !_mapOps.height || !_mapOps.center || !_mapOps.zoom)
AppParamError.throw();
try {
const job = await Job.findById(dlReq.jobId, 'name');
if (!job) AppError.throw(Errors.JOB_NOT_FOUND);
const dlType = dlReq.type || 0, userInfo = req.userInfo;
targetFileName = path.join(env.TEMP_DIR, job._id + '_' + uniqid());
let imgFilePath = targetFileName + '.jpg', imgTifFileName, imgUTMFileName, realName = job.name,
pageWidth = Math.trunc(_mapOps.width), pageHeight = Math.trunc(_mapOps.height);
const renderParams = urlHelper.encodeQueryData({
base: _mapOps.base,
zoom: _mapOps.zoom,
clat: _mapOps.center.lat,
clon: _mapOps.center.lng,
width: pageWidth,
height: pageHeight,
premium: (userInfo ? userInfo.premium : 0)
});
const reportUrl = `${req.protocol}://${req.hostname}/public/downloadMap.html?${renderParams}`;
await fs.ensureDir(env.TEMP_DIR);
await webUtil.webShot({
url: reportUrl,
type: 'jpeg', quality: 100, width: pageWidth, height: pageHeight,
path: imgFilePath
});
// 1. Re-project the image from Web Mercater to UTM at the zone based on the center of the bounds
let northWest = _mapOps.bounds.NW,
southEast = _mapOps.bounds.SE,
center_UTM = new app.locals.LatLonUTM(_mapOps.center.lat, _mapOps.center.lng).toUtm();
outFileName = `${targetFileName}-out`;
imgTifFileName = outFileName + '.tiff';
imgUTMFileName = `${outFileName}-utm.tiff`;
const ullr = ` -a_ullr ${northWest.x} ${northWest.y} ${southEast.x} ${southEast.y}`;
const cmd1 = `gdal_translate ${ullr} -a_srs EPSG:3857 ${imgFilePath} ${imgTifFileName} -co COMPRESS=JPEG`;
await utils.execAsync(cmd1);
// 1.1. Check and rescale if the image size constraints over MAX_MAP_DIMEN pixels on the largest dimension
let maxDimen = Math.max(pageWidth, pageHeight);
if (maxDimen >= MAX_MAP_DIMEN) {
let newWidth, newHeight;
if (pageWidth > pageHeight) {
newWidth = MAX_MAP_DIMEN;
newHeight = (pageHeight / pageWidth) * MAX_MAP_DIMEN;
}
else {
newHeight = MAX_MAP_DIMEN;
newWidth = (pageWidth / pageHeight) * MAX_MAP_DIMEN;
}
pageWidth = Math.trunc(newWidth);
pageHeight = Math.trunc(newHeight);
// console.log('New WxH:', pageWidth, pageHeight);
const outSize = `-outsize ${pageWidth} ${pageHeight}`, croppedTif = outFileName + '_crop.tiff';
imgTifFileName = croppedTif;
const cmd11 = `gdal_translate ${imgTifFileName} ${croppedTif} ${outSize} -co COMPRESS=JPEG`;
await utils.execAsync(cmd11);
}
// 2. Reproject Image from Web Mecarter to UTM projection
let zoneDef = `+zone=${center_UTM.zone}`;
if (center_UTM.hemisphere === 'S')
zoneDef = zoneDef + ' +south';
const cmd2 = `gdalwarp ${imgTifFileName} ${imgUTMFileName} -s_srs EPSG:3857 -t_srs "+proj=utm ${zoneDef} +ellps=WGS84 +datum=WGS84 +units=m +no_defs" -overwrite -co COMPRESS=JPEG`;
await utils.execAsync(cmd2);
// 3. Crop 4% on all edges toward center to remove black edges if any
let cropX = Math.trunc(pageWidth * 0.04), cropY = Math.trunc(pageHeight * 0.04);
const srcWin = `-srcwin ${cropX} ${cropY} ${pageWidth - (2 * cropX)} ${pageHeight - (2 * cropY)}`;
const cmd3 = `gdal_translate -of jpeg ${srcWin} ${imgUTMFileName} ${outFileName}.jpg`;
await utils.execAsync(cmd3);
// 4. Build and response a .zip file contains the image + geo-referenced meta-data file
// Read <filename>.aux.xml for aux meta-data info
const meta = await fs.readFile(outFileName + '.jpg.aux.xml');
const parseResult = await parserXMLAsync(meta);
const geotf = parseResult.PAMDataset.GeoTransform[0].split(',');
const origin = { x: parseFloat(geotf[0]), y: parseFloat(geotf[3]) };
await writeWorldFileAync(outFileName,
{
origin: origin,
scaleX: parseFloat(geotf[1]),
scaleY: parseFloat(geotf[5]),
rx: 0.0,
ry: 0.0
});
// 5. Create zip file to response
let archiveItems = [];
if (dlType > 0) // dlType with 0: 'Map Only', 1:AgNav, 2:Agnav Prj, 3:Shape
archiveItems = await makeDownloadItemsAsync(req);
archiveItems.push({ file: outFileName + '.jpg', meta: { name: realName + '.jpg' } }, { file: outFileName + '.jgw', meta: { name: realName + '.jgw' } });
await sendArchiveAsync(archiveItems, res);
if (dlType) await writeJobLog(job._id, 2, req.uid, req.ut);
} catch (err) {
if (targetFileName) debug("input:", req.body);
debug(err);
throw err;
}
finally {
// Clean up temp files if any
await removeTempFilesAsync(targetFileName, outFileName);
}
}
function writeWorldFile(fileName, meta, cb) {
try {
const lineEnd = '\r\n';
// Line 1 X pixel scale
let content = utils.truncR(meta.scaleX, 6) + lineEnd;
// Line 2 X rotation angle
content = content.concat(meta.rx + lineEnd);
// Line 3 Y rotation angle
content = content.concat(meta.ry + lineEnd);
// Line 4 Y pixel scale
content = content.concat(utils.truncR(meta.scaleY, 6) + lineEnd);
// Line 5 X origin (Easting)
content = content.concat(utils.truncR(meta.origin.x, 6) + lineEnd);
// Line 6 Y origin (Northing)
content = content.concat(utils.truncR(meta.origin.y, 6) + lineEnd);
fs.writeFile(fileName + '.jgw', content, { encoding: 'utf8', flag: 'w' }, (err) => {
if (err)
return cb(err, false);
return cb(null, true);
});
} catch (err) {
// debug('Error writing World File:' + fileName + '.jgw', error);
return cb(err, false);
}
}
/**
* Record Download log entry and update Job status to DOWNLOADED
* @param {*} jobId Job Id
* @param {*} type 0: New, 1: Ready, 2: Download, 3: Sprayed
* @param {*} userId the userId that performed the event
* @param {*} userType the user type
*/
async function writeJobLog(jobId, type = AssignStatus.DOWNLOADED, userId, userType) {
if (!jobId || !userId) return;
// Use the reusable job log utility
await jobUtil.writeJobLog(jobId, type, userId);
// Update assignment status for device users using the reusable utility
if (userType === UserTypes.DEVICE) {
await jobUtil.updateAssignStatus(userId, jobId, AssignStatus.DOWNLOADED);
}
}
/**
* Find the root area in a list which has lines info (latlngHeading, lines, etc.)
* @param {*} areas
*/
function findRootArea(sprayAreas, firstIsRoot = false) {
let rootArea = null;
if (utils.isEmptyArray(sprayAreas)) return rootArea;
rootArea = sprayAreas[0];
const firstId = sprayAreas[0]._id.toHexString();
for (let i = 0; i < sprayAreas.length; i++) {
if (firstIsRoot) {
if (!utils.isEmptyArray(sprayAreas[i].mems) && sprayAreas[i].mems.includes(firstId)) {
rootArea = sprayAreas[i];
break;
}
}
else {
if (utils.getProp(sprayAreas[i], "latlngHeading", null)) {
rootArea = sprayAreas[i];
break;
}
}
}
return rootArea;
}
function removeTempFiles(targetFileName, outFileName, cb) {
let files = [];
if (outFileName)
files = [
outFileName + '.jpg', outFileName + '.jgw', outFileName + '.jpg.aux.xml',
outFileName + '.tiff', outFileName + '-utm.tiff', outFileName + '.zip'
];
if (targetFileName)
files.push(targetFileName + '.jpg');
fileHelper.removeFiles(files, cb);
}
/**
* Detect partner code from file extension
* @param {string} fileName - The filename to check
* @returns {string|null} Partner code or null if not a partner file
*/
function detectPartnerFromExtension(fileName) {
const ext = path.extname(fileName).toLowerCase();
// Map file extensions to partner codes
const { PartnerFileExtensions } = require('../helpers/constants');
return PartnerFileExtensions[ext] || null;
}
/**
* Resolve file path based on partner code
* @param {string} fileName - The filename to resolve
* @param {string} partnerCode - Partner code (e.g., 'SATLOC')
* @returns {string} Full file path
*/
function resolvePartnerFilePath(fileName, partnerCode) {
if (partnerCode) {
const { PartnerCodes } = require('../helpers/constants');
const codeUpper = partnerCode.toUpperCase();
// Handle partner-specific storage paths
if (codeUpper === PartnerCodes.SATLOC) {
const SatlocService = require('../services/satloc_service');
const satlocService = new SatlocService();
return satlocService.resolveLogFilePath(fileName);
}
// Add more partner codes here as needed
}
// Default: use UPLOAD_DIR for regular application files
return path.join(env.UPLOAD_DIR, fileName);
}
async function downloadAppfile_get(req, res) {
const fileName = decodeURIComponent(req.query.file);
if (!fileName) return writeNotFound(res);
let fileLoc;
try {
// Fast path: Check file extension first to avoid database query for known partner files
const partnerCodeFromExt = detectPartnerFromExtension(fileName);
if (partnerCodeFromExt) {
// Known partner special/unique file extension - resolve directly without DB lookup
fileLoc = resolvePartnerFilePath(fileName, partnerCodeFromExt);
} else {
// Unknown extension - check database for partner association
// This handles edge cases like renamed files or custom partner formats
const { PartnerLogTracker } = require('../model');
const jobId = req.query.jobId;
let query;
if (jobId && !isNaN(jobId)) {
// Efficient lookup using jobId and filename
query = {
'matchedJobs.jobId': Number(jobId),
$or: [
{ logFileName: fileName },
{ savedLocalFile: fileName }
]
};
} else {
// Fallback to filename-only lookup
query = {
$or: [
{ logFileName: fileName },
{ savedLocalFile: fileName }
]
};
}
const tracker = await PartnerLogTracker.findOne(query, { partnerCode: 1 }).lean();
if (tracker && tracker.partnerCode) {
// Partner file found in database - resolve using partner service
fileLoc = resolvePartnerFilePath(fileName, tracker.partnerCode);
} else {
// Regular application file - use default upload directory
fileLoc = path.join(env.UPLOAD_DIR, fileName);
}
}
} catch (err) {
// On error, fall back to default upload directory
debug('Error looking up file source:', err);
fileLoc = path.join(env.UPLOAD_DIR, fileName);
}
const stream = fs.createReadStream(fileLoc);
// This will wait until we know the readable stream is actually valid before piping
stream.on('open', () => {
let length = fileName.lastIndexOf('_');
length = length != -1 ? length : fileName.length;
// File exists, stream it to user
res.writeHead(200, {
"Content-Disposition": "attachment;filename=" + `${fileName.substring(0, length)}`,
});
// This just pipes the read stream to the response object (which goes to the client)
stream.pipe(res);
});
stream.on('error', () => {
writeNotFound(res);
});
}
function writeNotFound(res) {
res.writeHead(404, 'Not Found');
res.write('404: File Not Found!');
res.end();
}
async function downloadObs_post(req, res) {
let obs = [];
const userInfo = req.userInfo;
if (userInfo) {
obs = await Obstacle.find({ $or: [{ byUser: ObjectId(req.uid) }, { byUser: ObjectId(userInfo.puid) }], "properties.type": "USER" }).lean();
}
res.setHeader('Content-disposition', 'attachment; filename=obstacles.dat');
res.setHeader('Content-type', 'text/plain');
res.charset = 'UTF-8';
res.write(writeFAAUserObs(obs));
res.end();
}
/**
* Write obstacles to FAA text content
* @param {*} obstacles collection
*/
function writeFAAUserObs(obs) {
if (!obs || !obs.length)
obs = [];
const Dms = app.locals.Dms;
Dms.separator = '';
let content = [];
const currencyDate = moment.utc().format('MM/DD/Y');
content.push(' CURRENCY DATE = %s'.format(currencyDate));
content = content.concat([
' LATITUDE LONGITUDE OBSTACLE AGL AMSL LT ACC MAR FAA ACTION',
'OAS# V CO ST CITY DEG MIN SEC DEG MIN SEC TYPE HT HT H V IND STUDY JDATE',
'-------------------------------------------------------------------------------------------------------------------------------'
]);
let ob, dataObj;
for (let i = 0; i < obs.length; i++) {
ob = obs[i];
let prop = ob.properties, coor = ob.geometry.coordinates;
// Validate coordinates before processing
if (!coor || coor.length < 2 || !isFinite(Number(coor[0])) || !isFinite(Number(coor[1]))) {
continue; // Skip invalid coordinates
}
const agl = (Number(prop.agl) || 0).toFixed(0);
// Validate agl and index before using in format strings
if (!isFinite(Number(agl)) || !isFinite(i)) {
continue; // Skip if agl or index is invalid
}
dataObj = {
osa: '%-9s'.format(utils.normalizeName(prop.name || `TOWER_${i + 1}`).substring(0, 9)),
ver: 'U',
country2: '--',
state2: '--',
city16: '%-16s'.format('UNKNOWN'),
latDMS: Dms.toLat(Number(coor[1]), 'dms', 2).replace('°', ' ').replace('', ' ').replace('″', ''),
lonDM: Dms.toLon(Number(coor[0]), 'dms', 2).replace('°', ' ').replace('', ' ').replace('″', ''),
type: '%-18s'.format('TOWER'),
qty: '1',
agl: '%s'.format(utils.padZero(agl, 5)), // pad left 0 - feet
amsl: '%s'.format(utils.padZero(utils.isNumber(Number(prop.amsl)) ? Number(prop.amsl).toFixed(0) : agl, 5)) // pad left 0 - feet
};
let line = Object.values(dataObj).join(' ');
line = '%-128s'.format(line + ' U 9 I U %-14s A %-7s'.format('', utils.julianDate(new Date().toUTCString(), true).dateStr));
content.push(line);
}
return content.join('\n');
}
return ({
anyJob_post, newJobs_post, downloadJob_post, downloadMap_post, downloadAppfile_get, downloadObs_post
});
}