Files

401 lines
11 KiB
JavaScript

function main(event, page = event.target.body) {
;;; check_for_json_file_name: ;;;
let name = page.ownerDocument.location.pathname;
if (!name.endsWith(".json") && !name.endsWith(".json.html")) {
logDiagnostic("not treated as JSON: " + name);
return null;
}
;;; json_extraction_and_parsing: ;;;
// assert(page.ownerDocument.compatMode == "BackCompat")
let json = getJSONEncoding(page); // showing on old page
page = fixQuirks(page.ownerDocument); // empty, new page
// assert(page.ownerDocument.compatMode == "CSS1Compat")
page.innerHTML = (`<${'pre'}>`);
page.querySelector("pre").textContent = json;
let error = null;
let processor = new WatchlistProcessor(page);
try {
var data = JSON.parse(json, $revive);
} catch (ex) {
logDiagnostic("file contains invalid JSON: " + name);
error = ex;
}
function $revive(key, value, ...rest) {
try {
processor.observe(key, value, ...rest);
} catch (ex) {
logDiagnostic("process failed during observation: " + name);
error = error || ex; // keep the first error (if we saw one)
}
return value;
}
;;; finished_parsing: ;;;
if (error) throw error;
;;; parse_succeeded: ;;;
processor.render(json, data, page.querySelector("pre").firstChild);
}
// <script>
class WatchlistProcessor {
constructor(page) {
this.controls = new WatchlistDataControls(page);
this.currentSourceGroup = [];
this.sourceItems = [];
this.renderValue = null;
}
observe(key, value, context) {
// NB: This JSON processor is NOT written to handle pathological cases
// (e.g. adversarial inputs); the "watchlib.js" in this demo is not a
// general-purpose library fit for production use. It will work on the
// input in this demo (and will work on some other inputs, too), but this
// remains just a demo and will fall apart at the slightest perturbation.
//
// (To give one example: we assume that all keys are unescaped. And our
// helpers don't fare well on object definitions with duplicate keys.)
if ("source" in context) {
let item = new WatchlistProcessor.SourceItem(context.source, key);
this.sourceItems.push(item);
if (key == "id" || key == "title") {
this.currentSourceGroup.push(item);
}
} else if (!Array.isArray(value)) {
if ("id" in value && "title" in value) {
let count = this.currentSourceGroup.length;
let items = this.currentSourceGroup.splice(0, count);
for (let i = 0; i < count; ++i) {
items[i].object = value;
}
}
}
}
render(unparsed, parsed, node) {
// assert(node.nodeValue == unparsed)
let doc = node.ownerDocument;
let caret = 0;
let newNodes = doc.createDocumentFragment();
for (let i = 0, n = this.sourceItems.length; i < n; ++i) {
let item = this.sourceItems[i];
// assert(item.source.length > 0)
item.offset = unparsed.indexOf(item.source, caret);
// assert(item.offset >= 0 && item.offset < unparsed.length)
let span = doc.createElement("span");
span.className = "source-item";
span.append(doc.createTextNode(item.source));
newNodes.append(
doc.createTextNode(unparsed.substring(caret, item.offset)),
span
);
item.node = span;
if (item.key == "title") {
span.classList.add("movie-title");
let title = item.object["title"];
if (item.source != JSON.stringify(title)) {
span.setAttribute("title", title);
span.classList.add("escaped-value");
}
} else if (item.key == "id") {
span.classList.add("foreign-key");
let id = item.object["id"];
if (id.startsWith("[") && id.endsWith("]")) {
let pair = id.substring(1, id.length - 1);
let split = pair.split(":");
if (split.length == 2) {
switch (split[0]) {
case "wikidata":
this.controls.linkPropertyToURL(
"title", item.object,
"https://www.wikidata.org/wiki/Special:GoToLinkedPage?" +
"site=enwiki&itemid=" + split[1]
);
this.controls.linkPropertyToURL(
"id", item.object,
"https://www.wikidata.org/wiki/Special:EntityPage/" +
split[1],
pair
);
break;
case "tmdb":
this.controls.linkPropertyToURL(
"title", item.object,
"https://themoviedb.org/" +
split[1]
);
this.controls.linkPropertyToURL(
"id", item.object,
"https://themoviedb.org/" +
split[1],
pair
);
break;
}
}
}
} else if (item.key == "@render") {
span.classList.add("loader-string");
// Fun fact: If you do it right, you can apply a similar trick to JS
// files; the browser doesn't care about the content type (or the file
// extension) of a script loaded with the src attribute, so you could
// make polyglot JS+HTML files, too (and even use it as the loader...)
}
caret = item.offset + item.source.length;
}
newNodes.append(doc.createTextNode(unparsed.substring(caret)));
node.parentElement.className = "json-source";
WatchlistDataControls.applySkin(doc.head);
this.controls.insertLinks(this.sourceItems);
node.parentNode.replaceChild(newNodes, node);
}
}
// A "source" refers to the source code of a (primitive) value from a
// key/value pair. We go ahead and keep track of the name of the key it's
// defined for and the object that the key is defined on because this is
// something we already have access to, and it's useful information.
//
// (These are NOT for complex/composite values--only primitives.)
WatchlistProcessor.SourceItem = class {
constructor(source, kind, object = null) {
this.node = null;
this.offset = -1;
this.source = source;
this.key = kind;
this.object = object;
}
}
// <script>
class WatchlistDataControls {
constructor(page) {
this.page = page;
this.links = [];
}
static applySkin(parent) {
let styleElement = parent.ownerDocument.createElement("style");
parent.appendChild(styleElement);
let $ = styleElement.sheet.insertRule.bind(styleElement.sheet);
$("body {" +
"background-color: #FDFDFD;" +
"}");
$(".json-source {" +
"color: #222222;" +
"}");
$(".source-item:not(.loader-string) {" +
"color: #000000;" +
"}");
$(".movie-title {" +
"font-weight: bold;" +
"}");
$(".movie-title a {" +
"color: #090909;" +
"}");
$(".foreign-key a {" +
"color: #000000;" +
"}");
$(".source-item a:not(:hover) {" +
"text-decoration: none;" +
"}");
$(".escaped-value {" +
"text-decoration: dashed underline;" +
"}");
}
// Expects span to have exactly one text node as a child.
linkContentsToURL(span, start, end, url) {
span.firstChild.splitText(end);
let content = span.firstChild.splitText(start);
if (!span.firstChild.textContent.length) {
span.removeChild(span.firstChild);
}
if (!span.lastChild.textContent.length) {
span.removeChild(span.lastChild);
}
let link = span.ownerDocument.createElement("a");
link.target = "_top";
link.append(span.replaceChild(link, content));
link.href = url;
}
linkPropertyToURL(key, owner, url, text = null) {
const { LinkTransform } = WatchlistDataControls;
let transform = new LinkTransform(key, owner, url, text);
this.links.push(transform);
}
insertLinks(sourceCollection, linksCollection = this.links) {
// We need to go from a given key & object definition to the source span
// for the appropriate value in the key/value pair.
//
// Basic strategy: figure out which key/value pairs belong together (i.e.
// they're properties defined on the the same object), and then when we
// have a given transform, we go from transform to containing object to
// property value to its corresponding span in the DOM.
let propertyGroups = new Map();
for (let i = 0, n = sourceCollection.length; i < n; ++i) {
let obj = sourceCollection[i].object;
if (!obj) continue;
if (!propertyGroups.has(obj)) {
propertyGroups.set(obj, new Set());
}
let group = propertyGroups.get(obj);
group.add(sourceCollection[i]);
}
for (let i = 0, n = linksCollection.length; i < n; ++i) {
let transform = linksCollection[i];
if (transform.finished) continue;
let group = propertyGroups.get(transform.owner);
if (!group) {
logDiagnostic("property definitions unexpectedly unavailable");
continue;
}
let found = ([ ...group ]).filter((x) => (
x.key == transform.key
));
if (found.length != 1) {
logDiagnostic(
"couldn't find key to link property for url: " + transform.url
);
continue;
}
let span = found[0].node;
let start = 0;
let end = span.textContent.length;
if (transform.text) {
start = span.textContent.indexOf(transform.text);
if (start < 0) {
start = 0;
} else {
end = start + transform.text.length;
}
} else if (span.textContent.startsWith(`"`) &&
span.textContent.endsWith(`"`)) {
++start;
--end;
}
this.linkContentsToURL(span, start, end, transform.url); // XXX
transform.finished = true;
}
}
}
WatchlistDataControls.LinkTransform = class {
constructor(key, owner, url, text) {
this.finished = false;
this.key = key;
this.owner = owner;
this.url = url;
this.text = text;
}
}
// <script>
function getJSONEncoding(page) {
let elements = [ ...page.querySelectorAll("*") ];
if (elements.length != 1) {
return fail();
}
let [ script ] = elements.filter((x) => (
x.nodeName.toLowerCase() == "script" &&
x.previousSibling.nodeType == page.TEXT_NODE &&
x.previousSibling.nodeValue.trim().replace((/ /g), "").endsWith(
`"@render":"`
)
));
if (typeof(script) == "undefined") {
return fail();
}
let allKids = ([ ...page.childNodes ]);
let holding = [];
holding.push(...allKids.map((x) => {
if (x.nodeType == page.TEXT_NODE) {
return x.nodeValue.replace((/</g), "\\u003c");
} else if (x == script) {
return script.outerHTML.replace((/"/g), "'");
}
return fail();
}));
return holding.join("");
function fail() {
throw Error("payload is malformed; expecting loader script element");
}
}
// <script>
function fixQuirks(doc, title = "") {
doc.open();
doc.write(
"<!DOCTYPE html><html><head><meta charset='utf-8'><title>" + title
);
doc.close();
return doc.body;
}
// <script>
function logDiagnostic(...args) {
if (typeof(__WATCHLIB_LOGGING__) != "undefined" &&
typeof(console) != "undefined") {
console.debug(...args);
}
}
// <script>
void function _start() {
if (typeof(document) == "undefined") throw Error("panic!");
if (typeof(window) == "undefined") throw Error("panic!");
if (document.readyState == "loading") {
return void(window.onload = main);
}
return void(main(null, document.body));
} ()
// </script>