Skip to content
Skip to chapter

Chapter 37

Advanced integrations and data

Package CSV, JSON, and a PDF for handoff

Build one ZIP in Apps Script with checked CSV, a JSON manifest, and a Google Doc converted to PDF with getAs(), then verify the saved file.

By the end

Build one private ZIP with normalized CSV, a checked JSON manifest, and a packing-note PDF, then inspect the saved ZIP and match it to its record.

Chapter navigation

Prepare one garden handoff

Package a fictional delivery of seed trays, plant labels, and a care card into one garden-handoff.zip. Another program can read its CSV and JSON members, and the person packing the supplies can read its PDF instructions. The archive keeps the three parts together in one Drive file.

You need the arrays chapter for rows, records, and callbacks, and the HTTP chapter for the difference between parsing and validating a value. You also need the Drive and Docs chapters for selecting files by ID, reading a native document, and recovering an output whose ID you know. This project needs no network endpoint, spreadsheet, email, or practice files from an earlier chapter.

The archive reflects the inputs at the time you create it. One standalone project tracks one attempted handoff. After the archive is complete, a rerun checks the saved archive. It does not make another one or pick up later edits to the inputs.

Distinguish text, bytes, blobs, and files

The project uses five related representations:

RepresentationIn this projectWhat it provides
JavaScript valueAn item record with numeric quantityFields the calculation can inspect and add
JavaScript stringCSV or JSON textCharacters that a parser or serializer interprets
Byte arrayblob.getBytes()The encoded data, including binary PDF and ZIP content
Apps Script BlobA CSV, JSON, PDF, or ZIP memberBytes plus a content-type label and a name for service calls
Drive FileThe selected input or saved ZIPA persistent resource with a file ID, permissions, and metadata

A Blob is an Apps Script object that holds data you pass between services. Making one does not save a Drive file. folder.createFile(blob) is the separate operation that persists it in the chosen folder and returns a File with its own ID.

Check the filename, MIME type, and contents separately. items.csv is a name. text/csv is a MIME type, a label describing the representation. The bytes must still contain valid CSV with this project's headers and values. A file named packing-note.pdf containing ordinary text is not a PDF. Likewise, setContentType('application/pdf') changes only a Blob's label. The bytes stay in their original format. Conversion needs a supported source and a conversion operation.

Supply the files where Apps Script can read them

Use a new private folder directly in My Drive, one Google account, and one operator. Do not use a shared drive, shortcut, shared ancestor folder, or existing business document. The script checks IDs, immediate parents, types, and Trash state. It does not check ownership or privacy. Under Drive's permission inheritance rules, anyone who can open the destination folder can open the output, even though the code never shares it.

  1. Create a folder named Garden handoff practice. In Share, confirm General access > Restricted, that you own it, and that no other person or group has access.

  2. Download the supplied items.csv and delivery.json. Use New > File upload inside that folder to upload them as ordinary files. Do not import the CSV into Sheets or convert either file to a native editor document. Keep their bytes unchanged for the baseline.

  3. In the same folder, create a new blank Google Doc named Garden packing note. Keep one document tab, no child tabs, and File > Page setup > Pages. Paste the complete packing-note.txt contents as plain text, retaining paragraph breaks. Use ordinary 11- or 12-point text, and make the first paragraph an 18-point bold title if you like. Keep the note on one short page, with no extra tabs, images, headers, footers, links, or confidential material.

  4. Wait for Docs to finish saving. Inspect the entire note, not only its first paragraph. The script converts this native Doc to the PDF. It does not use packing-note.txt directly or build the note from CSV rows.

  5. Copy the folder ID after /folders/ in its URL. For each uploaded file, open its Drive preview and copy the ID between /file/d/ and the next slash. For the native Doc, copy the ID between /document/d/ and the next slash. Stop before any query string.

The project includes these complete sample files:

items.csv
item,description,quantity
seed-tray,"Tray, café corner",2
plant-label,"Label ""Herbs""",6
watering-card,"Water slowly
Keep soil damp",1

Download items.csv
delivery.json
{
  "schemaVersion": 1,
  "title": "Garden supplies",
  "deliveryDate": "2026-10-03",
  "reference": null
}

Download delivery.json
packing-note.txt
Garden packing note

Prepare the items listed in manifest.json.
Keep the tray for the café corner upright.
Place the labels in a dry envelope.
Read the care card before watering.

Practice handoff only. No customer or personal data.

Download packing-note.txt

Only Code.gs and appsscript.json belong in the Apps Script editor. The script reads items.csv and delivery.json from Drive by ID, and packing-note.txt supplies the text you pasted into the native Doc. Keep expected-manifest.json on your computer to compare against the generated output.

Install, authorize, and preview

  1. At script.google.com, create a new standalone project named Garden file formats. Replace all of Code.gs with the complete source below.

  2. In Project Settings, enable Show "appsscript.json" manifest file in editor. Replace that file with the supplied manifest. It selects V8 and the Etc/UTC time zone. You do not need to enable an advanced service or library.

  3. Set FILE_FORMATS.folderId, csvFileId, jsonFileId, and nativeDocId to the four IDs you obtained. Leave approvedPreviewDigest and confirmedOutputId as empty strings. Keep the output name and limits unchanged.

  4. Save, select previewFormatsBundle, and click Run. Review the permissions before continuing. Open Execution log and compare the selected IDs, input names, MIME types, and planned member names with your actual practice files.

Code.gs
/**
 * @typedef {{folderId: string, csvFileId: string, jsonFileId: string, nativeDocId: string}} FormatsTarget
 * @typedef {{item: string, description: string, quantity: number}} FormatsItem
 * @typedef {{schemaVersion: 1, title: string, deliveryDate: string, reference: string|null}} FormatsDelivery
 * @typedef {{name: string, bytes: number, sha256: string}} FormatsMember
 */

/**
 * @typedef {object} FormatsRecord
 * @property {1} version
 * @property {FormatsTarget} target
 * @property {string} previewDigest
 * @property {string} attemptedAt
 * @property {string} outputName
 * @property {string} outputFileId
 * @property {'pending'|'complete'} status
 * @property {number} zipBytes
 * @property {string} zipSha256
 * @property {FormatsMember[]} members
 */

const FILE_FORMATS = {
  folderId: 'REPLACE_WITH_PRIVATE_FOLDER_ID',
  csvFileId: 'REPLACE_WITH_ITEMS_CSV_ID',
  jsonFileId: 'REPLACE_WITH_DELIVERY_JSON_ID',
  nativeDocId: 'REPLACE_WITH_NATIVE_GOOGLE_DOC_ID',
  approvedPreviewDigest: '', // Copy only after inspecting the restored inputs and native Doc.
  confirmedOutputId: '', // Recovery only: an existing ZIP's exact Drive file ID.
};
const FORMATS_RECORD_KEY = 'fileFormatsBundle.v1';
const FORMATS_OUTPUT_NAME = 'garden-handoff.zip';
/** @type {Array<keyof FormatsTarget>} */
const FORMATS_TARGET_KEYS = ['folderId', 'csvFileId', 'jsonFileId', 'nativeDocId'];
const FORMATS_MEMBER_NAMES = ['items.csv', 'manifest.json', 'packing-note.pdf'];
const FORMATS_MAX_TEXT_BYTES = 16 * 1024;
const FORMATS_MAX_PDF_BYTES = 1024 * 1024;
const FORMATS_MAX_ZIP_BYTES = 2 * 1024 * 1024;

function previewFormatsBundle() {
  withFormatsLock_(() => {
    const plan = prepareFormatsPlan_();
    console.log(JSON.stringify({
      target: plan.sources.target,
      inputs: plan.sources.metadata,
      previewDigest: plan.sources.digest,
      plannedMembers: FORMATS_MEMBER_NAMES,
      outputName: FORMATS_OUTPUT_NAME,
      record: readFormatsRecord_(),
    }, null, 2));
    console.log('Planned manifest.json:\n' + plan.jsonText);
    console.log('Planned items.csv:\n' + plan.csvText);
    console.log('Preview only: no PDF conversion, file creation, or property write.');
  });
}

function createFormatsBundle() {
  withFormatsLock_(() => {
    const target = formatsTarget_();
    const existing = readFormatsRecord_();
    if (existing !== null) {
      if (!FORMATS_TARGET_KEYS.every((key) => existing.target[key] === target[key])) {
        throw new Error('Restore the recorded target IDs. Changing configuration is not a new export.');
      }
      if (existing.status !== 'complete') {
        throw new Error('A creation is pending. Show the record and verify/reconcile that output; do not create again.');
      }
      const output = verifyFormatsOutput_(existing, existing.outputFileId);
      console.log('No conversion or creation. Recorded output: ' + output.getUrl());
      return;
    }
    const plan = prepareFormatsPlan_();
    if (!isFormatsDigest_(FILE_FORMATS.approvedPreviewDigest) ||
        FILE_FORMATS.approvedPreviewDigest !== plan.sources.digest) {
      throw new Error('Run previewFormatsBundle, inspect its inputs and native Doc, then copy the current previewDigest.');
    }
    if (plan.sources.folder.getFilesByName(FORMATS_OUTPUT_NAME).hasNext()) {
      throw new Error('That output name already exists. Inspect it; this project will not overwrite or adopt by name.');
    }

    // Convert the actual native Google Doc. Renaming text or changing its MIME label is not conversion.
    const pdf = plan.sources.nativeDoc.getAs('application/pdf').setName('packing-note.pdf');
    const pdfBytes = pdf.getBytes();
    if (pdf.getContentType() !== 'application/pdf' || pdfBytes.length === 0 ||
        pdfBytes.length > FORMATS_MAX_PDF_BYTES) {
      throw new Error('The native conversion did not return a PDF within the one MiB lesson allowance.');
    }
    // Detect changed source bytes/metadata, without claiming a revision-pinned native export.
    if (readFormatsSources_().digest !== plan.sources.digest) {
      throw new Error('An input changed during preparation. No ZIP file was created. Inspect and preview again.');
    }
    const csv = formatsTextBlob_(plan.csvText, 'text/csv', 'items.csv');
    const json = formatsTextBlob_(plan.jsonText, 'application/json', 'manifest.json');
    const csvBytes = csv.getBytes();
    const jsonBytes = json.getBytes();
    if (csvBytes.length > FORMATS_MAX_TEXT_BYTES || jsonBytes.length > FORMATS_MAX_TEXT_BYTES) {
      throw new Error('A generated text member exceeds the lesson allowance.');
    }
    const zip = Utilities.zip([csv, json, pdf], FORMATS_OUTPUT_NAME).setContentType('application/zip');
    const zipBytes = zip.getBytes();
    if (zipBytes.length === 0 || zipBytes.length > FORMATS_MAX_ZIP_BYTES) {
      throw new Error('The generated ZIP exceeds the two MiB lesson allowance.');
    }
    /** @type {FormatsRecord} */
    const record = {
      version: 1,
      target,
      previewDigest: plan.sources.digest,
      attemptedAt: new Date().toISOString(),
      outputName: FORMATS_OUTPUT_NAME,
      outputFileId: '',
      status: 'pending',
      zipBytes: zipBytes.length,
      zipSha256: formatsSha256_(zipBytes),
      members: [
        { name: 'items.csv', bytes: csvBytes.length, sha256: formatsSha256_(csvBytes) },
        { name: 'manifest.json', bytes: jsonBytes.length, sha256: formatsSha256_(jsonBytes) },
        { name: 'packing-note.pdf', bytes: pdfBytes.length, sha256: formatsSha256_(pdfBytes) },
      ],
    };
    // The intent and exact generated-byte digests precede the only Drive creation call.
    saveFormatsRecord_(record);
    const output = plan.sources.folder.createFile(zip);
    record.outputFileId = output.getId();
    saveFormatsRecord_(record); // Retain the returned ID before reading the saved archive.
    console.log('Drive returned output ID: ' + record.outputFileId);
    verifyFormatsOutput_(record, record.outputFileId);
    record.status = 'complete';
    saveFormatsRecord_(record);
    console.log('Saved archive and all three member digests verified: ' + output.getUrl());
  });
}

function showFormatsRecord() {
  console.log(JSON.stringify(readFormatsRecord_(), null, 2));
}

function verifyFormatsBundle() {
  withFormatsLock_(() => {
    const record = readFormatsRecord_();
    if (record === null || record.outputFileId === '') {
      throw new Error('No output ID is saved. Inspect the pending attempt and use explicit reconciliation.');
    }
    const output = verifyFormatsOutput_(record, record.outputFileId);
    if (record.status !== 'complete') {
      record.status = 'complete';
      saveFormatsRecord_(record);
    }
    console.log('Verified the same saved archive without conversion or creation: ' + output.getUrl());
  });
}

function reconcileFormatsBundle() {
  withFormatsLock_(() => {
    const record = readFormatsRecord_();
    if (record === null) throw new Error('No saved attempt exists. Do not adopt a file or repeat a reported creation.');
    requireFormatsId_(FILE_FORMATS.confirmedOutputId, 'confirmedOutputId');
    if (record.outputFileId !== '' && record.outputFileId !== FILE_FORMATS.confirmedOutputId) {
      throw new Error('Use the already recorded output ID; a replacement cannot be adopted.');
    }
    const output = verifyFormatsOutput_(record, FILE_FORMATS.confirmedOutputId);
    record.outputFileId = output.getId();
    record.status = 'complete';
    saveFormatsRecord_(record);
    console.log('Recorded the inspected existing ZIP. No file changed: ' + output.getUrl());
    console.log('Reset confirmedOutputId to an empty string; retain the saved record.');
  });
}

