// ==UserScript==
// @name Kanka SDK
// @namespace https://greasyfork.org/en/users/1029479-infinitegeek
// @version 0.0.1
// @description Tools for Kanking.
// @author InfiniteGeek
// @supportURL Infinite @ https://discord.gg/rhsyZJ4
// @license MIT
// @match https://app.kanka.io/w/*
// @icon https://www.google.com/s2/favicons?domain=kanka.io
// @keywords kanka,sdk
// @grant none
// ==/UserScript==
/******/ (() => { // webpackBootstrap
System.register([], function (exports_1, context_1) {
'use strict';
var emit_debug, Api, Uri, Session, entityBits, editBits, Entity, EntityTypeAttributes, Util, Kanka;
var __moduleName = context_1 && context_1.id;
//const emit_debug = console.log;
function getElementPromise(...selectorChain) {
let intervalHandle;
let doc;
return new Promise((resolve, reject) => {
const getElement = () => {
if (!jQuery)
return undefined;
try {
let lmnt = (doc ??= jQuery(document.documentElement));
const selectors = [...selectorChain];
let selector = null;
while (selector = selectors.shift()) {
lmnt = lmnt.find(selector);
if (!lmnt)
return undefined;
}
if (!lmnt)
return null;
intervalHandle && clearInterval(intervalHandle);
resolve(lmnt);
return lmnt;
}
catch (error) {
intervalHandle && clearInterval(intervalHandle);
reject(error);
return null;
}
};
if (typeof MutationObserver) {
// if we have the MutationObserver API, hook to document changes
const observer = new MutationObserver(() => getElement() && observer.disconnect());
observer.observe(document.documentElement, { childList: true, subtree: true });
}
else {
// if not, use a sad timer
intervalHandle = setInterval(getElement, 333);
}
});
}
/**
* Extract metadata from the classes on the <body>
*/
function parseBodyClasses(body) {
const classes = Array.from(body.classList);
const entity = { id: '', entityType: 'default', type: '' };
const tags = [];
const kankaClassRegex = /^kanka-(\w+)-(\w+)$/;
let tempTag = null;
function processTag(isValueNumeric, value) {
// tags are emitted as id/name pairs
// parent tags also end up in the list as ID-only entries
// any name is associated with the ID prior
if (isValueNumeric) {
tempTag = value;
}
else if (tempTag !== null) {
tags.push({ id: tempTag, entityType: value });
tempTag = null;
}
}
classes
.map(className => className.match(kankaClassRegex))
.filter(match => !!match)
.forEach((match) => {
const [, key, value] = match;
const isValueNumeric = !isNaN(Number(value));
switch (key) {
// kanka-entity-{entityID} kanka-entity-{entityType}
case 'entity':
if (isValueNumeric) {
entity['id'] = value;
}
else {
entity['entityType'] = value;
}
break;
// kanka-type-{typeValue}
case 'type':
entity.type = value;
break;
// kanka-tag-{id} kanka-tag-{name}
case 'tag':
processTag(isValueNumeric, value);
break;
default:
console.warn("What's this? 💀🎃", match);
break;
}
});
return { entity, tags };
}
/**
* Builds a comparison function for sorting by similarity to a provided term.
* Intended for sorting typeahead results.
*/
/*
Example:
term: 'tre'
"Treasure of the Sierra Madre" => 26 (starts with, case mismatch)
"one tree hill" => 15 (includes, start of word, case match)
*/
function createMatchinessComparator(term, converter = item => item.toString()) {
const locale = Intl.Collator().resolvedOptions().locale;
const pattern = {
startsWith: '^' + term,
startsWord: '\\b' + term,
};
const regex = {
startsWith: new RegExp(pattern.startsWith),
startsWithI: new RegExp(pattern.startsWith, 'i'),
startsWord: new RegExp(pattern.startsWord),
startsWordI: new RegExp(pattern.startsWord, 'i'),
includes: new RegExp(term),
includesI: new RegExp(term, 'i'),
};
// assign a score based on how well the value matches the search term
const computeMatchiness = (value) => {
switch (true) {
// exact match
case value === term: return 30;
// close match, just varying by accents and/or case
case value.localeCompare(term, locale, { usage: 'search', sensitivity: 'variant' }) === 0: return 28;
case value.localeCompare(term, locale, { usage: 'search', sensitivity: 'accent' }) === 0: return 27;
case value.localeCompare(term, locale, { usage: 'search', sensitivity: 'case' }) === 0: return 26;
case value.localeCompare(term, locale, { usage: 'search', sensitivity: 'base' }) === 0: return 25;
// starts with (including case-insensitive)
case regex.startsWith.test(value): return 20;
case regex.startsWithI.test(value): return 18;
// includes at the start of a word (including case-insensitive)
case regex.startsWord.test(value): return 15;
case regex.startsWordI.test(value): return 13;
// includes anywhere (including case-insensitive)
case regex.includes.test(value): return 10;
case regex.includesI.test(value): return 9;
// no match
default: return 0;
}
};
return (a, b) => {
const textA = converter(a);
const textB = converter(b);
const scoreA = computeMatchiness(textA);
const scoreB = computeMatchiness(textB);
const relativeMatchiness = Math.sign(scoreB - scoreA);
// sort by score, then alphabetically when equal
// localeCompare impls may not be 1|0|-1 only
return relativeMatchiness || textA.localeCompare(textB);
};
}
return {
setters: [],
execute: function () {
emit_debug = (...args) => { };
Api = {
getXMLHttpRequest: (method) => {
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.open(method, Uri.buildUri(Entity.entityType, Entity.typedID), false);
Api.headers.setCsrf(xhr);
Api.headers.setXMLHttpRequest(xhr);
return xhr;
},
headers: {
setCsrf: (xhr) => xhr.setRequestHeader('x-csrf-token', Session.csrfToken),
setXMLHttpRequest: (xhr) => xhr.setRequestHeader('x-requested-with', 'XMLHttpRequest'),
},
createPostParams: () => {
const params = new URLSearchParams();
params.append('_token', Session.csrfToken);
params.append('datagrid-action', 'batch');
// this needs the plural
params.append('entity', Entity.entityType);
params.append('mode', 'table');
// typedID is different from entityID
params.append('models', Entity.typedID);
params.append('undefined', '');
return params;
},
fetch_success: async (response) => {
emit_debug('Success:', response);
window.showToast(response.statusText, 'bg-success text-success-content');
return { ok: response.ok, document: $.parseHTML(await response.text()) ?? [] };
},
post: (url, body) => {
return fetch(url, {
method: 'POST',
redirect: 'follow',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
.then(Api.fetch_success)
.catch((error) => {
console.error('Error:', error);
window.showToast(error, 'bg-primary text-error-content');
return { ok: false, document: [], error };
});
}
};
Uri = {
rootUri: 'https://app.kanka.io',
route: window.location.pathname,
buildUri: (...segments) => [Uri.rootUri, 'w', Session.campaignID, ...segments].join('/'),
getEditUri: () => document.querySelector('a[href$=edit]').getAttribute('href'),
getEntityUri: () => document.querySelector('head link[rel=canonical]').getAttribute('href'),
};
Session = {
csrfToken: document.head.querySelector('meta[name="csrf-token"]')?.getAttribute('content'),
campaignID: Uri.route.match(/w\/(?<id>\d+)\//).groups.id ?? '0',
};
entityBits = Uri.getEntityUri().match(/w\/\d+\/entities\/(?<id>\d+)/);
editBits = Uri.getEditUri().match(/\/(?<type>\w+)\/(?<id>\d+)\/edit$/);
Entity = {
/**
* this is the plural, not values from EntityType
*/
entityType: editBits.groups.type,
/**
* this is the 'larger' ID: entities/__[5328807]__ === characters/1357612
*/
entityID: entityBits.groups.id,
/**
* this is the 'smaller' ID: entities/5328807 === characters/__[1357612]__
*/
typedID: editBits.groups.id,
meta: parseBodyClasses(document.body),
};
EntityTypeAttributes = {
/**
* this encapsulates the definitions from the system
* - some entities have a location, some don't
* - some entities have a link in the header, some use the sidebar
* - some entities can have multiple locations, some can't
*/
hasLocation: ({
default: {},
character: { headerLink: true },
location: { headerLink: true },
map: { headerLink: true },
organisation: { sidebarLink: true },
family: { headerLink: true },
creature: { sidebarLink: true, multiple: true },
race: { sidebarLink: true, multiple: true },
event: { sidebarLink: true },
journal: { sidebarLink: true },
item: { sidebarLink: true },
tag: {},
note: {},
quest: {},
}),
};
Util = {
createMatchinessComparator,
getElementPromise,
parseBodyClasses,
};
Kanka = {
Uri,
Session,
Entity,
EntityTypeAttributes,
Util,
Api,
};
exports_1("default", Kanka);
}
};
});
/******/ })()
;