Skip to content
Skip to chapter

Chapter 14

Workspace services

DriveApp files, folders, and scopes

Copy a Google Doc by ID with DriveApp and save the copy's ID, then see how Google Picker and the drive.file scope give access to one picked file.

By the end

Copy a specific Google Doc once, stop instead of copying again when an earlier run's result is unknown, and explain how a Picker-based drive.file workflow reads a file's metadata or bytes.

Chapter navigation

Make one review copy of a Google Doc in a Drive folder. First inspect the source document and the destination folder without changing them. Then create the copy and save its file ID in Script Properties. Later runs use that ID to find the copy without creating another.

You need Work with JavaScript arrays and Understand the runtime and permissions first.

Work with file metadata through DriveApp

A DriveApp.File object gives you a file's Drive metadata (name, ID, MIME type, timestamps) and file-level operations such as copying and moving. To read or edit the paragraphs or cells inside a file, use DocumentApp or SpreadsheetApp.

A container-bound script is attached to one file, such as a spreadsheet. This project is a standalone script. It is attached to neither the source document nor the destination folder, and it finds both by their Drive IDs. Copying a file in Drive also copies any script bound to that file.

Create a practice document and folder

Create the test document and folders in My Drive, not in a shared drive. Shared drives need the advanced Drive service, which this project does not use.

  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. In My Drive, create a folder named Drive copy practice. Leave Harbor field visit where it is, because the source must stay outside the destination folder. Inside Drive copy practice, create an empty folder named Notes. Add no files to Drive copy practice.

  3. Open Share on both Harbor field visit and Drive copy practice and confirm that your account owns them and that General access is Restricted.

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

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

Drive names are not unique. Several files or folders can share one name, so getFilesByName() returns an iterator over every match. This script instead looks items up by ID with getFileById() and getFolderById(). Both throw an error when the item does not exist or the running account cannot open it.

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 names the Google Doc to copy. destinationFolderId names Drive copy practice. outputName is the new copy's title. It must be 1 to 120 characters long, with no leading or trailing spaces. confirmedCopyId is used only when you reconcile an interrupted run. Before it calls Drive or Properties, the script checks that each ID is non-empty and is neither a placeholder nor a 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 File.makeCopy(name, destination) requires. Reading metadata works with drive.readonly, but copying a file needs the full drive scope, which covers every file the account can open.

Google's Drive scope guide also defines https://www.googleapis.com/auth/drive.file. It grants access only to files the app created or the user opened through Google Picker. Passing a hardcoded file ID to DriveApp does not count as opening the file, so drive.file does not cover this project.

Use Google Picker for per-file access

When a dialog or web app should ask for drive.file instead of full drive access, use Google Picker so the user chooses the 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 in it.

  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. Get a short-lived OAuth token on the server with ScriptApp.getOAuthToken() and pass it to the HTML dialog only when the dialog opens Picker.

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

  6. In the browser, check the picked event and file metadata 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. It needs the same Cloud project number, API key, OAuth token, and scope.

The token from ScriptApp.getOAuthToken() is a bearer credential. Anyone who holds it can act as the user. Never log it, store it in Properties, or put it in a URL. After the user picks a file under drive.file, read its metadata and permissions through the advanced Drive service with Drive.Files.get(fileId, { fields: 'id,name,mimeType,capabilities' }). Check capabilities before you copy, edit, or download the file.

Inspect metadata and list a few folder items

Select inspectDriveCopy, click Run, and authorize the project when prompted. The Execution log shows:

  • DRIVE_FILES_COPY: No recorded attempt.

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

  • The destination folder's name, ID, and URL, and the planned copy name from 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(). The file name does not decide it. inspectDriveCopy checks that the source has 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. Both cover only the folder's direct children.

Each while (count < limit && iterator.hasNext()) loop calls iterator.next() up to a fixed cap (5 files, 3 folders), then calls iterator.hasNext() once more to report whether more items remain.

Save the copy's file ID between runs

Script memory does not survive an execution, so the project stores a JSON record under the key DRIVE_FILES_COPY in PropertiesService.getScriptProperties(). The record holds the source ID, the destination folder ID, the requested output name, and the attempt timestamp. It also stores a status of 'pending' or 'complete' and the outputFileId.

Two executions that start at the same time could both read an empty record and create two copies. To prevent that, copySelectedDriveFile and reconcileDriveCopy take LockService.getScriptLock() with tryLock(1000) and release it in finally. See LockService and Lock.

Writing Script Properties and copying a Drive file are separate service calls, not one transaction. The script therefore writes a 'pending' record before it calls makeCopy(). The stored state decides what copySelectedDriveFile does:

