Kanka Keybinds

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

目前為 2024-03-10 提交的版本,檢視 最新版本

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