function prepareFormatsPlan_() {
  const sources = readFormatsSources_();
  const items = parseFormatsCsv_(sources.csv.text);
  const delivery = parseFormatsDelivery_(sources.json.text);
  const manifest = {
    schemaVersion: 1,
    title: delivery.title,
    deliveryDate: delivery.deliveryDate,
    reference: delivery.reference,
    items,
    totalQuantity: items.reduce((sum, item) => sum + item.quantity, 0),
    packingNote: 'packing-note.pdf',
  };
  const rows = [
    ['item', 'description', 'quantity'],
    ...items.map((item) => [item.item, item.description, String(item.quantity)]),
  ];
  // Quote every field, double embedded quotes, and use CRLF between records.
  const csvText = rows.map((row) => row.map((field) =>
    '"' + field.replace(/"/g, '""') + '"').join(',')).join('\r\n') + '\r\n';
  return { sources, csvText, jsonText: JSON.stringify(manifest, null, 2) + '\n' };
}

function readFormatsSources_() {
  const target = formatsTarget_();
  const folder = DriveApp.getFolderById(target.folderId);
  if (folder.isTrashed()) throw new Error('The selected practice folder is in Trash.');
  const csvFile = requireFormatsChild_(target.csvFileId, target.folderId);
  const jsonFile = requireFormatsChild_(target.jsonFileId, target.folderId);
  const nativeDoc = requireFormatsChild_(target.nativeDocId, target.folderId);
  if (!['text/csv', 'text/plain', 'application/csv'].includes(csvFile.getMimeType())) {
    throw new Error('The CSV input must be an uploaded CSV/plain-text file, not a converted Sheet or shortcut.');
  }
  if (!['application/json', 'text/plain'].includes(jsonFile.getMimeType())) {
    throw new Error('The JSON input must be an uploaded JSON/plain-text file. Inspect an unexpected MIME type.');
  }
  if (nativeDoc.getMimeType() !== 'application/vnd.google-apps.document') {
    throw new Error('The packing note must be the native Google Doc, not a PDF, DOCX, text file, or shortcut.');
  }
  const csv = readFormatsUtf8_(csvFile);
  const json = readFormatsUtf8_(jsonFile);
  const nativeUpdatedAt = nativeDoc.getLastUpdated().toISOString();
  const digestText = JSON.stringify({ target, csvSha256: csv.sha256, jsonSha256: json.sha256, nativeUpdatedAt });
  return {
    target, folder, nativeDoc, csv, json,
    digest: formatsSha256_(Utilities.newBlob(digestText).getBytes()),
    metadata: [
      { id: csvFile.getId(), name: csvFile.getName(), mimeType: csvFile.getMimeType(), bytes: csv.bytes, codeUnits: csv.text.length },
      { id: jsonFile.getId(), name: jsonFile.getName(), mimeType: jsonFile.getMimeType(), bytes: json.bytes, codeUnits: json.text.length },
      { id: nativeDoc.getId(), name: nativeDoc.getName(), mimeType: nativeDoc.getMimeType(), updatedAt: nativeUpdatedAt },
    ],
  };
}

/** @param {DriveApp.File} file @returns {{text: string, bytes: number, sha256: string}} */
function readFormatsUtf8_(file) {
  if (file.getSize() > FORMATS_MAX_TEXT_BYTES) throw new Error('Each uploaded text input must be at most 16 KiB.');
  const blob = file.getBlob();
  const bytes = blob.getBytes();
  if (bytes.length === 0 || bytes.length > FORMATS_MAX_TEXT_BYTES) {
    throw new Error('The text input is empty or exceeds 16 KiB.');
  }
  const text = blob.getDataAsString('UTF-8');
  const encoded = Utilities.newBlob('').setDataFromString(text, 'UTF-8').getBytes();
  if (bytes.length !== encoded.length || !bytes.every((byte, index) => byte === encoded[index])) {
    throw new Error('The text bytes do not round-trip as UTF-8. Re-save the practice fixture as UTF-8.');
  }
  // A single leading BOM is tolerated on input; generated text has no BOM.
  return { text: text.replace(/^\uFEFF/, ''), bytes: bytes.length, sha256: formatsSha256_(bytes) };
}

/** @param {string} text @returns {FormatsItem[]} */
function parseFormatsCsv_(text) {
  // Remove at most one optional terminal record delimiter, never split physical lines.
  const rows = Utilities.parseCsv(text.replace(/\r?\n$/, ''));
  if (rows.length !== 4 || rows.some((row) => row.length !== 3) ||
      !['item', 'description', 'quantity'].every((header, column) => rows[0][column] === header)) {
    throw new Error('Use exactly the item,description,quantity header and three three-field records.');
  }
  /** @type {Set<string>} */
  const ids = new Set();
  return rows.slice(1).map((row) => {
    const [item, rawDescription, quantityText] = row;
    const description = rawDescription.replace(/\r\n?/g, '\n');
    if (!/^[a-z][a-z0-9-]{0,31}$/.test(item) || ids.has(item)) {
      throw new Error('Item IDs must be distinct, start with a lowercase letter, and use at most 32 letters/digits/hyphens.');
    }
    requireFormatsText_(description, 240, true, 'description');
    if (!/^[1-9][0-9]?$/.test(quantityText) || Number(quantityText) > 20) {
      throw new Error('Each quantity must be a decimal whole count from 1 to 20, without signs, spaces, or leading zeroes.');
    }
    ids.add(item);
    return { item, description, quantity: Number(quantityText) };
  });
}

/** @param {string} text @returns {FormatsDelivery} */
function parseFormatsDelivery_(text) {
  /** @type {unknown} */
  let delivery;
  try {
    delivery = JSON.parse(text);
  } catch (error) {
    throw new Error('delivery.json is not valid JSON text. Restore its quotes, commas, and escapes.');
  }
  const keys = ['schemaVersion', 'title', 'deliveryDate', 'reference'];
  if (!isFormatsObject_(delivery) || Object.keys(delivery).length !== keys.length ||
      !keys.every((key) => Object.prototype.hasOwnProperty.call(delivery, key)) || delivery.schemaVersion !== 1) {
    throw new Error('delivery.json must contain exactly schemaVersion 1, title, deliveryDate, and reference.');
  }
  const title = requireFormatsText_(delivery.title, 80, false, 'title');
  if (typeof delivery.deliveryDate !== 'string' || !/^20[0-9]{2}-[0-9]{2}-[0-9]{2}$/.test(delivery.deliveryDate)) {
    throw new Error('deliveryDate must be a calendar date string YYYY-MM-DD in years 2000-2099.');
  }
  const date = new Date(delivery.deliveryDate + 'T00:00:00.000Z');
  if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== delivery.deliveryDate) {
    throw new Error('deliveryDate is not a real calendar date.');
  }
  const reference = delivery.reference === null
    ? null : requireFormatsText_(delivery.reference, 40, false, 'reference');
  return { schemaVersion: 1, title, deliveryDate: delivery.deliveryDate, reference };
}

/**
 * @param {unknown} value
 * @param {number} maxLength
 * @param {boolean} multiline
 * @param {string} name
 * @returns {string}
 */
function requireFormatsText_(value, maxLength, multiline, name) {
  // A conservative lesson policy, not a universal spreadsheet CSV sanitizer.
  if (typeof value !== 'string' || value.length > maxLength || !/^[A-Za-z]/.test(value) ||
      value !== value.trim() || /[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F]/.test(value) ||
      (!multiline && /[\r\n]/.test(value))) {
    throw new Error(name + ' must start with an ASCII letter, have no outer whitespace/unsafe controls, and fit the lesson length limit.');
  }
  for (const character of value) {
    const point = character.codePointAt(0);
    if (point !== undefined && point >= 0xD800 && point <= 0xDFFF) {
      throw new Error(name + ' contains an unpaired Unicode surrogate.');
    }
  }
  return value;
}

/** @param {string} text @param {string} mimeType @param {string} name @returns {Blob} */
function formatsTextBlob_(text, mimeType, name) {
  return Utilities.newBlob('').setDataFromString(text, 'UTF-8').setContentType(mimeType).setName(name);
}

/** @param {FormatsRecord} record @param {string} outputId @returns {DriveApp.File} */
function verifyFormatsOutput_(record, outputId) {
  requireFormatsId_(outputId, 'output ID');
  if (FORMATS_TARGET_KEYS.some((key) => record.target[key] === outputId)) {
    throw new Error('An input or the folder cannot be adopted as the output.');
  }
  const folder = DriveApp.getFolderById(record.target.folderId);
  if (folder.isTrashed()) throw new Error('The recorded folder is in Trash.');
  const file = requireFormatsChild_(outputId, record.target.folderId);
  if (file.getName() !== record.outputName || file.getMimeType() !== 'application/zip' ||
      file.getSize() !== record.zipBytes || file.getSize() > FORMATS_MAX_ZIP_BYTES) {
    throw new Error('The output name, type, size, or location differs from the saved attempt.');
  }
  const zip = file.getBlob();
  const bytes = zip.getBytes();
  if (bytes.length !== record.zipBytes || formatsSha256_(bytes) !== record.zipSha256) {
    throw new Error('The saved ZIP bytes do not match the generated archive. Preserve the file and record.');
  }
  // Only unpack the exact archive generated by this attempt, not an arbitrary candidate ZIP.
  const entries = Utilities.unzip(zip);
  if (entries.length !== record.members.length) throw new Error('The archive member count is wrong.');
  /** @type {Set<string>} */
  const seen = new Set();
  for (const entry of entries) {
    const name = entry.getName();
    const expected = record.members.find((member) => member.name === name);
    if (name === null || !expected || seen.has(name)) {
      throw new Error('An archive member name is unexpected or duplicated.');
    }
    const entryBytes = entry.getBytes();
    if (entryBytes.length !== expected.bytes || formatsSha256_(entryBytes) !== expected.sha256) {
      throw new Error('Archive member bytes changed: ' + name);
    }
    seen.add(name);
  }
  return file;
}

/** @param {string} id @param {string} folderId @returns {DriveApp.File} */
function requireFormatsChild_(id, folderId) {
  const file = DriveApp.getFileById(id);
  if (file.isTrashed()) throw new Error('A selected file is in Trash: ' + id);
  const parents = file.getParents();
  if (!parents.hasNext() || parents.next().getId() !== folderId || parents.hasNext()) {
    throw new Error('Every selected file must be directly inside the recorded private practice folder.');
  }
  return file;
}

/** @returns {FormatsTarget} */
function formatsTarget_() {
  const target = {
    folderId: requireFormatsId_(FILE_FORMATS.folderId, 'folderId'),
    csvFileId: requireFormatsId_(FILE_FORMATS.csvFileId, 'csvFileId'),
    jsonFileId: requireFormatsId_(FILE_FORMATS.jsonFileId, 'jsonFileId'),
    nativeDocId: requireFormatsId_(FILE_FORMATS.nativeDocId, 'nativeDocId'),
  };
  if (new Set(Object.values(target)).size !== FORMATS_TARGET_KEYS.length) {
    throw new Error('The folder, CSV, JSON, and native Doc must have distinct IDs.');
  }
  return target;
}

/** @param {unknown} id @param {string} name @returns {string} */
function requireFormatsId_(id, name) {
  if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(id) || id.startsWith('REPLACE_')) {
    throw new Error('Set ' + name + ' to the actual practice resource ID, not a filename, placeholder, or whole URL.');
  }
  return id;
}

/** @param {number[]} bytes @returns {string} */
function formatsSha256_(bytes) {
  return Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, bytes)
    .map((byte) => (byte & 255).toString(16).padStart(2, '0')).join('');
}

/** @param {unknown} value @returns {value is string} */
function isFormatsDigest_(value) {
  return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
}

/** @param {unknown} value @returns {value is Record<string, unknown>} */
function isFormatsObject_(value) {
  return value !== null && typeof value === 'object' && !Array.isArray(value);
}

/** @returns {FormatsRecord|null} */
function readFormatsRecord_() {
  const raw = PropertiesService.getScriptProperties().getProperty(FORMATS_RECORD_KEY);
  if (raw === null) return null;
  /** @type {unknown} */
  let record;
  try {
    record = JSON.parse(raw);
  } catch (error) {
    throw new Error('The saved export record is not valid JSON. Preserve it; do not erase the attempt.');
  }
  if (!isFormatsObject_(record) || record.version !== 1 || !isFormatsObject_(record.target) ||
      Object.keys(record.target).length !== FORMATS_TARGET_KEYS.length ||
      (record.status !== 'pending' && record.status !== 'complete') || record.outputName !== FORMATS_OUTPUT_NAME ||
      typeof record.outputFileId !== 'string' || (record.status === 'complete' && record.outputFileId === '') ||
      !isFormatsDigest_(record.previewDigest) || !isFormatsDigest_(record.zipSha256) ||
      typeof record.zipBytes !== 'number' || !Number.isInteger(record.zipBytes) ||
      record.zipBytes < 1 || record.zipBytes > FORMATS_MAX_ZIP_BYTES ||
      typeof record.attemptedAt !== 'string' || !Number.isFinite(Date.parse(record.attemptedAt)) ||
      !Array.isArray(record.members) || record.members.length !== FORMATS_MEMBER_NAMES.length) {
    throw new Error('The saved export record is damaged or unsupported. Preserve it; do not erase the attempt.');
  }
  const target = {
    folderId: requireFormatsId_(record.target.folderId, 'record.folderId'),
    csvFileId: requireFormatsId_(record.target.csvFileId, 'record.csvFileId'),
    jsonFileId: requireFormatsId_(record.target.jsonFileId, 'record.jsonFileId'),
    nativeDocId: requireFormatsId_(record.target.nativeDocId, 'record.nativeDocId'),
  };
  if (new Set(Object.values(target)).size !== FORMATS_TARGET_KEYS.length) {
    throw new Error('The saved input identities are not distinct.');
  }
  if (record.outputFileId !== '') requireFormatsId_(record.outputFileId, 'record.outputFileId');
  /** @type {unknown[]} */
  const rawMembers = record.members;
  /** @type {FormatsMember[]} */
  const members = [];
  /** @type {Set<string>} */
  const names = new Set();
  for (const member of rawMembers) {
    if (!isFormatsObject_(member) || typeof member.name !== 'string' ||
        !FORMATS_MEMBER_NAMES.includes(member.name) || names.has(member.name) ||
        !isFormatsDigest_(member.sha256) || typeof member.bytes !== 'number' ||
        !Number.isInteger(member.bytes) || member.bytes < 1 ||
        member.bytes > (member.name === 'packing-note.pdf' ? FORMATS_MAX_PDF_BYTES : FORMATS_MAX_TEXT_BYTES)) {
      throw new Error('The saved archive member identity is damaged. Preserve the record.');
    }
    names.add(member.name);
    members.push({ name: member.name, bytes: member.bytes, sha256: member.sha256 });
  }
  return {
    version: 1,
    target,
    previewDigest: record.previewDigest,
    attemptedAt: record.attemptedAt,
    outputName: record.outputName,
    outputFileId: record.outputFileId,
    status: record.status,
    zipBytes: record.zipBytes,
    zipSha256: record.zipSha256,
    members,
  };
}

/** @param {FormatsRecord} record @returns {void} */
function saveFormatsRecord_(record) {
  const text = JSON.stringify(record);
  if (Utilities.newBlob(text).getBytes().length > 8000) throw new Error('The lesson record is too large; nothing was reset.');
  PropertiesService.getScriptProperties().setProperty(FORMATS_RECORD_KEY, text);
}

/** @param {() => void} action @returns {void} */
function withFormatsLock_(action) {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(5000)) throw new Error('Another export operation is running. Wait, then inspect the saved record.');
  try {
    return action();
  } finally {
    lock.releaseLock();
  }
}

Download Code.gs
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/drive"
  ]
}

Download appsscript.json

The manual execution acts as you, the user at the keyboard. The manifest requests the broad https://www.googleapis.com/auth/drive scope because Folder.createFile(blob) needs Drive write access. The configured folder does not restrict that OAuth grant to one folder. Even preview can request the project's scope, because Apps Script does not wait for the create function to ask for consent. Google's authorization guide explains when a project asks for permission and whose access a run uses.

Continue only if you may authorize the project, read the inputs, export the native Doc, add a file to the destination, and read and download that output. A grant does not override file restrictions or organization policy. drive.readonly cannot authorize creation. The drive.file scope covers only files the app creates or the user opens with it, such as through a file picker. Typing an ID in the source does not count as opening a file that way. No Docs editing scope is needed here, because DriveApp.File.getAs exports an existing file without using DocumentApp to change it.

Preview reads the inputs and the saved operation record, prepares the CSV and JSON in memory, and logs the plan. It performs no PDF conversion, Drive creation, or property write. With fresh setup, expect a null record, three planned archive members, a numeric totalQuantity of 9, and a final message beginning Preview only. Inspect the native Doc separately. Preview logs only its modification metadata, without its body or rendered pages.

Parse quoted CSV records

The supplied CSV has a header and three data records, even though the care-card description occupies two physical lines. The parser must know whether a comma or newline is inside a quoted field:

  • The comma in Tray, café corner is part of one description.

  • "Label ""Herbs""" encodes a field whose value is Label "Herbs". Two adjacent quotes inside a quoted field represent one literal quote.

  • The newline between Water slowly and Keep soil damp stays inside that description instead of starting another item.

Utilities.parseCsv returns a two-dimensional array of strings, including numeric-looking fields. Splitting first on newlines or calling split(',') would break quoted fields that contain commas or newlines. RFC 4180 describes the common quoting and record conventions. Because CSV implementations still differ in encodings, delimiters, and line-ending details, a receiving system needs an agreed format.

This parser removes at most one trailing record delimiter, then requires exactly four rows of three fields: the header item,description,quantity in that order, three distinct lowercase item IDs, and decimal quantities from 1 to 20. Number runs only after the text matches that pattern. The parser rejects blank values, signs, spaces, leading zeroes, fractions, and 2 boxes. It does not guess a count from them. Descriptions must be at most 240 UTF-16 code units and must pass the text policy below.

Each accepted row becomes a record with item, description, and numeric quantity. A Set stores accepted item IDs, where has detects a duplicate and add records the next accepted ID. The header is not included in the item array. The total uses reduce((sum, item) => sum + item.quantity, 0). The sum starts at zero, and each callback returns the next running total: 0 -> 2 -> 8 -> 9 for the sample data.

Serialize CSV without changing its fields

Serialization goes in the opposite direction. The program creates the header plus three row arrays, converts validated quantities to decimal text, quotes every field, and doubles each embedded " using replace(/"/g, '""'). An inner join(',') separates fields, and an outer join('\r\n') separates records. Quoting an ordinary field is allowed even when it contains no comma or quote.

Here \r means carriage return and \n means line feed. The output uses CRLF between records and includes one final CRLF. Within descriptions, the parser normalizes input CRLF or CR to LF. That keeps each line break while storing it one way in memory. The generated CSV therefore keeps the accepted field values, but not every original byte or quoting choice. The multiline description remains quoted when serialized.

CSV quoting does not stop a spreadsheet from running formulas. An importing spreadsheet can still interpret a correctly quoted field containing =1+2 as a formula. OWASP's CSV injection guidance explains why quotes, leading characters, application behavior, and later saving and reopening all matter. No single sanitizer works for every consumer.

The validator rejects text that fails its rules instead of changing it. Item IDs must start with a lowercase letter. Descriptions, the delivery title, and a non-null reference must start with an ASCII letter. It also rejects outer whitespace and unsafe control characters. The program generates the count text from the validated positive integers. The supplied descriptions include non-ASCII text, a comma, quotes, and an internal newline.

Inspect the CSV in a UTF-8 text editor. Do not import it into a spreadsheet. If you later need arbitrary user text in Sheets or Excel, define that destination's text-import policy separately. Do not remove this guard on the assumption that CSV quoting makes such text safe.

Turn JSON text into a checked delivery value

delivery.json holds the delivery metadata. JSON.parse converts its text into a JavaScript value and checks only the syntax: null, [], or "Garden supplies" are valid JSON, but they are not this delivery object.

The helper treats the parsed value as unknown, checks that it is an object that is neither null nor an array, and requires exactly the four expected properties. schemaVersion must be the number 1, not the string "1". The title is limited to 80 code units. reference must be either null or a checked string up to 40 code units. A missing property and an empty string are not substitutes for null.

deliveryDate is a real calendar-date string in years 2000-2099. The helper validates its digits and separators, then builds a temporary UTC date from it and checks that formatting that date reproduces the same label. 2026-02-30 fails that round trip. The output keeps the original YYYY-MM-DD string. It does not convert the value back into a Date.

After validation, the program builds a new manifest object from the delivery fields and accepted items. JSON.stringify(manifest, null, 2) creates indented JSON text, where null means no replacer function and 2 requests two-space indentation. It escapes the description's quote and newline, while quantity and totalQuantity remain JSON numbers. Compare the baseline with this complete expected result:

expected-manifest.json
{
  "schemaVersion": 1,
  "title": "Garden supplies",
  "deliveryDate": "2026-10-03",
  "reference": null,
  "items": [
    {
      "item": "seed-tray",
      "description": "Tray, café corner",
      "quantity": 2
    },
    {
      "item": "plant-label",
      "description": "Label \"Herbs\"",
      "quantity": 6
    },
    {
      "item": "watering-card",
      "description": "Water slowly\nKeep soil damp",
      "quantity": 1
    }
  ],
  "totalQuantity": 9,
  "packingNote": "packing-note.pdf"
}

Download expected-manifest.json

JSON strings contain double quotes around names and string values. A newline inside a JSON string is written as \n and becomes a newline in the parsed value. That differs from the literal line break inside a quoted CSV field. JSON does not allow comments or trailing commas. Keep property names unique. JSON.parse has already resolved duplicate names before the shape checks run, so those checks do not detect duplicate keys in the original text. RFC 8259 explains the interoperable JSON value and encoding rules.

JSON.stringify omits undefined, functions, and symbols in objects, and turns unsupported array values and non-finite numbers into null. It also converts Date instances to strings and throws on circular references or BigInt values. Large integers can exceed JavaScript's exact numeric range. This manifest avoids those losses by building a value with no circular references from length-limited strings, small integers, arrays, and null.

The JSDoc FormatsItem, FormatsDelivery, and FormatsRecord types describe those shapes for tooling. Neither these annotations nor a cast validates file contents. The runtime checks decide what the next function receives, and each helper returns a new object built from the checked values.

Count UTF-8 bytes

CSV and JSON become bytes when saved or transmitted. The project reads UTF-8 with blob.getDataAsString('UTF-8') and writes it with setDataFromString(text, 'UTF-8'). Utilities.newBlob(string) also encodes strings as UTF-8.

JavaScript string length counts UTF-16 code units, not UTF-8 bytes or necessarily visible characters. For example, café has four code units but occupies five UTF-8 bytes because é needs two bytes. Some Unicode characters use two UTF-16 code units, and combining marks can make a visible character span several code points. Use length to limit code units and a byte count to limit stored size.

Preview logs both bytes and codeUnits for each text input. With the unchanged CSV, the accented letter makes those counts differ. CRLF also occupies two bytes where LF occupies one. A BOM, or byte-order mark, is an optional marker at the start of some text files. This reader accepts one after the UTF-8 check and removes it before parsing. Generated members contain no BOM. Do not strip arbitrary whitespace from a whole CSV document, because leading or trailing spaces can be data inside fields.

Before accepting a text input, the helper decodes it and encodes it back to UTF-8, then compares every byte. every requires all byte positions to match. A file saved in the wrong encoding, or decoded with replacement characters, fails because its size or some byte differs. This round trip does not prove the author's intended spelling or repair already-corrupted text that happens to be valid UTF-8. Inspect café as text as well as checking the sizes. JSON string validation also rejects unpaired UTF-16 surrogates so escaped malformed characters cannot bypass the uploaded-byte check.

