Unable to download file from retool storage programmatically

I have the pdf files stored in retool storage and I made them public URL. In the retool app table, I'm trying to download the file from the button click on the custom column. Depending on which row button is clicked, the file name varies. I'm trying to write the JS script to download this file triggered from the button click.
First: I tried with below JS code and it downloads dummy pdf(corrupted).

const fileUrl = currentRow.payslip_url;

if (!fileUrl) {
console.error("Payslip URL is missing");
return;
}

utils.downloadFile(fileUrl, payslip_${currentRow.employee_id}_${currentRow.pay_period}.pdf);


Second: I tried to create a resource query and pointed to retool storage with the action "download a file", and the file name (Fx), there is no parameter to pass to this function so I can't pass the dynamic file name.

Third: Also tried the script with link instead of utils.downloadFile. Issue: Fetch does not work.

const fileUrl = currentRow.payslip_url;

if (!fileUrl) {
console.error("Payslip URL is missing");
return;
}

fetch(fileUrl)
.then(response => response.blob()) // Convert response to a binary blob
.then(blob => {
const url = window.URL.createObjectURL(blob); // Create a URL for the blob
const a = document.createElement("a");
a.href = url;
a.download = payslip_${currentRow.employee_id}_${currentRow.pay_period}.pdf; // Set filename
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url); // Clean up URL object
})
.catch(error => console.error("Error downloading the PDF:", error));

Hi @Dhivya,

You first need to set up a Retool Storage resource fetchFile and a variable fileIdVar.

On success add an event handler and add some js:

const fileData = fetchFile.data; // Get the query output
const base64Data = fileData?.base64Data; // Extract Base64 string
let fileName = fileData?.name || "downloaded_file.pdf"; // Extract filename
const fileType = fileData?.type || "application/pdf"; // Extract MIME type

// Extract only the file extension (e.g., "pdf" instead of "application/pdf")
const extension = fileType.split("/")[1]; // Gets "pdf"

// Check if fileName already has the correct extension
if (!fileName.toLowerCase().endsWith(`.${extension}`)) {
  fileName = `${fileName}.${extension}`;
}

if (!base64Data) {
  utils.showNotification({
    title: "Error",
    description: "File data is missing. Unable to download.",
    style: "error",
  });
} else {
  utils.downloadFile(
    { base64Binary: base64Data }, // Base64 data inside curly braces
    fileName // ✅ Remove `fileType` parameter to avoid double extensions
  );
}

Then in your button on click, run script to set your variable (getting the file id from the URL) and triggering fetchFile.

const fileUrl = table1.selectedRow?.file_url; // Get full file URL
const fileId = fileUrl?.split("/").pop(); // Extract last part (file ID)

if (!fileId) {
  utils.showNotification({
    title: "Error",
    description: "File ID is missing. Unable to fetch file.",
    style: "error",
  });
} else {
  console.log("Extracted File ID:", fileId); // Debugging
  fileIdVar.setValue(fileId); // ✅ Store fileId in the temporary state
  fetchFile.trigger(); // ✅ Trigger the query after setting the value
}

I have a working test app if you want to play around with it.

1 Like

Thank you so much! This actually worked for me. I'm also trying to download the pdf files in bulk, the fileIds can be retrieved from record.payslip_url from the payrollTable. How do I go about this? I tried the following script but doesn't work. The button that triggers this script "Download All" is outside the table.

(async () => {
const JSZip = window.JSZip || (await import("https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js")).default;
const zip = new JSZip();
const folderName = "Payslips";
const payrollRecords = payrollTable.data || ;

console.log("Payroll records:", payrollRecords);

if (payrollRecords.length === 0) {
utils.showNotification({
title: "No Payslips Found",
description: "There are no payslip files available for download.",
style: "error",
});
zip.file("no_payslips.txt", new Blob(["No payslips found"], { type: "text/plain" }));
} else {
utils.showNotification({
title: "Processing Payslips",
description: Fetching ${payrollRecords.length} payslips...,
style: "info",
});

const processPayslip = async (record) => {
  if (!record.payslip_url) {
    console.warn(`Skipping ${record.employee_id} - No payslip URL found.`);
    return;
  }

  const fileId = record.payslip_url.split("/").pop();
  const fileName = `payslip_${record.employee_id}_${record.pay_period}.pdf`;

  try {
    fileIdVar.setValue(fileId);
    await fetchFileContent.trigger();
    const fileData = fetchFileContent.data;
    console.log(`Fetched data for ${fileName}:`, fileData);

    if (!fileData || !fileData.base64Data) {
      console.error(`Base64 data missing for ${fileName}`);
      return;
    }

    const base64Data = fileData.base64Data;
    const byteCharacters = atob(base64Data);
    const byteNumbers = new Uint8Array(byteCharacters.length);
    for (let i = 0; i < byteCharacters.length; i++) {
      byteNumbers[i] = byteCharacters.charCodeAt(i);
    }
    const blob = new Blob([byteNumbers], { type: "application/pdf" });

    zip.file(fileName, blob);
    console.log(`Added ${fileName} to ZIP.`);
  } catch (error) {
    console.error(`Error processing ${fileName}:`, error);
  }
};

await Promise.all(payrollRecords.map(processPayslip));

}

const zipFiles = Object.keys(zip.files);
console.log("Files in ZIP:", zipFiles);

if (zipFiles.length === 0) {
utils.showNotification({
title: "Error",
description: "No payslips were added to the ZIP file.",
style: "error",
});
zip.file("error.txt", new Blob(["No payslips were processed"], { type: "text/plain" }));
}

const zipBlob = await zip.generateAsync({ type: "blob" });
console.log("ZIP Blob size:", zipBlob.size);

const zipUrl = URL.createObjectURL(zipBlob);
const a = document.createElement("a");
a.href = zipUrl;
a.download = ${folderName}.zip;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(zipUrl);

utils.showNotification({
title: "Download Complete",
description: "All payslips have been downloaded successfully in a ZIP file.",
style: "success",
});
})();

