Created
November 5, 2015 13:33
-
-
Save adriannier/79b0e59770b259fd8321 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* Open with Apple’s Script Editor in OS X 10.10 or later and set language to JavaScript */ | |
function run(argv) { | |
try { | |
// Create a new Quatermain instance | |
var qm = new Quatermain() | |
// Make sure Safari is running | |
if (qm.safariRunning() == false) { | |
throw 'Make sure Safari shows an Apple Wiki page.' | |
} | |
// Capture the frontmost Safari tab | |
qm.setTargetTab(qm.frontmostTab()) | |
// Inject JQuery into page | |
qm.injectJQuery() | |
// Change CSS on page | |
qm.do(function() { | |
$('div#content-inner') | |
.css('width', '100%') | |
.css('margin', '0') | |
.css('padding', '0') | |
$('div#content-primary') | |
.css('width', '100%') | |
$('div.editable.wrapchrome') | |
.css('width', '100%') | |
.css('margin', '0') | |
.css('padding', '0') | |
}) | |
} catch (e) { | |
log('Error: ' + e) | |
} | |
} | |
function Quatermain() { | |
this.targettedTab = false | |
this.injectedJQueryVersion = '2.1.3' | |
this.loadFinishedTest = false | |
this.safariUIActionDelay = 0.3 | |
this.safari = function() { | |
return Application('Safari') | |
} | |
this.systemEvents = function() { | |
return Application('System Events') | |
} | |
this.safariUI = function() { | |
return this.systemEvents().processes['Safari'] | |
} | |
this.targetWindowUI = function() { | |
return this.safariUI().windows[this.windowIndexForTab(this.targetTab())] | |
} | |
this.activateSafari = function() { | |
if (!this.safariUI().frontmost()) { | |
this.safari().activate() | |
delay(this.safariUIActionDelay) | |
} | |
} | |
/* | |
Available key code or keystroke modifiers: | |
'command down', 'control down', 'option down', 'shift down' | |
*/ | |
this.safariKeyCode = function(n, modifiers) { | |
this.activateSafari() | |
if (modifiers) { | |
this.systemEvents().keyCode(n, {using: modifiers}) | |
} else { | |
this.systemEvents().keyCode(n) | |
} | |
delay(this.safariUIActionDelay) | |
} | |
this.safariKeystroke = function(s, modifiers) { | |
this.activateSafari() | |
if (modifiers) { | |
this.systemEvents().keystroke(s, {using: modifiers}) | |
} else { | |
this.systemEvents().keystroke(s) | |
} | |
delay(this.safariUIActionDelay) | |
} | |
this.safariRunning = function() { | |
if (Application('System Events').processes.whose({name: 'Safari'}).length > 0) { | |
return true | |
} else { | |
return false | |
} | |
} | |
this.printAsPDF = function(saveDirectory, fileName) { | |
// Generate unique path | |
var pdfFilePath = this.uniqueFilePath(saveDirectory, fileName, '.pdf') | |
var previousFrontmostTab = this.raiseTab() | |
// For some reason the index of the target window changes from 0 to 1 as soon as the go-to-folder sheet appears. This causes problems when we need this index to be right when passed to System Events. Workaround is to save the System Events reference to the window now and reuse it throughout this function | |
var currentTargetWindowUI = this.targetWindowUI() | |
this.safariKeystroke('p', ['command down']) | |
this.waitFor('print sheet', function() { | |
if (currentTargetWindowUI.sheets().length > 0) { | |
return true | |
} else { | |
return false | |
} | |
}.bind(this)) | |
// Open the PDF menu | |
var pdfMenuButton = currentTargetWindowUI.sheets[0].menuButtons['PDF'] | |
pdfMenuButton.click() | |
// Click the Save as PDF… menu item | |
pdfMenuButton.menus[0].menuItems[1].click() | |
this.waitFor('save sheet', function() { | |
if (currentTargetWindowUI.sheets[0].sheets().length > 0) { | |
return true | |
} else { | |
return false | |
} | |
}.bind(this)) | |
// Invoke 'Go to the folder' sheet | |
this.safariKeystroke('g', ['command down', 'shift down']) | |
this.waitFor('go-to-folder sheet', function() { | |
log('Target tab') | |
oLog(this.targetTab()) | |
log('Window index: ' + this.windowIndexForTab(this.targetTab())) | |
log('Target window') | |
oLog(currentTargetWindowUI) | |
if (currentTargetWindowUI.sheets[0].sheets[0].sheets().length > 0) { | |
return true | |
} else { | |
return false | |
} | |
}.bind(this)) | |
// Fill the 'Go to the folder' field with the PDF file's path | |
currentTargetWindowUI.sheets[0].sheets[0].sheets[0].textFields[0].value = pdfFilePath | |
// Dismiss 'Go to the folder' sheet | |
this.safariKeyCode(52) // Return key | |
this.waitFor('go-to-folder sheet to close', function() { | |
if (currentTargetWindowUI.sheets[0].sheets[0].sheets().length == 0) { | |
return true | |
} else { | |
return false | |
} | |
}.bind(this)) | |
// Dismiss 'Save as' sheet | |
this.safariKeyCode(52) // Return key | |
this.waitFor('save sheet to close', function() { | |
try { | |
// Try to click the replace button if it exists | |
currentTargetWindowUI.sheets[0].sheets[0].sheets[0].buttons['Replace'].click() | |
} catch(e) { | |
} | |
if (currentTargetWindowUI.sheets[0].sheets().length == 0) { | |
return true | |
} else { | |
return false | |
} | |
}.bind(this)) | |
this.waitFor('target window to have no sheets', function() { | |
try { | |
// Try to click the replace button if it exists | |
currentTargetWindowUI.sheets[0].sheets[0].sheets[0].buttons['Replace'].click() | |
} catch(e) { | |
} | |
if (currentTargetWindowUI.sheets().length == 0) { | |
return true | |
} else { | |
return false | |
} | |
}.bind(this)) | |
this.raiseTab(previousFrontmostTab) | |
} | |
this.targetWindow = function() { | |
return this.windowForTab(this.targetTab()) | |
} | |
this.windowForTab = function(tab) { | |
// Get the tab specifier as text | |
var tabPath = Automation.getDisplayString(tab) | |
// Get the path up to the specification of the tab | |
var found = tabPath.match(/(.*)\.tabs/i) | |
if (!found) { | |
// Couldn't get the path to the window | |
return false | |
} else { | |
// Found window path; eval it to return the actual window object | |
return eval(found[1]) | |
} | |
} | |
this.windowIndexForTab = function(tab) { | |
return this.windowForTab(tab).index() - 1 | |
} | |
this.targetTab = function() { | |
if (this.targettedTab !== false) { | |
return this.targettedTab | |
} else { | |
throw 'No target tab specified' | |
} | |
} | |
this.setTargetTab = function(arg) { | |
if (typeof arg == 'string') { | |
// Argument is a string; look for a tab with that name | |
this.targettedTab = this.firstTabWithName(arg) | |
} else { | |
var classOfArg | |
try { | |
classOfArg = arg.class() | |
} catch (e) { | |
classOfArg = '' | |
} | |
if (classOfArg == 'tab') { | |
this.targettedTab = arg | |
} else { | |
throw 'Please specify the tab or the tab\'s name' | |
} | |
} | |
log('Target tab set to \'' + this.targettedTab.name() + '\' (' + Automation.getDisplayString(this.targettedTab) + ')') | |
} | |
this.frontmostTab = function() { | |
return this.safari().windows[0].currentTab() | |
} | |
this.firstTabWithName = function(tabName) { | |
var windows = this.safari().windows() | |
for (var i = 0; i < windows.length; i++) { | |
// Calling tabs() on some windows might produce an error | |
var tabs | |
try { | |
tabs = windows[i].tabs() | |
} catch (e) { | |
tabs = [] | |
} | |
for (var j = 0; j < tabs.length; j++) { | |
// Calling name() on some tabs might produce an error | |
var name | |
try { | |
name = tabs[j].name() | |
if (name == tabName) { | |
return tabs[j] | |
} | |
} catch (e) { | |
// Do nothing; not interested in errors | |
} | |
} | |
} | |
throw 'Safari has no tab with the name \'' + tabName + '\'' | |
} | |
this.raiseTab = function(tab) { | |
try { | |
tab = tab || this.targetTab() | |
var previousFrontmostTab = this.frontmostTab() | |
// Get the window and its index | |
var window = this.windowForTab(tab) | |
var windowIndex = window.index() - 1 | |
// Make sure the target tab is the current one in the window | |
window.currentTab = tab | |
// Bring the window to the front | |
this.safariUI().windows[windowIndex].actions['AXRaise'].perform() | |
delay(this.safariUIActionDelay) | |
return previousFrontmostTab | |
} catch(e) { | |
throw 'Could not raise tab: ' + e | |
} | |
} | |
this.url = function() { | |
return this.targetTab().url() | |
} | |
this.setURL = function(url) { | |
this.do(function(url) { | |
document.body.innerHTML = '' | |
window.location = url | |
}, url) | |
// Wait until page content is visible | |
this.waitFor('body element to have content', function() { | |
if (this.do("document.body.innerHTML") != '') { | |
return true | |
} else { | |
return false | |
} | |
}.bind(this)) | |
if (this.do("document.body.innerHTML") != '') { | |
this.injectJQuery() | |
} else { | |
throw 'Failed to load ' + url | |
} | |
if (typeof this.loadFinishedTest == 'function') { | |
this.waitFor('page to pass custom load finished tester', function() { | |
try { | |
return this.loadFinishedTest() | |
} catch(e) { | |
throw 'Could not test if page finished loading: ' + e | |
} | |
}.bind(this)) | |
} | |
} | |
this.do = function(code) { | |
var returnValues | |
if (typeof code == 'function') { | |
returnValues = this.returnValues(code) | |
code = this.stringifyReturns(code.toString()) | |
code = this.makeNamedFunction(code) | |
code = this.insertFunctionCall(code, arguments) | |
} else if (typeof code == 'string') { | |
// Do nothing with strings | |
returnValues = [code] | |
code = 'JSON.stringify(' + code +')' | |
} else { | |
log('The do() method requires either a function or a string, but a ' + typeof code + ' was specified.') | |
} | |
code = code.replace(/\$\(/g, 'window.injectedJQuery(') | |
var result = this.safari().doJavaScript(code, {in: this.targetTab()}) | |
if (returnValues.length > 0) { | |
if (result == '') { | |
return '' | |
} else { | |
return JSON.parse(result) | |
} | |
} else { | |
return | |
} | |
} | |
this.stringifyReturns = function(code) { | |
code = code.replace(/(\s*)return\s*([^\n;]*)/gi, "$1return JSON.stringify($2)") | |
return code | |
} | |
this.returnValues = function(code) { | |
var regex = /\s*return[ \t]+(\S+[^\n;]*)/gm | |
var matches = [] | |
var found | |
while (found = regex.exec(code)) { | |
matches.push(found[1]) | |
regex.lastIndex = found.index + found[0].length + 1 | |
} | |
return matches | |
} | |
this.insertFunctionCall = function(fn, fnArgs) { | |
var found = fn.match(/function\s*[_a-z0-9]*\s*\(([^)]*)/i) | |
if (!found) { | |
// No arguments found | |
return fn | |
} | |
var functionCall | |
var functionName = this.functionName(fn) | |
var resultVarName = functionName + '_result' | |
if (this.trimString(found[1]) == '') { | |
functionCall = "var " + resultVarName + ' = ' + functionName + '()' | |
} else { | |
var argNames = found[1].split(',') | |
// Does the count of the function arguments and the specified values match? | |
if ((argNames.length + 1) != fnArgs.length) { | |
throw 'Argument count mismatch for function.' | |
} | |
var argumentValues = [] | |
var argName | |
for (var i = 0; i < argNames.length; i++) { | |
argName = this.trimString(argNames[i]) | |
argumentValues.push("JSON.parse(" + this.stringify(fnArgs[i + 1]) + ")") | |
} | |
functionCall = "var " + resultVarName + ' = ' + functionName + '(' + argumentValues.join(', ') + ')' | |
} | |
return fn + "\n" + functionCall + "\n" + functionName + ' = undefined' + "\n" + resultVarName | |
} | |
this.stringify = function(v) { | |
var jsonString = JSON.stringify(v) | |
jsonString = jsonString.replace(/[\\]/g, '\\\\') | |
.replace(/[\"]/g, '\\\"') | |
.replace(/[\/]/g, '\\/') | |
.replace(/[\b]/g, '\\b') | |
.replace(/[\f]/g, '\\f') | |
.replace(/[\n]/g, '\\n') | |
.replace(/[\r]/g, '\\r') | |
.replace(/[\t]/g, '\\t') | |
return "\"" + jsonString + "\"" | |
} | |
this.makeNamedFunction = function(fn) { | |
var functionName = this.functionName(fn) | |
if (functionName === false) { | |
throw 'The do() method requires a function.' | |
} else if ( functionName == '' || functionName == ' ') { | |
functionName = this.randomFunctionName() | |
} | |
fn = fn.replace(/function(\s*[_a-z0-9]*\s*)\(/i, 'function ' + functionName + '(') | |
return fn | |
} | |
this.functionName = function(fn) { | |
var found = fn.match(/function(\s*[_a-z0-9]*\s*)\(/i) | |
if (!found) { | |
return false | |
} else { | |
return this.trimString(found[1]) | |
} | |
} | |
this.randomFunctionName = function() { | |
var text = ""; | |
var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_"; | |
for(var i = 0; i < 25; i++ ) { | |
text += possible.charAt(Math.floor(Math.random() * possible.length)); | |
} | |
return text; | |
} | |
this.isJQueryInjected = function() { | |
return this.do(function(jqVersion) { | |
if (typeof window.injectedJQuery != 'undefined' && window.injectedJQuery.fn.jquery == jqVersion) { | |
return true | |
} else { | |
return false | |
} | |
}, this.injectedJQueryVersion) | |
} | |
this.injectJQuery = function() { | |
if (this.isJQueryInjected() === false) { | |
this.do(function(jqVersion) { | |
var s0 = document.createElement('script') | |
document.head.appendChild(s0) | |
s0.type = 'text/javascript' | |
s0.onload = function() { window.injectedJQuery = $.noConflict(true) } | |
s0.src = 'https://code.jquery.com/jquery-' + jqVersion + '.min.js' | |
}, this.injectedJQueryVersion) | |
this.waitFor('jQuery to be injected', function() { | |
return this.isJQueryInjected() } | |
.bind(this)) | |
} | |
} | |
this.trimString = function(str) { | |
return str.replace(/^\s\s*/, '').replace(/\s\s*$/, ''); | |
} | |
this.waitFor = function(description, fn, maxSeconds) { | |
maxSeconds = maxSeconds || 10 | |
// Calculate the future date at which the timeout should happen | |
var maxDate = new Date() | |
maxDate.setTime(maxDate.getTime() + maxSeconds * 1000) | |
// Initialize timer | |
var secondsElapsed = 0 | |
/* | |
The way that elapsed seconds are kept track of is highly inaccurate. | |
For example it does not take into account the time it takes to run | |
the testing function. This serves only to protect against waiting | |
forever when the code executes in the middle of a time change. | |
*/ | |
var firstTest = true | |
while (maxSeconds >= secondsElapsed) { | |
try { | |
if (fn()) { | |
log('Done waiting for ' + description) | |
return | |
} | |
if (firstTest && description) { | |
log('Waiting for ' + description) | |
firstTest = false | |
} | |
} catch(e) { | |
log('Error while waiting for ' + description + ': ' + e) | |
} | |
this.wait(0.1) | |
secondsElapsed += 0.1 // Keep track of elapsed time | |
if (new Date() >= maxDate) { throw 'Timeout while waiting for ' + description } | |
} | |
throw 'Timeout while waiting for ' + description | |
} | |
this.wait = function(s) { | |
delay(s) | |
// var app = Application.currentApplication() | |
// app.includeStandardAdditions = true | |
// app.doShellScript('/bin/sleep ' + s) | |
} | |
this.uniqueFilePath = function(parentFolderPath, fileName, suffix) { | |
/* | |
Only for use with JavaScript for Automation (JXA) | |
Generates a unique path by appending a number | |
to the end of the file name. | |
If suffix is not specified it is extracted | |
from the file name. | |
If neither file name nor suffix is specified, | |
it is assumed that the first argument holds | |
the complete path to file. | |
*/ | |
if (!fileName) { | |
// Get the file name from the first argument | |
var components = $.NSString.alloc.initWithUTF8String(parentFolderPath).pathComponents.js // Return native array with NSStrings | |
// Get an array of native strings | |
var jsComponents = [] | |
components.forEach(function(component) { | |
jsComponents.push(component.js) | |
}) | |
// Get the file name | |
fileName = jsComponents[jsComponents.length - 1] | |
// Get the parent folder path | |
jsComponents.splice(-1, 1) | |
parentFolderPath = jsComponents.join('/') | |
} | |
if (!suffix) { | |
// Get suffix from file name | |
fileNameNSS = $.NSString.alloc.initWithUTF8String(fileName) | |
suffix = fileNameNSS.pathExtension.js | |
fileName = fileNameNSS.stringByDeletingPathExtension.js | |
} | |
if (suffix.indexOf('.') != 0) { | |
// Add dot to beginning of suffix | |
suffix = '.' + suffix | |
} | |
// Clean up file name | |
fileName = fileName.replace(/[:\/\\]/gi,'-') | |
// Shorten file name if necessary | |
var availableFileNameLength = 255 - suffix.length | |
if (fileName.length > availableFileNameLength) { | |
fileName = fileName.substr(0, availableFileNameLength ) | |
} | |
// Expand tilde in parent folder path | |
parentFolderPath = $.NSString.alloc.initWithUTF8String(parentFolderPath).stringByExpandingTildeInPath.js | |
// Add trailing colon to parent folder path | |
if ( parentFolderPath.slice(-1) != '/') { parentFolderPath += '/' } | |
var loopNumber = 2 | |
var tempFilePath = parentFolderPath + fileName + suffix | |
while (Application('System Events').exists(Path(tempFilePath))) { | |
// Shorten file name if necessary | |
availableFileNameLength = 255 - suffix.length - loopNumber.toString().length - 1 | |
if (fileName.length > availableFileNameLength) { | |
fileName = fileName.substr(0, availableFileNameLength) | |
} | |
tempFilePath = parentFolderPath + fileName + ' ' + loopNumber + suffix | |
loopNumber++ | |
} | |
return tempFilePath | |
} | |
} | |
function oLog(o) { | |
log(Automation.getDisplayString(o)) | |
} | |
function sLog(msg) { | |
log(JSON.stringify(msg)) | |
} | |
function log(msg) { | |
this.zeroPad = function(str, len, pad, padAtEnd) { | |
str = String(str) | |
len = len || 2 | |
pad = pad || '0' | |
padAtEnd = padAtEnd || false | |
while (str.length < len) { | |
if (padAtEnd) { | |
str = str + pad | |
} else { | |
str = pad + str | |
} | |
} | |
return str | |
} | |
this.timestamp = function() { | |
var d = new Date(); | |
var h = this.zeroPad(d.getHours()) | |
var m = this.zeroPad(d.getMinutes()) | |
var s = this.zeroPad(d.getSeconds()) | |
var ms = this.zeroPad(d.getMilliseconds(), 3) | |
var y = d.getFullYear() | |
var month = this.zeroPad(d.getMonth() + 1) | |
var day = this.zeroPad(d.getDate() + 1) | |
return y + '-' + month + '-' + day + ' ' + h + ':' + m + ':' + s + '.' + ms | |
} | |
console.log(this.timestamp() + " " + msg) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment