Kanka.io Keybinds

Set your own keyboard shortcuts for entity view page on Kanka.

目前为 2024-03-07 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Kanka.io Keybinds
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.8.3-4
  5. // @description Set your own keyboard shortcuts for entity view page on Kanka.
  6. // @author Infinite
  7. // @license MIT
  8. // @match https://app.kanka.io/w/*/entities/*
  9. // @icon https://www.google.com/s2/favicons?domain=kanka.io
  10. // @run-at document-idle
  11. // @grant none
  12. // @require https://craig.global.ssl.fastly.net/js/mousetrap/mousetrap.min.js?a4098
  13. // @require https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.min.js
  14. // ==/UserScript==
  15.  
  16. /******/ (() => { // webpackBootstrap
  17. /******/ "use strict";
  18. /******/ var __webpack_modules__ = ({
  19.  
  20. /***/ 519:
  21. /***/ (function(__unused_webpack_module, exports, __webpack_require__) {
  22.  
  23.  
  24. var __importDefault = (this && this.__importDefault) || function (mod) {
  25. return (mod && mod.__esModule) ? mod : { "default": mod };
  26. };
  27. var _a, _b, _c, _d, _e, _f;
  28. Object.defineProperty(exports, "__esModule", ({ value: true }));
  29. /* ====================================
  30. You can change these keybinds
  31. ====================================
  32. */
  33. const keybinds = {
  34. LABEL: 'l',
  35. MOVE: 'm',
  36. HELP: '?',
  37. };
  38. /*
  39.  
  40. ## Combination of keys - generic mod helper sets cross platform shortcuts
  41. 'mod+s' => command+s / ctrl+s
  42.  
  43. ## Sequence of keys - keys separated by a space will be considered a sequence
  44. 'g i'
  45.  
  46. ## Shift key - handled magically
  47. '?' instead of 'shift+/'
  48.  
  49. ## Text fields - keyboard events will not fire in textarea, input, or select
  50. enable with [class='mousetrap']
  51.  
  52. */
  53. /* =======================================
  54. You probably shouldn't edit below
  55. ======================================= */
  56. const mousetrap_1 = __importDefault(__webpack_require__(802));
  57. // import tippy from 'tippy';
  58. const emit_debug = console.log;
  59. $.prototype.blink = function (times, duration) {
  60. for (let i = 0; i < times; i++) {
  61. this.animate({ opacity: 0 }, duration)
  62. .animate({ opacity: 1 }, duration);
  63. }
  64. return this;
  65. };
  66. function parseBodyClasses(body) {
  67. const classes = Array.from(body.classList);
  68. const entity = { id: '', entityType: 'default', type: '' };
  69. const tags = [];
  70. const regex = /^kanka-(\w+)-(\w+)$/;
  71. let tempTag = null;
  72. classes.forEach(className => {
  73. const match = className.match(regex);
  74. if (match) {
  75. const [, key, value] = match;
  76. const isValueNumeric = !isNaN(Number(value));
  77. switch (key) {
  78. case 'entity':
  79. entity[isValueNumeric ? 'id' : 'entityType'] = value;
  80. break;
  81. case 'type':
  82. entity.type = value;
  83. break;
  84. case 'tag':
  85. if (isValueNumeric) {
  86. tempTag = value;
  87. }
  88. else {
  89. tags.push({
  90. id: tempTag,
  91. entityType: value,
  92. });
  93. tempTag = null;
  94. }
  95. break;
  96. default:
  97. emit_debug("what's this?", match);
  98. break;
  99. }
  100. }
  101. });
  102. return { tags, entity };
  103. }
  104. const route = window.location.pathname;
  105. // this is necessary to get the typedID and the plural
  106. const editButtonLink = (_a = $('div#entity-submenu a[href$="edit"]').attr('href')) !== null && _a !== void 0 ? _a : $('div.header-buttons a[href$="edit"]').attr('href');
  107. const kanka = {
  108. csrfToken: (_b = document.head.querySelector('meta[name="csrf-token"]')) === null || _b === void 0 ? void 0 : _b.getAttribute('content'),
  109. route,
  110. campaignID: ((_c = route.match(/w\/(\d+)\//)) !== null && _c !== void 0 ? _c : [null, '0'])[1],
  111. // this is the plural, not values from EntityType
  112. entityType: ((_d = editButtonLink === null || editButtonLink === void 0 ? void 0 : editButtonLink.match(/\/(\w+)\/\d+\/edit$/)) !== null && _d !== void 0 ? _d : [null, '0'])[1],
  113. // this is the 'larger' ID: entities/*5328807* === characters/1357612
  114. entityID: ((_e = route.match(/w\/\d+\/entities\/(\d+)/)) !== null && _e !== void 0 ? _e : [null, '0'])[1],
  115. // this is the 'smaller' ID: entities/5328807 === characters/*1357612*
  116. typedID: ((_f = editButtonLink === null || editButtonLink === void 0 ? void 0 : editButtonLink.match(/\/(\d+)\/edit$/)) !== null && _f !== void 0 ? _f : [null, '0'])[1],
  117. meta: parseBodyClasses(document.body),
  118. entityTypeHasLocation: ({
  119. default: {},
  120. character: { headerLink: true },
  121. location: { headerLink: true },
  122. map: { headerLink: true },
  123. organisation: { sidebarLink: true },
  124. family: { headerLink: true },
  125. creature: { sidebarLink: true, multiple: true },
  126. race: { sidebarLink: true, multiple: true },
  127. event: { sidebarLink: true },
  128. journal: { sidebarLink: true },
  129. item: { sidebarLink: true },
  130. tag: {},
  131. note: {},
  132. quest: {},
  133. }),
  134. bulkEditUrl: '',
  135. entityEditUrl: '',
  136. };
  137. kanka.bulkEditUrl = `/w/${kanka.campaignID}/bulk/process`;
  138. kanka.entityEditUrl = `/w/${kanka.campaignID}/${kanka.entityType}/${kanka.typedID}`;
  139. const handlers = {
  140. [keybinds.LABEL]: function (evt, combo) {
  141. initSelector(templates.TAG_SELECT, processTagSelection);
  142. },
  143. [keybinds.MOVE]: function (evt, combo) {
  144. initSelector(templates.LOCATION_SELECT, processLocationSelection);
  145. },
  146. [keybinds.HELP]: function (evt, combo) {
  147. // TODO show a modal describing the keybinds
  148. },
  149. };
  150. const identifiers = {
  151. Sidebar: {
  152. Class: '.entity-sidebar',
  153. ProfileClass: '.sidebar-section-profile',
  154. ProfileElementsID: '#sidebar-profile-elements',
  155. },
  156. };
  157. const templates = {
  158. SIDEBAR_PROFILE: () => `
  159. <div class="sidebar-section-box ${identifiers.Sidebar.ProfileClass.slice(1)} overflow-hidden flex flex-col gap-2">
  160. <div class="sidebar-section-title cursor-pointer text-lg user-select border-b element-toggle" data-animate="collapse" data-target="#sidebar-profile-elements">
  161. <i class="fa-solid fa-chevron-up icon-show " aria-hidden="true"></i>
  162. <i class="fa-solid fa-chevron-down icon-hide " aria-hidden="true"></i>
  163. Profile
  164. </div>
  165.  
  166. <div class="sidebar-elements grid overflow-hidden" id="${identifiers.Sidebar.ProfileElementsID.slice(1)}">
  167. </div>
  168. </div>`.trim(),
  169. SELECT_ELEMENT: (dataUrl, placeholder) => `
  170. <select class="form-tags select2"
  171. style="width: 100%"
  172. data-url="${dataUrl}"
  173. data-allow-new="false"
  174. data-allow-clear="true"
  175. data-placeholder="${placeholder}"
  176. data-dropdown-parent="#app"
  177. </select>`.trim(),
  178. SELECT_ITEM: (text, image) => {
  179. if (!!image) {
  180. return $(`
  181. <span class="flex gap-2 items-center text-left">
  182. <img src="${image}" class="rounded-full flex-none w-6 h-6" />
  183. <span class="grow">${text}</span>
  184. </span>`.trim());
  185. }
  186. return $(`<span>${text}</span>`);
  187. },
  188. TAG_SELECT: () => templates.SELECT_ELEMENT(`https://app.kanka.io/w/${kanka.campaignID}/search/tags`, 'Apply Tag'),
  189. TAG_URL: (tagID) => `https://app.kanka.io/w/${kanka.campaignID}/tags/${tagID}`,
  190. TAG_LINK: (tagID, text) => `
  191. <a href="${templates.TAG_URL(tagID)}" title="Refresh to get full tooltip functionality">
  192. <span class="badge color-tag rounded-sm px-2 py-1">${text}</span>
  193. </a>`.trim(),
  194. LOCATION_SELECT: () => templates.SELECT_ELEMENT(`https://app.kanka.io/w/${kanka.campaignID}/search/locations`, 'Move to...'),
  195. LOCATION_URL: (locationID) => `https://app.kanka.io/w/${kanka.campaignID}/entities/${locationID}`,
  196. LOCATION_LINK: (locationID, text) => `<a class="name" href="${templates.LOCATION_URL(locationID)}" title="Refresh to get full tooltip functionality">${text}</a>`,
  197. // TODO - get popper/tippy working to enable preview tooltips
  198. // data-toggle="tooltip-ajax" data-id="${locationID}" data-url="${templates.LOCATION_URL(locationID)}/tooltip">
  199. };
  200. /// making my own container for the select to avoid any interference
  201. function createFloatingElement(template) {
  202. let floatingDiv = document.getElementById('#infinite-select2');
  203. if (!floatingDiv) {
  204. floatingDiv = document.createElement('div');
  205. floatingDiv.id = 'infinite-select2';
  206. // Add styles to make it float and position it as needed
  207. floatingDiv.style.position = 'absolute';
  208. floatingDiv.style.top = '5%';
  209. floatingDiv.style.left = '41%';
  210. floatingDiv.style.minWidth = '200px';
  211. floatingDiv.style.width = '18%';
  212. floatingDiv.style.maxWidth = '400px';
  213. }
  214. floatingDiv.innerHTML = '';
  215. $(template()).appendTo(floatingDiv);
  216. document.body.appendChild(floatingDiv);
  217. return floatingDiv;
  218. }
  219. function createPostParams() {
  220. const params = new URLSearchParams();
  221. params.append('_token', kanka.csrfToken);
  222. params.append('datagrid-action', 'batch');
  223. // this needs the plural
  224. params.append('entity', kanka.entityType);
  225. params.append('mode', 'table');
  226. // typedID is different from entityID
  227. params.append('models', kanka.typedID);
  228. params.append('undefined', '');
  229. return params;
  230. }
  231. async function fetch_success(response) {
  232. var _a;
  233. emit_debug('Success:', response);
  234. const document = await ((_a = response.body) === null || _a === void 0 ? void 0 : _a.getReader().read().then(content => {
  235. const responseHtml = new TextDecoder().decode(content.value);
  236. return $.parseHTML(responseHtml);
  237. }));
  238. return { ok: response.ok, document: document !== null && document !== void 0 ? document : [] };
  239. }
  240. function post(url, body) {
  241. return fetch(url, {
  242. method: 'POST',
  243. redirect: 'follow',
  244. headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  245. body,
  246. })
  247. .then(fetch_success)
  248. .catch((error) => {
  249. console.error('Error:', error);
  250. return { ok: error.ok, document: [] };
  251. });
  252. }
  253. async function edit(body) {
  254. emit_debug({ edit_data: [...body.entries()] });
  255. var xhr = new XMLHttpRequest();
  256. xhr.withCredentials = true;
  257. xhr.open('POST', kanka.entityEditUrl, false);
  258. xhr.setRequestHeader('x-csrf-token', kanka.csrfToken);
  259. xhr.setRequestHeader('x-requested-with', 'XMLHttpRequest');
  260. xhr.send(body);
  261. emit_debug({ req: xhr });
  262. return {
  263. ok: xhr.status == 200,
  264. document: $.parseHTML(xhr.responseText),
  265. };
  266. return fetch(kanka.entityEditUrl, {
  267. method: 'POST',
  268. headers: {
  269. "x-csrf-token": kanka.csrfToken,
  270. "x-requested-with": "XMLHttpRequest"
  271. },
  272. "referrer": kanka.entityEditUrl + "/edit",
  273. "referrerPolicy": "strict-origin-when-cross-origin",
  274. "mode": "cors",
  275. "credentials": "include",
  276. redirect: 'follow',
  277. body,
  278. })
  279. .then(fetch_success)
  280. .catch((error) => {
  281. console.error('Error:', error);
  282. return { ok: error.ok, document: [] };
  283. });
  284. }
  285. async function processLocationSelection(event) {
  286. const { id: locationID, text } = event.params.data;
  287. const thisEntityTypeHasLocation = kanka.entityTypeHasLocation[kanka.meta.entity.entityType];
  288. if (thisEntityTypeHasLocation.multiple) {
  289. alert('This entity type can have multiple locations. This feature is not yet implemented.');
  290. return;
  291. const data = new FormData();
  292. data.append('_token', kanka.csrfToken);
  293. // this is kinda BS, but it's the cleanest way to get
  294. // - the list of typed IDs
  295. // - the other stuff
  296. await fetch(`https://app.kanka.io/w/${kanka.campaignID}/creatures/${kanka.typedID}/edit`, {
  297. method: 'GET',
  298. headers: { 'Content-Type': 'text/html' }
  299. })
  300. .then(fetch_success)
  301. .then(response => $(response.document)
  302. .find('form#entity-form')
  303. .serializeArray()
  304. // .filter(kvp => {
  305. // if (kvp.value == '') return false;
  306. // if (kvp.value == '0') return false;
  307. // if (kvp.value == 'inherit') return false;
  308. // })
  309. .forEach(kvp => data.append(kvp.name, kvp.value)));
  310. data.append('locations[]', locationID);
  311. await edit(data)
  312. .then(response => {
  313. const doc = $(response.document);
  314. emit_debug({
  315. header: doc.find('.entity-header'),
  316. sidebar: doc.find('#sidebar-profile-elements'),
  317. });
  318. });
  319. return;
  320. }
  321. const params = createPostParams();
  322. params.append('location_id', locationID);
  323. const response = await post(kanka.bulkEditUrl, params);
  324. if (!response.ok) {
  325. emit_debug('Error:', response);
  326. return false;
  327. }
  328. const sub = (selector) => {
  329. const newBlock = $(response.document).find(selector);
  330. emit_debug({
  331. original: $(selector).html(),
  332. replacement: newBlock.html(),
  333. });
  334. $(selector).replaceWith(newBlock);
  335. return $(selector);
  336. };
  337. if (thisEntityTypeHasLocation.headerLink) {
  338. sub('.entity-header')
  339. .find('.entity-header-sub.entity-header-line')
  340. .blink(3, 125);
  341. }
  342. if (thisEntityTypeHasLocation.sidebarLink) {
  343. const sidebar = $(identifiers.Sidebar.Class);
  344. if (sidebar.find(identifiers.Sidebar.ProfileClass).length == 0) {
  345. emit_debug('adding profile');
  346. sidebar.find('.entity-pins').after(templates.SIDEBAR_PROFILE());
  347. }
  348. if (sidebar.find(identifiers.Sidebar.ProfileElementsID).length == 0) {
  349. emit_debug('adding profile elements');
  350. sidebar.find(identifiers.Sidebar.ProfileClass).append(`<div id="${identifiers.Sidebar.ProfileElementsID.slice(1)}"></div>`);
  351. }
  352. sub(identifiers.Sidebar.ProfileElementsID)
  353. .find('.profile-location')
  354. .blink(3, 125);
  355. }
  356. }
  357. function processTagSelection(event) {
  358. const { id: tagID, text } = event.params.data;
  359. const params = createPostParams();
  360. params.append('save-tags', '1');
  361. params.append('tags[]', tagID);
  362. const header = $('.entity-header .entity-header-text');
  363. if (header.has('.entity-tags').length == 0) {
  364. $('<div class="entity-tags entity-header-line text-xs flex flex-wrap gap-2"></div>')
  365. .insertBefore(header.find('.header-buttons'));
  366. }
  367. const hasTag = !!kanka.meta.tags.find(tag => tag.id == tagID);
  368. params.append('bulk-tagging', hasTag ? 'remove' : 'add');
  369. return post(`/w/${kanka.campaignID}/bulk/process`, params)
  370. .then((ok) => {
  371. const tagBar = header.find('.entity-tags');
  372. ok && hasTag
  373. ? tagBar.children().remove(`[href="${templates.TAG_URL(tagID)}"]`)
  374. : tagBar.append($(templates.TAG_LINK(tagID, text)));
  375. });
  376. /*
  377. // was doing it using the simple 'add entity under tag' API
  378. // but why not consolidate?
  379. params.append('entities[]', kanka.meta.entity.id);
  380. params.append('tag_id', tagID);
  381. post(`/w/${kanka.campaignID}/tags/${tagID}/entity-add/`, params)
  382. .then((ok) => ok && tagBar.append($(templates.TAG_LINK(tagID, text))));
  383. */
  384. }
  385. function initSelector(template, processSelection) {
  386. const floatingDiv = createFloatingElement(template);
  387. $(floatingDiv).find('select.select2')
  388. .each(function () {
  389. const me = $(this);
  390. me.select2({
  391. tags: false,
  392. placeholder: me.data('placeholder'),
  393. allowClear: me.data('allowClear') || true,
  394. language: me.data('language'),
  395. minimumInputLength: 0,
  396. dropdownParent: $(me.data('dropdownParent')) || '',
  397. width: '100%',
  398. sorter: (data) => {
  399. const term = $('input.select2-search__field').val().toLowerCase();
  400. return data.sort(byMatchiness(term));
  401. },
  402. ajax: {
  403. delay: 500, // quiet ms
  404. url: me.data('url'),
  405. dataType: 'json',
  406. data: (params) => { var _a; return ({ q: (_a = params.term) === null || _a === void 0 ? void 0 : _a.trim() }); },
  407. processResults: (data) => ({ results: data }),
  408. error: function (jqXHR, textStatus, errorThrown) {
  409. if (textStatus === 'abort') {
  410. // it does this for the empty field, I think?
  411. return;
  412. }
  413. if (jqXHR.status === 503) {
  414. window.showToast(jqXHR.responseJSON.message, 'error');
  415. }
  416. emit_debug('error', jqXHR, textStatus, errorThrown);
  417. return { results: [] };
  418. },
  419. cache: true
  420. },
  421. templateResult: (item) => templates.SELECT_ITEM(item.text, item.image),
  422. })
  423. .on('select2:select', processSelection)
  424. .on('select2:close', () => {
  425. setTimeout(() => { $(floatingDiv).remove(); }, 100);
  426. });
  427. setTimeout(() => { me.select2('open'); }, 0);
  428. });
  429. }
  430. function byMatchiness(term) {
  431. return (a, b) => {
  432. const textA = a.text.toLowerCase();
  433. const textB = b.text.toLowerCase();
  434. // Assign a score based on how well the option matches the search term
  435. const scoreA = textA === term ? 3 : textA.startsWith(term) ? 2 : textA.includes(term) ? 1 : 0;
  436. const scoreB = textB === term ? 3 : textB.startsWith(term) ? 2 : textB.includes(term) ? 1 : 0;
  437. // Sort by score. If the scores are equal, sort alphabetically
  438. return scoreB - scoreA || textA.localeCompare(textB);
  439. };
  440. }
  441. (function () {
  442. if (!document.body.className.includes('kanka-entity-')) {
  443. return;
  444. }
  445. for (const key in handlers) {
  446. mousetrap_1.default.bind(key, handlers[key]);
  447. }
  448. emit_debug({ kanka });
  449. })();
  450.  
  451.  
  452. /***/ }),
  453.  
  454. /***/ 802:
  455. /***/ ((module) => {
  456.  
  457. module.exports = Mousetrap;
  458.  
  459. /***/ })
  460.  
  461. /******/ });
  462. /************************************************************************/
  463. /******/ // The module cache
  464. /******/ var __webpack_module_cache__ = {};
  465. /******/
  466. /******/ // The require function
  467. /******/ function __webpack_require__(moduleId) {
  468. /******/ // Check if module is in cache
  469. /******/ var cachedModule = __webpack_module_cache__[moduleId];
  470. /******/ if (cachedModule !== undefined) {
  471. /******/ return cachedModule.exports;
  472. /******/ }
  473. /******/ // Create a new module (and put it into the cache)
  474. /******/ var module = __webpack_module_cache__[moduleId] = {
  475. /******/ // no module.id needed
  476. /******/ // no module.loaded needed
  477. /******/ exports: {}
  478. /******/ };
  479. /******/
  480. /******/ // Execute the module function
  481. /******/ __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);
  482. /******/
  483. /******/ // Return the exports of the module
  484. /******/ return module.exports;
  485. /******/ }
  486. /******/
  487. /************************************************************************/
  488. /******/
  489. /******/ // startup
  490. /******/ // Load entry module and return exports
  491. /******/ // This entry module is referenced by other modules so it can't be inlined
  492. /******/ var __webpack_exports__ = __webpack_require__(519);
  493. /******/
  494. /******/ })()
  495. ;