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.
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.
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.
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.In My Drive, create a folder named
Drive copy practice. LeaveHarbor field visitwhere it is, because the source must stay outside the destination folder. InsideDrive copy practice, create an empty folder namedNotes. Add no files toDrive copy practice.Open Share on both
Harbor field visitandDrive copy practiceand confirm that your account owns them and that General access is Restricted.Open
Harbor field visitand copy its document ID from the URL. Inhttps://docs.google.com/document/d/SOURCE_FILE_ID/edit, the ID is between/d/and/edit.Open
Drive copy practiceand copy its folder ID from the URL. Inhttps://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
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 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.
// 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 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:
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.
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(...).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.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.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 byChild 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 state | What the copy function does |
|---|---|
| No record | Checks the source and destination, and that no direct child file already has the name outputName. Saves status: 'pending', then calls makeCopy(). |
pending | Throws an error, so you can inspect the destination folder before you decide how to reconcile. |
complete | Logs the saved outputFileId and reads that file's current metadata. Creates no copy. |
| Malformed record, or configuration that differs from the record | Throws 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
statusiscomplete, the copy and its property write both succeeded. Only a later log line failed.If
statusispending, openDrive copy practicein Drive and look forHarbor field visit - review copy.makeCopy()may have created it before the run stopped.
If the copy is there:
Open it and copy its file ID from the URL. Check that the ID differs from
sourceFileId.Confirm that it is a Google Doc, not in Trash, directly inside
Drive copy practice, and namedHarbor field visit - review copy.Paste that file ID into
confirmedCopyIdat the top ofCode.gs, save, and runreconcileDriveCopy.The log shows
Recorded the existing copy. No file created or changed.RunshowDriveCopyRecordto confirmstatus: 'complete'and theoutputFileId. Then setconfirmedCopyIdback to''and saveCode.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
| Symptom | Action before another attempt |
|---|---|
Set sourceFileId... or Set destinationFolderId... | Replace the placeholder with the ID from the item's URL. |
| Source-type error | Check that sourceFileId is the Google Doc's ID. A shortcut, PDF, folder, or script project is rejected. |
| Access or authorization error | Check the running account, the granted scopes, and the account's Drive permissions on the source document and the destination folder. |
Destination already contains outputName | Open the existing file in Drive copy practice. If an earlier pending run created it, record it with reconcileDriveCopy. |
| Another operation holds the script lock | Wait in Executions for the active execution to finish, then run showDriveCopyRecord. |
| Configuration differs from the recorded plan | Set sourceFileId, destinationFolderId, and outputName back to the values that showDriveCopyRecord prints. |
Exercise: rerun, rename, and recover the copy
After the first copy, change
Status: Ready for reviewtoStatus: Reviewedin the source document. In Drive, rename the copied document toHarbor field visit - annotated.Run
copySelectedDriveFile. It looks up the savedoutputFileId, logs the copy under its new nameHarbor field visit - annotated, and creates no file. Open the copy. It still saysStatus: Ready for review, because a copy does not follow later edits to the source.To test the
pendingrecovery path, rename the copy back toHarbor field visit - review copy. RunshowDriveCopyRecordand note theoutputFileId.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).Run
copySelectedDriveFile. It stops with the unresolved-outcome error and creates no second copy.Set
confirmedCopyIdinCode.gsto the output file ID from step 3, save, and runreconcileDriveCopy. Then setconfirmedCopyIdback to''.showDriveCopyRecordnow reportsstatus: '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.canCopyandcapabilities.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:
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.
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.