Часть 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:
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) после аутентификации и секретный пароль. В общих чертах процесс переадресации идет по следующему сценарию:
- Отправили пользователя на страницу запроса авторизации.
- На URL редиректа получили ответ с кодом.
- Получили из кода токен для доступа к сервисам.
Проблема возникает именно с редиректом, поскольку каждый скрипт имеет в облаке уникальный идентификатор, и URL редиректа должен соответствовать этому идентификатору. Поэтому, в тиражируемом решении, есть такие варианты:
- объяснять каждому клиенту, как регистрировать oAuth 2.0 client_id в Google Developer Console;
- или каждый раз у себя делать новый client_id с URL редиректа, соответствующим новой копии скрипта (завязка на свой аккаунт);
- или написать универсальный скрипт, который будет по переданным параметрам генерировать токен… но, опять-таки, скрипт будет завязан на аккаунт разработчика и при любых проблемах с этим аккаунтом программа просто перестанет функционировать у всех клиентов.
Все эти методы не очень удобны, они или для себя (первый) или для in-house разработки (второй, иногда третий). К сожалению, сама архитектура oAuth не предполагает возможности, что что-то изменится в данном отношении. Я бы рекомендовал для тиражируемого решения третий вариант или, если клиент согласен предоставить доступ к своему аккаунту/создать нейтральный новый, — первый.
Я приведу пример кода по первому варианту, поскольку третий вариант я не стал писать, только продумал, а второй по коду ничем не отличается от первого, разница только в том, где создается client_id — у клиента или у разработчика.
Шаг 1. Создаем client_id
- Открываем Google Developers Console и создаем новый проект.
- Переходим в APIs&auth ->Credentials, нажимаем Create new Client ID.
- Тип — Web Application; Authorized JavaScript origins — script.google.com/; Authorized redirect URIs — смотрим вверху Script Editor URL нашего скрипта, не включая /edit и далее, добавляя в конце /usercallback
Должно получиться примерно так:
Шаг 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-ский ответ со списком подключенных принтеров.
Еще один метод, не буду рекомендовать, но для «себя» подойдет и проще в исполнении:
Тем не менее, существует способ данные права в него добавить. Для этого нужно вызвать окно запроса авторизации (при необходимости, сбросив текущий токен вызовом ScriptApp.invalidateAuth()) и скопировать URL данного окна (окно закрыть без подтверждения!):
В скопированном 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 просто не срабатывает. Вот пример кода, создания гаджета не происходит:
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 документ создается, но он абсолютно пуст, поэтому данный путь отпадает. Варианты обхода:
- Google Cloud Print умеет печатать Google Spreadsheet, как оказалось. Можно перенести выбор в новую таблицу и отдать команду на печать.
- Более элегантный путь: в меню Google Spreadsheet есть опция «Download as...» с возможностью выбора PDF-формата. И этот вариант, в отличие от конвертации силами Google Apps Script, работает.
Во втором варианте браузер переходит по специально сформированной ссылке. Напишем код, превращающий переданный диапазон Spreadsheet в 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 из ранее полученного ответа:
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:
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 (как самодостаточное решение):
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);
}
Дополнительно к разобранным кусочкам кода сделан диалог (и пункт меню) для выбора принтера. Инструкция по установке:
- Предварительно: настроить Google Cloud Print, проверить тестовую печать
- Создать новую Google Spreadsheet, написать что-либо в диапазоне A1:D12
- Открыть Script Editor, создать новый пустой проект
- Скопировать код, сохранить, вызвать функцию validate — чтобы авторизовать все необходимые права
- Вызвать функцию Print. При первом вызове на вкладке таблицы откроется диалог выбора принтера
Автор: Valmount