1

I am looking to simulate the behavior of Google Classroom's assignment feature that allows you to make a copy of a document for a list of students. This copy retains ownership by the creator and adds the student as an editor.

I have succeed in making this work with a script attached to a template doc. The teachers can paste a list of email addresses into a custom pull-out, and on submission a loop creates each doc with the permissions.

Attempts:

  • I can use copy() functionality to copy the doc, but the attached script goes along for the ride and is then accessible by the students. This is not a major security risk, but has the potential to be abused.
  • I can have moderate success by using the regularly mentioned method of looping through all elements and appending them to the new doc, but so far I have not been able to make everything work in this way. Some images, tables, and other elements that teachers might create do not format properly in the new doc.

Hopeful solutions:

  • Is there a way to remove the script from the copied doc? or
  • Is there a way to use permissions to only allow the script to be run by faculty? (We do have an Org Unit for faculty, but my tests with the AdminDirectory module leave me concerned about permissions once all faculty is using the tool.) or
  • Knowing that our student email addresses are formatted differently from our faculty email addresses, could I programmatically block the script based on email address parsing of the current user?

I've gone in circles and keep ending up at the posts explaining how to copy elements one at a time into a new doc. This does not appear to be sufficient due to formatting so I'm hoping one of the other solutions involving keeping the copy() function is possible.

Sidebar Code sidebar.html

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <script>
    
    </script>
    <style>
    textarea{
    font-size: .9em;
    width: 90%;
    height: 300px;
    }
    </style>
  </head>
  <body>
  

<textarea id="addressList">
Paste class email list here with spaces between addresses.
</textarea>
<p>
<input id="submit" type="button" value="Distribute to Students" onclick="distributor();" />
<input id="reset" type="button" value="Reset" onclick="reset();" />

</p>

<script>

function reset(){
   document.getElementById('addressList').value = "Paste class email list here with spaces between addresses.";
   document.getElementById("submit").disabled = false;
}

var mainOut = "";

function cleanUp(output){
   mainOut += output;
   document.getElementById('addressList').value = mainOut;
}

function distributor(){
  document.getElementById("submit").disabled = true;
  
  var nameList = document.getElementById('addressList').value; // get email list
  var list = nameList.split("\n"); // break it up
  
  // loop through names
  for(let i = 0; i < list.length; i++){
     // create a doc for each student and return success to the UI
     google.script.run.withSuccessHandler(cleanUp).distro(list[i],i+1,list.length);
  }
}

</script>
</body>
</html>

Current Script Code.gs

// student email addresses end in a 2 digit class year, faculty does not
var event;
var emailParts = Session.getEffectiveUser().getEmail().split('@');
var username = emailParts[0];
var classCheck = username.substring(username.length-2);
var validUser = false;

// using school email address style, determine if student or teacher to hide the UI changes
if(isNaN(classCheck)){
  validUser = true;
} 

// Custom  Menu
function onOpen(e){
  if(validUser){
    event = e;
    var ui = DocumentApp.getUi();
    var faMenu = ui.createMenu('FA')
    .addItem('Open Distribution Tool', 'openTool').addToUi();
  }
}

/**
* Creates Custom Sidebar for emailing teams from spreadsheet
*/
function openTool() {
  if(validUser){
    var html = HtmlService.createHtmlOutputFromFile('sidebar');
    html.setTitle('Share with Students');
    html.setWidth(400);
    html.setContent(html.getContent());
    DocumentApp.getUi().showSidebar(html);
  }
}

// create copy, set permissions
function distro(studentEmail,count,total){
  var output = "";
  if(validUser){
    
    var teacherEmail = 'xxx@xxx.org'; // this will be replaced by email of logged in user
    var thisDoc = DocumentApp.getActiveDocument();
    let studentName = studentEmail.split('@')[0];
      
    if(studentEmail != "" && studentEmail != null){
      
      let filename = thisDoc.getName() + "- " + studentName;
      let newDoc = DriveApp.getFileById(thisDoc.getId()).makeCopy(filename);
      
      newDoc.setOwner(studentEmail);
      newDoc.addEditor(teacherEmail);
      output += "Created doc " + count + "/" + total + ": " + filename + "\n";
    }
  }
  return output;
}

IglooWhite
  • 119
  • 9
  • Can you provide a copy of the script you are using? Also, would having a [standalone script](https://developers.google.com/apps-script/guides/standalone) instead of a [bound script](https://developers.google.com/apps-script/guides/bound) be an appropriate workaround for you? – Iamblichus Dec 08 '20 at 13:13
  • @lamblichus I can add a copy, although it's extremely basic. Would a standalone script still allow a teacher using the original to trigger the copying mechanism via a clean UI (like a menu or button) or would it require using the script via the script interface? – IglooWhite Dec 08 '20 at 15:42
  • The Apps Script Advanced API does allow one to edit scripts in other projects but I’m not certain that you can delete them but if you can edit them sufficiently you may be able to render them useless – Cooper Dec 08 '20 at 17:32
  • @IglooWhite Although a menu or button will only be able to fire a function in the bound script, I thought this bound script could be using a [library](https://developers.google.com/apps-script/guides/libraries) from a standalone script, which would include the actual code to copy the file. The library source code would not be available to the script bound to the copied file, which could only run the different functions defined in the library. So do you think this workaround would be appropriate for your situation? In that case, I'd consider posting an answer explaining this. – Iamblichus Dec 09 '20 at 09:37
  • @Cooper The problem about using Apps Script API, I think, is that you cannot programmatically retrieve the `scriptId` from a different container-bound script than the current one. So you wouldn't be able to edit this other script programmatically. See [this](https://stackoverflow.com/a/63368270), for example. – Iamblichus Dec 09 '20 at 09:40
  • @IglooWhite Also, adding a copy of the script you're using could be useful for evaluating the feasibility of the workaround I suggested before, or for potential other workarounds. – Iamblichus Dec 09 '20 at 09:42
  • I added the scripts. @lamblichus, I think the library might be sufficient. If I'm seeing correctly, when the doc gets copied, the script ALSO gets copied, so the kids can only do limited damage on their copy, and even then, I don't want them to have the script, so the more broken, the better. I had run into issues when I tried it as a library getting the sidebar to pull out, but that was likely just operator error. I hadn't thought of the fact that it would protect the source code. It would also benefit me in that teacher copies of the original could get *updates* when I improve it. – IglooWhite Dec 09 '20 at 15:36
  • Instead of copying the doc that contains the script, what about making the doc that needs to be copied separate and calling it using openByUrl()? The suggestion of it being made an add-on instead of container-bound also sounds like a good solution. – Luke Mar 04 '21 at 19:26

1 Answers1

0

Issue:

I don't think you can programmatically remove the bound script from a copied document.

In theory, this is possible if you use Apps Script API, by calling projects.updateContent and set and empty content for your Files.

Nevertheless, this requires knowing the scriptId, and you cannot programmatically retrieve the scriptId of a bound script which is not the current one (for the current one, Session.getScriptId() can be used). See this answer, for example, and this related feature request:

Workaround - use libraries:

As a workaround, I'd suggest putting at least some of the script code in a different, standalone script, and make your template call this library. This way, the library source code would not be available to the script bound to the copied file, which could only run the different functions defined in the library.

For example, you could move onOpen and distro to another script:

Library Code.gs:

// student email addresses end in a 2 digit class year, faculty does not
var event;
var emailParts = Session.getEffectiveUser().getEmail().split('@');
var username = emailParts[0];
var classCheck = username.substring(username.length-2);
var validUser = false;

// using school email address style, determine if student or teacher to hide the UI changes
if(isNaN(classCheck)){
  validUser = true;
}

// Custom  Menu
function onOpen(e){
  if(validUser){
    event = e;
    var ui = DocumentApp.getUi();
    var faMenu = ui.createMenu('FA')
    .addItem('Open Distribution Tool', 'openTool').addToUi();
  }
}

// create copy, set permissions
function distro(studentEmail,count,total){
  var output = "";
  if(validUser){
    
    var teacherEmail = 'xxx@xxx.org'; // this will be replaced by email of logged in user
    var thisDoc = DocumentApp.getActiveDocument();
    let studentName = studentEmail.split('@')[0];
      
    if(studentEmail != "" && studentEmail != null){
      
      let filename = thisDoc.getName() + "- " + studentName;
      let newDoc = DriveApp.getFileById(thisDoc.getId()).makeCopy(filename);
      
      newDoc.setOwner(studentEmail);
      newDoc.addEditor(teacherEmail);
      output += "Created doc " + count + "/" + total + ": " + filename + "\n";
    }
  }
  return output;
}

Then, share this script as a library, and use it in your template script, which could be like this (where LIBRARY is the identifier for your previously shared library):

Template Code.gs:

// student email addresses end in a 2 digit class year, faculty does not
var event;
var emailParts = Session.getEffectiveUser().getEmail().split('@');
var username = emailParts[0];
var classCheck = username.substring(username.length-2);
var validUser = false;

// using school email address style, determine if student or teacher to hide the UI changes
if(isNaN(classCheck)){
  validUser = true;
} 

// Custom  Menu
function onOpen(e){
  LIBRARY.onOpen(e);
}

/**
* Creates Custom Sidebar for emailing teams from spreadsheet
*/
function openTool() {
  if(validUser){
    var html = HtmlService.createHtmlOutputFromFile('sidebar');
    html.setTitle('Share with Students');
    html.setWidth(400);
    html.setContent(html.getContent());
    DocumentApp.getUi().showSidebar(html);
  }
}

// create copy, set permissions
function distro(studentEmail,count,total){
  LIBRARY.distro(studentEmail, count, total);
}

In this example, sidebar.html would also be contained in your template script.

Note:

  • This is a basic example just to show how this could be done, and could probably be improved. For example, it should be possible to also move openTool and the .html file to the library code, even though calling distro via google.script.run could become tricky: see Call Library function from html with google.script.run.

Reference:

Iamblichus
  • 18,540
  • 2
  • 11
  • 27
  • @lamblichus This is excellent and while I had tried something similar, your last link about calling `distro` from `google.script.run` was where I had run into problems. and had thus ignored libraries, which was probably premature. The reality is, though, your concept will almost definitely make the "unsafe" part safe. I will be trying this today and will report back. Thank you for your answer! – IglooWhite Dec 10 '20 at 14:12