Add CSV event import.

Closes #336
This commit is contained in:
D. Berge
2025-08-14 13:26:06 +02:00
parent 2009d73a2b
commit 09e4cd2467
11 changed files with 363 additions and 2 deletions

View File

@@ -235,6 +235,15 @@ app.map({
patch: [ mw.auth.access.write, mw.event.patch ],
delete: [mw.auth.access.write, mw.event.delete ]
},
'import': {
put: [ mw.auth.access.write, mw.event.import.csv, mw.event.import.put ],
post: [ mw.auth.access.write, mw.event.import.csv, mw.event.import.put ],
'/:filename': {
put: [ mw.auth.access.read, mw.event.import.csv, mw.event.import.put ],
post: [ mw.auth.access.write, mw.event.import.csv, mw.event.import.put ],
delete: [ mw.auth.access.write, mw.event.import.delete ]
},
},
},
/*

View File

@@ -0,0 +1,146 @@
const Busboy = require('busboy');
const { parse } = require('csv-parse/sync');
async function middleware(req, res, next) {
const contentType = req.headers['content-type'] || '';
let csvText = null;
let filename = null;
if (req.params.filename && contentType.startsWith('text/csv')) {
csvText = typeof req.body === 'string' ? req.body : req.body.toString('utf8');
filename = req.params.filename;
processCsv();
} else if (contentType.startsWith('multipart/form-data')) {
const busboy = Busboy({ headers: req.headers });
let found = false;
busboy.on('file', (name, file, info) => {
if (found) {
file.resume();
return;
}
if (info.mimeType === 'text/csv') {
found = true;
filename = info.filename || 'unnamed.csv';
csvText = '';
file.setEncoding('utf8');
file.on('data', (data) => { csvText += data; });
file.on('end', () => {});
} else {
file.resume();
}
});
busboy.on('field', () => {}); // Ignore fields
busboy.on('finish', () => {
if (!found) {
return next();
}
processCsv();
});
req.pipe(busboy);
return;
} else {
return next();
}
function processCsv() {
let records;
try {
records = parse(csvText, {
relax_quotes: true,
quote: '"',
escape: '"',
skip_empty_lines: true,
trim: true
});
} catch (e) {
return res.status(400).json({ error: 'Invalid CSV' });
}
if (!records.length) {
return res.status(400).json({ error: 'Empty CSV' });
}
const headers = records[0].map(h => h.toLowerCase().trim());
const rows = records.slice(1);
let lastDate = null;
let lastTime = null;
const currentDate = new Date().toISOString().slice(0, 10);
const currentTime = new Date().toISOString().slice(11, 19);
const events = [];
for (let row of rows) {
let object = { labels: [] };
for (let k = 0; k < headers.length; k++) {
let key = headers[k];
let val = row[k] ? row[k].trim() : '';
if (!key) continue;
if (['remarks', 'event', 'comment', 'comments', 'text'].includes(key)) {
object.remarks = val;
} else if (key === 'label') {
if (val) object.labels.push(val);
} else if (key === 'labels') {
if (val) object.labels.push(...val.split(';').map(l => l.trim()).filter(l => l));
} else if (key === 'sequence' || key === 'seq') {
if (val) object.sequence = Number(val);
} else if (['point', 'shot', 'shotpoint'].includes(key)) {
if (val) object.point = Number(val);
} else if (key === 'date') {
object.date = val;
} else if (key === 'time') {
object.time = val;
} else if (key === 'timestamp') {
object.timestamp = val;
} else if (key === 'latitude') {
object.latitude = parseFloat(val);
} else if (key === 'longitude') {
object.longitude = parseFloat(val);
}
}
if (!object.remarks) continue;
let useSeqPoint = Number.isFinite(object.sequence) && Number.isFinite(object.point);
let tstamp = null;
if (!useSeqPoint) {
if (object.timestamp) {
tstamp = new Date(object.timestamp);
}
if (!tstamp || isNaN(tstamp.getTime())) {
let dateStr = object.date || lastDate || currentDate;
let timeStr = object.time || lastTime || currentTime;
if (timeStr.length === 5) timeStr += ':00';
let full = `${dateStr}T${timeStr}.000Z`;
tstamp = new Date(full);
if (isNaN(tstamp.getTime())) continue;
}
if (object.date) lastDate = object.date;
if (object.time) lastTime = object.time;
}
let event = {
remarks: object.remarks,
labels: object.labels,
meta: {
author: "*CSVImport*",
"*CSVImport*": {
filename,
tstamp: new Date().toISOString()
}
}
};
if (!isNaN(object.latitude) && !isNaN(object.longitude)) {
event.meta.geometry = {
type: "Point",
coordinates: [object.longitude, object.latitude]
};
}
if (useSeqPoint) {
event.sequence = object.sequence;
event.point = object.point;
} else if (tstamp) {
event.tstamp = tstamp.toISOString();
} else {
continue;
}
events.push(event);
}
req.body = events;
next();
}
}
module.exports = middleware;

View File

@@ -0,0 +1,18 @@
const { event } = require('../../../../lib/db');
module.exports = async function (req, res, next) {
try {
if (req.params.project && req.params.filename) {
await event.unimport(req.params.project, req.params.filename, req.query);
res.status(204).end();
} else {
res.status(400).send({message: "Malformed request"});
}
next();
} catch (err) {
next(err);
}
};

View File

@@ -0,0 +1,6 @@
module.exports = {
csv: require('./csv'),
put: require('./put'),
delete: require('./delete'),
}

View File

@@ -0,0 +1,16 @@
const { event } = require('../../../../lib/db');
module.exports = async function (req, res, next) {
try {
const payload = req.body;
await event.import(req.params.project, payload, req.query);
res.status(200).send(payload);
next();
} catch (err) {
next(err);
}
};

View File

@@ -7,5 +7,6 @@ module.exports = {
put: require('./put'),
patch: require('./patch'),
delete: require('./delete'),
changes: require('./changes')
changes: require('./changes'),
import: require('./import'),
}