Skip to content
Skip to chapter

Chapter 37

Advanced integrations and data

Package CSV, JSON, and a PDF for handoff

Validate a supplies table in Google Apps Script, export CSV or JSON blobs with Utilities.newBlob(), and package a Google Doc PDF with getAs().

By the end

Build one private ZIP containing normalized CSV, a typed JSON manifest, and a useful packing-note PDF, then inspect and reconcile that exact output.

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; the person packing the supplies can read its PDF instructions. Creating one archive keeps the three parts together without creating a Drive file for every row or format.

The arrays lesson supplies rows, records, and callbacks. The HTTP lesson separates parsing from validating a value. The Drive and Docs lessons supply file selection by ID, native document structure, and recovery on a known output. This project needs no network endpoint, spreadsheet, email, or earlier lesson's practice files.

The result is a snapshot, not a folder synchronization. One standalone project tracks one attempted handoff. A completed rerun checks the saved archive rather than making another one or incorporating later input edits.

Distinguish text, bytes, blobs, and files

These representations are related, but they are not interchangeable:

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 data-interchange object. 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.

The filename, MIME type, and actual data answer different questions. 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 a Blob's label, not its 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 certify ownership or privacy. Drive's permission inheritance rules make the destination an audience decision even when code never calls a sharing method.

  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; you can make the first paragraph an 18-point bold title. 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. This native Doc will be the PDF source; the script does not turn the uploaded text fixture into a document or generate 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 fixtures:

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 Etc/UTC; no advanced service or library needs enabling.

  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; consent is not delayed until the create function. Google's authorization guide explains those project and execution boundaries.

Continue only if you may authorize the project, read the inputs, export the native Doc, add a file to the destination, and read/download that output. A grant does not override file restrictions or organization policy. drive.readonly cannot authorize creation, and entering an ID is not a drive.file selection flow. No Docs editing scope is needed here: DriveApp.File.getAs exports an existing file without using DocumentApp to change it.

Preview reads the inputs and named operation record, prepares 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 its modification metadata, not its body or rendered pages.

Parse CSV records, not physical lines

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; it does not start another item.

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

This parser removes at most one optional terminal record delimiter, then requires exactly four rows of three fields. It requires 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 passes that count pattern. Blank values, signs, spaces, leading zeroes, fractions, and 2 boxes are rejected rather than guessed into counts. Descriptions must fit 240 UTF-16 code units and the text policy below.

Each accepted row becomes a record with item, description, and numeric quantity. A Set remembers already-seen item IDs; 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): sum starts at zero and each callback returns the next running total, 0 -> 2 -> 8 -> 9 for the fixture.

Serialize CSV without changing its fields

Export 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; 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, input CRLF or CR is normalized to LF. That choice preserves a logical line break while giving it one in-memory representation. The generated CSV therefore preserves the accepted field values, not every original byte or quoting choice. The multiline description remains quoted when serialized.

CSV quoting is not spreadsheet formula neutralization. A correctly quoted field containing =1+2 can still be interpreted as a formula by an importing spreadsheet. OWASP's CSV injection guidance explains why quotes, leading characters, application behavior, and later save/reopen operations all matter; there is no one sanitizer for every consumer.

This small handoff uses a restrictive policy instead of silently changing imported text: item IDs start with a lowercase letter; descriptions, the delivery title, and a non-null reference start with an ASCII letter; outer whitespace and unsafe control characters are rejected. Counts are generated from the validated positive integers. The supplied descriptions still demonstrate non-ASCII text, a comma, quotes, and an internal newline. Inspect the CSV in a UTF-8 text editor, not an uncontrolled spreadsheet import. 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, proving syntax only: null, [], or "Garden supplies" are valid JSON but not this delivery object.

The helper starts with unknown, checks for a non-null non-array object, and requires exactly the four expected properties. schemaVersion must be the number 1, not the string "1". The title is bounded 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 checks that constructing a temporary UTC date and formatting it back reproduces the same label. 2026-02-30 fails that round trip. The output keeps the original YYYY-MM-DD string; it does not schedule an event, choose a local timezone, or automatically revive the value as 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; null here 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; after parsing, it is a newline in the value. That differs from the literal line break inside a quoted CSV field. Comments and trailing commas are not accepted JSON. 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, turns unsupported array values and non-finite numbers into null, converts Date instances to strings, and throws on circular references or BigInt values. Large integers can also exceed JavaScript's exact numeric range. This manifest avoids those losses by constructing a finite, acyclic value from bounded strings, small integers, arrays, and null.

The JSDoc FormatsItem, FormatsDelivery, and FormatsRecord types describe those shapes for tooling. They do not validate file contents. The runtime checks and reconstructed return values establish what the next function receives; an annotation or cast alone would not.

Count encoded bytes, not string characters

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; combining marks can make a visible character span several code points. A string-length limit and a byte limit protect different boundaries.

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 rather than LF's one. A BOM, or byte-order mark, is an optional leading Unicode marker in some text files; this reader tolerates 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: 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 size mismatch or differing byte rejects a file saved in the wrong encoding or decoded with replacement characters. This 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 native packing note, not a MIME label

