Друк з Google Apps Script

Частина 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 me to authorize 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
Повинно вийти приблизно так:

Крок 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 цієї вікна (вікно закрити без підтвердження!):

У скопированном 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 ElementCode.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 повертається методом search API) і 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 me to authorize 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();
}

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


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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.