The generated text blobs receive their MIME type and member name after encoding. In contrast, the PDF and ZIP stay as Blobs or byte arrays throughout conversion, packaging, and verification. Calling getDataAsString on binary data and re-encoding would corrupt non-text bytes. When a transport requires ASCII text, Utilities.base64Encode(bytes) and base64Decode(text) convert between bytes and base64 strings, though Utilities.zip accepts binary Blobs directly.

Convert the packing note to PDF

The PDF comes from the saved native Google Doc selected by nativeDocId. getMimeType() must report application/vnd.google-apps.document. The script rejects a DOCX, shortcut, uploaded text file, or existing PDF as this source. The call nativeDoc.getAs('application/pdf') asks Drive to perform the conversion, and setName('packing-note.pdf') then gives the returned Blob its archive name.

File.getAs documents conversion and its automatic filename-extension replacement. A name such as ShoppingList.12.25.2014 can become ShoppingList.12.25.pdf because getAs treats everything after the last period as an extension. Calling setName('packing-note.pdf') avoids depending on extension guessing.

Google's export-format table includes native Docs to PDF. Not every export in that table is available through getAs for every Workspace file type. Apps Script's getAs normally supports PDF conversion, with additional conversions for supported image formats and Markdown for Google Docs.

Other export APIs have their own restrictions. For example, the Drive API's files.export documents a 10 MB exported-content limit. That separate endpoint is not called here.

The conversion includes all of the selected Doc's content, including anything you did not see when you copied its ID. Keep the supplied one-tab, one-page note and inspect it before approval. The script checks the native type and modification metadata. It does not inspect every document element, check the page count, or guarantee that fonts and page breaks come out as in Docs.

The script checks the PDF byte limit after conversion has produced the Blob. A native file's getSize() is not a reliable preflight measurement of its future PDF, and the File reference documents special size behavior for Workspace files.

The script inserts no imported CSV or JSON value into HTML or raw PDF syntax. You pasted the note's text into a native document as plain text. If you later build an HTML report, CSV quote-doubling is the wrong encoder. Literal < and & in an HTML text node need HTML escaping, and other HTML contexts have their own rules. HtmlOutput.appendUntrusted provides contextual escaping for an HTML output. This project builds no HTML.

Predict and restore the plan before creating it

Make these changes only to your disposable input files, while no execution is running. Keep the original downloads so you can restore exact bytes. For an uploaded CSV or JSON file, select the file with that same ID in Drive and use More > Manage versions > Upload new version to replace its contents. Google's file-version instructions distinguish this from a native Doc's edit history. Editing a local download without uploading a version changes nothing the script reads.

  1. In a local copy of items.csv, change only the seed-tray quantity from 2 to 3. Save as UTF-8 and upload it as a new version of the selected CSV file.

  2. Before running, predict a total of 10, the seed-tray record's numeric quantity of 3, and still three data records. Run previewFormatsBundle and inspect both planned formats. The native packing note is unchanged, and no PDF or ZIP is created.

  3. Upload the original items.csv as another version of the same file. Preview again and expect the baseline total of 9 and the original descriptions.

  4. For a separate error, change the quantity to 2 boxes, upload that version, and preview. Expect the script to refuse it before any conversion or creation. The script does not read it as a quantity of 2. Restore the original CSV again.

  5. In delivery.json, temporarily change "schemaVersion": 1 to "schemaVersion": "1", then upload it as a new version of that selected JSON file. Predict that parsing succeeds but the type check refuses the value. Preview, then restore the original JSON and preview the baseline again.

As an optional text-policy check, replace one whole CSV description with =1+2. Preview should reject its first character even if the field is correctly quoted. Do not open that experimental file in a spreadsheet. Restore the original bytes before continuing. Preview never creates output. Once an attempt exists, these exercises do not let you create another one.

Approve one conversion and one archive

The preview produces a digest, a fixed-length SHA-256 fingerprint of a particular input selection. Its inputs include the four IDs, digests of the uploaded CSV and JSON bytes, and the native Doc's modification time. After you have restored and inspected the inputs, copy that current previewDigest into approvedPreviewDigest. It records that you reviewed these exact inputs. It is not a password, and it does not show that the document is safe to share.

The creation function rereads the sources and requires that digest before converting. It reads them again after conversion to detect changes during preparation. These checks cannot stop someone from editing the files during the run, and the native Doc's modification time is not a copy of its bytes. Keep the files unchanged, the project private, and one operator in control throughout the run.

Before clicking Run, approve this exact effect: one conversion of your selected native Doc, followed by creation of one ZIP in the private practice folder, verification reads, and writes to this project's operation property. The script never sends the archive, changes sharing, overwrites an existing file, or creates a temporary Google Doc. Conversion itself consumes service quota even before a file exists in Drive.

  1. Verify there is no unresolved attempt and no earlier garden-handoff.zip to account for. Keep confirmedOutputId empty. Select createFormatsBundle and click Run once.

  2. Open the returned output URL and confirm its exact file ID and direct parent folder. Inspect Share before downloading, and never infer privacy from the filename or ZIP format.

  3. Download the ZIP and extract it locally. Expect exactly items.csv, manifest.json, and packing-note.pdf, with no extra files or directories.

  4. Open the CSV and JSON files as UTF-8 text. Compare the JSON with expected-manifest.json. Check all three counts, the total of 9, preserved null, café, the literal quotes around Herbs, and the multiline care instruction. The CSV's quoting and line endings differ from its input, but its parsed field values agree with those records.

  5. Open the PDF in a PDF viewer. Confirm its full packing note is readable, including the café-corner tray instruction, dry envelope, watering instruction, and practice-only sentence. Check the page count, accent rendering, and any formatting you applied to the native Doc.

  6. Run showFormatsRecord. Expect status to be complete with that exact outputFileId and three member entries. Preserve this record while the project might run again.

Utilities.zip packages the three named Blobs without converting their contents. The code then calls the selected folder's createFile once. It does not call DriveApp.createFile, which would save to the root of My Drive. The saved fingerprints of the archive and its members let later runs check the bytes without converting the Doc again, because a second conversion might not produce identical bytes.

Know what a completed or pending record means

fileFormatsBundle.v1 is a Script Properties key shared within this project. The Properties service stores strings, so the script saves the record as JSON text and parses it again on another run. Changing the reconstructed object requires another setProperty call to persist that change. The record stores IDs, sizes, fingerprints, timestamps, and status.

StateWhat the next create call doesWhat you must not infer
No recordValidates and prepares the first approved attemptThat a separately reported creation never happened or that a same-named file is safe to adopt
pending, empty output IDRefuses creationThat Drive created nothing, because the call may have succeeded without its ID being saved
pending, saved output IDRefuses creation, while verification can finish that same outputThat the saved file is already verified or that a failed verification needs a replacement
completeVerifies and reports the saved output without conversion or creationThat nobody later changed, moved, renamed, trashed, or changed access to it
Invalid record or changed target IDsStops and preserves the attemptThat clearing the record is a way to recover

Before the only Drive creation call, the program saves pending with the generated ZIP's exact byte size and SHA-256, plus each member's size and SHA-256. After Drive returns, it saves the output ID before reading the file back. It saves complete only after integrity verification succeeds. A creation and a property write are separate service operations. An interruption can leave a pending record with no output, or with an existing output.

The script lock makes runs of this project take turns. A run waits at most five seconds to acquire the lock and releases it in finally. It does not lock Drive, other projects, or people editing files, and it cannot undo a call that has already been sent. A same-name check stops a first attempt from overwriting or adopting an existing file with that name. It does not make filenames unique or prevent a second file from being created later.

For the ordinary repeat exercise, start from a completed baseline. Change only approvedPreviewDigest back to '' and run createFormatsBundle again. Predict the same output ID and no new conversion or file. This run does not need fresh approval because it only verifies the saved output and makes no new export. Keep the field empty when finished, and leave the stored record intact.

Verify and reconcile the existing output

The verification helper first checks the output's saved ID, name, type, size, parent, and Trash state. It reads at most one parent from the parent iterator and rejects unexpected additional parents. It then compares the ZIP's byte size and SHA-256 with the saved attempt before calling Utilities.unzip. It never unpacks an unchecked file only because its extension is .zip. Each unpacked member must have one of the three distinct expected names and the exact saved byte size and digest.

Utilities.computeDigest returns bytes. The helper formats each byte as two hexadecimal digits, where byte & 255 handles the signed-byte representation and padStart(2, '0') preserves a leading zero. The resulting 64-character SHA-256 hex digest lets you check later that the bytes have not changed. Another project editor with access to Script Properties and Drive could still modify both.

If a run fails or is interrupted, do not start another export. Open Executions, wait for active work to stop, then run showFormatsRecord. Preserve its full contents and any returned ID in the failed execution's log.

  • With a saved outputFileId, inspect that exact archive. Run verifyFormatsBundle to check it. If its pending record passes verification, that function marks it complete without another conversion or file creation.

  • With pending and no output ID, inspect the recorded destination, creation history, actual candidate contents, and any returned-ID log. Account for outputs that someone may have moved, renamed, or trashed. A matching name or an empty folder is not conclusive. If you cannot identify the output, keep the attempt pending, because there is no automatic reset or creation retry.

  • Once you have identified an existing ZIP from that attempt, put its exact Drive file ID in confirmedOutputId and run reconcileFormatsBundle. The function checks the saved sizes and digests and records that existing file. It refuses any different candidate if an output ID was already saved. Clear confirmedOutputId afterward.

  • If saving the reconciled record fails, inspect the property and use the same confirmed ID again. If a recorded file was moved or renamed, restore only that known disposable file's recorded details after inspecting it. Never alter an unrelated file to make it pass.

  • A ZIP modified or recompressed after creation will fail the saved-byte check, even if its extracted contents match. Preserve the archive and the record. Do not overwrite the saved digest.

