diff --git a/lib/api/devicestatus/index.js b/lib/api/devicestatus/index.js index c6e981da46a..05e1583b46c 100644 --- a/lib/api/devicestatus/index.js +++ b/lib/api/devicestatus/index.js @@ -18,7 +18,8 @@ function configure (app, wares, ctx, env) { api.use(wares.jsonParser); // also support url-encoded content-type api.use(wares.urlencodedParser); - // text body types get handled as raw buffer stream + // Add format extension support + api.use(wares.extensions(['json', 'csv', 'txt', 'tsv'])); api.use(ctx.authorization.isPermitted('api:devicestatus:read')); @@ -41,6 +42,46 @@ function configure (app, wares, ctx, env) { } } + /** + * @function formatWithSeparator + * Format devicestatus data as CSV/TSV + */ + function formatWithSeparator(data, separator) { + if (data === null || data.constructor !== Array || data.length == 0) return ""; + + // Flatten the devicestatus data for CSV export + var outputdata = []; + data.forEach(function(d) { + var devicestatus = { + "_id": d._id || '', + "device": d.device || '', + "created_at": d.created_at || '', + "mills": d.mills || '', + "uploaderBattery": d.uploaderBattery || '', + "pump": JSON.stringify(d.pump || ''), + "openaps": JSON.stringify(d.openaps || ''), + "loop": JSON.stringify(d.loop || '') + }; + outputdata.push(devicestatus); + }); + + if (outputdata.length === 0) return ""; + + var fields = Object.keys(outputdata[0]); + var replacer = function(key, value) { + return value === null ? '' : value; + }; + // Create header row + var csv = [fields.join(separator)]; + // Add data rows + csv = csv.concat(outputdata.map(function(row) { + return fields.map(function(fieldName) { + return JSON.stringify(row[fieldName], replacer); + }).join(separator); + })); + return csv.join('\r\n'); + } + // List settings available api.get('/devicestatus/', function(req, res) { var q = req.query; @@ -51,19 +92,47 @@ function configure (app, wares, ctx, env) { const inMemoryData = ctx.cache.devicestatus ? ctx.cache.devicestatus : []; const canServeFromMemory = inMemoryData.length >= q.count && Object.keys(q).length == 1 ? true : false; + var results; if (canServeFromMemory) { const sorted = _.sortBy(inMemoryData, function(item) { return -item.mills; }); - - return res.json(processDates(_take(sorted, q.count))); + results = processDates(_take(sorted, q.count)); + return serveResponse(req, res, results); } ctx.devicestatus.list(q, function(err, results) { - return res.json(processDates(results)); + if (err) { + return res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err); + } + results = processDates(results); + return serveResponse(req, res, results); }); }); + function serveResponse(req, res, results) { + return res.format({ + 'text/plain': function() { + var output = formatWithSeparator(results, "\t"); + res.send(output); + }, + 'text/tab-separated-values': function() { + var output = formatWithSeparator(results, '\t'); + res.send(output); + }, + 'text/csv': function() { + var output = formatWithSeparator(results, ','); + res.send(output); + }, + 'application/json': function() { + res.json(results); + }, + 'default': function() { + res.json(results); + } + }); + } + function config_authed (app, api, wares, ctx) { function doPost (req, res) { diff --git a/lib/api/food/index.js b/lib/api/food/index.js index 5db962db403..6dc5025928e 100644 --- a/lib/api/food/index.js +++ b/lib/api/food/index.js @@ -14,15 +14,80 @@ function configure (app, wares, ctx) { api.use(wares.jsonParser); // also support url-encoded content-type api.use(wares.urlencodedParser); - // text body types get handled as raw buffer stream - // shortcut to use extension to specify output content-type + // Add format extension support + api.use(wares.extensions(['json', 'csv', 'txt', 'tsv'])); api.use(ctx.authorization.isPermitted('api:food:read')); + /** + * @function formatWithSeparator + * Format food data as CSV/TSV + */ + function formatWithSeparator(data, separator) { + if (data === null || data.constructor !== Array || data.length == 0) return ""; + + // Flatten the food data for CSV export + var outputdata = []; + data.forEach(function(f) { + var food = { + "_id": f._id || '', + "name": f.name || '', + "category": f.category || '', + "subcategory": f.subcategory || '', + "portions": JSON.stringify(f.portions || ''), + "created_at": f.created_at || '', + "carbs": f.carbs || '', + "protein": f.protein || '', + "fat": f.fat || '', + "energy": f.energy || '' + }; + outputdata.push(food); + }); + + if (outputdata.length === 0) return ""; + + var fields = Object.keys(outputdata[0]); + var replacer = function(key, value) { + return value === null ? '' : value; + }; + // Create header row + var csv = [fields.join(separator)]; + // Add data rows + csv = csv.concat(outputdata.map(function(row) { + return fields.map(function(fieldName) { + return JSON.stringify(row[fieldName], replacer); + }).join(separator); + })); + return csv.join('\r\n'); + } + // List foods available api.get('/food/', function(req, res) { - ctx.food.list(function (err, attribute) { - return res.json(attribute); + ctx.food.list(function (err, results) { + if (err) { + return res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err); + } + + return res.format({ + 'text/plain': function() { + var output = formatWithSeparator(results, "\t"); + res.send(output); + }, + 'text/tab-separated-values': function() { + var output = formatWithSeparator(results, '\t'); + res.send(output); + }, + 'text/csv': function() { + var output = formatWithSeparator(results, ','); + res.send(output); + }, + 'application/json': function() { + res.json(results); + }, + 'default': function() { + res.json(results); + } + }); }); }); diff --git a/lib/api/profile/index.js b/lib/api/profile/index.js index 57cce59e69a..75dd2ed88da 100644 --- a/lib/api/profile/index.js +++ b/lib/api/profile/index.js @@ -14,16 +14,56 @@ function configure (app, wares, ctx) { api.use(wares.jsonParser); // also support url-encoded content-type api.use(wares.urlencodedParser); - // text body types get handled as raw buffer stream + // Add format extension support + api.use(wares.extensions(['json', 'csv', 'txt', 'tsv'])); api.use(ctx.authorization.isPermitted('api:profile:read')); + /** + * @function formatWithSeparator + * Format profile data as CSV/TSV + */ + function formatWithSeparator(data, separator) { + if (data === null || data.constructor !== Array || data.length == 0) return ""; + + // Flatten the profile data for CSV export + var outputdata = []; + data.forEach(function(p) { + var profile = { + "_id": p._id || '', + "defaultProfile": p.defaultProfile || '', + "created_at": p.created_at || '', + "startDate": p.startDate || '', + "mills": p.mills || '', + "units": p.units || '', + "dia": p.dia || '', + "timezone": p.timezone || '' + }; + outputdata.push(profile); + }); + + if (outputdata.length === 0) return ""; + + var fields = Object.keys(outputdata[0]); + var replacer = function(key, value) { + return value === null ? '' : value; + }; + // Create header row + var csv = [fields.join(separator)]; + // Add data rows + csv = csv.concat(outputdata.map(function(row) { + return fields.map(function(fieldName) { + return JSON.stringify(row[fieldName], replacer); + }).join(separator); + })); + return csv.join('\r\n'); + } /** * @function query_models * Perform the standard query logic, translating API parameters into mongo * db queries in a fairly regimented manner. - * This middleware executes the query, returning the results as JSON + * This middleware executes the query, returning the results as JSON/CSV */ function query_models (req, res, next) { var query = req.query; @@ -35,7 +75,30 @@ function configure (app, wares, ctx) { // perform the query ctx.profile.list_query(query, function payload(err, profiles) { - return res.json(profiles); + if (err) { + return res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err); + } + + return res.format({ + 'text/plain': function() { + var output = formatWithSeparator(profiles, "\t"); + res.send(output); + }, + 'text/tab-separated-values': function() { + var output = formatWithSeparator(profiles, '\t'); + res.send(output); + }, + 'text/csv': function() { + var output = formatWithSeparator(profiles, ','); + res.send(output); + }, + 'application/json': function() { + res.json(profiles); + }, + 'default': function() { + res.json(profiles); + } + }); }); } diff --git a/lib/api/treatments/index.js b/lib/api/treatments/index.js index 996c646a02d..1ba508e5e91 100644 --- a/lib/api/treatments/index.js +++ b/lib/api/treatments/index.js @@ -23,61 +23,104 @@ function configure (app, wares, ctx, env) { // also support url-encoded content-type api.use(wares.urlencodedParser); + // Add format extension support + api.use(wares.extensions(['json', 'csv', 'txt', 'tsv'])); + // invoke common middleware api.use(wares.sendJSONStatus); api.use(ctx.authorization.isPermitted('api:treatments:read')); - function serveTreatments(req,res, err, results) { - - var ifModifiedSince = req.get('If-Modified-Since'); - - var d1 = null; - - const deNormalizeDates = env.settings.deNormalizeDates; - - _forEach(results, function clean (t) { - t.carbs = Number(t.carbs); - t.insulin = Number(t.insulin); - - if (deNormalizeDates && Object.prototype.hasOwnProperty.call(t, 'utcOffset')) { - const d = moment(t.created_at).utcOffset(t.utcOffset); - t.created_at = d.toISOString(true); - delete t.utcOffset; - } - - var d2 = null; - - if (Object.prototype.hasOwnProperty.call(t, 'created_at')) { - d2 = new Date(t.created_at); - } else { - if (Object.prototype.hasOwnProperty.call(t, 'timestamp')) { - d2 = new Date(t.timestamp); - } - } - - if (d2 == null) { return; } - - if (d1 == null || d2.getTime() > d1.getTime()) { - d1 = d2; - } - }); - - if (!_isNil(d1)) { - res.setHeader('Last-Modified', d1.toUTCString()); - - if (ifModifiedSince && d1.getTime() <= moment(ifModifiedSince).valueOf()) { - res.status(304).send({ - status: 304 - , message: 'Not modified' - , type: 'internal' - }); - return; - } - } - - return res.json(results); - } + function serveTreatments(req, res, err, results) { +var ifModifiedSince = req.get('If-Modified-Since'); +var d1 = null; +const deNormalizeDates = env.settings.deNormalizeDates; +_forEach(results, function clean (t) { +t.carbs = Number(t.carbs); +t.insulin = Number(t.insulin); +if (deNormalizeDates && Object.prototype.hasOwnProperty.call(t, 'utcOffset')) { +const d = moment(t.created_at).utcOffset(t.utcOffset); +t.created_at = d.toISOString(true); +delete t.utcOffset; +} +var d2 = null; +if (Object.prototype.hasOwnProperty.call(t, 'created_at')) { +d2 = new Date(t.created_at); +} else if (Object.prototype.hasOwnProperty.call(t, 'timestamp')) { +d2 = new Date(t.timestamp); +} +if (d2 == null) { return; } +if (d1 == null || d2.getTime() > d1.getTime()) { +d1 = d2; +} +}); +if (!_isNil(d1)) { +res.setHeader('Last-Modified', d1.toUTCString()); +if (ifModifiedSince && d1.getTime() <= moment(ifModifiedSince).valueOf()) { +res.status(304).send({ +status: 304, +message: 'Not modified', +type: 'internal' +}); +return; +} +} +// Format-aware response +function formatWithSeparator(data, separator) { +if (data === null || data.constructor !== Array || data.length == 0) return ""; +// Select key fields for CSV export +var outputdata = []; +data.forEach(function(t) { +var treatment = { +"created_at": t.created_at, +"eventType": t.eventType, +"carbs": t.carbs || '', +"insulin": t.insulin || '', +"glucose": t.glucose || '', +"glucoseType": t.glucoseType || '', +"notes": t.notes || '', +"enteredBy": t.enteredBy || '', +"duration": t.duration || '', +"_id": t._id +}; +outputdata.push(treatment); +}); + + var fields = Object.keys(outputdata[0]); +var replacer = function(key, value) { +return value === null ? '' : value; +}; +// Create header row +var csv = [fields.join(separator)]; +// Add data rows +csv = csv.concat(outputdata.map(function(row) { +return fields.map(function(fieldName) { +return JSON.stringify(row[fieldName], replacer); +}).join(separator); +})); +return csv.join('\r\n'); +} +return res.format({ +'text/plain': function() { +var output = formatWithSeparator(results, "\t"); +res.send(output); +}, +'text/tab-separated-values': function() { +var output = formatWithSeparator(results, '\t'); +res.send(output); +}, +'text/csv': function() { +var output = formatWithSeparator(results, ','); +res.send(output); +}, +'application/json': function() { +res.json(results); +}, +'default': function() { +res.json(results); +} +}); +} // List treatments available api.get('/treatments', function(req, res) { diff --git a/lib/client/index.js b/lib/client/index.js index c4c83b32a01..efd2d555631 100644 --- a/lib/client/index.js +++ b/lib/client/index.js @@ -1391,6 +1391,68 @@ client.load = function load (serverSettings, callback) { chart.updateContext(); } } + + client.exportData = function exportData(type) { + var translate = client.translate; + + // Check if user is authenticated + if (!client.hashauth.isAuthenticated()) { + alert(translate('You must be authenticated to export data')); + client.hashauth.requestAuthentication(); + return; + } + + // Build the API URL based on the type + var apiUrl = '/api/v1/' + type; + + // Add query parameters to get more records + var queryParams = '?count=10000'; + + // Create a temporary link element to trigger download + var link = document.createElement('a'); + link.style.display = 'none'; + + // Make an AJAX request to get the CSV data + $.ajax({ + method: 'GET', + url: apiUrl + queryParams, + headers: $.extend(client.headers(), { + 'Accept': 'text/csv' + }), + success: function(csvData) { + // Create a blob from the CSV data + var blob = new Blob([csvData], { type: 'text/csv' }); + var url = window.URL.createObjectURL(blob); + + // Set up the download + link.href = url; + var timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + link.download = type + '_export_' + timestamp + '.csv'; + + // Trigger the download + document.body.appendChild(link); + link.click(); + + // Clean up + setTimeout(function() { + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + }, 100); + + console.log('Export successful for ' + type); + }, + error: function(jqXHR, textStatus, errorThrown) { + console.error('Export failed:', textStatus, errorThrown); + if (jqXHR.status === 401 || jqXHR.status === 403) { + alert(translate('Authentication required. Please authenticate and try again.')); + client.hashauth.requestAuthentication(); + } else { + alert(translate('Export failed. Please try again.')); + } + } + }); + }; + }; -module.exports = client; +module.exports = client; \ No newline at end of file diff --git a/static/report/js/exports.js b/static/report/js/exports.js new file mode 100644 index 00000000000..dee7bbea811 --- /dev/null +++ b/static/report/js/exports.js @@ -0,0 +1,68 @@ +// Function to check authentication and add API secret to URL +function authenticatedExport(baseUrl, dateField, fromVal, toVal) { + // Check if client and hashauth are available + if (typeof Nightscout === 'undefined' || !Nightscout.client || !Nightscout.client.hashauth) { + alert('Authentication system not initialized. Please refresh the page.'); + return; + } + + var hashauth = Nightscout.client.hashauth; + + // Check if user is authenticated + if (!hashauth.isAuthenticated()) { + // Prompt user to authenticate + alert('You must authenticate with your API secret to export data.'); + hashauth.requestAuthentication(); + return; + } + + // Build URL with API secret + var url = baseUrl; + var apiSecretHash = hashauth.hash(); + + // Add secret parameter if available + if (apiSecretHash) { + url += '&secret=' + encodeURIComponent(apiSecretHash); + } + + // Add date range filters + if (fromVal) { + url += '&find[' + dateField + '][$gte]=' + encodeURIComponent(fromVal); + } + if (toVal) { + url += '&find[' + dateField + '][$lte]=' + encodeURIComponent(toVal); + } + + // Open the export URL + window.open(url, '_blank'); +} + +$('#rp_exportEntries').click(function() { + var from = $('#rp_from').val(); + var to = $('#rp_to').val(); + authenticatedExport('/api/v1/entries.csv?count=100000', 'dateString', from, to); +}); + +$('#rp_exportTreatments').click(function() { + var from = $('#rp_from').val(); + var to = $('#rp_to').val(); + authenticatedExport('/api/v1/treatments.csv?count=100000', 'created_at', from, to); +}); + +$('#rp_exportProfile').click(function() { + var from = $('#rp_from').val(); + var to = $('#rp_to').val(); + authenticatedExport('/api/v1/profile.csv?count=100000', 'created_at', from, to); +}); + +$('#rp_exportDeviceStatus').click(function() { + var from = $('#rp_from').val(); + var to = $('#rp_to').val(); + authenticatedExport('/api/v1/devicestatus.csv?count=100000', 'created_at', from, to); +}); + +$('#rp_exportFood').click(function() { + var from = $('#rp_from').val(); + var to = $('#rp_to').val(); + authenticatedExport('/api/v1/food.csv?count=100000', 'created_at', from, to); +}); diff --git a/views/index.html b/views/index.html index 87a4413a502..01f1e4a4893 100644 --- a/views/index.html +++ b/views/index.html @@ -163,7 +163,7 @@
- +