The PDF comes from the saved native Google Doc selected by nativeDocId. getMimeType() must report application/vnd.google-apps.document; a DOCX, shortcut, uploaded text file, or existing PDF is rejected 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 everything after the last period is treated as an extension. Calling setName('packing-note.pdf') avoids depending on extension guessing.

Google's export-format table includes native Docs to PDF. It does not imply that every getAs method supports every export listed for every Workspace type. Apps Script's Blob contract normally accepts PDF conversion, with additional conversions for supported image formats and Markdown for Google Docs. Renaming CSV or labeling JSON or HTML bytes as application/pdf does not render a PDF. 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 the selected Doc's content, not only the words that were visible when you copied its ID. Keep the supplied one-tab, one-page note and inspect it before approval. The script checks native type and modification metadata; it does not inspect every document element, certify a page count, or guarantee font and pagination fidelity. Its PDF byte limit is checked after the conversion has produced the Blob. A native file's getSize() is not a reliable preflight measurement of its future PDF; the File reference documents special size behavior for Workspace files.

No imported CSV or JSON value is inserted into HTML or raw PDF syntax. The note's text was pasted 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 neither needs it nor substitutes an unescaped HTML string for its native Doc conversion.

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 that same file 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 three data records rather than four. Run previewFormatsBundle and inspect both planned formats. The native packing note is unchanged; 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 original descriptions.

  4. For a separate error, change the quantity to 2 boxes, upload that version, and preview. Expect refusal before conversion or creation, not a silently parsed 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 shape/type validation refuses the value. Preview, then restore the original JSON and preview the baseline again.

An optional text-policy check is to 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. None of these preview exercises grants permission to create another output after an attempt already exists.

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, uploaded CSV/JSON byte digests, and native Doc modification time. After you have restored and inspected the inputs, copy that current previewDigest into approvedPreviewDigest. It is a change-detection acknowledgement, not a credential or proof that the document is appropriate to disclose.

The creation function rereads the sources and requires that digest before converting. It reads them again after conversion to detect changes during preparation. These comparisons are not a revision-pinned transaction: native modification metadata is not a byte snapshot, and a script lock cannot stop human edits. 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; 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 CSV and JSON 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 page count, accent rendering, and any formatting you applied to the native Doc. The PDF is the packing note, not an automatically generated table of CSV quantities.

  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 select the root of My Drive rather than this destination. The archive and member byte fingerprints allow later integrity checks without reconverting the Doc and hoping its output bytes are identical.

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 JSON serialization saves the record and parsing reconstructs it 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; the call may have succeeded without its ID being saved
pending, saved output IDRefuses creation; 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 state is a recovery procedure

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. Only successful integrity verification is followed by complete. A creation and a property write are separate service operations: an interruption can leave either no output or an existing output behind a pending record.

The script lock serializes cooperating operations in this project, waiting at most five seconds for acquisition and releasing in finally. It is not a lock on Drive, other projects, human edits, or an already-dispatched mutation. A same-name check stops a first attempt from overwriting or adopting a collision; it does not make filenames unique or establish idempotency.

For the ordinary repeat exercise, after a completed baseline change only approvedPreviewDigest back to '' and run createFormatsBundle again. Predict the same output ID and no new conversion or file. The completed-record path does not need fresh approval because it performs verification, not a new export. Keep the field empty when finished; 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 arbitrary candidate 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 small helper formats each byte as two hexadecimal digits; byte & 255 handles the signed-byte representation, and padStart(2, '0') preserves a leading zero. The resulting 64-character SHA-256 hex digest identifies byte content for integrity comparison; another project editor with access to Script Properties and Drive could still modify both.

If a run fails or is interrupted, stop starting exports. 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 possible moved, renamed, or trashed outputs. A matching name or an empty folder is not conclusive. If you cannot identify the output, keep the attempt pending; there is no automatic reset or creation retry.

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

  • If saving reconciliation 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 rather than overwriting the saved digest.

Respect conversion, size, and destination limits

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

BoundaryLesson 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 materialize data; they are not streaming readers with a user-controlled cutoff. The uploaded-file metadata check can avoid reading a known oversized text input, but a concurrent change can still require rejecting its bytes after retrieval. Likewise, a PDF or ZIP may already have consumed memory and service work before its size check fails. Several representations can coexist, 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 uses no conversion; a first create path can make one conversion request, including a run that later fails before saving a ZIP.

Use the complete error and recorded phase to decide what to inspect. A quota or export-permission error is not fixed by setting another MIME label, sleeping in a loop, or broadening sharing. A wrong UTF-8 or CSV shape error needs an input correction, not a larger byte allowance. 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 automatically retry creation while an earlier file is uncertain.

Retire only the identified practice resources

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

To clean up the disposable Drive files, open the exact recorded output ID and each configured input ID, 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 and glossary 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"
}