Kanka.io Keybinds (master)

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

当前为 2024-03-09 提交的版本,查看 最新版本

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