# Images & file samples

Packs have native support for accepting images and files as parameters and returning them as results, always passed as URLs. Packs can either return a "live" URL to a hosted image (`ImageReference`) or a temporary URL that the platform should upload to the doc (`ImageAttachment`). The utility provided at `content.temporaryBlobStorage` can be used to save private images to a temporary location for later upload, which can be used in conjunction with the `ImageAttachment` hint type to permanently ingest an image resource using the temporary URL. Packs also provide support for embedded SVGs, including support for dark mode.

[Learn More](../../../guides/advanced/images/)

## Image parameter

A formula that takes an image as a parameter. This sample returns the file size of an image.

```
import * as sdk from "@codahq/packs-sdk";
export const pack = sdk.newPack();

// Regular expression that matches Coda-hosted images.
const HostedImageUrlRegex = new RegExp("^https://(?:[^/]*\.)?codahosted.io/.*");

// Formula that calculates the file size of an image.
pack.addFormula({
  name: "FileSize",
  description: "Gets the file size of an image, in bytes.",
  parameters: [
    sdk.makeParameter({
      type: sdk.ParameterType.Image,
      name: "image",
      description:
        "The image to operate on. Not compatible with Image URL columns.",
    }),
  ],
  resultType: sdk.ValueType.Number,
  execute: async function ([imageUrl], context) {
    // Throw an error if the image isn't Coda-hosted. Image URL columns can
    // contain images on any domain, but by default Packs can only access image
    // attachments hosted on codahosted.io.
    if (!imageUrl.match(HostedImageUrlRegex)) {
      throw new sdk.UserVisibleError("Not compatible with Image URL columns.");
    }
    // Fetch the image content.
    let response = await context.fetcher.fetch({
      method: "GET",
      url: imageUrl,
      isBinaryResponse: true, // Required when fetching binary content.
    });
    // The binary content of the response is returned as a Node.js Buffer.
    // See: https://nodejs.org/api/buffer.html
    let buffer = response.body as Buffer;
    // Return the length, in bytes.
    return buffer.length;
  },
});
```

## Image result

A formula that return an external image. This sample returns a random photo of a cat.

```
import * as sdk from "@codahq/packs-sdk";
export const pack = sdk.newPack();

// Formula that fetches a random cat photo, with various options.
pack.addFormula({
  name: "CatPhoto",
  description: "Gets a random cat image.",
  parameters: [
    sdk.makeParameter({
      type: sdk.ParameterType.String,
      name: "text",
      description: "Text to display over the image.",
      optional: true,
    }),
    sdk.makeParameter({
      type: sdk.ParameterType.String,
      name: "filter",
      description: "A filter to apply to the image.",
      autocomplete: ["mono", "negate"],
      optional: true,
    }),
  ],
  resultType: sdk.ValueType.String,
  codaType: sdk.ValueHintType.ImageReference,
  execute: async function (args, context) {
    let [text, filter] = args;
    let url = "https://cataas.com/cat";
    if (text) {
      url += "/says/" + encodeURIComponent(text);
    }
    url = sdk.withQueryParams(url, {
      filter: filter,
      json: true,
    });
    let response = await context.fetcher.fetch({
      method: "GET",
      url: url,
      cacheTtlSecs: 0, // Don't cache the result, so we can get a fresh cat.
    });
    return response.body.url;
  },
});

// Allow the pack to make requests to Cat-as-a-service API.
pack.addNetworkDomain("cataas.com");
```

## Image result from temporary URL

A formula that returns an image uploaded to `temporaryBlobStorage`. This sample returns a random avatar using an API that returns SVG code used to generate an avatar. You could also imagine procedurally generating a SVG or image in your packs code and uploading it to `temporaryBlobStorage`.

