Skip to content

Instantly share code, notes, and snippets.

@jcsteh
Created August 24, 2021 11:11
Show Gist options
  • Save jcsteh/0d9238bab60b4accc61ffc1642c0e468 to your computer and use it in GitHub Desktop.
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
// 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