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.
Copy a specific Google Doc without silently repeating an uncertain action, and explain how a Picker-based drive.file workflow retrieves metadata or bytes.
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):
In Drive, choose New > Google Docs > Blank document, name it
Harbor field visit, and paste the contents ofsample.txtinto the document body.Download sample.txtsample.txtHarbor 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.Outside that document's location in My Drive, create a new folder named
Drive copy practice. InsideDrive copy practice, create an empty child folder namedNotes, leaving no files directly insideDrive copy practice.Confirm in Share that both
Harbor field visitandDrive copy practiceare owned by your account with General access set to Restricted.Open
Harbor field visitand copy its document ID from the URL (https://docs.google.com/document/d/SOURCE_FILE_ID/edit, between/d/and/edit).Open
Drive copy practiceand 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
At
script.google.com, create a New project namedDrive copy practice script.Replace
Code.gswith the complete project file below.In Project Settings, enable Show "appsscript.json" manifest file in editor, replace
appsscript.jsonwith the project manifest, and save.At the top of
Code.gs, replaceREPLACE_WITH_SOURCE_FILE_IDandREPLACE_WITH_DESTINATION_FOLDER_IDwith your two Drive IDs. LeaveoutputNameset to'Harbor field visit - review copy'andconfirmedCopyIdset 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.
// 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.');
}
}
{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/drive"
]
}
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:
Link the Apps Script project to a standard Google Cloud project and enable both the Google Picker API and the Google Drive API.
Declare
https://www.googleapis.com/auth/drive.fileinappsscript.json.Pass the Cloud project number (the numeric project number, not the project ID string) to Picker's
.setAppId(...).Generate a short-lived OAuth token on the server with
ScriptApp.getOAuthToken()and pass it to the HTML dialog only when opening Picker.Configure a browser API key restricted to the Google Picker API and expected HTTP referrers, and set the Picker origin to
google.script.host.origininside an Apps Script dialog or sidebar.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 practiceand the plannedoutputName.Child files shown: 0; more remain: false.One child-folder line for
Notes, followed byChild 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 state | What the copy function does |
|---|---|
| No record | Validates the source, destination, and absence of a same-named child file; saves status: 'pending'; then calls makeCopy(). |
pending | Throws an error so you can inspect the destination folder before deciding how to reconcile. |
complete | Logs the saved outputFileId and reads that file's current metadata without creating another copy. |
| Malformed record or mismatched config | Throws 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
statusiscomplete, the copy and its property checkpoint both succeeded before a final logging error occurred.If
statusispending, openDrive copy practicein Drive and check whethermakeCopy()createdHarbor field visit - review copybefore the run stopped.
When you locate the created copy in Drive copy practice:
Open the copied document and copy its file ID from the URL, verifying that it differs from
sourceFileId.Confirm that it is an untrashed Google Doc located directly inside
Drive copy practicewith the nameHarbor field visit - review copy.Paste that file ID into
confirmedCopyIdat the top ofCode.gs, save, and runreconcileDriveCopy.Expect
Recorded the existing copy. No file created or changed.RunshowDriveCopyRecordto confirmstatus: 'complete'andoutputFileId, then resetconfirmedCopyIdto''and saveCode.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
| Symptom | Action before another attempt |
|---|---|
Set sourceFileId... or Set destinationFolderId... | Replace the placeholder with the ID string from the resource URL. |
| Source-type error | Verify sourceFileId points to the Google Doc rather than a shortcut, PDF, folder, or script project. |
| Access or authorization error | Check the executing account, granted scopes, and Drive permissions on both the source document and destination folder. |
Destination already contains outputName | Inspect the existing file in Drive copy practice; if it came from an earlier pending run, reconcile it with reconcileDriveCopy. |
| Another operation holds the script lock | Wait for the active execution to finish in Executions and check showDriveCopyRecord. |
| Configuration differs from the recorded plan | Restore sourceFileId, destinationFolderId, and outputName to match the values in showDriveCopyRecord. |
Exercise: predict identity and recovery
After completing the first copy, edit the source document to change
Status: Ready for reviewtoStatus: Reviewed, and rename the copied document in Drive toHarbor field visit - annotated.Run
copySelectedDriveFile. It looks up the savedoutputFileId, logs the renamed copy (Harbor field visit - annotated), and creates no new file. Opening the copy shows that it still containsStatus: Ready for review.To test the
pendingrecovery path, rename the copy back toHarbor field visit - review copyand note its file ID fromshowDriveCopyRecord.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).Run
copySelectedDriveFile. It stops with the unresolved-outcome error instead of creating a second copy.Set
confirmedCopyIdinCode.gsto your saved output file ID, save, and runreconcileDriveCopy, then resetconfirmedCopyIdto''.showDriveCopyRecordnow reportsstatus: '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):
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
Download file// 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{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/drive"
]
}
sample.txt
Download fileHarbor 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.