Respect conversion, size, and destination limits

The code's caps are much smaller than a general file-processing system would use:

Input or outputLesson allowance
Uploaded CSV or JSONNonempty, at most 16 KiB each
Parsed CSVOne exact header plus three records, three fields each
Generated CSV or JSON memberAt most 16 KiB each
Converted PDFNonempty, at most 1 MiB
ZIPNonempty, at most 2 MiB, exactly three named members
Stored operation recordAt most 8,000 encoded bytes

A KiB is 1,024 bytes and a MiB is 1,048,576 bytes. getBlob, getBytes, PDF conversion, ZIP creation, and unzip load all of the data into memory. You cannot stop them partway through. The uploaded-file metadata check can avoid reading a known oversized text input, but a concurrent change can still force the script to reject its bytes after retrieving them. Likewise, a PDF or ZIP may already have consumed memory and service work before its size check fails. Several representations of the same data can be in memory at once, so a 2 MiB ZIP limit is not a 2 MiB memory guarantee.

The current Apps Script quota table lists 2,000 files converted per day for consumer accounts and 4,000 for Google Workspace accounts, with stricter conditions possible for new or trial domains. Quotas are per user, reset 24 hours after the first request, can change, and are shared with that user's other work. The table also lists six minutes per ordinary execution and a 9 KB property-value limit. This project neither reserves quota nor retries a failed conversion automatically. Preview makes no conversion request. A first create run can make one, even if that run later fails before saving a ZIP.

Use the complete error and recorded phase to decide what to inspect. Changing the MIME type, waiting in a loop, or widening sharing does not fix a quota or export-permission error. A UTF-8 or CSV format error needs a corrected input. Raising the byte limit does not fix it. Restore "schemaVersion": 1 after a JSON type error, and use a real calendar date after a date error. If the PDF exceeds the lesson limit or is unreadable, inspect the selected native Doc and its full contents. Do not retry creation while an earlier output is uncertain.

Clean up only the practice files

Wait until no execution is running and resolve any uncertain creation first. Clear approvedPreviewDigest and confirmedOutputId, keep the operation record, and stop running the project.

To clean up the disposable Drive files, open the file with the exact recorded output ID and each configured input file, confirm their contents, and move only those files to Trash. Remove the practice folder only after verifying it is empty. When the project is no longer needed, you can also revoke its Google connection through the account-settings procedure.

Next, use Reference, glossary, and troubleshooting for quick lookup of services, quotas, and terms across the book.

Project files

Complete source files for this chapter's examples.

Build one garden handoff archive
Code.gs
/**
 * @typedef {{folderId: string, csvFileId: string, jsonFileId: string, nativeDocId: string}} FormatsTarget
 * @typedef {{item: string, description: string, quantity: number}} FormatsItem
 * @typedef {{schemaVersion: 1, title: string, deliveryDate: string, reference: string|null}} FormatsDelivery
 * @typedef {{name: string, bytes: number, sha256: string}} FormatsMember
 */

/**
 * @typedef {object} FormatsRecord
 * @property {1} version
 * @property {FormatsTarget} target
 * @property {string} previewDigest
 * @property {string} attemptedAt
 * @property {string} outputName
 * @property {string} outputFileId
 * @property {'pending'|'complete'} status
 * @property {number} zipBytes
 * @property {string} zipSha256
 * @property {FormatsMember[]} members
 */

const FILE_FORMATS = {
  folderId: 'REPLACE_WITH_PRIVATE_FOLDER_ID',
  csvFileId: 'REPLACE_WITH_ITEMS_CSV_ID',
  jsonFileId: 'REPLACE_WITH_DELIVERY_JSON_ID',
  nativeDocId: 'REPLACE_WITH_NATIVE_GOOGLE_DOC_ID',
  approvedPreviewDigest: '', // Copy only after inspecting the restored inputs and native Doc.
  confirmedOutputId: '', // Recovery only: an existing ZIP's exact Drive file ID.
};
const FORMATS_RECORD_KEY = 'fileFormatsBundle.v1';
const FORMATS_OUTPUT_NAME = 'garden-handoff.zip';
/** @type {Array<keyof FormatsTarget>} */
const FORMATS_TARGET_KEYS = ['folderId', 'csvFileId', 'jsonFileId', 'nativeDocId'];
const FORMATS_MEMBER_NAMES = ['items.csv', 'manifest.json', 'packing-note.pdf'];
const FORMATS_MAX_TEXT_BYTES = 16 * 1024;
const FORMATS_MAX_PDF_BYTES = 1024 * 1024;
const FORMATS_MAX_ZIP_BYTES = 2 * 1024 * 1024;

function previewFormatsBundle() {
  withFormatsLock_(() => {
    const plan = prepareFormatsPlan_();
    console.log(JSON.stringify({
      target: plan.sources.target,
      inputs: plan.sources.metadata,
      previewDigest: plan.sources.digest,
      plannedMembers: FORMATS_MEMBER_NAMES,
      outputName: FORMATS_OUTPUT_NAME,
      record: readFormatsRecord_(),
    }, null, 2));
    console.log('Planned manifest.json:\n' + plan.jsonText);
    console.log('Planned items.csv:\n' + plan.csvText);
    console.log('Preview only: no PDF conversion, file creation, or property write.');
  });
}

function createFormatsBundle() {
  withFormatsLock_(() => {
    const target = formatsTarget_();
    const existing = readFormatsRecord_();
    if (existing !== null) {
      if (!FORMATS_TARGET_KEYS.every((key) => existing.target[key] === target[key])) {
        throw new Error('Restore the recorded target IDs. Changing configuration is not a new export.');
      }
      if (existing.status !== 'complete') {
        throw new Error('A creation is pending. Show the record and verify/reconcile that output; do not create again.');
      }
      const output = verifyFormatsOutput_(existing, existing.outputFileId);
      console.log('No conversion or creation. Recorded output: ' + output.getUrl());
      return;
    }
    const plan = prepareFormatsPlan_();
    if (!isFormatsDigest_(FILE_FORMATS.approvedPreviewDigest) ||
        FILE_FORMATS.approvedPreviewDigest !== plan.sources.digest) {
      throw new Error('Run previewFormatsBundle, inspect its inputs and native Doc, then copy the current previewDigest.');
    }
    if (plan.sources.folder.getFilesByName(FORMATS_OUTPUT_NAME).hasNext()) {
      throw new Error('That output name already exists. Inspect it; this project will not overwrite or adopt by name.');
    }

    // Convert the actual native Google Doc. Renaming text or changing its MIME label is not conversion.
    const pdf = plan.sources.nativeDoc.getAs('application/pdf').setName('packing-note.pdf');
    const pdfBytes = pdf.getBytes();
    if (pdf.getContentType() !== 'application/pdf' || pdfBytes.length === 0 ||
        pdfBytes.length > FORMATS_MAX_PDF_BYTES) {
      throw new Error('The native conversion did not return a PDF within the one MiB lesson allowance.');
    }
    // Detect changed source bytes/metadata, without claiming a revision-pinned native export.
    if (readFormatsSources_().digest !== plan.sources.digest) {
      throw new Error('An input changed during preparation. No ZIP file was created. Inspect and preview again.');
    }
    const csv = formatsTextBlob_(plan.csvText, 'text/csv', 'items.csv');
    const json = formatsTextBlob_(plan.jsonText, 'application/json', 'manifest.json');
    const csvBytes = csv.getBytes();
    const jsonBytes = json.getBytes();
    if (csvBytes.length > FORMATS_MAX_TEXT_BYTES || jsonBytes.length > FORMATS_MAX_TEXT_BYTES) {
      throw new Error('A generated text member exceeds the lesson allowance.');
    }
    const zip = Utilities.zip([csv, json, pdf], FORMATS_OUTPUT_NAME).setContentType('application/zip');
    const zipBytes = zip.getBytes();
    if (zipBytes.length === 0 || zipBytes.length > FORMATS_MAX_ZIP_BYTES) {
      throw new Error('The generated ZIP exceeds the two MiB lesson allowance.');
    }
    /** @type {FormatsRecord} */
    const record = {
      version: 1,
      target,
      previewDigest: plan.sources.digest,
      attemptedAt: new Date().toISOString(),
      outputName: FORMATS_OUTPUT_NAME,
      outputFileId: '',
      status: 'pending',
      zipBytes: zipBytes.length,
      zipSha256: formatsSha256_(zipBytes),
      members: [
        { name: 'items.csv', bytes: csvBytes.length, sha256: formatsSha256_(csvBytes) },
        { name: 'manifest.json', bytes: jsonBytes.length, sha256: formatsSha256_(jsonBytes) },
        { name: 'packing-note.pdf', bytes: pdfBytes.length, sha256: formatsSha256_(pdfBytes) },
      ],
    };
    // The intent and exact generated-byte digests precede the only Drive creation call.
    saveFormatsRecord_(record);
    const output = plan.sources.folder.createFile(zip);
    record.outputFileId = output.getId();
    saveFormatsRecord_(record); // Retain the returned ID before reading the saved archive.
    console.log('Drive returned output ID: ' + record.outputFileId);
    verifyFormatsOutput_(record, record.outputFileId);
    record.status = 'complete';
    saveFormatsRecord_(record);
    console.log('Saved archive and all three member digests verified: ' + output.getUrl());
  });
}

