Skip to content

Instantly share code, notes, and snippets.

@gibson042
Last active September 8, 2023 21:52
Show Gist options
  • Save gibson042/6e42cb4341f6a308af5e8515cceb3d5a to your computer and use it in GitHub Desktop.
Save gibson042/6e42cb4341f6a308af5e8515cceb3d5a to your computer and use it in GitHub Desktop.
Confluence Fixer user script
// ==UserScript==
// @name Confluence Fixer
// @namespace https://github.com/gibson042
// @description Fixes Confluence annoyances (inline comment jumps and page reloads, stacked notifications, hidden anchors and excerpts, etc.).
// @source https://gist.github.com/gibson042/6e42cb4341f6a308af5e8515cceb3d5a
// @updateURL https://gist.github.com/gibson042/6e42cb4341f6a308af5e8515cceb3d5a/raw/confluence-fixer.user.js
// @downloadURL https://gist.github.com/gibson042/6e42cb4341f6a308af5e8515cceb3d5a/raw/confluence-fixer.user.js
// @version 0.6.0
// @date 2023-09-08
// @author Richard Gibson <@gmail.com>
// ==/UserScript==
//
// **COPYRIGHT NOTICE**
//
// To the extent possible under law, the author(s) have dedicated all copyright
// and related and neighboring rights to this software to the public domain
// worldwide. This software is distributed without any warranty.
// For the CC0 Public Domain Dedication, see
// <https://creativecommons.org/publicdomain/zero/1.0/>.
//
// **END COPYRIGHT NOTICE**
//
//
// Changelog:
// 0.6.0 (2023-09-08)
// * New: CC0 Public Domain Dedication.
// 0.5.3 (2022-03-21)
// * Improved: Better user script manager compatibility.
// 0.5.2 (2022-01-27)
// * Fixed: Missing return value from compareNodes.
// 0.5.1 (2022-01-15)
// * Fixed: Corrected CSS typos.
// 0.5.0 (2022-01-14)
// * New: Catch and override show-comment page reloads with in-place content replacement.
// 0.4.1 (2021-04-12)
// * Improved: Added styling to indicate embedded excerpt foreign content (which blocks inline comments).
// 0.4.0 (2020-03-31)
// * New: Removed notification stacking to allow reviewing and visiting new comments in arbitrary order.
// 0.3.0 (2020-03-16)
// * New: Added a fix for inline comment jumping.
// 0.2.0 (2018-07-09)
// * New: Added styling to make anchors visible, since Stylish can't be trusted.
// 0.1.0 (2018-06-26)
// original release
(function() {
"use strict";
const ID="gibson042-confluence-fixer",
DEFAULT_PORTS={"http:": 80, "https:": 443},
D=document, L=location,
AEL="addEventListener", AC="appendChild", QS="querySelector", QSA=QS+"All",
CDP_DIS=Node.DOCUMENT_POSITION_DISCONNECTED, CDP_BEFORE=Node.DOCUMENT_POSITION_PRECEDING;
D.head.insertAdjacentHTML('beforeend', `
<style type="text/css" class="${ID}">
/* Make anchors visible. */
.confluence-anchor-link[id][data-hasbody="false"]::before {
color: black;
background: #eee;
font-weight: bold;
content: '⚓';
}
.confluence-anchor-link[id][data-hasbody="false"]:hover::before {
font-style: italic;
content: '⚓ #' attr(id);
}
.confluence-anchor-link[id][data-hasbody="false"]::after {
content: ' ';
}
/* Indicate embedded excerpt foreign content (which blocks inline comments). */
div[data-macro-name="multiexcerpt-include"], div[data-contains-macro-name~="excerpt-include"],
span[data-macro-name="multiexcerpt-include"], span[data-contains-macro-name~="excerpt-include"] {
/* Fix rendering for <span>s that contain block-level content. */
display: block;
position: relative;
/* background-color matches .aui-message-error */
background-color: #ffebe6;
border: 1px solid red !important;
box-shadow: 5px 5px 3px silver;
}
div[data-macro-name="multiexcerpt-include"]::before, div[data-contains-macro-name~="excerpt-include"]::before,
span[data-macro-name="multiexcerpt-include"]::before, span[data-contains-macro-name~="excerpt-include"]::before {
position: absolute;
left: -3px;
margin-top: -0.7em;
border: 1px dashed slategray;
padding: 0 0.5ex 2px;
font: italic small-caps smaller/100% sans-serif;
color: darkslategray;
background: lightgoldenrodyellow;
content: "embedded content";
}
/* Allow menus to cover notifications. */
#action-menu {
z-index: 5000;
}
/* Improve unread notification visibility. */
#mw-container .mw-notification-item.unread {
background-color: hsl(216deg 66% 92%);
}
/* Unstack floating notifications. */
#aui-flag-container > .aui-flag-stack > .aui-flag {
position: static !important;
margin-bottom: 0 !important;
transform: none !important;
}
/* ...and correspondingly update appearance/behavior. */
#aui-flag-container {
pointer-events: auto;
user-select: none;
/* Limit size. */
max-height: calc(max(19em, 33vh));
overflow: auto;
/* Provide better visual boundaries. */
border-left: 1px solid hsl(0deg 0% 67% / 75%);
box-shadow: hsl(0deg 0% 67% / 50%) 5px 5px 7px;
/* Further reduce space consumption. */
transform-origin: right top;
transform: translateX(15px) perspective(100vh) translateZ(-30vh) rotateX(18deg);
transition: transform 0.5s;
}
#aui-flag-container:active {
/* Flatten on mousedown. */
transition-delay: 0.1s;
transform: none;
}
/* Improve the notification "leave" transition. */
#aui-flag-container > .aui-flag-stack > .aui-flag[aria-hidden=true] {
opacity: inherit;
max-height: 0;
filter: contrast(0%) brightness(150%);
transition: max-height .5s .5s, filter 1s;
}
</style>
`);
// Fix bad links with an invalid href hostname "localhost".
D[AEL]("mouseover", function( { target: a } ) {
try {
const url = new URL(a.href);
if( url.hostname === "localhost" ) {
a.href = Object.assign(url, {
protocol: L.protocol,
host: L.host,
// A portless host doesn't override port, so make that explicit.
port: L.port || DEFAULT_PORTS[L.protocol]
});
}
} catch ( x ) {}
}, true);
// Bring comments back into view after bad navigation.
D[AEL]("click", function(evt) {
// Abort if the target element is not a comment navigation button.
let navButton = evt.target.closest("#ic-nav-next, #ic-nav-previous");
if ( !navButton ) return;
if ( navButton.closest("#ic-display-comment-view, #ic-comment-container") == null ) {
for ( let el = navButton; el; el = el.parentNode ) {
if ( el === D ) return;
}
}
// Bring the new current navigation button into view with `focus()`
// and/or `scrollIntoView({block: "center"})`.
setTimeout(() => {
navButton = D.getElementById(navButton.id);
navButton.scrollIntoView({block: "center", behavior: "smooth"});
navButton.focus();
}, 100);
});
// Add attributes to the relevant enclosing ancestor of each excerpt-include marker.
// (`<span class="conf-macro output-inline" data-hasbody="false" data-macro-name="excerpt-include"> </span>`)
let mutationQueue = null;
function onMutation ( mutations ) {
const isEntryPoint = mutationQueue == null;
if ( isEntryPoint ) mutationQueue = [];
try {
// Enqueue mutations that add content.
mutationQueue.push(...mutations.filter(m => m.addedNodes.length > 0));
// In case of reentrancy, abort after adding new mutations to the queue.
if ( !isEntryPoint ) return;
for ( const mutation of mutationQueue ) {
for ( const elMarker of mutation.target[QSA](".output-inline[data-macro-name='excerpt-include']") ) {
const elContainer = elMarker.parentNode.closest("div, span");
if ( !elContainer ) continue;
const curList = (elContainer.getAttribute("data-contains-macro-name") || "").match(/[^ ]+/g) || [];
if ( curList.includes("excerpt-include") ) continue;
elContainer.setAttribute("data-contains-macro-name", curList.concat("excerpt-include").join(" "));
}
}
} finally {
if ( isEntryPoint ) mutationQueue = null;
}
}
(new MutationObserver(onMutation)).observe(D.body, {childList: true, subtree: true});
// Catch and override show-comment page reloads by making them special in-page navigations.
must((...args)=>require(...args))(["confluence/meta","confluence/api/event"], must((meta,events)=>{
if(D[ID]) return;
D[ID]=true;
const getUrl=meta.Links.canonical,
reHijack=RegExp(`^##${ID}=(?<u>[^#]*?[?&]focusedCommentId=(?<commentId>.*?)(&|#|$)|[?&](?<q>.*)|.*)`);
meta.Links.canonical=()=>`##${ID}=`;
let click={timeStamp:-Infinity, screenX:0, screenY:0, detail:0}, elCloseable;
D[AEL]("click", evt=>{
/*force reload on quintuple-click*/
if (evt.timeStamp - click.timeStamp >= 300 ||
evt.screenX != click.screenX || evt.screenY != click.screenY) {
for(let k in click) click[k]=evt[k];
click.detail=1;
} else {
click.timeStamp=evt.timeStamp;
if (++click.detail==5) reloadContent();
}
/*capture a clicked closeable*/
if(elCloseable=evt.target.closest(".aui-message.closeable")) setTimeout(()=>{elCloseable=null}, 150);
});
window[AEL]("hashchange", must(()=>{
const elTarget=elCloseable, m=reHijack.exec(L.hash)?.groups;
if(!m) return;
if(m.commentId!=null){
history.back();
reloadContent().then(()=>{
events.trigger("qr:show-new-thread", m.commentId);
elTarget?.querySelector(".icon-close,.aui-close-button")?.click();
}).catch(panic);
}else if(m.q!=null){
L.replace(getUrl.call(meta.Links).replace(/\?.*|$/, q => q ? q+"&" : "?") + m.q);
}else{
L.replace(m.u);
}
}));
}));
// reloadContent reloads a Confluence page in-place,
// picking up new inline comments without destroying new-comment notifications.
async function reloadContent(){
const M=D[QS]("#main-content"),
tmp=D.createDocumentFragment()[AC](make("html")),
resp=await fetch(L.href);
if(!resp.ok) throw {message:`HTTP ${resp.status} (${resp.statusText})`, response:resp};
tmp.innerHTML=await resp.text();
/*replace old content*/
for(let s of ["#main-content", ".page-metadata"]) D[QS](s).innerHTML=tmp[QS](s).innerHTML;
/*refresh TOC*/
M[QSA](".client-side-toc-macro").forEach(el=>{
if(/\S/.test(el.textContent)) return;
let list=el[AC](make("ul")), li, depth=1;
M[QSA](el.getAttribute("data-headerelements") || "H1,H2,H3").forEach(h=>{
const hD=+h.nodeName.slice(1);
while(hD>depth++){ if(!li) li=list[AC](make("li", {className:"toc-empty-item"})); list=li[AC](make("ul")); li=null; }
while(hD<(--depth)) list=list.parentNode.parentNode;
li=list[AC](make("li"));
li[AC](make("a", {className:"toc-link", href:"#"+h.id})).textContent=h.textContent;
});
});
/*reload diagrams etc.*/
[...M[QSA]("script")].forEach(el=>{
const el2=make("script", {text: el.text});
for(let a of el.attributes) el2.setAttributeNodeNS(a.cloneNode());
console.log(ID, "evaluating script", el);
el.replaceWith(el2);
});
/*fix expand/collapse macros*/
const toggleConfig={
".expand-control": [
{ classes: ["right", "down"].map(s => "aui-iconfont-chevron-"+s) },
{ closest: ".expand-container", elements: ":scope > .expand-content", classes: ["expand-hidden"] },
],
".codeHeader .collapse-source": [
{
closest: ".conf-macro.code,[data-macro-name=code],[data-hasbody=true]",
elements: ".expand-control-icon,.collapsed,.expanded",
classes: ["collapsed", "expanded"],
forceIndex: elC=>+elC.querySelector(".hide-toolbar")?.classList.toggle("show-border-top"),
},
],
};
Object.entries(toggleConfig).forEach(([sel, rules]) => {
M[QSA](sel).forEach(elToggle=>{
elToggle.style.display="";
elToggle[AEL]("click", evt=>{
for (let {closest, elements, classes, forceIndex} of rules) {
const elC = closest ? elToggle.closest(closest) : elToggle,
classIndex = elC && forceIndex ? forceIndex(elC, elToggle) : null;
if (!elC) return;
for (let el of elC[QSA](elements ?? "."+classes.join(",."))) {
classes.forEach((c,i)=>el.classList.toggle(...[c].concat(classIndex==null ? [] : i==classIndex)));
}
}
});
});
});
/*reactivate syntax highlighting*/
try { window.SyntaxHighlighter.highlight(); }catch(ex){}
/*fix comment-to-DOM refs*/
const commentCaches=[...new Set(Object.values(Backbone._events).flat().map(v=>v?.ctx?.collection).filter(c=>c?.getCommentsOnPage))];
commentCaches.flatMap(c=>c.models).forEach(o=>o.highlight && o._setHighlights(o.highlight.attr("data-ref")));
/*load new comments*/
await Promise.all(commentCaches.map(c =>
c.fetch({cache:false,remove:false,merge:false}).then(()=>{
c.models.sort((a,b)=>compareNodes(a.highlight?.[0], b.highlight?.[0]))
})
));
}
/*helpers*/
function compareNodes(a,b){ const bPos=a?.compareDocumentPosition(b || a) || CDP_DIS; return a===b ? 0 : (bPos & CDP_DIS)===0 ? (bPos & CDP_BEFORE) || -1 : !a - !b; }
function make(name,props={}){ return Object.assign(D.createElement(name), props); }
function must(fn){ return function(){ try{ return fn.apply(this,arguments) }catch(ex){ panic(ex) } }; }
function panic(err){ console.error(err); alert(err.message); throw err; }
})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment