Kanka Mention Previewer

Adds the ability to load a preview of any mentioned entity in a modal.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Kanka Mention Previewer
// @namespace    http://tampermonkey.net/
// @license      MIT
// @version      0.8
// @description  Adds the ability to load a preview of any mentioned entity in a modal.
// @author       Salvatos
// @match        https://app.kanka.io/*
// @icon         https://www.google.com/s2/favicons?domain=kanka.io
// @grant        GM_addStyle
// @connect      kanka.io
// ==/UserScript==

// Get campaign ID from URL
const campaignID = window.location.href.match(/w\/(.+)/)[1].split("/")[0];

// Create empty dialog to be populated
const MentionPreviewer = `
 <dialog id="MentionPreviewer" class="box-entity-entry">
   <div class="box-header with-border">
    <h1 class="box-title"></h1>
    <div class="box-tools">
     <form method="dialog">
      <button class="bg-gray">Close</button>
     </form>
    </div>
   </div>
   <section id="MentionPreviewer-content">
    <article id="MentionPreviewer-entry" class="box-body entity-content"></article>
    <aside id="MentionPreviewer-attributes"></aside>
   </section>
 </dialog>
`;
document.querySelector('body').insertAdjacentHTML("afterbegin", MentionPreviewer);
let PreviewDialog = document.getElementById('MentionPreviewer');

// Add listener to close the dialog when clicking outside of it
PreviewDialog.addEventListener("click", function (event) {
  if (event.target === PreviewDialog) {
    PreviewDialog.close();
  }
});

// Process each mention at page load, excluding entity tags in headers
$( "a[data-toggle='tooltip-ajax']:not([data-tag-slug])" ).each(runThroughMentions);

// On pages with child lists, e.g. organisation members, we need to watch for the list to be loaded in
if (document.getElementById("datagrid-parent")) {
    function setObserver() {
        // Set and run the observer until datagrid is loaded in
        let observer = new MutationObserver(function(mutations) {
            if ($('#datagrid-parent tr').length) {
                observer.disconnect();

                // Process each mention in the table body
                $( "#datagrid-parent tbody a[data-toggle='tooltip-ajax']" ).each(runThroughMentions);

                // Restart observer if more pages exist
                if ($('#datagrid-parent .pagination').length) {
                    setObserver();
                }
            }
        });

        observer.observe(document.getElementById("datagrid-parent"), {attributes: false, childList: true, characterData: false, subtree:false});
    }

    // Run at page load
    setObserver();
}

