Created
August 24, 2021 11:11
-
-
Save jcsteh/0d9238bab60b4accc61ffc1642c0e468 to your computer and use it in GitHub Desktop.
iOS Scriptable script to allow line by line reading of text with Siri and/or Apple Watch
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
// Variables used by Scriptable. | |
// These must be at the very top of the file. Do not edit. | |
// icon-color: deep-purple; icon-glyph: magic; | |
// Author: James Teh <jamie@jantrid.net> | |
// Copyright 2021 James Teh | |
// License: GNU General Public License version 2.0 | |
const fm = FileManager.iCloud(); | |
const docs = fm.documentsDirectory(); | |
const confFile = fm.joinPath(docs, "SiriInteractiveReader.json"); | |
const textFile = fm.joinPath(docs, "SiriInteractiveReader.txt"); | |
let conf = {}; | |
try { | |
await fm.downloadFileFromiCloud(confFile); | |
const confRaw = fm.readString(confFile); | |
if (confRaw) { | |
conf = JSON.parse(confRaw); | |
} | |
} catch (e) { | |
} | |
await fm.downloadFileFromiCloud(textFile); | |
let text = fm.readString(textFile); | |
let pos = conf["pos"]; | |
if (!pos || pos > text.length) { | |
pos = 0; | |
} | |
const textAfterPos = text.substring(pos); | |
const textBeforePos = text.substring(0, pos); | |
const actions = { | |
nextLine: function() { | |
const match = /\n(^.+$)/m.exec(textAfterPos); | |
if (!match) { | |
return {}; | |
} | |
return { | |
output: match[1], | |
// We add 1 for the line break character matched before the target line. | |
newPos: pos + match.index + 1, | |
}; | |
}, | |
repeatLine: function() { | |
const match = /(^.+$)/m.exec(textAfterPos); | |
if (!match) { | |
return {}; | |
} | |
return { | |
output: match[1], | |
}; | |
}, | |
previousLine: function() { | |
// We can't explicitly ask for the last match. Instead, we consume all the | |
// text up until the last line as one group and then consume the last line | |
// as a second group. | |
const match = /([^]*\n?)(^.+$)/m.exec(textBeforePos); | |
if (!match) { | |
return {}; | |
} | |
return { | |
output: match[2], | |
// match[1] is all the text before the target line. | |
newPos: match[1].length, | |
}; | |
}, | |
nextSection: function() { | |
const match = /\n#+ (.+$)/m.exec(textAfterPos); | |
if (!match) { | |
return {}; | |
} | |
return { | |
output: match[1], | |
// We add 1 for the line break character matched before the target line. | |
newPos: pos + match.index + 1, | |
}; | |
}, | |
previousSection: function() { | |
// Similar to previousLine, we must consume all the text before our target | |
// line as the first group. We then consume the target heading text as the | |
// second group. Because we want the previous section, we need to skip back | |
// two headings, since one heading would take us back to the top of the | |
// current section. So, we consume subsequent text up to and including | |
// another heading. | |
const match = /([^]*\n?)^#+ (.+$)[^]*\n^#+ .+$/m.exec(textBeforePos); | |
if (!match) { | |
return {}; | |
} | |
return { | |
output: match[2], | |
// match[1] is all the text before the target line. | |
newPos: match[1].length, | |
}; | |
}, | |
readLineContaining: function() { | |
if (args.plainTexts.length < 2) { | |
return {}; | |
} | |
// todo: Regexp escape search string. | |
const search = args.plainTexts[1]; | |
// We want the whole line where the match occurred, so we can't just use | |
// String.indexOf. | |
const match = new RegExp(`^.*\\b${search}\\b.*\$`, "mi").exec(text); | |
return match ? { output: match[0] } : {}; | |
}, | |
browse: async function() { | |
let html = '<html>\n<body>\n'; | |
html += '<style> .line { height: 100vh; font-size: 50vh; } </style>\n'; | |
html += '<div><button onclick="focusBookmark();">Move to bookmark</button></div>'; | |
const lines = text.split("\n"); | |
let linePos = 0; | |
for (const line of lines) { | |
if (line.startsWith("#")) { | |
tag = "h1"; | |
} else { | |
tag = "p"; | |
} | |
html += `<${tag} id="line${linePos}" class="line" tabindex="-1">${line}</${tag}>\n`; | |
// Add 1 for the line feed character which was removed by split. | |
linePos += line.length + 1; | |
} | |
html += `<script> | |
function focusBookmark() { | |
document.getElementById("line${pos}").focus(); | |
} | |
setTimeout(focusBookmark, 1500); | |
</script>`; | |
// Opening URLs isn't supported from within Siri, so we can't support | |
// moving the bookmark in this case. | |
if (!config.runsWithSiri) { | |
html += `<script> | |
function onLineClick(event) { | |
const pos = parseInt(event.target.id.substr(4)); | |
// window.close() doesn't work to dismiss the WebView from within. | |
// Therefore, we use the Scriptable URL scheme to pass info to a new | |
// instance of the script. | |
window.location = "${URLScheme.forRunningScript()}?setPos=" + pos; | |
} | |
for (const line of document.querySelectorAll(".line")) { | |
line.addEventListener("click", onLineClick); | |
} | |
</script>`; | |
} | |
html += '</body>\n</html>'; | |
await WebView.loadHTML(html); | |
return {}; | |
}, | |
list: function() { | |
// Scriptable doesn't support returning a list directly, so we wrap it in a | |
// dictionary and return that. | |
return { output: { list: text.split("\n").filter(line => line.trim()) } }; | |
}, | |
}; | |
function setPos(newPos) { | |
conf["pos"] = newPos; | |
const confRaw = JSON.stringify(conf); | |
fm.writeString(confFile, confRaw); | |
} | |
/*** Entry points ***/ | |
// handle calls via URL scheme. This is used by actions.browse() above. | |
if (args.queryParameters.setPos) { | |
setPos(parseInt(args.queryParameters.setPos)); | |
return; | |
} | |
// Handle calls from Siri shortcuts. | |
// The first plain text shortcut argument is the action to perform. | |
const result = await actions[args.plainTexts[0]](); | |
if (result.newPos !== undefined) { | |
setPos(result.newPos); | |
} | |
return result.output ? result.output : "Fail"; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment