Печать из Google Apps Script

в 13:56, , рубрики: Google API, google apps script, google cloud print, javascript, Программирование

Часть 1. Challenge

Читая ленту на oDesk, наткнулся на интересный проект по моему направлению (а я отслеживаю, в основном, задачи на написать что-то, прикрутить что-то или иным способом замучить Google Apps Script или приложения Google Apps). Клиент просил написать скрипт, который будет отсылать ему выделенный фрагмент из Google Spreadsheet по нажатию кнопки. Но была в описании одна фраза, зацепившая меня — «Мне сказали, что невозможно создать скрипт, который будет печатать из Google Apps». Я всегда очень любил и люблю «невозможные» задачи:

— Мы сами знаем, что она не имеет решения, — сказал Хунта, немедленно ощетиниваясь. — Мы хотим знать, как её решать.
Аркадий и Борис Стругацкие. Понедельник начинается в субботу

Статья рассчитана на читателей, уже знакомых с Google Apps Script и сопутствующими технологиями.

Часть 2. Мучения

Решение изначально было очевидно — воспользоваться сервисом Google Cloud Print, а печатный документ передавать в форме PDF. Изучение API показало, что необходимо изначально аутентифицироваться в сервисе, затем — послать запрос на печать. Итак, я настроил сервис, настроил принтеры и начал дергать API. Все работает и печатается (из REST клиента)! Пора писать скрипт…

Аутентификация

… и сразу со всего размаха налетаю на первый подводный камень: аутентификация. Google Cloud Print не хватает простого логина, у него есть собственный authentication scope. Игры в OAuth Playground позволили подобрать нужный scope (легко угадываемый, но в документации почему-то не нашел) —

https://www.googleapis.com/auth/cloudprint

Начинаем писать скрипт, используем oAuth 1.0:

Аутентификация через oAuth 1.0

function authorize_() {
  var oauthConfig = UrlFetchApp.addOAuthService("print");
  oauthConfig.setConsumerKey("anonymous");
  oauthConfig.setConsumerSecret("anonymous");
  oauthConfig.setRequestTokenUrl("https://www.google.com/accounts/OAuthGetRequestToken?scope=https://www.googleapis.com/auth/cloudprint");
  oauthConfig.setAuthorizationUrl("https://accounts.google.com/OAuthAuthorizeToken");    
  oauthConfig.setAccessTokenUrl("https://www.google.com/accounts/OAuthGetAccessToken");
}

function invokeCloudPrint_(method,payload) {
  var baseurl = "https://www.google.com/cloudprint/";
  var options = {
    method: "post",
    muteHttpExceptions: true,
    oAuthServiceName: "print",
    oAuthUseToken: "always"
  };
  if (payload != undefined)
    options.payload = payload;
  authorize_();
  var response = UrlFetchApp.fetch(baseurl+method,options);
  if (response.getResponseCode() == 403) {
    Browser.msgBox("Please authorize me to print!");
  }
  return JSON.parse(response.getContentText());
}

function test() {
  var searchAnswer = invokeCloudPrint_("search");
  Logger.log(searchAnswer);
}

После запуска функции test() появляется запрос авторизации, после чего всё отлично отрабатывает и в логе консоли виден ответ от Google Cloud Print. Проблема решена? Не совсем. Во-первых, как выяснилось, авторизация отрабатывает только в том случае, если её запустить из редактора скриптов. То есть пользователь копии скрипта должен зайти в редактор скриптов и там вызвать любую функцию, которая обратится к Google Cloud Print с запросом авторизации. Во-вторых,…

oAuth 2.0

...oAuth 1.0 доживает последние месяцы и после 20 апреля 2015 года поддержка данного протокола не гарантируется. При переходе к oAuth 2.0 авторизации в сервисах Google, при необходимости тиражировать решение, возникает проблема с client_id и редиректом. А именно, в аутентификационном запросе указывается уникальный client_id, ему соответствует определенный URL редиректа (или несколько URL) после аутентификации и секретный пароль. В общих чертах процесс переадресации идет по следующему сценарию:

  1. Отправили пользователя на страницу запроса авторизации.
  2. На URL редиректа получили ответ с кодом.
  3. Получили из кода токен для доступа к сервисам.

Проблема возникает именно с редиректом, поскольку каждый скрипт имеет в облаке уникальный идентификатор, и URL редиректа должен соответствовать этому идентификатору. Поэтому, в тиражируемом решении, есть такие варианты:

  • объяснять каждому клиенту, как регистрировать oAuth 2.0 client_id в Google Developer Console;
  • или каждый раз у себя делать новый client_id с URL редиректа, соответствующим новой копии скрипта (завязка на свой аккаунт);
  • или написать универсальный скрипт, который будет по переданным параметрам генерировать токен… но, опять-таки, скрипт будет завязан на аккаунт разработчика и при любых проблемах с этим аккаунтом программа просто перестанет функционировать у всех клиентов.

Все эти методы не очень удобны, они или для себя (первый) или для in-house разработки (второй, иногда третий). К сожалению, сама архитектура oAuth не предполагает возможности, что что-то изменится в данном отношении. Я бы рекомендовал для тиражируемого решения третий вариант или, если клиент согласен предоставить доступ к своему аккаунту/создать нейтральный новый, — первый.
Я приведу пример кода по первому варианту, поскольку третий вариант я не стал писать, только продумал, а второй по коду ничем не отличается от первого, разница только в том, где создается client_id — у клиента или у разработчика.

Аутентификация через oAuth 2.0

Шаг 1. Создаем client_id

  1. Открываем Google Developers Console и создаем новый проект.
  2. Переходим в APIs&auth ->Credentials, нажимаем Create new Client ID.
  3. Тип — Web Application; Authorized JavaScript origins — script.google.com/; Authorized redirect URIs — смотрим вверху Script Editor URL нашего скрипта, не включая /edit и далее, добавляя в конце /usercallback

Должно получиться примерно так:
Печать из Google Apps Script - 1

Шаг 2. Код для авторизации

Здесь все просто — показываем пользователю кнопку, которая перебросит его на URL для авторизации по oAuth 2.0. Редирект пойдет назад в указанную нами функцию:

function test() {
  var html = HtmlService.createTemplateFromFile("Auth").evaluate().setSandboxMode(HtmlService.SandboxMode.NATIVE).setTitle("Test");
  SpreadsheetApp.getUi().showModalDialog(html, "Test");
}
function getAuthURL() {
  var options= {
    client_id : "110560935370-jdoq9cc7tvna2r94va4j9o3310m6ghth.apps.googleusercontent.com", // заменить на свой
    scope : "https://www.googleapis.com/auth/cloudprint",
    redirect_uri : "https://script.google.com/macros/d/MDYeOxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/usercallback", // заменить на свой
    state : ScriptApp.newStateToken().withMethod("getAuthResponse").createToken()
  };
  var url = "https://accounts.google.com/o/oauth2/auth?response_type=code&access_type=offline";
  for(var i in options) 
    url += "&"+i+"="+encodeURIComponent(options[i]);
  return url;
}

Auth.html:

<a href='<?!= getAuthURL(); ?>' target='_blank'>
<button>Authorize!</button>
</a>

Здесь ключевой является функция ScriptApp.newStateToken(), которая позволяет создать параметр для метода usercallback, влекущий вызов указанной функции (getAuthResponse). При запуске функции test() откроется диалоговое окно на вкладке таблицы с кнопкой для перехода на страницу авторизации.

Шаг 3. Получение oAuth token и вызов Google Cloud Print

После обратного вызова мы попадем в getAuthResponse(). Напишем этот метод и вызовем какой-либо метод Google Cloud Print с полученным токеном, отобразив результат на экране:

function getAuthResponse(q) {
  var options = {
    method: "post",
    muteHttpExceptions: true,
    payload: {
      code: q.parameter.code,
      client_id : "110560935370-jdoq9cc7tvna2r94va4j9o3310m6ghth.apps.googleusercontent.com", // заменить на свой
      client_secret : "xxxxxxxxxxxxxxxxxxxxxxxx", // заменить на свой
      redirect_uri: "https://script.google.com/macros/d/MDYeOxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/usercallback", // заменить на свой
      grant_type: "authorization_code"
    }
  }
  var response = JSON.parse(UrlFetchApp.fetch("https://accounts.google.com/o/oauth2/token", options));
  var auth_string = response.token_type+" "+response.access_token;
  options.method = "get";
  options.payload = null;
  options.headers = {Authorization: auth_string};
  response = UrlFetchApp.fetch("https://www.google.com/cloudprint/search",options);
  return ContentService.createTextOutput(response.getContentText());
}

Если все сделано правильно — в результате после нажатия кнопки Authorize и авторизации в открывшемся окне, на экране отобразится JSON-ский ответ со списком подключенных принтеров.

Еще один метод, не буду рекомендовать, но для «себя» подойдет и проще в исполнении:

Грязный хак

Вообще говоря, Google Apps Script поддерживает собственный токен авторизации oAuth 2.0. Его можно получить вызовом ScriptApp.getOAuthToken(). Но в данном токене, разумеется, никаких прав доступа к Google Cloud Print не предусмотрено.
Тем не менее, существует способ данные права в него добавить. Для этого нужно вызвать окно запроса авторизации (при необходимости, сбросив текущий токен вызовом ScriptApp.invalidateAuth()) и скопировать URL данного окна (окно закрыть без подтверждения!):
Печать из Google Apps Script - 2
В скопированном URL один из параметров будет выглядеть, как «scope=https://+https://» (набор прав, необходимых скрипту). Достаточно добавить в конце данного параметра

+https://www.googleapis.com/auth/cloudprint

и открыть измененный URL в новой вкладке браузера, после чего подтвердить авторизацию. В результате, скрипт получит права доступа к Google Cloud Print и эти права сохранятся до момента переавторизации (если, например, вышеупомянутым вызовом invalidateAuth сбросить токен).

GCP Web Element

Из-за этих сложностей с oAuth 2.0 я решил попробовать GCP Web Element. Не очень долго копал данную тему, поскольку у меня уже были работающие варианты решения. Вкратце: результат полностью отрицательный. Дело в том, что Google Apps Script переписывает код JavaScript для отображения в браузере. В результате, GCP Web Element просто не срабатывает. Вот пример кода, создания гаджета не происходит:

GCP Web Element

Code.gs:

function test() {
  var html = HtmlService.createTemplateFromFile("Print").evaluate().setSandboxMode(HtmlService.SandboxMode.NATIVE).setTitle("Test");
  SpreadsheetApp.getUi().showModalDialog(html, "Test");
}

Print.html:

<button onclick="alert(window.gadget); window.gadget=new cloudprint.Gadget(); alert(window.gadget);">Initiate Gadget</button>
<script src="https://www.google.com/cloudprint/client/cpgadget.js" />

В итоге я остановился пока на oAuth 1.0, как на наиболее тиражируемом варианте (хоть и работоспособен метод до 20 апреля, тем не менее, как первое решение он подходит лучше — проще объяснить клиенту и клиент не будет напуган сложностью oAuth 2.0).

Контент и печать

Если бы API Google Apps Script работал бы так, как указано в документации, жизнь, несомненно была бы намного проще. Google Spreadsheet (точнее, приложение для работы с таблицей SpreadsheetApp) поддерживает конвертацию «на лету» в pdf:

function test() {
   var pdf = SpreadsheetApp.getActiveSpreadsheet().getAs("application/pdf");
}

Идея была в том, чтобы перенести выбранный диапазон в новую Spreadsheet и конвертировать её в pdf. К сожалению, мешает баг в Google Apps Script — PDF документ создается, но он абсолютно пуст, поэтому данный путь отпадает. Варианты обхода:

  1. Google Cloud Print умеет печатать Google Spreadsheet, как оказалось. Можно перенести выбор в новую таблицу и отдать команду на печать.
  2. Более элегантный путь: в меню Google Spreadsheet есть опция «Download as...» с возможностью выбора PDF-формата. И этот вариант, в отличие от конвертации силами Google Apps Script, работает.

Во втором варианте браузер переходит по специально сформированной ссылке. Напишем код, превращающий переданный диапазон Spreadsheet в PDF:

Конвертация в PDF

function cloudPrint_(strrange,portrait) {
  var searchAnswer = invokeCloudPrint_("search");
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var rangess = ss.getRange(strrange);
  var gid = rangess.getSheet().getSheetId();
  var r1=rangess.getRow()-1;  
  var c1=rangess.getColumn()-1;  
  var r2=r1+rangess.getHeight();
  var c2=c1+rangess.getWidth();
  var docurl="https://docs.google.com/spreadsheets/d/"+SpreadsheetApp.getActiveSpreadsheet().getId()+"/export?format=pdf&size=0&fzr=false&portrait="+portrait+"&fitw=true&gid="+gid+"&r1="+r1+"&c1="+c1+"&r2="+r2+"&c2="+c2+"&ir=false&ic=false&gridlines=false&printtitle=false&sheetnames=false&pagenum=UNDEFINED&attachment=true";
  return docurl;
}

function test() {
  Logger.log(cloudPrint_("A1:D12",true));
}

Отлично, URL получен! Осталось сущая мелочь — выгрузить файл и передать запрос в Google Cloud Print, чтобы насладиться печатью. Дополнительно необходимо указать printerid (список id возвращается методом API search) и xsrf из ранее полученного ответа:

Попытка 1. Не работает

function test() {
  var searchAnswer = invokeCloudPrint_("search");
  var url = cloudPrint_("A1:D12",true);
  var file = UrlFetchApp.fetch(url);
  var payload = {
       printerid: printer,
       xsrf: searchAnswer.xsrf_token,
       title: rangess.getSheet().getName(),
       ticket: "{"version": "1.0","print": {}}",
       contentType: "application/pdf",
       content: file.getBlob()
  };
  var printstatus = invokeCloudPrint_("submit",payload);
  Browser.msgBox(printstatus.message);
}

Но данный код не работает, проблемы возникают в двух местах. Во-первых, oAuth 1.0 отваливается и не срабатывает при попытке передать файл (привет багам Google Apps Script). Во-вторых, контекст аутентификации скрипта не совпадает с контекстом пользователя, вызвавшего скрипт, и к URL для выгрузки просто нет доступа. Получается, необходимо открывать на время печати spreadsheet для «внешнего мира» и закрывать по окончании печати. Но тогда нет смысла в промежуточной выгрузке PDF (все равно не работает с oAuth), можно сразу передать URL выгрузки в Google Cloud Print:

Попытка 2. Работает!

function test() {
  var searchAnswer = invokeCloudPrint_("search");
  var url = cloudPrint_("A1:D12",true);
  var payload = {
       printerid: printer,
       xsrf: searchAnswer.xsrf_token,
       title: rangess.getSheet().getName(),
       ticket: "{"version": "1.0","print": {}}",
       contentType: "url",
       content: url
  };
  var drivefile = DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId());
  var oldaccess = drivefile.getSharingAccess();
  var oldpermission = drivefile.getSharingPermission();
  drivefile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
  var printstatus = invokeCloudPrint_("submit",payload);
  drivefile.setSharing(oldaccess, oldpermission);
  Browser.msgBox(printstatus.message);
}

Часть 3. Итоги

В итоге, после путешествия по лабиринту багов и проблем, печать заработала. Привожу полный код с oAuth 1.0 (как самодостаточное решение):

Печать из Google Apps Script

var contextauth=false;

function cloudPrint_(strrange,portrait,size) {
  var searchAnswer = invokeCloudPrint_("search");
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var rangess = ss.getRange(strrange);
  var gid = rangess.getSheet().getSheetId();
  var r1=rangess.getRow()-1;  
  var c1=rangess.getColumn()-1;  
  var r2=r1+rangess.getHeight();
  var c2=c1+rangess.getWidth();
  
  portrait = typeof portrait !== 'undefined' ? portrait : true;
  size = typeof size !== 'undefined' ? size : 0;
  
  var docurl="https://docs.google.com/spreadsheets/d/"+SpreadsheetApp.getActiveSpreadsheet().getId()+"/export?format=pdf&size=0&fzr=false&portrait="+portrait+"&fitw=true&gid="+gid+"&r1="+r1+"&c1="+c1+"&r2="+r2+"&c2="+c2+"&ir=false&ic=false&gridlines=false&printtitle=false&sheetnames=false&pagenum=UNDEFINED&attachment=true";
  var prop = PropertiesService.getUserProperties();
  var printer = prop.getProperty("printer");
  if (printer == null) {
    selectPrinterDlg(strrange,portrait,size);
    return;
  }
  ss.toast("Printing...");
  var drivefile = DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId());
  var oldaccess = drivefile.getSharingAccess();
  var oldpermission = drivefile.getSharingPermission();
  drivefile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
  var payload={
    printerid: printer,
    xsrf: searchAnswer.xsrf_token,
    title: rangess.getSheet().getName(),
    ticket: "{"version": "1.0","print": {}}",
    contentType: "url",
    content: docurl
  };
  var printstatus = invokeCloudPrint_("submit",payload);
  drivefile.setSharing(oldaccess, oldpermission);
  Browser.msgBox(printstatus.message);
}

function selectPrinterDlg(strrange,portrait,size) {
  var searchAnswer = invokeCloudPrint_("search");

  var ui = UiApp.createApplication();
  var panel = ui.createVerticalPanel();
  var lb = ui.createListBox(false).setId('lb').setName('lb');
  strrange = typeof strrange !== 'undefined' ? strrange : "";
  portrait = typeof portrait !== 'undefined' ? portrait : "";
  size = typeof size !== 'undefined' ? size : "";
  var hidden1 = ui.createTextBox().setVisible(false).setValue(strrange).setId("range").setName("range");
  var hidden2 = ui.createTextBox().setVisible(false).setValue(portrait.toString()).setId("portrait").setName("portrait");
  var hidden3 = ui.createTextBox().setVisible(false).setValue(size.toString()).setId("printsize").setName("printsize");
  for (var i in searchAnswer.printers) {
    var connPrinter = searchAnswer.printers[i];
    lb.addItem(connPrinter.displayName, connPrinter.id);
  }
  var button = ui.createButton("Save");
  var handler = ui.createServerHandler("SavePrinter_").addCallbackElement(panel);
  button.addClickHandler(ui.createClientHandler().forEventSource().setEnabled(false).setText("Saving..."));
  button.addClickHandler(handler);
  panel.add(lb).setCellHorizontalAlignment(button, UiApp.HorizontalAlignment.CENTER);
  panel.add(hidden1);
  panel.add(hidden2);
  panel.add(button);
  ui.add(panel);
  SpreadsheetApp.getUi().showModalDialog(ui, "Select printer");
  return;
}

function clear() {
  PropertiesService.getUserProperties().deleteProperty("printer");
  ScriptApp.invalidateAuth();
}

function SavePrinter_(e) {
  var ui = UiApp.getActiveApplication();
  PropertiesService.getUserProperties().setProperty("printer", e.parameter.lb);
  ui.close();
  if (e.parameter.range != "")
    cloudPrint_(e.parameter.range,e.parameter.portrait == "true",parseInt(e.parameter.printsize));
  return ui;
}

function invokeCloudPrint_(method,payload) {
  var baseurl = "https://www.google.com/cloudprint/";
  var options = {
    method: "post",
    muteHttpExceptions: true,
    oAuthServiceName: "print",
    oAuthUseToken: "always"
  };
  if (payload != undefined)
    options.payload = payload;
  authorize_();
  var response = UrlFetchApp.fetch(baseurl+method,options);
  if (response.getResponseCode() == 403) {
    Browser.msgBox("Please authorize me to print!");
  }
  return JSON.parse(response.getContentText());
}

function validate() {
  var searchAnswer = invokeCloudPrint_("search");
}

function authorize_() {
  if (contextauth)
    return;
  var oauthConfig = UrlFetchApp.addOAuthService("print");
  oauthConfig.setConsumerKey("anonymous");
  oauthConfig.setConsumerSecret("anonymous");
  oauthConfig.setRequestTokenUrl("https://www.google.com/accounts/OAuthGetRequestToken?scope=https://www.googleapis.com/auth/cloudprint");
  oauthConfig.setAuthorizationUrl("https://accounts.google.com/OAuthAuthorizeToken");    
  oauthConfig.setAccessTokenUrl("https://www.google.com/accounts/OAuthGetAccessToken");
  contextauth = true;
}

function onOpen() {
  SpreadsheetApp.getUi().createMenu("Printing").addItem("Select printer...", "selectPrinterDlg").addToUi();
}

function Print() {
  cloudPrint_("A1:D12",true);
}

Дополнительно к разобранным кусочкам кода сделан диалог (и пункт меню) для выбора принтера. Инструкция по установке:

  1. Предварительно: настроить Google Cloud Print, проверить тестовую печать
  2. Создать новую Google Spreadsheet, написать что-либо в диапазоне A1:D12
  3. Открыть Script Editor, создать новый пустой проект
  4. Скопировать код, сохранить, вызвать функцию validate — чтобы авторизовать все необходимые права
  5. Вызвать функцию Print. При первом вызове на вкладке таблицы откроется диалог выбора принтера

Автор: Valmount

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js