Stored stateWhat the copy function does
No recordChecks the source and destination, and that no direct child file already has the name outputName. Saves status: 'pending', then calls makeCopy().
pendingThrows an error, so you can inspect the destination folder before you decide how to reconcile.
completeLogs the saved outputFileId and reads that file's current metadata. Creates no copy.
Malformed record, or configuration that differs from the recordThrows an error. Creates no file and does not overwrite the property.

If a run fails after Drive creates the copy but before the script writes complete, the saved status stays pending. The next run then stops for inspection instead of creating a duplicate.

Make the copy and recognize a repeat

Select copySelectedDriveFile and click Run once. It creates a Google Doc named Harbor field visit - review copy inside Drive copy practice. The source document and the Notes folder do not change. Open the logged output URL and check that the copy contains the sample text and has a different file ID from the source.

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

Run showDriveCopyRecord to see the stored JSON record, then run copySelectedDriveFile a second time. It logs Already recorded. No new copy. Output ID: ... with the same file ID. Later runs look up the saved outputFileId directly, so renaming or moving the copy does not cause a second copy. The check for an existing file named outputName runs only on the first run, when no record exists yet.

Reconcile an uncertain outcome

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

  • If status is complete, the copy and its property write both succeeded. Only a later log line failed.

  • If status is pending, open Drive copy practice in Drive and look for Harbor field visit - review copy. makeCopy() may have created it before the run stopped.

If the copy is there:

  1. Open it and copy its file ID from the URL. Check that the ID differs from sourceFileId.

  2. Confirm that it is a Google Doc, not in Trash, directly inside Drive copy practice, and named Harbor field visit - review copy.

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

  4. The log shows Recorded the existing copy. No file created or changed. Run showDriveCopyRecord to confirm status: 'complete' and the outputFileId. Then set confirmedCopyId back to '' and save Code.gs.

reconcileDriveCopy checks the confirmed file's ID, MIME type, trash state, name, and parent folder (it must have exactly one, the destination). It then sets DRIVE_FILES_COPY to complete. It never creates or changes a Drive file.

Interpret other failures

SymptomAction before another attempt
Set sourceFileId... or Set destinationFolderId...Replace the placeholder with the ID from the item's URL.
Source-type errorCheck that sourceFileId is the Google Doc's ID. A shortcut, PDF, folder, or script project is rejected.
Access or authorization errorCheck the running account, the granted scopes, and the account's Drive permissions on the source document and the destination folder.
Destination already contains outputNameOpen the existing file in Drive copy practice. If an earlier pending run created it, record it with reconcileDriveCopy.
Another operation holds the script lockWait in Executions for the active execution to finish, then run showDriveCopyRecord.
Configuration differs from the recorded planSet sourceFileId, destinationFolderId, and outputName back to the values that showDriveCopyRecord prints.

Exercise: rerun, rename, and recover the copy

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

  2. Run copySelectedDriveFile. It looks up the saved outputFileId, logs the copy under its new name Harbor field visit - annotated, and creates no file. Open the copy. It still says Status: Ready for review, because a copy does not follow later edits to the source.

  3. To test the pending recovery path, rename the copy back to Harbor field visit - review copy. Run showDriveCopyRecord and note the outputFileId.

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

  5. Run copySelectedDriveFile. It stops with the unresolved-outcome error and creates no second copy.

  6. Set confirmedCopyId in Code.gs to the output file ID from step 3, save, and run reconcileDriveCopy. Then set confirmedCopyId back to ''. showDriveCopyRecord now reports status: 'complete' with the original output ID.

Know when DriveApp is not enough

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

The advanced Drive service adds the Drive API v3 global Drive. Enable it with Services > Add a service > Drive API. Use it for:

  • Shared drives (supportsAllDrives).

  • Capability checks such as capabilities.canCopy and capabilities.canAddChildren (see Drive file capabilities).

  • Custom file properties.

  • 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 the bytes of a picked file

DriveApp.getFileById(fileId).getBlob() needs drive.readonly or drive, and both cover the user's whole Drive. When your app uses drive.file with Google Picker instead, Drive.Files.get(fileId) returns a JSON metadata object, not a Blob. To download the bytes of a binary file such as a PDF or an image that the user picked under drive.file, call the Drive v3 media endpoint with UrlFetchApp. Pass the token from ScriptApp.getOAuthToken(), and add https://www.googleapis.com/auth/script.external_request to the manifest:

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/, check fileId before you build the URL, and never log the Authorization header or the response body on error.

?alt=media downloads files that Drive stores as bytes. Google Docs, Sheets, and Slides files have no stored bytes. Export them with the Drive API's files.export endpoint and an export MIME type instead (see download and export Drive files). Check mimeType and capabilities.canDownload in the metadata before you download.

Keep or remove the practice output

When you no longer need them, move Harbor field visit - review copy, Harbor field visit, the Drive copy practice folder, and the Drive copy practice script project to Trash. To remove the OAuth grant as well, revoke the project's access in your Google Account settings, as the authorization guide describes.

Next, Generate a 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.