Skip to content
Skip to chapter

Chapter 14

Workspace services

DriveApp files, folders, and scopes

Copy Google Drive files by ID with DriveApp, or pair Google Picker and the drive.file scope with the advanced Drive service for single-file access.

By the end

Copy a specific Google Doc without silently repeating an uncertain action, and explain how a Picker-based drive.file workflow retrieves metadata or bytes.

Chapter navigation

Make a review copy of a Google Doc in a target Drive folder. First inspect the source document and destination folder without changing them, then create the copy, record its file ID in Script Properties, and use that saved ID on subsequent runs instead of creating duplicates.

This chapter builds on Work with JavaScript arrays and Understand the runtime and permissions.

Separate the file from the script

A DriveApp.File handle exposes Drive metadata (name, ID, MIME type, timestamps) and file-level operations such as copying or moving; reading or editing paragraphs and cells inside a file requires DocumentApp or SpreadsheetApp.

Unlike a container-bound script attached to a spreadsheet, this standalone project lives separately from both the source document and the destination folder and selects them by their Drive IDs. Keep in mind that copying a container file in Drive also copies any script bound to it.

Prepare two disposable resources

Create the test document and folders in My Drive rather than a shared drive (shared drives require the advanced Drive service):

  1. In Drive, choose New > Google Docs > Blank document, name it Harbor field visit, and paste the contents of sample.txt into the document body.

    sample.txt
    Harbor field visit
    
    Project: Harbor garden
    Prepared by: Rowan
    Status: Ready for review
    
    Bring the site sketch and count the empty planters.
    Record questions here before the planning meeting.
    
    This is fictional practice data. Do not add personal or customer information.
    

    Download sample.txt
  2. Outside that document's location in My Drive, create a new folder named Drive copy practice. Inside Drive copy practice, create an empty child folder named Notes, leaving no files directly inside Drive copy practice.

  3. Confirm in Share that both Harbor field visit and Drive copy practice are owned by your account with General access set to Restricted.

  4. Open Harbor field visit and copy its document ID from the URL (https://docs.google.com/document/d/SOURCE_FILE_ID/edit, between /d/ and /edit).

  5. Open Drive copy practice and copy its folder ID from the URL (https://drive.google.com/drive/u/0/folders/DESTINATION_FOLDER_ID, after /folders/ and before any ? query string).

Drive names are display labels rather than unique identifiers: multiple files or folders can share the same name, so getFilesByName() returns an iterator over all matches. This script instead looks up resources by ID with getFileById() and getFolderById(), which throw an error if the item does not exist or the executing account lacks access.

Install the complete project

  1. At script.google.com, create a New project named Drive copy practice script.

  2. Replace Code.gs with the complete project file below.

  3. In Project Settings, enable Show "appsscript.json" manifest file in editor, replace appsscript.json with the project manifest, and save.

  4. At the top of Code.gs, replace REPLACE_WITH_SOURCE_FILE_ID and REPLACE_WITH_DESTINATION_FOLDER_ID with your two Drive IDs. Leave outputName set to 'Harbor field visit - review copy' and confirmedCopyId set to ''.

sourceFileId identifies the Google Doc to copy, destinationFolderId identifies Drive copy practice, outputName sets the new copy's title (1 to 120 characters, no leading or trailing spaces), and confirmedCopyId is used only when reconciling an interrupted run. Before calling Drive or Properties, the script validates that each ID is non-empty and not a placeholder or full URL.

Code.gs
// Use one standalone project and one operator for one planned copy.
const DRIVE_COPY = {
  sourceFileId: 'REPLACE_WITH_SOURCE_FILE_ID',
  destinationFolderId: 'REPLACE_WITH_DESTINATION_FOLDER_ID',
  outputName: 'Harbor field visit - review copy',
  // Set only after inspecting an existing output during reconciliation.
  confirmedCopyId: ''
};

/**
 * @typedef {{
 *   sourceFileId: string,
 *   destinationFolderId: string,
 *   outputName: string,
 *   attemptedAt: string,
 *   status: 'pending'|'complete',
 *   outputFileId: string
 * }} DriveCopyRecord
 */

const DRIVE_COPY_RECORD_KEY = 'DRIVE_FILES_COPY';
const DRIVE_COPY_MIME_TYPE = 'application/vnd.google-apps.document';

function inspectDriveCopy() {
  validateDriveCopyConfig_();
  showDriveCopyRecord();
  const source = getDriveCopySource_();
  const destination = getDriveCopyDestination_();
  logDriveFile_('Source', source);
  Logger.log('Destination: ' + destination.getName());
  Logger.log('Destination ID: ' + destination.getId());
  Logger.log('Destination URL: ' + destination.getUrl());
  Logger.log('Planned copy name: ' + DRIVE_COPY.outputName);

  const files = destination.getFiles();
  let fileCount = 0;
  while (fileCount < 5 && files.hasNext()) {
    logDriveFile_('Existing child file', files.next());
    fileCount += 1;
  }
  Logger.log('Child files shown: ' + fileCount + '; more remain: ' + files.hasNext());

  const folders = destination.getFolders();
  let folderCount = 0;
  while (folderCount < 3 && folders.hasNext()) {
    const folder = folders.next();
    Logger.log('Child folder: ' + folder.getName() + '; ID: ' + folder.getId());
    folderCount += 1;
  }
  Logger.log('Child folders shown: ' + folderCount + '; more remain: ' + folders.hasNext());
  Logger.log('Inspection only. No files or properties changed.');
}

function copySelectedDriveFile() {
  validateDriveCopyConfig_();
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) {
    throw new Error('Another operation holds the script lock. Wait for it to finish, then inspect the record.');
  }
  try {
    const properties = PropertiesService.getScriptProperties();
    const previous = readDriveCopyRecord_(properties);
    if (previous !== null) {
      requireMatchingDriveCopyPlan_(previous);
      if (previous.status === 'pending') {
        throw new Error('Copy outcome is unresolved. Run showDriveCopyRecord and reconcile; do not copy again.');
      }
      Logger.log('Already recorded. No new copy. Output ID: ' + previous.outputFileId);
      logDriveFile_('Recorded output', DriveApp.getFileById(previous.outputFileId));
      return;
    }

    const source = getDriveCopySource_();
    const destination = getDriveCopyDestination_();
    if (destination.getFilesByName(DRIVE_COPY.outputName).hasNext()) {
      throw new Error('The destination already contains that output name. Inspect it; no copy was attempted.');
    }

    const record = {
      sourceFileId: DRIVE_COPY.sourceFileId,
      destinationFolderId: DRIVE_COPY.destinationFolderId,
      outputName: DRIVE_COPY.outputName,
      attemptedAt: new Date().toISOString(),
      status: 'pending',
      outputFileId: ''
    };
    // Persist intent BEFORE the external action. Never clear it on an error.
    properties.setProperty(DRIVE_COPY_RECORD_KEY, JSON.stringify(record));
    Logger.log('Copy attempt recorded. If this execution fails, inspect before any further action.');
    const copy = source.makeCopy(record.outputName, destination);
    record.outputFileId = copy.getId();
    Logger.log('Drive returned copy ID: ' + record.outputFileId);
    record.status = 'complete';
    properties.setProperty(DRIVE_COPY_RECORD_KEY, JSON.stringify(record));
    Logger.log('Copy ID saved in ' + DRIVE_COPY_RECORD_KEY + '.');
    logDriveFile_('Created output', copy);
  } finally {
    lock.releaseLock();
  }
}

function showDriveCopyRecord() {
  const value = PropertiesService.getScriptProperties().getProperty(DRIVE_COPY_RECORD_KEY);
  Logger.log(DRIVE_COPY_RECORD_KEY + ': ' + (value === null ? 'No recorded attempt.' : value));
}

// This records a reader-confirmed existing copy. It NEVER calls makeCopy.
function reconcileDriveCopy() {
  validateDriveCopyConfig_();
  requireDriveId_(DRIVE_COPY.confirmedCopyId, 'confirmedCopyId');
  if (DRIVE_COPY.confirmedCopyId === DRIVE_COPY.sourceFileId) {
    throw new Error('The confirmed copy ID is the source ID. Leave the original untouched.');
  }
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) {
    throw new Error('Another operation holds the script lock. Wait for it to finish, then inspect the record.');
  }
  try {
    const properties = PropertiesService.getScriptProperties();
    const record = readDriveCopyRecord_(properties);
    if (record === null) {
      throw new Error('No attempt is recorded. Reconciliation cannot invent an attempt.');
    }
    requireMatchingDriveCopyPlan_(record);
    if (record.status === 'complete') {
      if (record.outputFileId !== DRIVE_COPY.confirmedCopyId) {
        throw new Error('A different output ID is already recorded. Do not replace it.');
      }
      Logger.log('That output ID is already recorded. No property or file changed.');
      return;
    }

    const destination = getDriveCopyDestination_();
    const copy = DriveApp.getFileById(DRIVE_COPY.confirmedCopyId);
    if (copy.isTrashed() || copy.getMimeType() !== DRIVE_COPY_MIME_TYPE) {
      throw new Error('The confirmed output must be an untrashed Google Doc.');
    }
    if (copy.getName() !== record.outputName) {
      throw new Error('The confirmed output does not have the recorded name. Inspect it before proceeding.');
    }
    const parents = copy.getParents();
    if (!parents.hasNext()) {
      throw new Error('The confirmed output has no accessible parent folder.');
    }
    const parentId = parents.next().getId();
    if (parentId !== destination.getId() || parents.hasNext()) {
      throw new Error('The confirmed output must be directly in the recorded destination only.');
    }
    // Metadata checks do not prove provenance. The reader must inspect contents.
    record.outputFileId = copy.getId();
    record.status = 'complete';
    properties.setProperty(DRIVE_COPY_RECORD_KEY, JSON.stringify(record));
    Logger.log('Recorded the existing copy. No file created or changed.');
    logDriveFile_('Reconciled output', copy);
  } finally {
    lock.releaseLock();
  }
}

function validateDriveCopyConfig_() {
  requireDriveId_(DRIVE_COPY.sourceFileId, 'sourceFileId');
  requireDriveId_(DRIVE_COPY.destinationFolderId, 'destinationFolderId');
  if (DRIVE_COPY.sourceFileId === DRIVE_COPY.destinationFolderId) {
    throw new Error('Select a source file and a different destination folder.');
  }
  const name = DRIVE_COPY.outputName;
  if (typeof name !== 'string' || name.trim() !== name || name.length < 1 || name.length > 120) {
    throw new Error('Set outputName to 1-120 characters, with no leading or trailing spaces.');
  }
}

/**
 * @param {string} value
 * @param {string} label
 * @returns {void}
 */
function requireDriveId_(value, label) {
  if (typeof value !== 'string' || value.startsWith('REPLACE_') || !/^[A-Za-z0-9_-]+$/.test(value)) {
    throw new Error('Set ' + label + ' to the chosen item ID, not its URL or name.');
  }
}

function getDriveCopySource_() {
  const source = DriveApp.getFileById(DRIVE_COPY.sourceFileId);
  if (source.isTrashed()) throw new Error('The source is in Trash. Restore the intended sample first.');
  if (source.getMimeType() !== DRIVE_COPY_MIME_TYPE) {
    throw new Error('Select the sample Google Doc itself, not a shortcut, folder, PDF, or script project.');
  }
  return source;
}

function getDriveCopyDestination_() {
  const destination = DriveApp.getFolderById(DRIVE_COPY.destinationFolderId);
  if (destination.isTrashed()) throw new Error('The destination is in Trash. Restore the intended folder first.');
  return destination;
}

/**
 * @param {string} label
 * @param {DriveApp.File} file
 * @returns {void}
 */
function logDriveFile_(label, file) {
  Logger.log(label + ': ' + JSON.stringify({
    id: file.getId(),
    name: file.getName(),
    mimeType: file.getMimeType(),
    createdAt: file.getDateCreated().toISOString(),
    trashed: file.isTrashed(),
    url: file.getUrl()
  }));
}

/**
 * @param {PropertiesService.Properties} properties
 * @returns {DriveCopyRecord|null}
 */
function readDriveCopyRecord_(properties) {
  const value = properties.getProperty(DRIVE_COPY_RECORD_KEY);
  if (value === null) return null;
  let record;
  try {
    record = JSON.parse(value);
  } catch (error) {
    throw new Error('The copy record is unreadable. Preserve it and inspect the output; do not clear it.');
  }
  if (record === null || typeof record !== 'object' || Array.isArray(record) ||
      typeof record.sourceFileId !== 'string' || typeof record.destinationFolderId !== 'string' ||
      typeof record.outputName !== 'string' || typeof record.attemptedAt !== 'string' ||
      typeof record.outputFileId !== 'string' ||
      (record.status !== 'pending' && record.status !== 'complete')) {
    throw new Error('The copy record has an unexpected shape. Preserve it; do not clear it.');
  }
  requireDriveId_(record.sourceFileId, 'record.sourceFileId');
  requireDriveId_(record.destinationFolderId, 'record.destinationFolderId');
  if (record.status === 'complete') {
    requireDriveId_(record.outputFileId, 'record.outputFileId');
    if (record.outputFileId === record.sourceFileId) {
      throw new Error('The record identifies the source as the output. Preserve it and investigate.');
    }
  } else if (record.outputFileId !== '') {
    throw new Error('A pending record contains an unexpected output ID. Preserve it and investigate.');
  }
  return record;
}

/**
 * @param {DriveCopyRecord} record
 * @returns {void}
 */
function requireMatchingDriveCopyPlan_(record) {
  if (record.sourceFileId !== DRIVE_COPY.sourceFileId ||
      record.destinationFolderId !== DRIVE_COPY.destinationFolderId ||
      record.outputName !== DRIVE_COPY.outputName) {
    throw new Error('Configuration differs from the recorded plan. Restore it; do not start another copy.');
  }
}

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

Download appsscript.json

Understand the requested access

A manual run executes as the user who clicks Run (see authorization for Google services).

The manifest declares https://www.googleapis.com/auth/drive, which is required by File.makeCopy(name, destination). Although metadata reads work under drive.readonly, copying a file requires the full drive scope.

Google's Drive scope guide also defines https://www.googleapis.com/auth/drive.file, which grants access only to files created by the app or opened by the user through Google Picker. Passing a hardcoded file ID to DriveApp does not satisfy drive.file.

Use Google Picker for per-file access

When building an interactive dialog or web app that should request drive.file instead of full drive access, use Google Picker so the user selects the target file in the browser:

  1. Link the Apps Script project to a standard Google Cloud project and enable both the Google Picker API and the Google Drive API.

  2. Declare https://www.googleapis.com/auth/drive.file in appsscript.json.

  3. Pass the Cloud project number (the numeric project number, not the project ID string) to Picker's .setAppId(...).

  4. Generate a short-lived OAuth token on the server with ScriptApp.getOAuthToken() and pass it to the HTML dialog only when opening Picker.

  5. Configure a browser API key restricted to the Google Picker API and expected HTTP referrers, and set the Picker origin to google.script.host.origin inside an Apps Script dialog or sidebar.

  6. Validate the picked event and file metadata on the client before passing the chosen file ID to a server function.

Google's @googleworkspace/drive-picker-element web component wraps PickerBuilder in a custom HTML element while requiring the same Cloud project number, API key, OAuth token, and scope configuration.

Treat the value returned by ScriptApp.getOAuthToken() as a bearer credential: never log it, store it in Properties, or place it in a URL. After the user picks a file under drive.file, query its metadata and permissions through the advanced Drive service (Drive.Files.get(fileId, { fields: 'id,name,mimeType,capabilities' })) and check capabilities before attempting to copy, edit, or download it.

Inspect metadata and a bounded collection

Select inspectDriveCopy, click Run, and authorize the project when prompted. In Execution log, expect:

  • DRIVE_FILES_COPY: No recorded attempt.

  • Source metadata showing the document's ID, name, URL, creation timestamp, and mimeType: application/vnd.google-apps.document.

  • Destination metadata for Drive copy practice and the planned outputName.

  • Child files shown: 0; more remain: false.

  • One child-folder line for Notes, followed by Child folders shown: 1; more remain: false.

  • Inspection only. No files or properties changed.

Drive reports a file's format through getMimeType() rather than its filename. inspectDriveCopy checks that the source matches Google's Docs MIME type (application/vnd.google-apps.document) and rejects other types, Drive shortcuts, and trashed items.

destination.getFiles() returns a FileIterator and destination.getFolders() returns a FolderIterator over direct children of the folder. Each while (count < limit && iterator.hasNext()) loop advances with iterator.next() up to a fixed cap (5 files and 3 folders), then checks iterator.hasNext() once more to report whether additional items remain.

Keep the output identity between executions

To record whether a copy has already been attempted across executions, the project stores a JSON string under the key DRIVE_FILES_COPY in PropertiesService.getScriptProperties(). The record holds the source ID, destination folder ID, requested output name, attempt timestamp, status ('pending' or 'complete'), and outputFileId.

To prevent two concurrent executions in this project from both reading an empty state and creating two copies, copySelectedDriveFile and reconcileDriveCopy acquire LockService.getScriptLock() with tryLock(1000) and release it in finally (see LockService and Lock).

Because updating Script Properties and copying a Drive file are separate service calls rather than a single transaction, the script writes a 'pending' checkpoint before calling makeCopy():

Stored stateWhat the copy function does
No recordValidates the source, destination, and absence of a same-named child file; saves status: 'pending'; then calls makeCopy().
pendingThrows an error so you can inspect the destination folder before deciding how to reconcile.
completeLogs the saved outputFileId and reads that file's current metadata without creating another copy.
Malformed record or mismatched configThrows an error without creating a file or overwriting the property.

Writing pending before makeCopy() ensures that if Drive creates the copy but the execution fails before the completion write finishes, the next run stops for inspection instead of creating a duplicate file.

Make the copy and recognize a repeat

Select copySelectedDriveFile and click Run once. It creates a new Google Doc named Harbor field visit - review copy inside Drive copy practice, leaving the source document and the Notes subfolder unchanged. Open the logged output URL to verify that the copy contains the sample text and has a distinct file ID.

makeCopy(name, destination) returns the new DriveApp.File. The script reads copy.getId(), logs Drive returned copy ID: ..., writes status: 'complete' with outputFileId to DRIVE_FILES_COPY, and logs Copy ID saved in DRIVE_FILES_COPY.

Run showDriveCopyRecord to inspect the stored JSON record, then run copySelectedDriveFile a second time. Expect Already recorded. No new copy. Output ID: ... with the same file ID. Because subsequent runs look up the saved outputFileId directly, renaming or moving the copied file does not trigger a second copy. On the initial run (when no record exists yet), the script also checks destination.getFilesByName(DRIVE_COPY.outputName).hasNext() and stops if a direct child file already uses outputName.

Reconcile an uncertain outcome

If copySelectedDriveFile fails or stops partway through, wait for any running execution to finish in Executions and run showDriveCopyRecord:

  • If status is complete, the copy and its property checkpoint both succeeded before a final logging error occurred.

  • If status is pending, open Drive copy practice in Drive and check whether makeCopy() created Harbor field visit - review copy before the run stopped.

When you locate the created copy in Drive copy practice:

  1. Open the copied document and copy its file ID from the URL, verifying that it differs from sourceFileId.

  2. Confirm that it is an untrashed Google Doc located directly inside Drive copy practice with the name Harbor field visit - review copy.

  3. Paste that file ID into confirmedCopyId at the top of Code.gs, save, and run reconcileDriveCopy.

  4. Expect Recorded the existing copy. No file created or changed. Run showDriveCopyRecord to confirm status: 'complete' and outputFileId, then reset confirmedCopyId to '' and save Code.gs.

reconcileDriveCopy validates the confirmed file's ID, MIME type, trash state, name, and single parent folder, then updates DRIVE_FILES_COPY to complete without creating or modifying any Drive files.

Interpret other failures

SymptomAction before another attempt
Set sourceFileId... or Set destinationFolderId...Replace the placeholder with the ID string from the resource URL.
Source-type errorVerify sourceFileId points to the Google Doc rather than a shortcut, PDF, folder, or script project.
Access or authorization errorCheck the executing account, granted scopes, and Drive permissions on both the source document and destination folder.
Destination already contains outputNameInspect the existing file in Drive copy practice; if it came from an earlier pending run, reconcile it with reconcileDriveCopy.
Another operation holds the script lockWait for the active execution to finish in Executions and check showDriveCopyRecord.
Configuration differs from the recorded planRestore sourceFileId, destinationFolderId, and outputName to match the values in showDriveCopyRecord.

Exercise: predict identity and recovery

  1. After completing the first copy, edit the source document to change Status: Ready for review to Status: Reviewed, and rename the copied document in Drive to Harbor field visit - annotated.

  2. Run copySelectedDriveFile. It looks up the saved outputFileId, logs the renamed copy (Harbor field visit - annotated), and creates no new file. Opening the copy shows that it still contains Status: Ready for review.

  3. To test the pending recovery path, rename the copy back to Harbor field visit - review copy and note its file ID from showDriveCopyRecord.

  4. In Project Settings > Script Properties > Edit script properties, edit DRIVE_FILES_COPY: change "status":"complete" to "status":"pending" and "outputFileId":"..." to "outputFileId":"", then click Save script properties (see the Properties guide).

  5. Run copySelectedDriveFile. It stops with the unresolved-outcome error instead of creating a second copy.

  6. Set confirmedCopyId in Code.gs to your saved output file ID, save, and run reconcileDriveCopy, then reset confirmedCopyId to ''. showDriveCopyRecord now reports status: 'complete' with the original output ID.

Know when DriveApp is not enough

DriveApp covers basic file and folder lookups, metadata inspection, copying, moving, renaming, and trashing in My Drive. To read or modify paragraphs and tables inside the copied Google Doc, pass the copy's outputFileId to DocumentApp.openById(), as shown in the next chapter.

Enable the advanced Drive service (Services > Add a service > Drive API, which exposes the Drive API v3 global Drive) when you need shared-drive support (supportsAllDrives), fine-grained capability checks (capabilities.canCopy, capabilities.canAddChildren; see Drive file capabilities), custom file properties, or revision history. If your script uses a standard Google Cloud project, also enable the Drive API in the Cloud console (see enable advanced services).

Download bytes without confusing metadata and content

DriveApp.getFileById(fileId).getBlob() requires drive.readonly or drive, which grant access across the user's entire Drive. When your app instead uses drive.file with Google Picker, Drive.Files.get(fileId) returns a JSON metadata object rather than a Blob. To download the raw bytes of a binary file (such as a PDF or image) authorized via Picker under drive.file, fetch the Drive v3 media endpoint with UrlFetchApp and ScriptApp.getOAuthToken() (which additionally requires the https://www.googleapis.com/auth/script.external_request scope):

Source
function downloadPickedBlob(fileId) {
  if (!/^[A-Za-z0-9_-]{10,}$/.test(fileId)) {
    throw new Error('Unexpected Drive file ID.');
  }

  const url = 'https://www.googleapis.com/drive/v3/files/' +
    encodeURIComponent(fileId) + '?alt=media';
  const response = UrlFetchApp.fetch(url, {
    method: 'get',
    headers: {
      Authorization: 'Bearer ' + ScriptApp.getOAuthToken()
    },
    muteHttpExceptions: true
  });

  const status = response.getResponseCode();
  if (status < 200 || status >= 300) {
    throw new Error('Drive download failed with HTTP ' + status + '.');
  }
  return response.getBlob();
}

Keep the request URL fixed to https://www.googleapis.com/drive/v3/files/, validate fileId before constructing the URL, and never log the Authorization header or response payload on error.

Note that ?alt=media downloads binary files stored in Drive, whereas native Google Workspace documents (Docs, Sheets, Slides) must be exported with the Drive API's files.export endpoint and an explicit export MIME type (see download and export Drive files). Check mimeType and capabilities.canDownload on the metadata resource before downloading.

Keep or remove the practice output

When you finish the exercise, move the Harbor field visit - review copy document, the Harbor field visit source document, the Drive copy practice folder, and the Drive copy practice script project to Trash if you no longer need them. To remove the OAuth grant as well, revoke the project's access from your Google Account settings as described in the authorization guide. Next, generate a formatted Google Docs report from a template.

Project files

Complete source files for this chapter’s examples.

Make one recorded Drive review copy
Code.gs
// Use one standalone project and one operator for one planned copy.
const DRIVE_COPY = {
  sourceFileId: 'REPLACE_WITH_SOURCE_FILE_ID',
  destinationFolderId: 'REPLACE_WITH_DESTINATION_FOLDER_ID',
  outputName: 'Harbor field visit - review copy',
  // Set only after inspecting an existing output during reconciliation.
  confirmedCopyId: ''
};

/**
 * @typedef {{
 *   sourceFileId: string,
 *   destinationFolderId: string,
 *   outputName: string,
 *   attemptedAt: string,
 *   status: 'pending'|'complete',
 *   outputFileId: string
 * }} DriveCopyRecord
 */

const DRIVE_COPY_RECORD_KEY = 'DRIVE_FILES_COPY';
const DRIVE_COPY_MIME_TYPE = 'application/vnd.google-apps.document';

function inspectDriveCopy() {
  validateDriveCopyConfig_();
  showDriveCopyRecord();
  const source = getDriveCopySource_();
  const destination = getDriveCopyDestination_();
  logDriveFile_('Source', source);
  Logger.log('Destination: ' + destination.getName());
  Logger.log('Destination ID: ' + destination.getId());
  Logger.log('Destination URL: ' + destination.getUrl());
  Logger.log('Planned copy name: ' + DRIVE_COPY.outputName);

  const files = destination.getFiles();
  let fileCount = 0;
  while (fileCount < 5 && files.hasNext()) {
    logDriveFile_('Existing child file', files.next());
    fileCount += 1;
  }
  Logger.log('Child files shown: ' + fileCount + '; more remain: ' + files.hasNext());

  const folders = destination.getFolders();
  let folderCount = 0;
  while (folderCount < 3 && folders.hasNext()) {
    const folder = folders.next();
    Logger.log('Child folder: ' + folder.getName() + '; ID: ' + folder.getId());
    folderCount += 1;
  }
  Logger.log('Child folders shown: ' + folderCount + '; more remain: ' + folders.hasNext());
  Logger.log('Inspection only. No files or properties changed.');
}

function copySelectedDriveFile() {
  validateDriveCopyConfig_();
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) {
    throw new Error('Another operation holds the script lock. Wait for it to finish, then inspect the record.');
  }
  try {
    const properties = PropertiesService.getScriptProperties();
    const previous = readDriveCopyRecord_(properties);
    if (previous !== null) {
      requireMatchingDriveCopyPlan_(previous);
      if (previous.status === 'pending') {
        throw new Error('Copy outcome is unresolved. Run showDriveCopyRecord and reconcile; do not copy again.');
      }
      Logger.log('Already recorded. No new copy. Output ID: ' + previous.outputFileId);
      logDriveFile_('Recorded output', DriveApp.getFileById(previous.outputFileId));
      return;
    }

    const source = getDriveCopySource_();
    const destination = getDriveCopyDestination_();
    if (destination.getFilesByName(DRIVE_COPY.outputName).hasNext()) {
      throw new Error('The destination already contains that output name. Inspect it; no copy was attempted.');
    }

    const record = {
      sourceFileId: DRIVE_COPY.sourceFileId,
      destinationFolderId: DRIVE_COPY.destinationFolderId,
      outputName: DRIVE_COPY.outputName,
      attemptedAt: new Date().toISOString(),
      status: 'pending',
      outputFileId: ''
    };
    // Persist intent BEFORE the external action. Never clear it on an error.
    properties.setProperty(DRIVE_COPY_RECORD_KEY, JSON.stringify(record));
    Logger.log('Copy attempt recorded. If this execution fails, inspect before any further action.');
    const copy = source.makeCopy(record.outputName, destination);
    record.outputFileId = copy.getId();
    Logger.log('Drive returned copy ID: ' + record.outputFileId);
    record.status = 'complete';
    properties.setProperty(DRIVE_COPY_RECORD_KEY, JSON.stringify(record));
    Logger.log('Copy ID saved in ' + DRIVE_COPY_RECORD_KEY + '.');
    logDriveFile_('Created output', copy);
  } finally {
    lock.releaseLock();
  }
}

function showDriveCopyRecord() {
  const value = PropertiesService.getScriptProperties().getProperty(DRIVE_COPY_RECORD_KEY);
  Logger.log(DRIVE_COPY_RECORD_KEY + ': ' + (value === null ? 'No recorded attempt.' : value));
}

// This records a reader-confirmed existing copy. It NEVER calls makeCopy.
function reconcileDriveCopy() {
  validateDriveCopyConfig_();
  requireDriveId_(DRIVE_COPY.confirmedCopyId, 'confirmedCopyId');
  if (DRIVE_COPY.confirmedCopyId === DRIVE_COPY.sourceFileId) {
    throw new Error('The confirmed copy ID is the source ID. Leave the original untouched.');
  }
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) {
    throw new Error('Another operation holds the script lock. Wait for it to finish, then inspect the record.');
  }
  try {
    const properties = PropertiesService.getScriptProperties();
    const record = readDriveCopyRecord_(properties);
    if (record === null) {
      throw new Error('No attempt is recorded. Reconciliation cannot invent an attempt.');
    }
    requireMatchingDriveCopyPlan_(record);
    if (record.status === 'complete') {
      if (record.outputFileId !== DRIVE_COPY.confirmedCopyId) {
        throw new Error('A different output ID is already recorded. Do not replace it.');
      }
      Logger.log('That output ID is already recorded. No property or file changed.');
      return;
    }

    const destination = getDriveCopyDestination_();
    const copy = DriveApp.getFileById(DRIVE_COPY.confirmedCopyId);
    if (copy.isTrashed() || copy.getMimeType() !== DRIVE_COPY_MIME_TYPE) {
      throw new Error('The confirmed output must be an untrashed Google Doc.');
    }
    if (copy.getName() !== record.outputName) {
      throw new Error('The confirmed output does not have the recorded name. Inspect it before proceeding.');
    }
    const parents = copy.getParents();
    if (!parents.hasNext()) {
      throw new Error('The confirmed output has no accessible parent folder.');
    }
    const parentId = parents.next().getId();
    if (parentId !== destination.getId() || parents.hasNext()) {
      throw new Error('The confirmed output must be directly in the recorded destination only.');
    }
    // Metadata checks do not prove provenance. The reader must inspect contents.
    record.outputFileId = copy.getId();
    record.status = 'complete';
    properties.setProperty(DRIVE_COPY_RECORD_KEY, JSON.stringify(record));
    Logger.log('Recorded the existing copy. No file created or changed.');
    logDriveFile_('Reconciled output', copy);
  } finally {
    lock.releaseLock();
  }
}

function validateDriveCopyConfig_() {
  requireDriveId_(DRIVE_COPY.sourceFileId, 'sourceFileId');
  requireDriveId_(DRIVE_COPY.destinationFolderId, 'destinationFolderId');
  if (DRIVE_COPY.sourceFileId === DRIVE_COPY.destinationFolderId) {
    throw new Error('Select a source file and a different destination folder.');
  }
  const name = DRIVE_COPY.outputName;
  if (typeof name !== 'string' || name.trim() !== name || name.length < 1 || name.length > 120) {
    throw new Error('Set outputName to 1-120 characters, with no leading or trailing spaces.');
  }
}

/**
 * @param {string} value
 * @param {string} label
 * @returns {void}
 */
function requireDriveId_(value, label) {
  if (typeof value !== 'string' || value.startsWith('REPLACE_') || !/^[A-Za-z0-9_-]+$/.test(value)) {
    throw new Error('Set ' + label + ' to the chosen item ID, not its URL or name.');
  }
}

function getDriveCopySource_() {
  const source = DriveApp.getFileById(DRIVE_COPY.sourceFileId);
  if (source.isTrashed()) throw new Error('The source is in Trash. Restore the intended sample first.');
  if (source.getMimeType() !== DRIVE_COPY_MIME_TYPE) {
    throw new Error('Select the sample Google Doc itself, not a shortcut, folder, PDF, or script project.');
  }
  return source;
}

function getDriveCopyDestination_() {
  const destination = DriveApp.getFolderById(DRIVE_COPY.destinationFolderId);
  if (destination.isTrashed()) throw new Error('The destination is in Trash. Restore the intended folder first.');
  return destination;
}

/**
 * @param {string} label
 * @param {DriveApp.File} file
 * @returns {void}
 */
function logDriveFile_(label, file) {
  Logger.log(label + ': ' + JSON.stringify({
    id: file.getId(),
    name: file.getName(),
    mimeType: file.getMimeType(),
    createdAt: file.getDateCreated().toISOString(),
    trashed: file.isTrashed(),
    url: file.getUrl()
  }));
}

/**
 * @param {PropertiesService.Properties} properties
 * @returns {DriveCopyRecord|null}
 */
function readDriveCopyRecord_(properties) {
  const value = properties.getProperty(DRIVE_COPY_RECORD_KEY);
  if (value === null) return null;
  let record;
  try {
    record = JSON.parse(value);
  } catch (error) {
    throw new Error('The copy record is unreadable. Preserve it and inspect the output; do not clear it.');
  }
  if (record === null || typeof record !== 'object' || Array.isArray(record) ||
      typeof record.sourceFileId !== 'string' || typeof record.destinationFolderId !== 'string' ||
      typeof record.outputName !== 'string' || typeof record.attemptedAt !== 'string' ||
      typeof record.outputFileId !== 'string' ||
      (record.status !== 'pending' && record.status !== 'complete')) {
    throw new Error('The copy record has an unexpected shape. Preserve it; do not clear it.');
  }
  requireDriveId_(record.sourceFileId, 'record.sourceFileId');
  requireDriveId_(record.destinationFolderId, 'record.destinationFolderId');
  if (record.status === 'complete') {
    requireDriveId_(record.outputFileId, 'record.outputFileId');
    if (record.outputFileId === record.sourceFileId) {
      throw new Error('The record identifies the source as the output. Preserve it and investigate.');
    }
  } else if (record.outputFileId !== '') {
    throw new Error('A pending record contains an unexpected output ID. Preserve it and investigate.');
  }
  return record;
}

/**
 * @param {DriveCopyRecord} record
 * @returns {void}
 */
function requireMatchingDriveCopyPlan_(record) {
  if (record.sourceFileId !== DRIVE_COPY.sourceFileId ||
      record.destinationFolderId !== DRIVE_COPY.destinationFolderId ||
      record.outputName !== DRIVE_COPY.outputName) {
    throw new Error('Configuration differs from the recorded plan. Restore it; do not start another copy.');
  }
}

appsscript.json

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

sample.txt

Download file
sample.txt
Harbor field visit

Project: Harbor garden
Prepared by: Rowan
Status: Ready for review

Bring the site sketch and count the empty planters.
Record questions here before the planning meeting.

This is fictional practice data. Do not add personal or customer information.