Export CSV directly to (browser) download instead of writing to disk
Based on the NodeJS pdfstreamer extension, I thought that I would create a csvstreamer as per
@benpley/wappler-csvstreamer - npm
Features
Stream CSV files for download
Optional inline display mode
Automatic content-type and caching headers
Browser caching support with ETag
304 Not Modified responses for optimal performance
Custom display filenames
UTF-8 encoding support
Comprehensive error handling
Supports both absolute and relative paths (site- or project-root folders)
Thank you! But as far as I understand, the CSV still needs to be written to disk first?
We got Claude to bang out a script for this based on response from an Action and did it first shot. Gave it the Action location, shared the response, fed in to Claude 4.5 and one shotted it. We were quite specific in its structure as is for one of our cleaning team reports but you're welcome to the source. Chuck it in Claude ask it to make some simple changes not specific to the Actions output and should do what you want @Apple
This is for an Excel output but just tell Claude you want CSV and what you want it to do (we often repurpose multiple scripts in this way and Claude 4.5 has no issue doing that as long as a working example is shared). Might help as 'the' example for Claude to adapt for your needs:
/**
* Export Excel Data Handler
* Handles the export of assignment data to Excel format
*/
(function() {
'use strict';
/**
* Format date to dd/MM/yyyy - HH:mm
* @param {string} dateString - ISO date string
* @returns {string} Formatted date string
*/
function formatDateTime(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${day}/${month}/${year} - ${hours}:${minutes}`;
}
/**
* Format date only to dd/MM/yyyy
* @param {string} dateString - ISO date string
* @returns {string} Formatted date string
*/
function formatDateOnly(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${day}/${month}/${year}`;
}
/**
* Format time only to HH:mm
* @param {string} dateString - ISO date string
* @returns {string} Formatted time string
*/
function formatTimeOnly(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
/**
* Find the next check-in for the same property
* @param {Array} data - All data items
* @param {Object} currentItem - Current item
* @returns {Object} Object with date and time for next check-in
*/
function findNextCheckIn(data, currentItem) {
const currentEndDate = new Date(currentItem.end);
const currentPropertyTitle = currentItem.cleanerPropertyTitle || currentItem.propertyTitle;
// Find all items for the same property that start after this one ends
const futureBookings = data
.filter(item => {
const itemPropertyTitle = item.cleanerPropertyTitle || item.propertyTitle;
return itemPropertyTitle === currentPropertyTitle &&
new Date(item.start) > currentEndDate;
})
.sort((a, b) => new Date(a.start) - new Date(b.start));
// Return the earliest next check-in
if (futureBookings.length > 0) {
return {
date: formatDateOnly(futureBookings[0].start),
time: formatTimeOnly(futureBookings[0].start)
};
}
return { date: '', time: '' };
}
/**
* Fetch data from the API and export to Excel
* @param {string} filterStartDate - Start date for filtering
* @param {string} filterEndDate - End date for filtering
* @param {number} limit - Limit for results
* @param {string} filterSupportTickets - Filter for support tickets
* @param {string} propertyFilter - Property title to filter by (optional)
*/
async function exportToExcel(filterStartDate, filterEndDate, limit = 129, filterSupportTickets = '', propertyFilter = '') {
try {
// Check if SheetJS library is loaded
if (typeof XLSX === 'undefined') {
console.error('SheetJS library is not loaded');
alert('Excel export library not loaded. Please refresh the page and try again.');
return;
}
// Encode the dates for URL
const encodedStartDate = encodeURIComponent(filterStartDate);
const encodedEndDate = encodeURIComponent(filterEndDate);
// Build the API URL with query parameters
const apiUrl = `/api/cleaner/supportAssignments/assignmentsExportData?filterstart=${encodedStartDate}&filterend=${encodedEndDate}&limit=${limit}&filtersupporttickets=${filterSupportTickets}`;
// Show loading indicator (using toast if available)
if (window.dmx && dmx.app && dmx.app.pmpToasts) {
dmx.app.pmpToasts.show({
message: 'Fetching data for export...',
title: 'Please wait'
});
}
// Fetch data from API
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
const jsonData = await response.json();
// Extract the data array from the response
const data = jsonData.assignmentasDataExport?.data || [];
if (data.length === 0) {
if (window.dmx && dmx.app && dmx.app.pmpToasts) {
dmx.app.pmpToasts.show({
message: 'No data found for the selected date range.',
title: 'No Data'
});
} else {
alert('No data found for the selected date range.');
}
return;
}
// Filter by property if specified
let filteredData = data;
if (propertyFilter && propertyFilter !== '') {
filteredData = data.filter(item => item.propertyTitle === propertyFilter);
if (filteredData.length === 0) {
if (window.dmx && dmx.app && dmx.app.pmpToasts) {
dmx.app.pmpToasts.show({
message: 'No data found for the selected property.',
title: 'No Data'
});
} else {
alert('No data found for the selected property.');
}
return;
}
}
// Transform data for Excel export (using full data array to find next check-ins)
const excelData = filteredData.map(item => {
const nextCheckIn = findNextCheckIn(data, item);
return {
'Check Out Date': formatDateOnly(item.end),
'Check Out Time': formatTimeOnly(item.end),
'Property': item.cleanerPropertyTitle || item.propertyTitle || '',
'Next Check In Date': nextCheckIn.date,
'Next Check In Time': nextCheckIn.time,
'_sortDate': new Date(item.end) // Keep for sorting
};
});
// Sort by check out date ascending (earliest first)
excelData.sort((a, b) => a._sortDate - b._sortDate);
// Remove the temporary sort field
excelData.forEach(item => delete item._sortDate);
// Create a new workbook
const workbook = XLSX.utils.book_new();
// Convert the data to a worksheet
const worksheet = XLSX.utils.json_to_sheet(excelData);
// Get the range of the worksheet
const range = XLSX.utils.decode_range(worksheet['!ref']);
// Style the header row (row 0) - make bold
for (let col = range.s.c; col <= range.e.c; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: 0, c: col });
if (!worksheet[cellAddress]) continue;
// Set bold style for header
worksheet[cellAddress].s = {
font: { bold: true }
};
}
// Set all cells to left alignment
for (let row = range.s.r; row <= range.e.r; row++) {
for (let col = range.s.c; col <= range.e.c; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
if (!worksheet[cellAddress]) continue;
// Initialize style object if it doesn't exist
if (!worksheet[cellAddress].s) {
worksheet[cellAddress].s = {};
}
// Set left alignment
worksheet[cellAddress].s.alignment = { horizontal: 'left' };
// Preserve bold for headers
if (row === 0) {
worksheet[cellAddress].s.font = { bold: true };
}
}
}
// Set column widths for better readability
worksheet['!cols'] = [
{ wch: 15 }, // Check Out Date
{ wch: 12 }, // Check Out Time
{ wch: 30 }, // Property
{ wch: 15 }, // Next Check In Date
{ wch: 12 } // Next Check In Time
];
// Add the worksheet to the workbook
XLSX.utils.book_append_sheet(workbook, worksheet, 'Assignments');
// Generate filename with current date
const now = new Date();
const filename = `property_turnovers_${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}.xlsx`;
// Write the file
XLSX.writeFile(workbook, filename);
// Show success message
if (window.dmx && dmx.app && dmx.app.pmpToasts) {
dmx.app.pmpToasts.show({
message: `Excel file "${filename}" has been downloaded successfully.`,
title: 'Export Complete'
});
}
} catch (error) {
console.error('Error exporting to Excel:', error);
if (window.dmx && dmx.app && dmx.app.pmpToasts) {
dmx.app.pmpToasts.show({
message: 'Failed to export data. Please try again.',
title: 'Export Error'
});
} else {
alert('Failed to export data. Please check the console for details.');
}
}
}
/**
* Initialize the export button click handler
*/
function initExportButton() {
const exportButton = document.getElementById('exportExcelFile2');
if (exportButton) {
exportButton.addEventListener('click', function() {
// Get the filter dates from the export modal inputs
let filterStartDate = '';
let filterEndDate = '';
let limit = 129;
let filterSupportTickets = '';
let propertyFilter = '';
// Try to get dates from the export modal date inputs first
const exportStartInput = document.getElementById('exportStartDate');
const exportEndInput = document.getElementById('exportEndDate');
const propertyFilterSelect = document.getElementById('exportPropertyFilter');
// Get property filter value
if (propertyFilterSelect) {
propertyFilter = propertyFilterSelect.value || '';
}
if (exportStartInput && exportEndInput && exportStartInput.value && exportEndInput.value) {
// User has set dates in the modal
filterStartDate = exportStartInput.value;
filterEndDate = exportEndInput.value;
} else {
// Fallback to the page filter dates
try {
// Get the dmx-value elements
const startDateElement = document.getElementById('filterStartDate');
const endDateElement = document.getElementById('filterEndDate');
if (startDateElement && endDateElement) {
// Access the dmx component data
filterStartDate = dmx.parse(startDateElement.getAttribute('dmx-bind:value'));
filterEndDate = dmx.parse(endDateElement.getAttribute('dmx-bind:value'));
}
// If parse doesn't work, try accessing via dmx.app
if (!filterStartDate || !filterEndDate) {
if (window.dmx && window.dmx.app) {
const startComponent = window.dmx.app.filterStartDate;
const endComponent = window.dmx.app.filterEndDate;
if (startComponent && endComponent) {
filterStartDate = startComponent.value;
filterEndDate = endComponent.value;
}
}
}
} catch (error) {
console.error('Error accessing date values:', error);
}
}
// Get limit and filter support tickets from supportTicketsContainer if available
try {
if (window.dmx && window.dmx.app && window.dmx.app.supportTicketsContainer) {
const container = window.dmx.app.supportTicketsContainer;
if (container.countSupportAssignments) {
limit = container.countSupportAssignments.value || 129;
}
if (container.filtersupporttickets) {
filterSupportTickets = container.filtersupporttickets.value || '';
}
}
} catch (error) {
console.error('Error accessing container values:', error);
}
// Validate that we have dates
if (!filterStartDate || !filterEndDate) {
console.log('Debug - filterStartDate:', filterStartDate);
console.log('Debug - filterEndDate:', filterEndDate);
console.log('Debug - dmx.app:', window.dmx?.app);
if (window.dmx && dmx.app && dmx.app.pmpToasts) {
dmx.app.pmpToasts.show({
message: 'Filter dates are not set. Please set the date range first.',
title: 'Missing Dates'
});
} else {
alert('Filter dates are not set. Please set the date range first.');
}
return;
}
// Call the export function
exportToExcel(filterStartDate, filterEndDate, limit, filterSupportTickets, propertyFilter);
});
}
}
// Wait for dmx to be fully loaded
function waitForDmx() {
if (window.dmx && window.dmx.app) {
// dmx is ready, wait a bit more for components to initialize
setTimeout(initExportButton, 500);
} else {
// Check again in 100ms
setTimeout(waitForDmx, 100);
}
}
// Initialize when DOM is ready and dmx is loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', waitForDmx);
} else {
// DOM already loaded
waitForDmx();
}
})();
Script is fired with a button with the id 'exportExcelFile2'. API URL denoted by 'apiUrl' (around line 110) which has variables contained within it in our case but those are also recognised.
Outputs:

I've been knocking up some Server Connect Extensions recently so will try to bang out one that does the above based on any Action output. If no one else does it first give us a day or two and will be happy to share...
They were for internal use cases but if anyone else wants to try them out and check they work for them please feel free...
In Wappler open Extensions and search for SSR (Server Side Rendering) and Comma Array (for splitting comma separated values from a specific column in any query response).
Created a simple download csv server action, it does stream the csv directly without writing to disk first.
scv.zip (1.7 KB)
Unzip to extensions/server_connect/modules. Create the folder first if it doesn't exist. Will perhaps put it on NPM later if there are any requests for it.