Kanka.io Keybinds

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

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

  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. function parseBodyClasses(body) {
  60. const classes = Array.from(body.classList);
  61. const entity = { id: '', entityType: 'default', type: '' };
  62. const tags = [];
  63. const regex = /^kanka-(\w+)-(\w+)$/;
  64. let tempTag = null;
  65. classes.forEach(className => {
  66. const match = className.match(regex);
  67. if (match) {
  68. const [, key, value] = match;
  69. const isValueNumeric = !isNaN(Number(value));
  70. switch (key) {
  71. case 'entity':
  72. entity[isValueNumeric ? 'id' : 'entityType'] = value;
  73. break;
  74. case 'type':
  75. entity.type = value;
  76. break;
  77. case 'tag':
  78. if (isValueNumeric) {
  79. tempTag = value;
  80. }
  81. else {
  82. tags.push({
  83. id: tempTag,
  84. entityType: value,
  85. });
  86. tempTag = null;
  87. }
  88. break;
  89. default:
  90. emit_debug("what's this?", match);
  91. break;
  92. }
  93. }
  94. });
  95. return { tags, entity };
  96. }
  97. const route = window.location.pathname;
  98. // this is necessary to get the typedID and the plural
  99. const editButtonLink = (_a = $('div#entity-submenu a[href$="edit"]').attr('href')) !== null && _a !== void 0 ? _a : $('div.header-buttons a[href$="edit"]').attr('href');
  100. const kanka = {
  101. csrfToken: (_b = document.head.querySelector('meta[name="csrf-token"]')) === null || _b === void 0 ? void 0 : _b.getAttribute('content'),
  102. route,
  103. campaignID: ((_c = route.match(/w\/(\d+)\//)) !== null && _c !== void 0 ? _c : [null, '0'])[1],
  104. // this is the plural, not values from EntityType
  105. entityType: ((_d = editButtonLink === null || editButtonLink === void 0 ? void 0 : editButtonLink.match(/\/(\w+)\/\d+\/edit$/)) !== null && _d !== void 0 ? _d : [null, '0'])[1],
  106. // this is the 'larger' ID: entities/*5328807* === characters/1357612
  107. entityID: ((_e = route.match(/w\/\d+\/entities\/(\d+)/)) !== null && _e !== void 0 ? _e : [null, '0'])[1],
  108. // this is the 'smaller' ID: entities/5328807 === characters/*1357612*
  109. typedID: ((_f = editButtonLink === null || editButtonLink === void 0 ? void 0 : editButtonLink.match(/\/(\d+)\/edit$/)) !== null && _f !== void 0 ? _f : [null, '0'])[1],
  110. meta: parseBodyClasses(document.body),
  111. entityTypeHasLocation: ({
  112. default: {},
  113. character: { headerLink: true },
  114. location: { headerLink: true },
  115. map: { headerLink: true },
  116. organisation: { sidebarLink: true },
  117. family: { headerLink: true },
  118. creature: { sidebarLink: true, multiple: true },
  119. race: { sidebarLink: true, multiple: true },
  120. event: { sidebarLink: true },
  121. journal: { sidebarLink: true },
  122. item: { sidebarLink: true },
  123. tag: {},
  124. note: {},
  125. quest: {},
  126. }),
  127. bulkEditUrl: '',
  128. entityEditUrl: '',
  129. };
  130. kanka.bulkEditUrl = `/w/${kanka.campaignID}/bulk/process`;
  131. kanka.entityEditUrl = `/w/${kanka.campaignID}/${kanka.entityType}/${kanka.typedID}`;
  132. const handlers = {
  133. [keybinds.LABEL]: function (evt, combo) {
  134. initSelector(templates.TAG_SELECT, processTagSelection);
  135. },
  136. [keybinds.MOVE]: function (evt, combo) {
  137. initSelector(templates.LOCATION_SELECT, processLocationSelection);
  138. },
  139. [keybinds.HELP]: function (evt, combo) {
  140. // TODO show a modal describing the keybinds
  141. },
  142. };
  143. const templates = {
  144. SELECT_ELEMENT: (dataUrl, placeholder) => `
  145. <select class="form-tags select2"
  146. style="width: 100%"
  147. data-url="${dataUrl}"
  148. data-allow-new="false"
  149. data-allow-clear="true"
  150. data-placeholder="${placeholder}"
  151. data-dropdown-parent="#app"
  152. </select>`.trim(),
  153. SELECT_ITEM: (text, image) => {
  154. if (!!image) {
  155. return $(`
  156. <span class="flex gap-2 items-center text-left">
  157. <img src="${image}" class="rounded-full flex-none w-6 h-6" />
  158. <span class="grow">${text}</span>
  159. </span>`.trim());
  160. }
  161. return $(`<span>${text}</span>`);
  162. },
  163. TAG_SELECT: () => templates.SELECT_ELEMENT(`https://app.kanka.io/w/${kanka.campaignID}/search/tags`, 'Apply Tag'),
  164. TAG_URL: (tagID) => `https://app.kanka.io/w/${kanka.campaignID}/tags/${tagID}`,
  165. TAG_LINK: (tagID, text) => `
  166. <a href="${templates.TAG_URL(tagID)}" title="Refresh to get full tooltip functionality">
  167. <span class="badge color-tag rounded-sm px-2 py-1">${text}</span>
  168. </a>`.trim(),
  169. LOCATION_SELECT: () => templates.SELECT_ELEMENT(`https://app.kanka.io/w/${kanka.campaignID}/search/locations`, 'Move to...'),
  170. LOCATION_URL: (locationID) => `https://app.kanka.io/w/${kanka.campaignID}/entities/${locationID}`,
  171. LOCATION_LINK: (locationID, text) => `<a class="name" href="${templates.LOCATION_URL(locationID)}" title="Refresh to get full tooltip functionality">${text}</a>`,
  172. // TODO - get popper/tippy working to enable preview tooltips
  173. // data-toggle="tooltip-ajax" data-id="${locationID}" data-url="${templates.LOCATION_URL(locationID)}/tooltip">
  174. };
  175. /// making my own container for the select to avoid any interference
  176. function createFloatingElement(template) {
  177. let floatingDiv = document.getElementById('#infinite-select2');
  178. if (!floatingDiv) {
  179. floatingDiv = document.createElement('div');
  180. floatingDiv.id = 'infinite-select2';
  181. // Add styles to make it float and position it as needed
  182. floatingDiv.style.position = 'absolute';
  183. floatingDiv.style.top = '5%';
  184. floatingDiv.style.left = '41%';
  185. floatingDiv.style.minWidth = '200px';
  186. floatingDiv.style.width = '18%';
  187. floatingDiv.style.maxWidth = '400px';
  188. }
  189. floatingDiv.innerHTML = '';
  190. $(template()).appendTo(floatingDiv);
  191. document.body.appendChild(floatingDiv);
  192. return floatingDiv;
  193. }
  194. function createPostParams() {
  195. const params = new URLSearchParams();
  196. params.append('_token', kanka.csrfToken);
  197. params.append('datagrid-action', 'batch');
  198. // this needs the plural
  199. params.append('entity', kanka.entityType);
  200. params.append('mode', 'table');
  201. // typedID is different from entityID
  202. params.append('models', kanka.typedID);
  203. params.append('undefined', '');
  204. return params;
  205. }
  206. async function fetch_success(response) {
  207. var _a;
  208. emit_debug('Success:', response);
  209. const document = await ((_a = response.body) === null || _a === void 0 ? void 0 : _a.getReader().read().then(content => {
  210. const responseHtml = new TextDecoder().decode(content.value);
  211. return $.parseHTML(responseHtml);
  212. }));
  213. return { ok: response.ok, document: document !== null && document !== void 0 ? document : [] };
  214. }
  215. function post(url, body) {
  216. return fetch(url, {
  217. method: 'POST',
  218. redirect: 'follow',
  219. headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  220. body,
  221. })
  222. .then(fetch_success)
  223. .catch((error) => {
  224. console.error('Error:', error);
  225. return { ok: error.ok, document: [] };
  226. });
  227. }
  228. function edit(body) {
  229. return fetch(kanka.entityEditUrl, {
  230. method: 'POST',
  231. redirect: 'follow',
  232. headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  233. body,
  234. })
  235. .then(fetch_success)
  236. .catch((error) => {
  237. console.error('Error:', error);
  238. return { ok: error.ok, document: [] };
  239. });
  240. }
  241. function processLocationSelection(event) {
  242. const { id: locationID, text } = event.params.data;
  243. const params = createPostParams();
  244. const thisEntityTypeHasLocation = kanka.entityTypeHasLocation[kanka.meta.entity.entityType];
  245. if (thisEntityTypeHasLocation.multiple) {
  246. // this is kinda BS, but it's the cleanest way to get
  247. // - the list of typed IDs
  248. // - the other stuff
  249. fetch(`https://app.kanka.io/w/${kanka.campaignID}/creatures/${kanka.typedID}/edit`, {
  250. method: 'GET',
  251. headers: { 'Content-Type': 'text/html' }
  252. }).then(fetch_success)
  253. .then(response => {
  254. const document = $(response.document);
  255. const form = document.find('form#entity-form');
  256. const values = form.serializeArray()
  257. .filter(kvp => {
  258. if (kvp.value == '')
  259. return false;
  260. if (kvp.value == '0')
  261. return false;
  262. if (kvp.value == 'inherit')
  263. return false;
  264. })
  265. .forEach(kvp => params.set(kvp.name, kvp.value));
  266. // return $(response.document).find(`select[name="locations[]"]`).val();
  267. });
  268. params.append('locations[]', locationID);
  269. // params.append('_method', 'PATCH');
  270. // params.append('save-locations', '1');
  271. /*
  272. save_locations: 1
  273. locations[]: 833738
  274. locations[]: 990390
  275. locations[]: 833786
  276. locations[]: 851820
  277. */
  278. edit(params).then(response => {
  279. const doc = $(response.document);
  280. emit_debug({
  281. header: doc.find('.entity-header'),
  282. sidebar: doc.find('#sidebar-profile-elements'),
  283. });
  284. });
  285. return;
  286. }
  287. params.append('location_id', locationID);
  288. post(kanka.bulkEditUrl, params)
  289. .then(response => {
  290. if (!response.ok)
  291. return false;
  292. if (thisEntityTypeHasLocation.headerLink) {
  293. $('.entity-header').replaceWith($(response.document).find('.entity-header'));
  294. }
  295. if (thisEntityTypeHasLocation.sidebarLink) {
  296. $('#sidebar-profile-elements').replaceWith($(response.document).find('#sidebar-profile-elements'));
  297. // const sidebar = $('#sidebar-profile-elements > div').first();
  298. // let sidebar_Location = sidebar.find('.profile-location')
  299. // if (!sidebar_Location) {
  300. // sidebar_Location = $('<div class="element profile-location">')
  301. // sidebar_Location.append($('<div class="title text-uppercase text-xs">Location</div>'));
  302. // sidebar.prepend(sidebar_Location);
  303. // }
  304. // const link = templates.LOCATION_LINK(locationID, text);
  305. // if (thisEntityLocation.multiple) {
  306. // sidebar_Location.append(link);
  307. // } else {
  308. // sidebar_Location.find('a').replaceWith(link);
  309. // }
  310. }
  311. });
  312. }
  313. function processTagSelection(event) {
  314. const { id: tagID, text } = event.params.data;
  315. const params = createPostParams();
  316. params.append('save-tags', '1');
  317. params.append('tags[]', tagID);
  318. const header = $('.entity-header .entity-header-text');
  319. if (header.has('.entity-tags').length == 0) {
  320. $('<div class="entity-tags entity-header-line text-xs flex flex-wrap gap-2"></div>')
  321. .insertBefore(header.find('.header-buttons'));
  322. }
  323. const hasTag = !!kanka.meta.tags.find(tag => tag.id == tagID);
  324. params.append('bulk-tagging', hasTag ? 'remove' : 'add');
  325. return post(`/w/${kanka.campaignID}/bulk/process`, params)
  326. .then((ok) => {
  327. const tagBar = header.find('.entity-tags');
  328. ok && hasTag
  329. ? tagBar.children().remove(`[href="${templates.TAG_URL(tagID)}"]`)
  330. : tagBar.append($(templates.TAG_LINK(tagID, text)));
  331. });
  332. /*
  333. // was doing it using the simple 'add entity under tag' API
  334. // but why not consolidate?
  335. params.append('entities[]', kanka.meta.entity.id);
  336. params.append('tag_id', tagID);
  337. post(`/w/${kanka.campaignID}/tags/${tagID}/entity-add/`, params)
  338. .then((ok) => ok && tagBar.append($(templates.TAG_LINK(tagID, text))));
  339. */
  340. }
  341. function initSelector(template, processSelection) {
  342. const floatingDiv = createFloatingElement(template);
  343. $(floatingDiv).find('select.select2')
  344. .each(function () {
  345. const me = $(this);
  346. me.select2({
  347. tags: false,
  348. placeholder: me.data('placeholder'),
  349. allowClear: me.data('allowClear') || true,
  350. language: me.data('language'),
  351. minimumInputLength: 0,
  352. dropdownParent: $(me.data('dropdownParent')) || '',
  353. width: '100%',
  354. sorter: (data) => {
  355. const term = $('input.select2-search__field').val().toLowerCase();
  356. return data.sort(byMatchiness(term));
  357. },
  358. ajax: {
  359. delay: 500, // quiet ms
  360. url: me.data('url'),
  361. dataType: 'json',
  362. data: (params) => { var _a; return ({ q: (_a = params.term) === null || _a === void 0 ? void 0 : _a.trim() }); },
  363. processResults: (data) => ({ results: data }),
  364. error: function (jqXHR, textStatus, errorThrown) {
  365. if (textStatus === 'abort') {
  366. // it does this for the empty field, I think?
  367. return;
  368. }
  369. if (jqXHR.status === 503) {
  370. window.showToast(jqXHR.responseJSON.message, 'error');
  371. }
  372. emit_debug('error', jqXHR, textStatus, errorThrown);
  373. return { results: [] };
  374. },
  375. cache: true
  376. },
  377. templateResult: (item) => templates.SELECT_ITEM(item.text, item.image),
  378. })
  379. .on('select2:select', processSelection)
  380. .on('select2:close', () => {
  381. setTimeout(() => { $(floatingDiv).remove(); }, 100);
  382. });
  383. setTimeout(() => { me.select2('open'); }, 0);
  384. });
  385. }
  386. function byMatchiness(term) {
  387. return (a, b) => {
  388. const textA = a.text.toLowerCase();
  389. const textB = b.text.toLowerCase();
  390. // Assign a score based on how well the option matches the search term
  391. const scoreA = textA === term ? 3 : textA.startsWith(term) ? 2 : textA.includes(term) ? 1 : 0;
  392. const scoreB = textB === term ? 3 : textB.startsWith(term) ? 2 : textB.includes(term) ? 1 : 0;
  393. // Sort by score. If the scores are equal, sort alphabetically
  394. return scoreB - scoreA || textA.localeCompare(textB);
  395. };
  396. }
  397. (function () {
  398. if (!document.body.className.includes('kanka-entity-')) {
  399. return;
  400. }
  401. for (const key in handlers) {
  402. mousetrap_1.default.bind(key, handlers[key]);
  403. }
  404. emit_debug(kanka);
  405. })();
  406.  
  407.  
  408. /***/ }),
  409.  
  410. /***/ 802:
  411. /***/ ((module) => {
  412.  
  413. module.exports = Mousetrap;
  414.  
  415. /***/ })
  416.  
  417. /******/ });
  418. /************************************************************************/
  419. /******/ // The module cache
  420. /******/ var __webpack_module_cache__ = {};
  421. /******/
  422. /******/ // The require function
  423. /******/ function __webpack_require__(moduleId) {
  424. /******/ // Check if module is in cache
  425. /******/ var cachedModule = __webpack_module_cache__[moduleId];
  426. /******/ if (cachedModule !== undefined) {
  427. /******/ return cachedModule.exports;
  428. /******/ }
  429. /******/ // Create a new module (and put it into the cache)
  430. /******/ var module = __webpack_module_cache__[moduleId] = {
  431. /******/ // no module.id needed
  432. /******/ // no module.loaded needed
  433. /******/ exports: {}
  434. /******/ };
  435. /******/
  436. /******/ // Execute the module function
  437. /******/ __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);
  438. /******/
  439. /******/ // Return the exports of the module
  440. /******/ return module.exports;
  441. /******/ }
  442. /******/
  443. /************************************************************************/
  444. /******/
  445. /******/ // startup
  446. /******/ // Load entry module and return exports
  447. /******/ // This entry module is referenced by other modules so it can't be inlined
  448. /******/ var __webpack_exports__ = __webpack_require__(519);
  449. /******/
  450. /******/ })()
  451. ;