```
import * as sdk from "@codahq/packs-sdk";
export const pack = sdk.newPack();

pack.addNetworkDomain("boringavatars.com");

pack.addFormula({
  name: "BoringAvatar",
  description: "Get a boring avatar image.",
  parameters: [
    sdk.makeParameter({
      type: sdk.ParameterType.Number,
      name: "size",
      description: "The size to generate the avatar in pixels.",
    }),
  ],
  resultType: sdk.ValueType.String,
  codaType: sdk.ValueHintType.ImageAttachment,
  execute: async function ([size], context) {
    let resp = await context.fetcher.fetch({
      method: "GET",
      url: `https://source.boringavatars.com/beam/${size}`,
      // Formats response as binary to get a Buffer of the svg data
      isBinaryResponse: true,
    });
    // This API returns direct SVG code used to generate the avatar.
    let svg = resp.body;

    let url = await context.temporaryBlobStorage
                      .storeBlob(svg, "image/svg+xml");
    return url;
  },
});
```

## Upload images

An action that downloads images from Coda and uploads them to another service. This sample uploads a list of files to Google Photos.

```
import * as sdk from "@codahq/packs-sdk";
export const pack = sdk.newPack();

// Regular expression that matches Coda-hosted images.
const HostedImageUrlRegex = new RegExp("^https://(?:[^/]*\.)?codahosted.io/.*");

// A custom type that bundles together the image buffer and content type.
interface ImageData {
  buffer: Buffer,
  contentType: string,
}

// Action that uploads a list of images to Google Photos.
pack.addFormula({
  name: "Upload",
  description: "Uploads images to Google Photos.",
  parameters: [
    sdk.makeParameter({
      type: sdk.ParameterType.ImageArray,
      name: "images",
      description: "The images to upload.",
    }),
  ],
  resultType: sdk.ValueType.Array,
  items: {
    type: sdk.ValueType.String,
    codaType: sdk.ValueHintType.Url,
  },
  isAction: true,
  execute: async function ([imageUrls], context) {
    // Download the images from Coda.
    let images = await downloadImages(imageUrls, context);
    // Upload the images to Google Photos, getting temporary tokens.
    let uploadTokens = await uploadImages(images, context);
    // Add the images to the user's library, using the tokens.
    let urls = await addImages(uploadTokens, context);
    // Return the URLs of the uploaded images.
    return urls;
  },
});

// Download the images from Coda, in parallel. For each image it returns a
// buffer of image data and the MIME type of the image.
async function downloadImages(imageUrls, context: sdk.ExecutionContext):
    Promise<ImageData[]> {
  let requests = [];
  for (let imageUrl of imageUrls) {
    // Reject images not hosted in Coda, since we can't download them.
    if (!imageUrl.match(HostedImageUrlRegex)) {
      throw new sdk.UserVisibleError("Not compatible with Image URL columns.");
    }

    // Start the download.
    let request = context.fetcher.fetch({
      method: "GET",
      url: imageUrl,
      isBinaryResponse: true,
      disableAuthentication: true,
    });
    requests.push(request);
  }
  // Wait for all the downloads to finish.
  let responses = await Promise.all(requests);

  // Extract the data from the responses.
  let images: ImageData[] = [];
  for (let response of responses) {
    let data = {
      buffer: response.body,
      contentType: response.headers["content-type"] as string,
    };
    images.push(data);
  }
  return images;
}

// Uploads the images to Google Photos, in parallel. For each image it returns a
// temporary upload token.
async function uploadImages(images: ImageData[],
    context: sdk.ExecutionContext): Promise<string[]> {
  let requests = [];
  for (let image of images) {
    // Start the upload.
    let request = context.fetcher.fetch({
      method: "POST",
      url: "https://photoslibrary.googleapis.com/v1/uploads",
      headers: {
        "Content-Type": "application/octet-stream",
        "X-Goog-Upload-Content-Type": image.contentType,
        "X-Goog-Upload-Protocol": "raw",
      },
      body: image.buffer,
    });
    requests.push(request);
  }
  // Wait for all the uploads to finish.
  let responses = await Promise.all(requests);

  // Extract the upload tokens from the responses.
  let uploadTokens = [];
  for (let response of responses) {
    let uploadToken = response.body;
    uploadTokens.push(uploadToken);
  }
  return uploadTokens;
}

