Export CSV directly to download instead of writing to disk

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

  • :white_check_mark: Stream CSV files for download
  • :white_check_mark: Optional inline display mode
  • :white_check_mark: Automatic content-type and caching headers
  • :white_check_mark: Browser caching support with ETag
  • :white_check_mark: 304 Not Modified responses for optimal performance
  • :white_check_mark: Custom display filenames
  • :white_check_mark: UTF-8 encoding support
  • :white_check_mark: Comprehensive error handling
  • :white_check_mark: Supports both absolute and relative paths (site- or project-root folders)
1 Like

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:

image

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...

1 Like

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.

1 Like