function runThroughMentions () {
    let $this = $(this); // optimization (don’t create a new $(object) every time)
    var apiURL = `https://app.kanka.io/w/${campaignID}/entities/` + $this.attr("data-id") + `/json-export`;
    var mentionURL = $this.attr("href");
    var mentionTags = $this.attr("data-entity-tags");

    /*
    // Clone the mention to destroy its native event listeners
    var thisMention = $this.clone().insertAfter( $this );
    $this.remove();

    // Prepare the dropdown of options
    let dropdown = `
		<ul class="MentionPreviewer-options dropdown-menu dropdown-menu-right" role="menu">`+
			<li>
				<a href="${mentionURL}" title="">
					<i class="fa-solid fa-square-arrow-up-right"></i> Open entity
				</a>
			</li>
			<li>
				<a href="${mentionURL}" target="_blank" title="">
					<i class="fa-solid fa-arrow-up-right-from-square"></i> Open entity in new tab
				</a>
			</li>
			<li>
				<a class="MentionPreviewer-loader" title="">
					<i class="fa-solid fa-eye"></i> View entry in modal
				</a>
			</li>
		</ul>
    */

    // Prepare the loader icon
    const loader = `<i class="fa-solid fa-magnifying-glass-arrow-right MentionPreviewer-loader" title="View entry in modal"></i>`;
    // In entity grid view, wrap the mention and loader together as one child of the flexbox
    if ($this.parent('.entities-grid')) {
        $this.wrap( "<span></span>" );
    }
    // Attach loader
    $this.after( $( loader ) );

    // Add event listener to each mention loader
    $this.next(".MentionPreviewer-loader").click(function(event) {
        //event.preventDefault();
        // Add or update target entity tags for custom styling
        PreviewDialog.setAttribute("data-entity-tags", mentionTags);
        // Remove title, header image and attributes if present
        document.querySelector('#MentionPreviewer .box-title').innerHTML = "";
        document.querySelector('#MentionPreviewer .box-header').classList.remove("has-header");
        document.querySelector('#MentionPreviewer .box-header').style.backgroundImage = "";
        document.getElementById('MentionPreviewer-attributes').innerHTML = "";
        // Display loading message
        document.getElementById('MentionPreviewer-entry').innerHTML = "<em>Loading entry...</em>";
        // Open modal if closed
        if (!PreviewDialog.open) {
            PreviewDialog.showModal();
        }

        // Request JSON for the target entity
        var xhr = new XMLHttpRequest();
        xhr.open("GET", apiURL, true);
        xhr.responseType = 'json';
        xhr.onload = function (e) {
            if (xhr.readyState === 4) {
                if (xhr.status === 200) {
                    let entityName = xhr.response.data.name,
                    entityPortrait = xhr.response.data.image_full,
                    entityHeader = xhr.response.data.header_full,
                    entityEntryParsed = xhr.response.data.entry_parsed,
                    entityRawAttributes = xhr.response.data.attributes,
                    entityNotes = xhr.response.data.posts;

                    // Prepend portrait to pinned attributes, if present
                    var entityPinnedAttributes = (entityPortrait) ? `<a href="${entityPortrait}" class="portrait"><img src="${entityPortrait}" /></a>` : "";

                    // Format pinned attributes
                    for (let i = 0; i < entityRawAttributes.length; i++) {
                        if (entityRawAttributes[i].is_star === true) {
                            // For range attributes, strip the range syntax out of the name
                            if (entityRawAttributes[i].name.match(/\[range:/)) {
                                entityRawAttributes[i].name = entityRawAttributes[i].name.replace(/\[range:.*\]/, '');
                            }
                            entityPinnedAttributes += `<dt>${entityRawAttributes[i].name}</dt><dd>${entityRawAttributes[i].parsed}</dd>`;
                        }
                    }
                    /* I could get pinned relations too, but I only have the ID so I would need another xhr for each to get their names... Seems excessive.
                    // Prepare pinned relations
                    for (let i = 0; i < entityRawRelations.length; i++) {
                        if (entityRawRelations[i].is_star === true) {
                            entityPinnedAttributes += `<dt>${entityRawRelations[i].relation}</dt><dd>${entityRawRelations[i].target_id}...</dd>`;
                        }
                    }
                    */

                    // Links and files could also be considered

                    // Wrap attributes block if not empty
                    entityPinnedAttributes = (entityPinnedAttributes == "") ? "" : `<dl>${entityPinnedAttributes}</dl>`;

                    // Prepare posts
                    let postsParsed = "";
                    for (let i = 0; i < entityNotes.length; i++) {
                        postsParsed += `
                        <hr />
                        <div class="entity-note">
							<h2 class="box-title">${entityNotes[i].name}</h2>
							<div class="entity-content entity-note-body">${entityNotes[i].entry_parsed}</div>
						</div>`;
                    }
                    let entityEntries = (entityEntryParsed) ? entityEntryParsed + postsParsed : postsParsed;

                    // Replace dialog title with current mention
                    document.querySelector('#MentionPreviewer .box-title').innerHTML = `<a href="${mentionURL}" class="entity-name">${entityName}</a>`;
                    // Replace dialog title background with header image if present
                    PreviewDialog.style.setProperty('--header-image', "url("+entityHeader+")");
                    //document.querySelector('#MentionPreviewer .box-header .box-title').style.backgroundImage = (entityHeader.length > 0) ? `url("${entityHeader}")` : "";
                    if (entityHeader.length > 0) { document.querySelector('#MentionPreviewer .box-header').classList.add("has-header"); }
                    // Replace dialog content with current entry
                    document.getElementById('MentionPreviewer-entry').innerHTML = (entityEntries) ? entityEntries : "";
                    // Replace dialog content with current attributes
                    document.getElementById('MentionPreviewer-attributes').innerHTML = `${entityPinnedAttributes}`;
                    // If attributes are shown, enable 2-column layout
                    document.getElementById('MentionPreviewer-content').className = (entityPinnedAttributes.length > 0 || entityPortrait) ? "with-attributes" : "";

                    // Run through the entry’s mentions to add loaders inside the modal
                    $( "#MentionPreviewer" ).find( "a[data-toggle='tooltip-ajax']" ).each(runThroughMentions);

                    // Request tooltips for the new mentions
                    $('#MentionPreviewer [data-toggle="tooltip"]').tooltip();
                    unsafeWindow.ajaxTooltip(); // (unsafeWindow is how you use window. functions in scripts where @grant is not set to 'none'
                } else {
                    console.error(xhr.statusText);
                }
            }
        };
        xhr.onerror = function (e) {
            console.error(xhr.statusText);
        };
        xhr.send(null);

        // In nested entity lists, prevent the trigger from drilling down to child entities
        event.stopPropagation();
    });
}

GM_addStyle ( `
    /* Keep loader on same line as transcluded entity title */
    .mention-field-entry .entity-mention-name {
        display: inline-block;
    }
    .MentionPreviewer-loader {
        font-size: 11px;
        margin-left: 3px;
        cursor: pointer;
    }
	.entities-grid .MentionPreviewer-loader {
    	float: right;
    	margin-right: 5px;
    	margin-top: -15px;
        transform: translateY(15px); /* bit of an odd trick to keep it in the corner of the tile without screwing with the flexbox’s proportions */
	}
    /* Modal */
    #MentionPreviewer[open] ~ #app {
        filter: grayscale(0.3) blur(1px);
    }
    #MentionPreviewer {
	    width: auto;
        max-width: 80vw;
	    max-height: 90vh;
	    background-color: #222;
	    color: var(--theme-main-text);
        padding: 0;
        border-radius: 10px;
	    border: 2px solid var(--theme-border);
        overflow: visible; /* For mention tooltips; hopefully this doesn’t break other things */
    }

	/* header */
    #MentionPreviewer .box-header {
        display: grid;
        grid-template-columns: 1fr auto;
        justify-items: center;
        align-items: center;
        height: 80px;
        background: var(--content-wrapper-background); /* To ensure a default background on this without overriding the value for the rest of the campaign */
    }
    #MentionPreviewer .box-header.has-header {
        background-image: var(--header-image);
        background-size: 100%;
        background-position-y: center;
        height: 120px;
    }
    .box-header::before, .box-header::after {
		display: none;
	}

	/* title */
    #MentionPreviewer .box-title .entity-name {
        text-align: center;
    	font-size: 25px;
    }
    #MentionPreviewer .has-header .box-title .entity-name {
    	font-size: 30px;
        color: #f9f9f9;
    	text-shadow: #111 2px 2px 5px;
    	background-image: radial-gradient(#1e1e1e6b, #60605c00, #56595600);
    	padding: 5px 25px;
    }

	/* close button */
    #MentionPreviewer .box-header > .box-tools {
    	align-self: start;
    	margin-top: 5px;
    }
    #MentionPreviewer button {
        float: right;
		margin: 5px;
		padding: 0 5px;
		border-radius: 5px;
        height: min-content;
	}
    #MentionPreviewer button:hover {
		background: #eee !important;
        color: #444 !important;
	}

	/* main content */
    #MentionPreviewer-content {
        padding: 5px 15px;
        background: var(--box-background);
        max-height: calc(90vh - 80px);
        overflow: auto;
    }
    .has-header + #MentionPreviewer-content {
        max-height: calc(90vh - 150px);
    }
    #MentionPreviewer-content.with-attributes {
        display: grid;
        grid-auto-flow: column;
        grid-template-columns: auto 200px;
        justify-content: space-between;
        grid-gap: 10px;
    }

    /* posts */
    h2.box-title {
	    text-decoration: underline;
    }

	/* attributes */
    #MentionPreviewer-content.with-attributes .portrait img {
        width: calc(100% + 10px);
        margin-bottom: 15px;
    }
    .with-attributes #MentionPreviewer-attributes {
        border-left: 2px solid gray;
        padding: 10px;
    }
    #MentionPreviewer-attributes dd {
        margin-bottom: 5px;
    }
    @media print {
        i.MentionPreviewer-loader {
            display: none;
        }
    }
` );