// Adds uploaded images to the user's library. For each image it returns the URL
// of the image in Google Photos.
async function addImages(uploadTokens: string[],
    context: sdk.ExecutionContext): Promise<string[]> {
  // Construct the request payload.
  let items = [];
  for (let uploadToken of uploadTokens) {
    let item  = {
      simpleMediaItem: {
        uploadToken: uploadToken,
      },
    };
    items.push(item);
  }
  let payload = {
    newMediaItems: items,
  };

  // Make the request to add all the images.
  let response = await context.fetcher.fetch({
    method: "POST",
    url: "https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(payload),
  });
  let results = response.body.newMediaItemResults;

  // Extract the URLs from the results.
  let urls = [];
  for (let [i, result] of results.entries()) {
    // Throw an error if any of the uploads failed.
    if (result.status.message !== "Success") {
      throw new sdk.UserVisibleError(
        `Upload failed for image ${i + 1}: ${result.status.message}`);
    }
    let url = result.mediaItem.productUrl;
    urls.push(url);
  }
  return urls;
}

pack.setUserAuthentication({
  type: sdk.AuthenticationType.OAuth2,
  authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
  tokenUrl: "https://oauth2.googleapis.com/token",
  scopes: [
    "https://www.googleapis.com/auth/photoslibrary.appendonly",
  ],
  additionalParams: {
    access_type: "offline",
    prompt: "consent",
  },
});

pack.addNetworkDomain("googleapis.com");
```

## Attach image data

A sync table that includes images sourced from raw data. This sample syncs files from Dropbox, including their thumbnail images.

```
import * as sdk from "@codahq/packs-sdk";
export const pack = sdk.newPack();

// Schema defining the fields to sync for each file.
const FileSchema = sdk.makeObjectSchema({
  properties: {
    name: { type: sdk.ValueType.String },
    path: { type: sdk.ValueType.String, fromKey: "path_display" },
    url: {
      type: sdk.ValueType.String,
      codaType: sdk.ValueHintType.Url,
    },
    thumbnail: {
      type: sdk.ValueType.String,
      // ImageAttachments instructs Coda to ingest the image and store it in the
      // doc. This is required, since the thumbnail image URLs returned by
      // TemporaryBlobStorage expire.
      codaType: sdk.ValueHintType.ImageAttachment,
    },
    id: { type: sdk.ValueType.String },
  },
  displayProperty: "name",
  idProperty: "id",
  featuredProperties: ["thumbnail", "url"],
});

// Sync table for files.
pack.addSyncTable({
  name: "Files",
  identityName: "File",
  schema: FileSchema,
  formula: {
    name: "SyncFiles",
    description: "Sync the files.",
    parameters: [],
    execute: async function ([], context) {
      // Get a batch of files.
      let filesResponse = await getFiles(context);
      let files = filesResponse.entries
        .filter(entry => entry[".tag"] === "file");
      let hasMore = filesResponse.has_more;

      // Get the URL for each file.
      let fileIds = files.map(file => file.id);
      let fileUrls = await getFileUrls(fileIds, context);
      for (let i = 0; i < files.length; i++) {
        files[i].url = fileUrls[i];
      }

      // Get the thumbnail for each file.
      let paths = files.map(file => file.path_lower);
      let thumbnails = await getThumbnails(paths, context);

      // The thumbnail images are returned as base64-encoded strings in the
      // response body, but the doc can only ingest an image URL. We'll parse
      // the image data and store it in temporary blob storage, and return those
      // URLs.

      // Collect the all of the temporary blob storage jobs that are started.
      let jobs = [];
      for (let thumbnail of thumbnails) {
        let job;
        if (thumbnail) {
          // Parse the base64 thumbnail content.
          let buffer = Buffer.from(thumbnail, "base64");
          // Store it in temporary blob storage.
          job = context.temporaryBlobStorage.storeBlob(buffer, "image/png");
        } else {
          // The file has no thumbnail, have the job return undefined.
          job = Promise.resolve(undefined);
        }
        jobs.push(job);
      }

      // Wait for all the jobs to complete, then copy the temporary URLs back
      // into the file objects.
      let temporaryUrls = await Promise.all(jobs);
      for (let i = 0; i < files.length; i++) {
        files[i].thumbnail = temporaryUrls[i];
      }

      // If there are more files to retrieve, create a continuation.
      let continuation;
      if (hasMore) {
        continuation = {
          cursor: filesResponse.cursor,
        };
      }

      // Return the results.
      return {
        result: files,
        continuation: continuation,
      };
    },
  },
});

// Gets a batch of files from the API.
async function getFiles(context: sdk.SyncExecutionContext): Promise<any> {
  let url = "https://api.dropboxapi.com/2/files/list_folder";
  let body;

  // Retrieve the cursor to continue from, if any.
  let cursor = context.sync.continuation?.cursor;
  if (cursor) {
    // Continue from the cursor.
    url = sdk.joinUrl(url, "/continue");
    body = {
      cursor: cursor,
    };
  } else {
    // Starting a new sync, list all of the files.
    body = {
      path: "",
      recursive: true,
      limit: 25,
    };
  }

  // Make the API request and return the response.
  let response = await context.fetcher.fetch({
    method: "POST",
    url: url,
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });
  return response.body;
}

// Get the thumbnail metadata for a list of file paths.
async function getThumbnails(paths, context: sdk.ExecutionContext):
    Promise<string[]> {
  // Use a batch URL to get all of the thumbnail metadata in one request.
  let url = "https://content.dropboxapi.com/2/files/get_thumbnail_batch";

  // Create a request entry for each file path.
  let entries = [];
  for (let path of paths) {
    let entry = {
      path: path,
      format: "png",
      size: "w256h256",
    };
    entries.push(entry);
  }

  // Make the API request and return the response.
  let response = await context.fetcher.fetch({
    method: "POST",
    url: url,
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      entries: entries,
    }),
  });
  return response.body.entries.map(entry => entry.thumbnail);
}

// Get the Dropbox URLs for a list of file IDs.
async function getFileUrls(fileIds, context: sdk.ExecutionContext):
    Promise<string[]> {
  // Use a batch URL to get all of the thumbnail metadata in one request.
  let url = "https://api.dropboxapi.com/2/sharing/get_file_metadata/batch";

  // Make the API request and return the response.
  let response = await context.fetcher.fetch({
    method: "POST",
    url: url,
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      files: fileIds,
    }),
  });
  return response.body.map(metadata => metadata.result.preview_url);
}

// Set per-user authentication using Dropbox's OAuth2.
pack.setUserAuthentication({
  type: sdk.AuthenticationType.OAuth2,
  authorizationUrl: "https://www.dropbox.com/oauth2/authorize",
  tokenUrl: "https://api.dropbox.com/oauth2/token",
  scopes: ["files.content.read", "sharing.read"],
  additionalParams: {
    token_access_type: "offline",
  },
});

// Allow access to the Dropbox domain.
pack.addNetworkDomain("dropboxapi.com");
```

## Attach private images

A sync table that includes images sourced from private URLs. This sample syncs files from Google Drive, including their thumbnail images.

```
import * as sdk from "@codahq/packs-sdk";
export const pack = sdk.newPack();

// Schema defining the fields to sync for each file.
const FileSchema = sdk.makeObjectSchema({
  properties: {
    name: { type: sdk.ValueType.String },
    url: {
      type: sdk.ValueType.String,
      codaType: sdk.ValueHintType.Url,
      fromKey: "webViewLink",
    },
    thumbnail: {
      type: sdk.ValueType.String,
      // ImageAttachments instructs Coda to ingest the image and store it in the
      // doc.
      codaType: sdk.ValueHintType.ImageAttachment,
    },
    id: { type: sdk.ValueType.String },
  },
  displayProperty: "name",
  idProperty: "id",
  featuredProperties: ["thumbnail", "url"],
});

// Sync table for files.
pack.addSyncTable({
  name: "Files",
  identityName: "File",
  schema: FileSchema,
  formula: {
    name: "SyncFiles",
    description: "Sync the files.",
    parameters: [],
    execute: async function ([], context) {
      // Retrieve the page token to use from the previous sync, if any.
      let pageToken = context.sync.continuation?.pageToken;

      // Get a batch of files.
      let url = "https://www.googleapis.com/drive/v3/files";
      url = sdk.withQueryParams(url, {
        fields: "files(id,name,webViewLink,thumbnailLink)",
        pageToken: pageToken,
      });
      let response = await context.fetcher.fetch({
        method: "GET",
        url: url,
      });
      let files = response.body.files;
      let nextPageToken = response.body.nextPageToken;

      // The thumbnail URLs that the Drive API returns require authentication
      // credentials to access, so the doc won't be able to ingest them as-is.
      // Instead, we'll download the thumbnails and store them in temporary
      // blob storage, and return those URLs.

      // Collect the all of the temporary blob storage jobs that are started.
      let jobs = [];
      for (let file of files) {
        let job;
        if (file.thumbnailLink) {
          // Download the thumbnail (with credentials) and store it in temporary
          // blob storage.
          job = context.temporaryBlobStorage.storeUrl(file.thumbnailLink);
        } else {
          // The file has no thumbnail, have the job return undefined.
          job = Promise.resolve(undefined);
        }
        jobs.push(job);
      }

      // Wait for all the jobs to complete, then copy the temporary URLs back
      // into the file objects.
      let temporaryUrls = await Promise.all(jobs);
      for (let i = 0; i < files.length; i++) {
        files[i].thumbnail = temporaryUrls[i];
      }

      // If there are more files to retrieve, create a continuation.
      let continuation;
      if (nextPageToken) {
        continuation = { pageToken: nextPageToken };
      }

      // Return the results.
      return {
        result: files,
        continuation: continuation,
      };
    },
  },
});

// Set per-user authentication using Google's OAuth2.
pack.setUserAuthentication({
  type: sdk.AuthenticationType.OAuth2,
  authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
  tokenUrl: "https://oauth2.googleapis.com/token",
  scopes: ["https://www.googleapis.com/auth/drive.readonly"],
  additionalParams: {
    access_type: "offline",
    prompt: "consent",
  },
  // Send the authentication information to all domains.
  // Note: Using auth with multiple domains requires approval from Superhuman.
  networkDomain: ["googleapis.com", "docs.google.com", "googleusercontent.com"],
});

// Allow access to the Google domains.
// Note: Using multiple domains in a Pack requires approval from Superhuman.
pack.addNetworkDomain("googleapis.com");
pack.addNetworkDomain("docs.google.com");
pack.addNetworkDomain("googleusercontent.com");
```

## Generated SVG

A formula that generated an SVG, and returns it as a data URI. This sample generates an image from the text provided.

```
import * as sdk from "@codahq/packs-sdk";
export const pack = sdk.newPack();

// A formula that generates an image using some input text.
pack.addFormula({
  name: "TextToImage",
  description: "Generates an image using the text provided.",
  parameters: [
    sdk.makeParameter({
      type: sdk.ParameterType.String,
      name: "text",
      description: "The text to include in the image.",
      suggestedValue: "Hello World!",
    }),
    sdk.makeParameter({
      type: sdk.ParameterType.String,
      name: "color",
      description: "The desired color of the text. Defaults to black.",
      optional: true,
    }),
  ],
  resultType: sdk.ValueType.String,
  codaType: sdk.ValueHintType.ImageReference,
  execute: async function ([text, color = "black"], context) {
    // Calculate the width of the generated image required to fit the text.
    // Using a fixed-width font to make this easy.
    let width = text.length * 6;
    // Generate the SVG markup. Prefer using a library for this when possible.
    let svg = `
      <svg viewBox="0 0 ${width} 10" xmlns="http://www.w3.org/2000/svg">
        <text x="0" y="8" font-family="Courier" font-size="10" fill="${color}">
          ${text}
        </text>
      </svg>
    `.trim();
    // Encode the markup as base64.
    let encoded = Buffer.from(svg).toString("base64");
    // Return the SVG as a data URL.
    return sdk.SvgConstants.DataUrlPrefix + encoded;
  },
});
```

## Dark mode SVG

A formula that generates an SVG that adapts if dark mode is enabled. This sample generates an image with static text, which changes color when dark mode is enabled.

```
import * as sdk from "@codahq/packs-sdk";
export const pack = sdk.newPack();

