The CreatorCon Call for Content is officially open! Get started here.

Automating Attachment Download – Bulk Attachment Downloads from List View

yashwanth_p
Tera Contributor

List View — Multi-Record Attachment Download

 

We’ve all faced it — opening multiple records, downloading attachments one by one, and repeating the same clicks endlessly.

Tedious, right?

Let’s simplify that too.

This enhancement adds a Download Attachments button right on the List View.
Select a few records (up to 10), click once, and every record’s attachments are collected, zipped, and ready for download — all in one go.
No background jobs, no leftover files, just a direct and clean download.

 

 

Why Build This

 

The goal was clear:

  • Enable downloading attachments for multiple records with a single click.

  • Keep it simple, native, and secure.

  • Ensure it works smoothly without temporary tables or extra storage.

With just one UI Action and one Processor, the job is done beautifully — again, fully in-memory and zero-cleanup.

 

 

What’s Inside

 

ComponentTypePurpose
Download AttachmentsUI Action (List View)The button that initiates the bulk download
exportListAttachmentsToZipProcessor (Global Scope)Collects attachments from multiple records, zips them, and streams to the browser

 

 

UI Action Setup

 

Action Script:

function list_attachment() {

    var selected = g_list.getChecked();
    if (!selected) {
        alert("Please select at least one record.");
        return;
    } else if (selected.split(',').length > 10) {
        alert("Please select records less than 10.");
        return;
    }

    var url = 'exportListAttachmentsToZip.do?sysparm_sys_id_list=' + selected + '&sysparm_table=' + g_list.getTableName();
    top.window.open(url,'_blank');
}
 
The script checks for selected records, ensures the limit stays under control, and then redirects to the Processor to handle the rest.

 

 

Processor Logic — exportListAttachmentsToZip

(function process(g_request, g_response, g_processor) {

    // Retrieve the table name and comma-separated sys_id list from request parameters
    var table = g_request.getParameter('sysparm_table');
    var idList = g_request.getParameter('sysparm_sys_id_list');

    // Validate inputs – exit early if either table name or sys_id list is missing
    if (!idList || !table) {
        gs.info("No records selected.");
        return;
    }

    // Convert comma-separated sys_id list into an array and remove extra spaces
    var sysIds = idList.split(',').map(function(id) { return id.trim(); });

    // Initialize GlideSysAttachment API for reading file bytes
    var sa = new GlideSysAttachment();

    // Name of the main ZIP file that will contain all per-record ZIPs
    var mainZipName = 'Attachments.zip';

    // -----------------------------------------
    // Configure HTTP response headers for file download
    // -----------------------------------------
    g_response.setHeader('Pragma', 'public');
    g_response.addHeader('Cache-Control', 'max-age=0');
    g_response.setContentType('application/octet-stream');
    g_response.addHeader('Content-Disposition', 'attachment;filename=' + mainZipName);

    // Obtain the response output stream
    var outputStream = g_response.getOutputStream();

    // Create a new ZIP output stream for writing the main ZIP
    var mainZip = new Packages.java.util.zip.ZipOutputStream(outputStream);

    // -----------------------------------------
    // Preload metadata (record number + caller info) for all records
    // -----------------------------------------
    var recordMap = {};
    var gr = new GlideRecord(table);
    gr.addQuery('sys_id', 'IN', sysIds);
    gr.query();

    while (gr.next()) {
        var callerName = gr.caller_id ? gr.caller_id.getDisplayValue().replace(/\s+/g, "_") : "Unknown";
        var num = gr.getValue('number') || gr.getDisplayValue();
        recordMap[gr.getUniqueValue()] = num + "_" + callerName + ".zip";
    }

    // -----------------------------------------
    // Query all attachments for the selected records
    // -----------------------------------------
    var att = new GlideRecord('sys_attachment');
    att.addQuery('table_sys_id', 'IN', sysIds);
    att.addQuery('table_name', table);
    att.orderBy('table_sys_id');
    att.query();

    // Variables used to manage nested ZIP creation
    var currentSysId = null;
    var zipOut = null;
    var baos = null;
    var zipName = null;
    var hadAnyEntryForCurrent = false;

    // Track filenames to prevent duplicates per record
    var usedFileNames = {};

    // -----------------------------------------
    // Iterate through each attachment and group them by record
    // -----------------------------------------
    while (att.next()) {
        var sysid = att.getValue('table_sys_id');
        if (!recordMap[sysid]) continue;

        // If sys_id changes, close the previous record ZIP and start a new one
        if (currentSysId !== sysid) {
            if (zipOut) {
                zipOut.close();
                if (hadAnyEntryForCurrent) {
                    var incidentBytes = baos.toByteArray();
                    mainZip.putNextEntry(new Packages.java.util.zip.ZipEntry(zipName));
                    mainZip.write(incidentBytes, 0, incidentBytes.length);
                    mainZip.closeEntry();
                }
            }

            // Start a new ZIP for the current record
            currentSysId = sysid;
            zipName = recordMap[sysid];
            baos = new Packages.java.io.ByteArrayOutputStream();
            zipOut = new Packages.java.util.zip.ZipOutputStream(baos);
            hadAnyEntryForCurrent = false;

            // Reset filename tracking for the new record
            usedFileNames = {};
        }

        try {
            var fileBytes = sa.getBytes(att);
            if (!fileBytes || fileBytes.length === 0) continue;

            var originalName = att.getValue('file_name') || "attachment";
            var fileName = originalName;
            var counter = 1;

            // Duplicate Prevention Logic
            while (usedFileNames[fileName]) {
                var extIndex = originalName.lastIndexOf(".");
                if (extIndex > -1) {
                    fileName = originalName.substring(0, extIndex) + "_" + counter + originalName.substring(extIndex);
                } else {
                    fileName = originalName + "_" + counter;
                }
                counter++;
            }

            // Mark this file name as used
            usedFileNames[fileName] = true;

            // Add this attachment into the current record ZIP
            zipOut.putNextEntry(new Packages.java.util.zip.ZipEntry(fileName));
            zipOut.write(fileBytes, 0, fileBytes.length);
            zipOut.closeEntry();

            hadAnyEntryForCurrent = true;

        } catch (e) {
            gs.error("Error adding attachment " + att.getValue('file_name') + ": " + e);
        }
    }

    // -----------------------------------------
    // Finalize the last record ZIP
    // -----------------------------------------
    if (zipOut) {
        zipOut.close();

        if (hadAnyEntryForCurrent) {
            var finalBytes = baos.toByteArray();
            mainZip.putNextEntry(new Packages.java.util.zip.ZipEntry(zipName));
            mainZip.write(finalBytes, 0, finalBytes.length);
            mainZip.closeEntry();
        }
    }

    // -----------------------------------------
    // Final step: Close the main ZIP stream
    // -----------------------------------------
    mainZip.close();

})(g_request, g_response, g_processor);

 

This processor loops through all selected records, gathers attachments from each, and compresses them into one ZIP file.
It prevents duplicate filenames, streams the ZIP directly to the browser, and cleans up instantly after download.

No persistence. No mess. Just results.

 

How It All Comes Together

 

  • Record Loop – Iterates through all selected records.

  • Attachment Lookup – Finds and processes attachments per record.

  • Smart Naming – Prefixes each file with its record number.

  • Duplicate Prevention – Safely handles repeated filenames.

  • Direct Streaming – Sends ZIP immediately to the browser.

  • Zero Cleanup – Once downloaded, everything’s cleared from memory.

 

Why This Works So Well

 

  • Gracefully handles empty or duplicate attachments.
  • Requires no background or scheduled jobs.
  • Logic easily scales for future email or automation flows.
  • Keeps your instance clean — no temporary files, no artifacts.

 

Performance Thoughts

 

Efficient, lightweight, and memory-based — ideal for small to medium workloads.
It can handle a minimum number of records at once with no delay.

If you ever plan to scale it for larger sets, a background or asynchronous approach would better balance performance and stability.

 

Final Outcome

 

A single click.
A single ZIP.
All attachments from multiple records — instantly downloaded and gone without a trace.

 

What’s Next

 

This use case makes it easy to download attachments from multiple records right from the List View.
Next, we’ll take this further — sending the generated ZIP by email automatically and implementing a cleanup mechanism to clear old files, keeping your instance lightweight and efficient.

 

 

Hope this helps!
Regards,
Yashwanth Pepakayala

1 REPLY 1

yashwanth_p
Tera Contributor

Want to see other approaches for downloading attachments? Check out:

  • Form View → Download attachments one record at a time directly from the form for precise control: Form View Immediate Download 

  • List View (Email) → Automate delivery of attachments to your email without manual intervention: List View Email