function showFormatsRecord() {
  console.log(JSON.stringify(readFormatsRecord_(), null, 2));
}

function verifyFormatsBundle() {
  withFormatsLock_(() => {
    const record = readFormatsRecord_();
    if (record === null || record.outputFileId === '') {
      throw new Error('No output ID is saved. Inspect the pending attempt and use explicit reconciliation.');
    }
    const output = verifyFormatsOutput_(record, record.outputFileId);
    if (record.status !== 'complete') {
      record.status = 'complete';
      saveFormatsRecord_(record);
    }
    console.log('Verified the same saved archive without conversion or creation: ' + output.getUrl());
  });
}

function reconcileFormatsBundle() {
  withFormatsLock_(() => {
    const record = readFormatsRecord_();
    if (record === null) throw new Error('No saved attempt exists. Do not adopt a file or repeat a reported creation.');
    requireFormatsId_(FILE_FORMATS.confirmedOutputId, 'confirmedOutputId');
    if (record.outputFileId !== '' && record.outputFileId !== FILE_FORMATS.confirmedOutputId) {
      throw new Error('Use the already recorded output ID; a replacement cannot be adopted.');
    }
    const output = verifyFormatsOutput_(record, FILE_FORMATS.confirmedOutputId);
    record.outputFileId = output.getId();
    record.status = 'complete';
    saveFormatsRecord_(record);
    console.log('Recorded the inspected existing ZIP. No file changed: ' + output.getUrl());
    console.log('Reset confirmedOutputId to an empty string; retain the saved record.');
  });
}

function prepareFormatsPlan_() {
  const sources = readFormatsSources_();
  const items = parseFormatsCsv_(sources.csv.text);
  const delivery = parseFormatsDelivery_(sources.json.text);
  const manifest = {
    schemaVersion: 1,
    title: delivery.title,
    deliveryDate: delivery.deliveryDate,
    reference: delivery.reference,
    items,
    totalQuantity: items.reduce((sum, item) => sum + item.quantity, 0),
    packingNote: 'packing-note.pdf',
  };
  const rows = [
    ['item', 'description', 'quantity'],
    ...items.map((item) => [item.item, item.description, String(item.quantity)]),
  ];
  // Quote every field, double embedded quotes, and use CRLF between records.
  const csvText = rows.map((row) => row.map((field) =>
    '"' + field.replace(/"/g, '""') + '"').join(',')).join('\r\n') + '\r\n';
  return { sources, csvText, jsonText: JSON.stringify(manifest, null, 2) + '\n' };
}

function readFormatsSources_() {
  const target = formatsTarget_();
  const folder = DriveApp.getFolderById(target.folderId);
  if (folder.isTrashed()) throw new Error('The selected practice folder is in Trash.');
  const csvFile = requireFormatsChild_(target.csvFileId, target.folderId);
  const jsonFile = requireFormatsChild_(target.jsonFileId, target.folderId);
  const nativeDoc = requireFormatsChild_(target.nativeDocId, target.folderId);
  if (!['text/csv', 'text/plain', 'application/csv'].includes(csvFile.getMimeType())) {
    throw new Error('The CSV input must be an uploaded CSV/plain-text file, not a converted Sheet or shortcut.');
  }
  if (!['application/json', 'text/plain'].includes(jsonFile.getMimeType())) {
    throw new Error('The JSON input must be an uploaded JSON/plain-text file. Inspect an unexpected MIME type.');
  }
  if (nativeDoc.getMimeType() !== 'application/vnd.google-apps.document') {
    throw new Error('The packing note must be the native Google Doc, not a PDF, DOCX, text file, or shortcut.');
  }
  const csv = readFormatsUtf8_(csvFile);
  const json = readFormatsUtf8_(jsonFile);
  const nativeUpdatedAt = nativeDoc.getLastUpdated().toISOString();
  const digestText = JSON.stringify({ target, csvSha256: csv.sha256, jsonSha256: json.sha256, nativeUpdatedAt });
  return {
    target, folder, nativeDoc, csv, json,
    digest: formatsSha256_(Utilities.newBlob(digestText).getBytes()),
    metadata: [
      { id: csvFile.getId(), name: csvFile.getName(), mimeType: csvFile.getMimeType(), bytes: csv.bytes, codeUnits: csv.text.length },
      { id: jsonFile.getId(), name: jsonFile.getName(), mimeType: jsonFile.getMimeType(), bytes: json.bytes, codeUnits: json.text.length },
      { id: nativeDoc.getId(), name: nativeDoc.getName(), mimeType: nativeDoc.getMimeType(), updatedAt: nativeUpdatedAt },
    ],
  };
}

/** @param {DriveApp.File} file @returns {{text: string, bytes: number, sha256: string}} */
function readFormatsUtf8_(file) {
  if (file.getSize() > FORMATS_MAX_TEXT_BYTES) throw new Error('Each uploaded text input must be at most 16 KiB.');
  const blob = file.getBlob();
  const bytes = blob.getBytes();
  if (bytes.length === 0 || bytes.length > FORMATS_MAX_TEXT_BYTES) {
    throw new Error('The text input is empty or exceeds 16 KiB.');
  }
  const text = blob.getDataAsString('UTF-8');
  const encoded = Utilities.newBlob('').setDataFromString(text, 'UTF-8').getBytes();
  if (bytes.length !== encoded.length || !bytes.every((byte, index) => byte === encoded[index])) {
    throw new Error('The text bytes do not round-trip as UTF-8. Re-save the practice fixture as UTF-8.');
  }
  // A single leading BOM is tolerated on input; generated text has no BOM.
  return { text: text.replace(/^\uFEFF/, ''), bytes: bytes.length, sha256: formatsSha256_(bytes) };
}

/** @param {string} text @returns {FormatsItem[]} */
function parseFormatsCsv_(text) {
  // Remove at most one optional terminal record delimiter, never split physical lines.
  const rows = Utilities.parseCsv(text.replace(/\r?\n$/, ''));
  if (rows.length !== 4 || rows.some((row) => row.length !== 3) ||
      !['item', 'description', 'quantity'].every((header, column) => rows[0][column] === header)) {
    throw new Error('Use exactly the item,description,quantity header and three three-field records.');
  }
  /** @type {Set<string>} */
  const ids = new Set();
  return rows.slice(1).map((row) => {
    const [item, rawDescription, quantityText] = row;
    const description = rawDescription.replace(/\r\n?/g, '\n');
    if (!/^[a-z][a-z0-9-]{0,31}$/.test(item) || ids.has(item)) {
      throw new Error('Item IDs must be distinct, start with a lowercase letter, and use at most 32 letters/digits/hyphens.');
    }
    requireFormatsText_(description, 240, true, 'description');
    if (!/^[1-9][0-9]?$/.test(quantityText) || Number(quantityText) > 20) {
      throw new Error('Each quantity must be a decimal whole count from 1 to 20, without signs, spaces, or leading zeroes.');
    }
    ids.add(item);
    return { item, description, quantity: Number(quantityText) };
  });
}

/** @param {string} text @returns {FormatsDelivery} */
function parseFormatsDelivery_(text) {
  /** @type {unknown} */
  let delivery;
  try {
    delivery = JSON.parse(text);
  } catch (error) {
    throw new Error('delivery.json is not valid JSON text. Restore its quotes, commas, and escapes.');
  }
  const keys = ['schemaVersion', 'title', 'deliveryDate', 'reference'];
  if (!isFormatsObject_(delivery) || Object.keys(delivery).length !== keys.length ||
      !keys.every((key) => Object.prototype.hasOwnProperty.call(delivery, key)) || delivery.schemaVersion !== 1) {
    throw new Error('delivery.json must contain exactly schemaVersion 1, title, deliveryDate, and reference.');
  }
  const title = requireFormatsText_(delivery.title, 80, false, 'title');
  if (typeof delivery.deliveryDate !== 'string' || !/^20[0-9]{2}-[0-9]{2}-[0-9]{2}$/.test(delivery.deliveryDate)) {
    throw new Error('deliveryDate must be a calendar date string YYYY-MM-DD in years 2000-2099.');
  }
  const date = new Date(delivery.deliveryDate + 'T00:00:00.000Z');
  if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== delivery.deliveryDate) {
    throw new Error('deliveryDate is not a real calendar date.');
  }
  const reference = delivery.reference === null
    ? null : requireFormatsText_(delivery.reference, 40, false, 'reference');
  return { schemaVersion: 1, title, deliveryDate: delivery.deliveryDate, reference };
}

/**
 * @param {unknown} value
 * @param {number} maxLength
 * @param {boolean} multiline
 * @param {string} name
 * @returns {string}
 */
function requireFormatsText_(value, maxLength, multiline, name) {
  // A conservative lesson policy, not a universal spreadsheet CSV sanitizer.
  if (typeof value !== 'string' || value.length > maxLength || !/^[A-Za-z]/.test(value) ||
      value !== value.trim() || /[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F]/.test(value) ||
      (!multiline && /[\r\n]/.test(value))) {
    throw new Error(name + ' must start with an ASCII letter, have no outer whitespace/unsafe controls, and fit the lesson length limit.');
  }
  for (const character of value) {
    const point = character.codePointAt(0);
    if (point !== undefined && point >= 0xD800 && point <= 0xDFFF) {
      throw new Error(name + ' contains an unpaired Unicode surrogate.');
    }
  }
  return value;
}

/** @param {string} text @param {string} mimeType @param {string} name @returns {Blob} */
function formatsTextBlob_(text, mimeType, name) {
  return Utilities.newBlob('').setDataFromString(text, 'UTF-8').setContentType(mimeType).setName(name);
}

/** @param {FormatsRecord} record @param {string} outputId @returns {DriveApp.File} */
function verifyFormatsOutput_(record, outputId) {
  requireFormatsId_(outputId, 'output ID');
  if (FORMATS_TARGET_KEYS.some((key) => record.target[key] === outputId)) {
    throw new Error('An input or the folder cannot be adopted as the output.');
  }
  const folder = DriveApp.getFolderById(record.target.folderId);
  if (folder.isTrashed()) throw new Error('The recorded folder is in Trash.');
  const file = requireFormatsChild_(outputId, record.target.folderId);
  if (file.getName() !== record.outputName || file.getMimeType() !== 'application/zip' ||
      file.getSize() !== record.zipBytes || file.getSize() > FORMATS_MAX_ZIP_BYTES) {
    throw new Error('The output name, type, size, or location differs from the saved attempt.');
  }
  const zip = file.getBlob();
  const bytes = zip.getBytes();
  if (bytes.length !== record.zipBytes || formatsSha256_(bytes) !== record.zipSha256) {
    throw new Error('The saved ZIP bytes do not match the generated archive. Preserve the file and record.');
  }
  // Only unpack the exact archive generated by this attempt, not an arbitrary candidate ZIP.
  const entries = Utilities.unzip(zip);
  if (entries.length !== record.members.length) throw new Error('The archive member count is wrong.');
  /** @type {Set<string>} */
  const seen = new Set();
  for (const entry of entries) {
    const name = entry.getName();
    const expected = record.members.find((member) => member.name === name);
    if (name === null || !expected || seen.has(name)) {
      throw new Error('An archive member name is unexpected or duplicated.');
    }
    const entryBytes = entry.getBytes();
    if (entryBytes.length !== expected.bytes || formatsSha256_(entryBytes) !== expected.sha256) {
      throw new Error('Archive member bytes changed: ' + name);
    }
    seen.add(name);
  }
  return file;
}

/** @param {string} id @param {string} folderId @returns {DriveApp.File} */
function requireFormatsChild_(id, folderId) {
  const file = DriveApp.getFileById(id);
  if (file.isTrashed()) throw new Error('A selected file is in Trash: ' + id);
  const parents = file.getParents();
  if (!parents.hasNext() || parents.next().getId() !== folderId || parents.hasNext()) {
    throw new Error('Every selected file must be directly inside the recorded private practice folder.');
  }
  return file;
}

/** @returns {FormatsTarget} */
function formatsTarget_() {
  const target = {
    folderId: requireFormatsId_(FILE_FORMATS.folderId, 'folderId'),
    csvFileId: requireFormatsId_(FILE_FORMATS.csvFileId, 'csvFileId'),
    jsonFileId: requireFormatsId_(FILE_FORMATS.jsonFileId, 'jsonFileId'),
    nativeDocId: requireFormatsId_(FILE_FORMATS.nativeDocId, 'nativeDocId'),
  };
  if (new Set(Object.values(target)).size !== FORMATS_TARGET_KEYS.length) {
    throw new Error('The folder, CSV, JSON, and native Doc must have distinct IDs.');
  }
  return target;
}

/** @param {unknown} id @param {string} name @returns {string} */
function requireFormatsId_(id, name) {
  if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(id) || id.startsWith('REPLACE_')) {
    throw new Error('Set ' + name + ' to the actual practice resource ID, not a filename, placeholder, or whole URL.');
  }
  return id;
}

/** @param {number[]} bytes @returns {string} */
function formatsSha256_(bytes) {
  return Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, bytes)
    .map((byte) => (byte & 255).toString(16).padStart(2, '0')).join('');
}

/** @param {unknown} value @returns {value is string} */
function isFormatsDigest_(value) {
  return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
}

/** @param {unknown} value @returns {value is Record<string, unknown>} */
function isFormatsObject_(value) {
  return value !== null && typeof value === 'object' && !Array.isArray(value);
}

/** @returns {FormatsRecord|null} */
function readFormatsRecord_() {
  const raw = PropertiesService.getScriptProperties().getProperty(FORMATS_RECORD_KEY);
  if (raw === null) return null;
  /** @type {unknown} */
  let record;
  try {
    record = JSON.parse(raw);
  } catch (error) {
    throw new Error('The saved export record is not valid JSON. Preserve it; do not erase the attempt.');
  }
  if (!isFormatsObject_(record) || record.version !== 1 || !isFormatsObject_(record.target) ||
      Object.keys(record.target).length !== FORMATS_TARGET_KEYS.length ||
      (record.status !== 'pending' && record.status !== 'complete') || record.outputName !== FORMATS_OUTPUT_NAME ||
      typeof record.outputFileId !== 'string' || (record.status === 'complete' && record.outputFileId === '') ||
      !isFormatsDigest_(record.previewDigest) || !isFormatsDigest_(record.zipSha256) ||
      typeof record.zipBytes !== 'number' || !Number.isInteger(record.zipBytes) ||
      record.zipBytes < 1 || record.zipBytes > FORMATS_MAX_ZIP_BYTES ||
      typeof record.attemptedAt !== 'string' || !Number.isFinite(Date.parse(record.attemptedAt)) ||
      !Array.isArray(record.members) || record.members.length !== FORMATS_MEMBER_NAMES.length) {
    throw new Error('The saved export record is damaged or unsupported. Preserve it; do not erase the attempt.');
  }
  const target = {
    folderId: requireFormatsId_(record.target.folderId, 'record.folderId'),
    csvFileId: requireFormatsId_(record.target.csvFileId, 'record.csvFileId'),
    jsonFileId: requireFormatsId_(record.target.jsonFileId, 'record.jsonFileId'),
    nativeDocId: requireFormatsId_(record.target.nativeDocId, 'record.nativeDocId'),
  };
  if (new Set(Object.values(target)).size !== FORMATS_TARGET_KEYS.length) {
    throw new Error('The saved input identities are not distinct.');
  }
  if (record.outputFileId !== '') requireFormatsId_(record.outputFileId, 'record.outputFileId');
  /** @type {unknown[]} */
  const rawMembers = record.members;
  /** @type {FormatsMember[]} */
  const members = [];
  /** @type {Set<string>} */
  const names = new Set();
  for (const member of rawMembers) {
    if (!isFormatsObject_(member) || typeof member.name !== 'string' ||
        !FORMATS_MEMBER_NAMES.includes(member.name) || names.has(member.name) ||
        !isFormatsDigest_(member.sha256) || typeof member.bytes !== 'number' ||
        !Number.isInteger(member.bytes) || member.bytes < 1 ||
        member.bytes > (member.name === 'packing-note.pdf' ? FORMATS_MAX_PDF_BYTES : FORMATS_MAX_TEXT_BYTES)) {
      throw new Error('The saved archive member identity is damaged. Preserve the record.');
    }
    names.add(member.name);
    members.push({ name: member.name, bytes: member.bytes, sha256: member.sha256 });
  }
  return {
    version: 1,
    target,
    previewDigest: record.previewDigest,
    attemptedAt: record.attemptedAt,
    outputName: record.outputName,
    outputFileId: record.outputFileId,
    status: record.status,
    zipBytes: record.zipBytes,
    zipSha256: record.zipSha256,
    members,
  };
}

/** @param {FormatsRecord} record @returns {void} */
function saveFormatsRecord_(record) {
  const text = JSON.stringify(record);
  if (Utilities.newBlob(text).getBytes().length > 8000) throw new Error('The lesson record is too large; nothing was reset.');
  PropertiesService.getScriptProperties().setProperty(FORMATS_RECORD_KEY, text);
}

/** @param {() => void} action @returns {void} */
function withFormatsLock_(action) {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(5000)) throw new Error('Another export operation is running. Wait, then inspect the saved record.');
  try {
    return action();
  } finally {
    lock.releaseLock();
  }
}

appsscript.json

Download file
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/drive"
  ]
}

items.csv

Download file
items.csv
item,description,quantity
seed-tray,"Tray, café corner",2
plant-label,"Label ""Herbs""",6
watering-card,"Water slowly
Keep soil damp",1

delivery.json

Download file
delivery.json
{
  "schemaVersion": 1,
  "title": "Garden supplies",
  "deliveryDate": "2026-10-03",
  "reference": null
}

packing-note.txt

Download file
packing-note.txt
Garden packing note

Prepare the items listed in manifest.json.
Keep the tray for the café corner upright.
Place the labels in a dry envelope.
Read the care card before watering.

Practice handoff only. No customer or personal data.

expected-manifest.json

Download file
expected-manifest.json
{
  "schemaVersion": 1,
  "title": "Garden supplies",
  "deliveryDate": "2026-10-03",
  "reference": null,
  "items": [
    {
      "item": "seed-tray",
      "description": "Tray, café corner",
      "quantity": 2
    },
    {
      "item": "plant-label",
      "description": "Label \"Herbs\"",
      "quantity": 6
    },
    {
      "item": "watering-card",
      "description": "Water slowly\nKeep soil damp",
      "quantity": 1
    }
  ],
  "totalQuantity": 9,
  "packingNote": "packing-note.pdf"
}