Kanka Atomic Entity Editor

Provides keyboard shortcuts for simple edits from the entity view page on Kanka.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         Kanka Atomic Entity Editor
// @namespace    https://greasyfork.org/en/users/1029479-infinitegeek
// @version      1.0.0
// @description  Provides keyboard shortcuts for simple edits from the entity view page on Kanka.
// @author       InfiniteGeek
// @supportURL   Infinite @ https://discord.gg/rhsyZJ4
// @license      MIT
// @match        https://app.kanka.io/w/*/entities/*
// @icon         https://www.google.com/s2/favicons?domain=kanka.io
// @keywords     kanka,keybind,keyboard,shortcut,hotkey,tag,location,entity,edit
// @run-at       document-idle
// @grant        none
// @require      https://craig.global.ssl.fastly.net/js/mousetrap/mousetrap.min.js?a4098
// ==/UserScript==

/******/ (() => { // webpackBootstrap
/******/ 	"use strict";
/******/ 	var __webpack_modules__ = ({

/***/ 519:
/***/ (function(__unused_webpack_module, exports, __webpack_require__) {


var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
var _a, _b, _c, _d, _e, _f;
Object.defineProperty(exports, "__esModule", ({ value: true }));
/*  ====================================
        You can change these keybinds
    ====================================
*/
const keybinds = {
    LABEL: 'l',
    MOVE: 'm',
    HELP: '?',
};
/*

## Combination of keys - generic mod helper sets cross platform shortcuts
    'mod+s' => command+s / ctrl+s

## Sequence of keys - keys separated by a space will be considered a sequence
    'g i'

## Shift key - handled magically
    '?' instead of 'shift+/'

## Text fields - keyboard events will not fire in textarea, input, or select
    enable on an element with [class='mousetrap']

*/
/*  =======================================================
        You probably shouldn't edit below... probably.
        Here there be `Dragons : Reptile<Mythical>[]`
    ======================================================= */
const mousetrap_1 = __importDefault(__webpack_require__(802));
// import tippy from 'tippy';
const emit_debug = console.log;
// this is a jQuery 'plugin' to make an element blink
$.prototype.blink = function (times, duration) {
    for (let i = 0; i < times; i++) {
        this.animate({ opacity: 0 }, duration)
            .animate({ opacity: 1 }, duration);
    }
    return this;
};
/**
 * 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 regex = /^kanka-(\w+)-(\w+)$/;
    let tempTag = null;
    classes.forEach(className => {
        const match = className.match(regex);
        if (match) {
            const [, key, value] = match;
            const isValueNumeric = !isNaN(Number(value));
            switch (key) {
                case 'entity':
                    entity[isValueNumeric ? 'id' : 'entityType'] = value;
                    break;
                case 'type':
                    entity.type = value;
                    break;
                case 'tag':
                    if (isValueNumeric) {
                        tempTag = value;
                    }
                    else {
                        tags.push({
                            id: tempTag,
                            entityType: value,
                        });
                        tempTag = null;
                    }
                    break;
                default:
                    emit_debug("what's this?", match);
                    break;
            }
        }
    });
    return { entity, tags };
}
const route = window.location.pathname;
// using the edit button is necessary to get the typedID and the plural :\
const editButtonLink = (_a = $('div#entity-submenu a[href$="edit"]').attr('href')) !== null && _a !== void 0 ? _a : $('div.header-buttons a[href$="edit"]').attr('href');
/**
 * This contains "all" the Kanka-specific data
 */
const kanka = {
    rootUri: 'https://app.kanka.io',
    getUri: (...segments) => [kanka.rootUri, 'w', kanka.campaignID, ...segments].join('/'),
    /**
     * Ye olde CSRF token
     */
    csrfToken: (_b = document.head.querySelector('meta[name="csrf-token"]')) === null || _b === void 0 ? void 0 : _b.getAttribute('content'),
    route,
    campaignID: ((_c = route.match(/w\/(\d+)\//)) !== null && _c !== void 0 ? _c : [null, '0'])[1],
    /**
     *  this is the plural, not values from EntityType
     */
    entityType: ((_d = editButtonLink === null || editButtonLink === void 0 ? void 0 : editButtonLink.match(/\/(\w+)\/\d+\/edit$/)) !== null && _d !== void 0 ? _d : [null, '0'])[1],
    /**
     *  this is the 'larger' ID: entities/__[5328807]__ === characters/1357612
     */
    entityID: ((_e = route.match(/w\/\d+\/entities\/(\d+)/)) !== null && _e !== void 0 ? _e : [null, '0'])[1],
    /**
     * this is the 'smaller' ID: entities/5328807 === characters/__[1357612]__
     */
    typedID: ((_f = editButtonLink === null || editButtonLink === void 0 ? void 0 : editButtonLink.match(/\/(\d+)\/edit$/)) !== null && _f !== void 0 ? _f : [null, '0'])[1],
    meta: parseBodyClasses(document.body),
    /**
     * 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
     */
    entityTypeHasLocation: ({
        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: {},
    }),
};
const identifiers = {
    Sidebar: {
        Class: '.entity-sidebar',
        ProfileClass: '.sidebar-section-profile',
        ProfileElementsID: '#sidebar-profile-elements',
    },
};
const templates = {
    SIDEBAR_PROFILE: () => `
<div class="sidebar-section-box ${identifiers.Sidebar.ProfileClass.slice(1)} overflow-hidden flex flex-col gap-2">
    <div class="sidebar-section-title cursor-pointer text-lg user-select border-b element-toggle" data-animate="collapse" data-target="#sidebar-profile-elements">
        <i class="fa-solid fa-chevron-up icon-show"></i>
        <i class="fa-solid fa-chevron-down icon-hide"></i>
        Profile
    </div>

    <div class="sidebar-elements grid overflow-hidden" id="${identifiers.Sidebar.ProfileElementsID.slice(1)}">
    </div>
</div>`.trim(),
    SELECT_ELEMENT: (dataUrl, placeholder) => `
<select class="form-tags select2"
    style="width: 100%"
    data-url="${dataUrl}"
    data-allow-new="false"
    data-allow-clear="true"
    data-placeholder="${placeholder}"
    data-dropdown-parent="#app"
</select>`.trim(),
    SELECT_ITEM: (text, image) => {
        if (!!image) {
            return $(`
<span class="flex gap-2 items-center text-left">
    <img src="${image}" class="rounded-full flex-none w-6 h-6" />
    <span class="grow">${text}</span>
</span>`.trim());
        }
        return $(`<span>${text}</span>`);
    },
    TAG_SELECT: () => templates.SELECT_ELEMENT(kanka.getUri('search/tags'), 'Apply Tag'),
    TAG_URL: (tagID) => kanka.getUri('tags', tagID),
    TAG_LINK: (tagID, text) => `
<a href="${templates.TAG_URL(tagID)}" title="Refresh to get full tooltip functionality">
    <span class="badge color-tag rounded-sm px-2 py-1">${text}</span>
</a>`.trim(),
    LOCATION_SELECT: () => templates.SELECT_ELEMENT(kanka.getUri('search/locations'), 'Move to...'),
    LOCATION_URL: (locationID) => kanka.getUri('entities', locationID),
    LOCATION_LINK: (locationID, text) => `<a class="name" href="${templates.LOCATION_URL(locationID)}" title="Refresh to get full tooltip functionality">${text}</a>`,
    // TODO - get popper/tippy working to enable preview tooltips 
    // data-toggle="tooltip-ajax" data-id="${locationID}" data-url="${templates.LOCATION_URL(locationID)}/tooltip">
};
/// making my own container for the select to avoid any interference
function createFloatingElement(template) {
    const divID = 'infinite-select2';
    let floatingDiv = document.getElementById(divID)
        || document.createElement('div');
    if (!floatingDiv.id) {
        floatingDiv.id = divID;
        // Add styles to make it float and position it as needed
        floatingDiv.style.position = 'absolute';
        floatingDiv.style.top = '5%';
        floatingDiv.style.left = '41%';
        floatingDiv.style.minWidth = '200px';
        floatingDiv.style.width = '18%';
        floatingDiv.style.maxWidth = '400px';
    }
    floatingDiv.innerHTML = '';
    $(template()).appendTo(floatingDiv);
    document.body.appendChild(floatingDiv);
    return floatingDiv;
}
function createPostParams() {
    const params = new URLSearchParams();
    params.append('_token', kanka.csrfToken);
    params.append('datagrid-action', 'batch');
    // this needs the plural
    params.append('entity', kanka.entityType);
    params.append('mode', 'table');
    // typedID is different from entityID
    params.append('models', kanka.typedID);
    params.append('undefined', '');
    return params;
}
async function fetch_success(response) {
    var _a;
    emit_debug('Success:', response);
    return { ok: response.ok, document: (_a = $.parseHTML(await response.text())) !== null && _a !== void 0 ? _a : [] };
}
function post(url, body) {
    return fetch(url, {
        method: 'POST',
        redirect: 'follow',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body,
    })
        .then(fetch_success)
        .catch((error) => {
        console.error('Error:', error);
        return { ok: false, document: [], error };
    });
}
async function edit(body) {
    // wat da faq
    emit_debug({ edit_data: [...body.entries()] });
    var xhr = new XMLHttpRequest();
    xhr.withCredentials = true;
    xhr.open('POST', kanka.getUri(kanka.entityType, kanka.typedID), false);
    xhr.setRequestHeader('x-csrf-token', kanka.csrfToken);
    xhr.setRequestHeader('x-requested-with', 'XMLHttpRequest');
    xhr.send(body);
    emit_debug({ req: xhr });
    return {
        ok: xhr.status == 200,
        document: $.parseHTML(xhr.responseText),
    };
    return fetch(kanka.getUri(kanka.entityType, kanka.typedID), {
        method: 'POST',
        headers: {
            "x-csrf-token": kanka.csrfToken,
            "x-requested-with": "XMLHttpRequest"
        },
        redirect: 'follow',
        body,
    })
        .then(fetch_success)
        .catch((error) => {
        console.error('Error:', error);
        return { ok: false, document: [], error };
    });
}
/**
 * Reacts when Location is selected via floaty dropdown. Sets the Location of the entity.
 *
 * @param event - The Select2 event object.
 * @returns A promise that resolves to a boolean indicating whether the processing was successful.
 */
async function processLocationSelection(event) {
    const { id: locationID, text } = event.params.data;
    const thisEntityTypeHasLocation = kanka.entityTypeHasLocation[kanka.meta.entity.entityType];
    if (thisEntityTypeHasLocation.multiple) {
        alert('This entity type can have multiple locations. This feature is not yet implemented.');
        /**
         * For the curious, it's because the edit endpoint needs:
         * - the list of typed IDs (which we don't have)
         * - some weird voodoo with XHR that I can't replicate (I get a 405 Method Not Allowed)
         */
        return false;
        const data = new FormData();
        data.append('_token', kanka.csrfToken);
        // this is kinda BS, but it's the cleanest way to get 
        // - the list of typed IDs
        // - the other stuff
        const editable = await fetch(kanka.getUri('creatures', kanka.typedID, 'edit'), {
            method: 'GET',
            headers: { 'Content-Type': 'text/html' }
        })
            .then(fetch_success);
        if (!editable.ok) {
            emit_debug('Error:', editable);
            return false;
        }
        $(editable.document)
            .find('form#entity-form')
            .serializeArray()
            // .filter(kvp => {
            //     if (kvp.value == '') return false;
            //     if (kvp.value == '0') return false;
            //     if (kvp.value == 'inherit') return false;
            // })
            .forEach(kvp => data.append(kvp.name, kvp.value));
        data.append('locations[]', locationID);
        const response = await edit(data);
        if (response.ok) {
            const doc = $(response.document);
            emit_debug({
                header: doc.find('.entity-header'),
                sidebar: doc.find('#sidebar-profile-elements'),
            });
        }
        return response.ok;
    }
    const params = createPostParams();
    params.append('location_id', locationID);
    const response = await post(kanka.getUri('bulk/process'), params);
    if (!response.ok) {
        emit_debug('Error:', response);
        return false;
    }
    const sub = (selector) => {
        $(selector).replaceWith($(response.document).find(selector));
        return $(selector);
    };
    const ensure = (parent, selector, defaultValue) => {
        if ($(selector).length == 0) {
            emit_debug(`adding ${selector} to ${parent}`);
            parent.append(defaultValue);
        }
    };
    if (thisEntityTypeHasLocation.headerLink) {
        // TODO [2024-03-06] - reduce the replacement scope to keep more functionality
        sub('.entity-header')
            .find('.entity-header-sub')
            .blink(3, 125);
    }
    if (thisEntityTypeHasLocation.sidebarLink) {
        const sidebar = $(identifiers.Sidebar.Class);
        // make sure the sidebar has the relevant childrens
        ensure(sidebar, identifiers.Sidebar.ProfileClass, templates.SIDEBAR_PROFILE());
        // an entity might have the sidebar, but not the Profile block
        ensure(sidebar, identifiers.Sidebar.ProfileElementsID, `<div id="${identifiers.Sidebar.ProfileElementsID.slice(1)}"></div>`);
        // and the Profile block may or may not have the Location
        ensure(sidebar.find(identifiers.Sidebar.ProfileElementsID), '.profile-location', `<div class="profile-location"></div>`);
        sub(identifiers.Sidebar.ProfileElementsID)
            .find('.profile-location')
            .blink(3, 125);
    }
    return true;
}
/**
 * Reacts when a Tag is selected via floaty dropdown. Toggles the presence of the tag on the entity.
 *
 * @param event - The Select2 event object.
 * @returns A promise that resolves to a boolean indicating whether the processing was successful.
 */
async function processTagSelection(event) {
    const { id: tagID, text } = event.params.data;
    const params = createPostParams();
    params.append('save-tags', '1');
    params.append('tags[]', tagID);
    const header = $('.entity-header .entity-header-text');
    if (header.has('.entity-tags').length == 0) {
        $('<div class="entity-tags entity-header-line text-xs flex flex-wrap gap-2"></div>')
            .insertBefore(header.find('.header-buttons'));
    }
    const hasTag = !!kanka.meta.tags.find(tag => tag.id == tagID);
    params.append('bulk-tagging', hasTag ? 'remove' : 'add');
    const result = await post(`/w/${kanka.campaignID}/bulk/process`, params);
    const tagBar = header.find('.entity-tags');
    if (result.ok) {
        (hasTag
            ? tagBar.children().remove(`[href="${templates.TAG_URL(tagID)}"]`)
            : tagBar.append($(templates.TAG_LINK(tagID, text))))
            .blink(3, 125);
    }
    return result.ok;
    /*
        // was doing it using the simple 'add entity under tag' API
        // but why not consolidate?
        params.append('entities[]', kanka.meta.entity.id);
        params.append('tag_id', tagID);
    
        post(kanka.getUri('tags', tagID, 'entity-add'), params)
            .then((ok) => ok && tagBar.append($(templates.TAG_LINK(tagID, text))));
    */
}
function initSelector(template, processSelection) {
    const floatingDiv = createFloatingElement(template);
    $(floatingDiv).find('select.select2')
        .each(function () {
        const me = $(this);
        me.select2({
            tags: false,
            placeholder: me.data('placeholder'),
            allowClear: me.data('allowClear') || true,
            language: me.data('language'),
            minimumInputLength: 0,
            dropdownParent: $(me.data('dropdownParent')) || '',
            width: '100%',
            sorter: (data) => {
                const term = $('input.select2-search__field').val().toLowerCase();
                return data.sort(byMatchiness(term));
            },
            ajax: {
                delay: 500, // quiet ms
                url: me.data('url'),
                dataType: 'json',
                data: (params) => { var _a; return ({ q: (_a = params.term) === null || _a === void 0 ? void 0 : _a.trim() }); },
                processResults: (data) => ({ results: data }),
                error: function (jqXHR, textStatus, errorThrown) {
                    if (textStatus === 'abort') {
                        // it does this for the empty field, I think?
                        return;
                    }
                    if (jqXHR.status === 503) {
                        window.showToast(jqXHR.responseJSON.message, 'error');
                    }
                    emit_debug('error', jqXHR, textStatus, errorThrown);
                    return { results: [] };
                },
                cache: true
            },
            templateResult: (item) => templates.SELECT_ITEM(item.text, item.image),
        })
            .on('select2:select', processSelection)
            .on('select2:close', () => {
            setTimeout(() => { $(floatingDiv).remove(); }, 100);
        });
        setTimeout(() => { me.select2('open'); }, 0);
    });
}
function byMatchiness(term) {
    return (a, b) => {
        const textA = a.text.toLowerCase();
        const textB = b.text.toLowerCase();
        // Assign a score based on how well the option matches the search term
        const scoreA = textA === term ? 3 : textA.startsWith(term) ? 2 : textA.includes(term) ? 1 : 0;
        const scoreB = textB === term ? 3 : textB.startsWith(term) ? 2 : textB.includes(term) ? 1 : 0;
        // Sort by score. If the scores are equal, sort alphabetically
        return scoreB - scoreA || textA.localeCompare(textB);
    };
}
/**
 * Map the keybinds to the handlers
 */
const handlers = {
    [keybinds.LABEL]: function (evt, combo) {
        initSelector(templates.TAG_SELECT, processTagSelection);
    },
    [keybinds.MOVE]: function (evt, combo) {
        initSelector(templates.LOCATION_SELECT, processLocationSelection);
    },
    [keybinds.HELP]: function (evt, combo) {
        // TODO show a modal describing the keybinds
    },
};
(function () {
    if (!document.body.className.includes('kanka-entity-')) {
        return;
    }
    for (const key in handlers) {
        mousetrap_1.default.bind(key, handlers[key]);
    }
    emit_debug({ kanka });
})();


/***/ }),

/***/ 802:
/***/ ((module) => {

module.exports = Mousetrap;

/***/ })

/******/ 	});
/************************************************************************/
/******/ 	// The module cache
/******/ 	var __webpack_module_cache__ = {};
/******/ 	
/******/ 	// The require function
/******/ 	function __webpack_require__(moduleId) {
/******/ 		// Check if module is in cache
/******/ 		var cachedModule = __webpack_module_cache__[moduleId];
/******/ 		if (cachedModule !== undefined) {
/******/ 			return cachedModule.exports;
/******/ 		}
/******/ 		// Create a new module (and put it into the cache)
/******/ 		var module = __webpack_module_cache__[moduleId] = {
/******/ 			// no module.id needed
/******/ 			// no module.loaded needed
/******/ 			exports: {}
/******/ 		};
/******/ 	
/******/ 		// Execute the module function
/******/ 		__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/ 	
/******/ 		// Return the exports of the module
/******/ 		return module.exports;
/******/ 	}
/******/ 	
/************************************************************************/
/******/ 	
/******/ 	// startup
/******/ 	// Load entry module and return exports
/******/ 	// This entry module is referenced by other modules so it can't be inlined
/******/ 	var __webpack_exports__ = __webpack_require__(519);
/******/ 	
/******/ })()
;