The below is the working code that downloads the zip folder with all pdf files in it, but when I opened any pdf file, they all belongs to the first record. So there is some state issue, retool is not recognizing the newly set value to the variable.

(async () => {
const JSZip = window.JSZip || (await import("https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js")).default;
const zip = new JSZip();
const folderName = "Payslips";
const payrollRecords = payrollTable.data || ;

if (payrollRecords.length === 0) {
utils.showNotification({
title: "No Payslips Found",
description: "There are no payslip files available for download.",
style: "error",
});
return;
}

utils.showNotification({
title: "Processing Payslips",
description: Fetching ${payrollRecords.length} payslips...,
style: "info",
});

const pdfPromises = payrollRecords.map(async (record) => {
if (!record.payslip_url) {
console.warn(Skipping ${record.employee_id} - No payslip URL found.);
return;
}

const fileId = record.payslip_url.split("/").pop();
if (!fileId) {
  console.warn(`Skipping ${record.employee_id} - Invalid File ID.`);
  return;
}

const fileName = `payslip_${record.employee_id}_${record.pay_period}.pdf`;

try {
  // Overwrite fileIdVar with an empty string before setting a new value
  fileIdVar.setValue("");

  // Wait briefly to allow Retool to register the cleared value
  await new Promise((resolve) => setTimeout(resolve, 100));

  // Set the new file ID
  fileIdVar.setValue(fileId);
  console.log("Fetching File ID:", fileIdVar.value);

  // Wait before triggering fetchFileContent to ensure proper recognition
  await new Promise((resolve) => setTimeout(resolve, 300));

  await fetchFileContent.trigger();

  console.log(`fetchFileContent response for ${fileName}:`, fetchFileContent.data);

  const fileData = fetchFileContent.data;
  if (!fileData || !fileData.base64Data) {
    console.error(`Base64 data missing for ${fileName}`);
    return;
  }

  const base64Data = fileData.base64Data;
  const byteCharacters = atob(base64Data);
  const byteNumbers = new Uint8Array(byteCharacters.length);
  for (let i = 0; i < byteCharacters.length; i++) {
    byteNumbers[i] = byteCharacters.charCodeAt(i);
  }
  const blob = new Blob([byteNumbers], { type: "application/pdf" });

  zip.file(fileName, blob);
  console.log(`Added ${fileName} to ZIP.`);

  // Overwrite fileIdVar again after fetching to avoid conflicts in the next iteration
  fileIdVar.setValue("");
} catch (error) {
  console.error(`Error processing ${fileName}:`, error);
}

});

// Wait for all PDF fetches to complete before generating ZIP
await Promise.all(pdfPromises);

console.log("ZIP contents before generating:", Object.keys(zip.files));

if (Object.keys(zip.files).length === 0) {
utils.showNotification({
title: "Error",
description: "No payslips were added to the ZIP file.",
style: "error",
});
console.error("ZIP file is empty. No files were added.");
return;
}

// Generate ZIP asynchronously
zip.generateAsync({ type: "blob" }).then((zipBlob) => {
console.log("ZIP file generated successfully.");

const zipUrl = URL.createObjectURL(zipBlob);
setTimeout(() => {
  const a = document.createElement("a");
  a.href = zipUrl;
  a.download = `${folderName}.zip`;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(zipUrl);

  utils.showNotification({
    title: "Download Complete",
    description: "All payslips have been downloaded successfully in a ZIP file.",
    style: "success",
  });

  console.log("ZIP download triggered.");
}, 100);

}).catch((error) => {
console.error("Error generating ZIP:", error);
utils.showNotification({
title: "ZIP Error",
description: "There was an error generating the ZIP file.",
style: "error",
});
});
})();

Glad it worked!

Downloading files in bulk is another thing (or another question) :slight_smile:

Here's a Chatgpt possible solution.
Sorry, I am catching a plane shortly and will be offline for a while.

1 Like

No worries, I figured out the bulk downloading. It wasn't working because the variable was not updating with the script. Once I enabled the checkbox in the Advanced settings of the script to update the variable references that are in sync with this script. My code started working.
Again, thank you so much for your time.

Loving the retool community!!

3 Likes