// A formula that demonstrates how to generate an SVG that adapts to the user's
// dark mode setting in Coda.
pack.addFormula({
  name: "HelloDarkMode",
  description: "Generates an image that adapts to the dark mode setting.",
  parameters: [],
  resultType: sdk.ValueType.String,
  codaType: sdk.ValueHintType.ImageReference,
  execute: async function ([], context) {
    // When loading your image in dark mode, Coda will append the URL fragment
    // "#DarkMode". Instead of hard-coding that value, it's safer to retrieve
    // it from the SDK.
    let darkModeId = sdk.SvgConstants.DarkModeFragmentId;
    // Generate the SVG markup. Prefer using a library for this when possible.
    let svg = `
      <svg viewBox="0 0 36 10" xmlns="http://www.w3.org/2000/svg">
        <!-- Add the dark mode ID to the root of the SVG. -->
        <g id="${darkModeId}">
          <text x="0" y="8" font-family="Courier" font-size="10" fill="black">
            Hello World!
          </text>
        </g>
        <style>
          /* Create a style rule that will be applied when the dark mode
             fragment is applied. */
          #${darkModeId}:target text { fill: white; }
        </style>
      </svg>
    `.trim();
    // Encode the markup as base64.
    let encoded = Buffer.from(svg).toString("base64");
    // Return the SVG as a data URL (using the dark mode prefix).
    return sdk.SvgConstants.DataUrlPrefixWithDarkModeSupport + encoded;
  },
});
```

## File parameter

A formula that takes an file as a parameter. This sample uploads the file to an AWS S3 bucket.

```
import * as sdk from "@codahq/packs-sdk";
export const pack = sdk.newPack();

// Action that uploads a file to Amazon S3.
pack.addFormula({
  name: "Upload",
  description: "Upload a file to AWS S3.",
  parameters: [
    sdk.makeParameter({
      type: sdk.ParameterType.File,
      name: "file",
      description: "The file to upload.",
    }),
    sdk.makeParameter({
      type: sdk.ParameterType.String,
      name: "name",
      description: "The target file name. Default: the original file name.",
      optional: true,
    }),
    sdk.makeParameter({
      type: sdk.ParameterType.String,
      name: "path",
      description: "The target directory path. Default: the root directory.",
      optional: true,
    }),
  ],
  resultType: sdk.ValueType.String,
  isAction: true,
  execute: async function ([fileUrl, name, path="/"], context) {
    // Fetch the file contents.
    let response = await context.fetcher.fetch({
      method: "GET",
      url: fileUrl,
      isBinaryResponse: true,
      disableAuthentication: true,
    });
    let buffer = response.body;
    let contentType = response.headers["content-type"] as string;
    let contentDisposition = response.headers["content-disposition"] as string;

    // Determine file name.
    if (!name && contentDisposition) {
      name = getFilename(contentDisposition);
    }
    if (!name) {
      // Fallback to last segment of the URL.
      name = fileUrl.split("/").pop();
    }

    // Upload to S3.
    let s3Url = sdk.joinUrl(context.endpoint, path, name);
    await context.fetcher.fetch({
      method: "PUT",
      url: s3Url,
      headers: {
        "Content-Type": contentType,
        "Content-Length": buffer.length.toString(),
      },
      body: buffer,
    });
    return s3Url;
  },
});

// Gets the filename from a Content-Disposition header value.
function getFilename(contentDisposition) {
  let match = contentDisposition.match(/filename=(.*?)(;|$)/);
  if (!match) {
    return;
  }
  let filename = match[1].trim();
  // Remove quotes around the filename, if present.
  filename = filename.replace(/^["'](.*)["']$/, "$1");
  return filename;
}

// Set per-user authentication using AWS Signature Version 4 with an access key.
pack.setUserAuthentication({
  type: sdk.AuthenticationType.AWSAccessKey,
  service: "s3",
  requiresEndpointUrl: true,
  endpointDomain: "amazonaws.com",
});

// Allow the pack to make requests to AWS.
pack.addNetworkDomain("amazonaws.com");
```
