// ==UserScript==
// @name Confluence Plus
// @namespace https://docs.scriptcat.org/
// @version 1.0.0
// @description Enhance your Confluence experience with extra features.
// @author Shu Su
// @homepage https://scriptcat.org/en/script-show-page/6271
// @homepageURL https://scriptcat.org/en/script-show-page/6271
// @match https://*.atlassian.net/wiki/*
// @connect *
// @icon data:image/x-icon;base64,AAABAAEAQEAAAAEAIAAoQAAAFgAAACgAAABAAAAAgAAAAAEAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANdqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANdqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADXahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADXahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADXahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADXahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANdqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/wAAAADXahn/AAAAAAAAAAAAAAAAAAAAAAAAAADXahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ////////////////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/wAAAAAAAAAAAAAAAAAAAAAAAAAA12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf//////////////////////////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf8AAAAAAAAAAAAAAAAAAAAA12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ///////////////////////////////////////////////////////Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/AAAAAAAAAAAAAAAAAAAAANdqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf///////////////////////////////////////////////////////////9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/////////////////////////////////////////////////Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/wAAAAAAAAAAAAAAAAAAAADXahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn//////////////////////////////////////////////////////////////////////9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ////////////////////////////////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf8AAAAAAAAAAAAAAADXahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/////////////////////////////////////////////////////////////////////////////////9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/////////////////////////////////////////////////////////////////9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/AAAAAAAAAADXahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/////////////////////////////////////////////////////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn////////////////////////////////////////////////////////////////////////////Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ///////////////////////////////////////////////////////////////////////////////////////Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf//////////////////////////////////////////////////////////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn////////////////////////////////////////////////////////////////////////////////////////////////////////////Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ////////////////////////////////////////////////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ////////////////////////////////////////////////////////////////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf//////12oZ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/////////////////////////////////////////////////////////////////////////////////////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf//////////////////////////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/////////////////////////////////////////////////////////////////////////////////9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/////////////////////////////////////////////////9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/////////////////////////////////////////////////////////////////////////////////9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/////////////////////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf//////////////////////////////////////////////////////////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/////////////////////////////////////////////////9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf8AAAAAAAAAANdqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn////////////Xahn////////////////////////////////////////////Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/////////////////////////////////Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf8AAAAAAAAAAAAAAADXahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ///////Xahn/12oZ/9dqGf///////////////////////////9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/////////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/AAAAAAAAAAAAAAAA12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ////////////12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/AAAAAAAAAAAAAAAAAAAAAAAAAADXahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/wAAAAAAAAAAAAAAAAAAAAAAAAAA12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADXahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADXahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANdqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANdqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADXahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADXahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/9dqGf/Xahn/12oZ/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_info
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/********************************************************
* CONFIG
********************************************************/
const images = {
plugin:
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgcj0iMjgiIGZpbGw9IiMwYzY2ZTQiLz48cGF0aCBkPSJtMjAgNDQgMjEtMjEiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSI2IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48cGF0aCBkPSJNMTggMThsMiA1IDUgMi01IDItMiA1LTItNS01LTIgNS0yWk00NSAzOWwxLjUgMy41TDUwIDQ0bC0zLjUgMS41TDQ1IDQ5bC0xLjUtMy41TDQwIDQ0bDMuNS0xLjVaIiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTM2IDE4aDh2OCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg==',
configChevron:
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTcgMTBsNSA1IDUtNSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIi8+PC9zdmc+',
};
const windowIcons = {
maximize: ``,
restore: ``,
close: ``,
};
const Config = {
storageKey: 'confluence_plus_state',
api: {
v1Path: '/wiki/rest/api',
v2Path: '/wiki/api/v2',
},
request: {
timeout: 15000,
},
ai: {
defaultApiVersion: '2025-03-01-preview',
defaultModelsApiVersion: '2024-10-21',
},
ui: {
defaultWidth: 420,
defaultHeight: 700,
minWidth: 320,
minHeight: 400,
},
settings: {
defaultLanguage: 'system',
defaultTheme: 'system',
defaultFloatingButtonRight: 120,
minFloatingButtonRight: 0,
maxFloatingButtonRight: 1000,
},
};
/********************************************************
* UTILS
********************************************************/
const Utils = {
extractPageId(url = location.href) {
const source = String(url || '');
const match = source.match(/\/pages\/(\d+)/i) ||
source.match(/\/page\/(\d+)/i) ||
source.match(/[?]pageId=(\d+)/i) ||
source.match(/[?]homepageId=(\d+)/i);
if (match) {
return match[1];
}
try {
const queryPageId = new URL(source, location.href).searchParams.get(
'homepageId'
);
if (/^\d+$/.test(queryPageId || '')) {
return queryPageId;
}
} catch {
// Fall back to the metadata lookup below.
}
// Confluence also exposes the current content id in page metadata on
// some routes where the URL itself does not contain /pages/{id}.
const meta = document.querySelector(
'meta[name="ajs-page-id"], meta[name="ajs-content-id"]'
);
const dataElement = document.querySelector(
'[data-page-id], [data-content-id]'
);
const fallback = meta?.content ||
dataElement?.getAttribute('data-page-id') ||
dataElement?.getAttribute('data-content-id') ||
'';
return /^\d+$/.test(fallback) ? fallback : '';
},
isValidPageId(pageId) {
return /^\d+$/.test(pageId);
},
isBlogPostUrl(url = location.href) {
try {
const pathname = new URL(url, location.href).pathname;
return /\/spaces\/[^/]+\/blog(?:\/|$)/i.test(pathname);
} catch {
return /\/spaces\/[^/]+\/blog(?:\/|$)/i.test(String(url || ''));
}
},
debounce(fn, delay = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
},
};
/********************************************************
* LOGGER
********************************************************/
const Logger = {
info(event, data) {
if (typeof data === 'undefined') {
console.info(`[Confluence Plus] ${event}`);
return;
}
console.info(`[Confluence Plus] ${event}`, data);
},
warn(event, data) {
console.warn(`[Confluence Plus] ${event}`, data);
},
error(event, error) {
console.error(`[Confluence Plus] ${event}`, error);
},
};
/********************************************************
* STORAGE
********************************************************/
const Storage = {
loadData() {
try {
const raw = GM_getValue(Config.storageKey, '{}');
const data = JSON.parse(raw);
// Remove credentials written by pre-session versions.
if (data.auth) {
delete data.auth;
GM_setValue(
Config.storageKey,
JSON.stringify(data)
);
}
return data;
} catch (error) {
Logger.error('storage.load-failed', error);
return {};
}
},
loadUi() {
const ui = this.loadData().ui || {};
// Only the panel size is persistent. Menu state and position
// intentionally reset for every page visit.
const result = {};
if (typeof ui.width === 'number') {
result.width = ui.width;
}
if (typeof ui.height === 'number') {
result.height = ui.height;
}
return result;
},
saveUi(ui) {
const data = this.loadData();
data.ui = {
width: ui.width,
height: ui.height,
};
GM_setValue(
Config.storageKey,
JSON.stringify(data)
);
},
loadAi() {
return this.loadData().ai || {};
},
saveAi(ai) {
const data = this.loadData();
data.ai = ai;
GM_setValue(
Config.storageKey,
JSON.stringify(data)
);
},
clearAi() {
const data = this.loadData();
delete data.ai;
GM_setValue(
Config.storageKey,
JSON.stringify(data)
);
},
loadSettings() {
return this.loadData().settings || {};
},
saveSettings(settings) {
const data = this.loadData();
data.settings = settings;
GM_setValue(Config.storageKey, JSON.stringify(data));
},
};
/********************************************************
* SETTINGS
********************************************************/
const Settings = (() => {
const validLanguages = ['system', 'en', 'zh-CN'];
const validThemes = ['system', 'light', 'dark'];
const defaults = {
version: 1,
general: {
language: Config.settings.defaultLanguage,
theme: Config.settings.defaultTheme,
floatingButtonRight: Config.settings.defaultFloatingButtonRight,
},
modules: {
content: {},
},
};
const cloneDefaults = () => JSON.parse(JSON.stringify(defaults));
function normalizeRight(value) {
const number = Number(value);
if (!Number.isFinite(number)) {
return Config.settings.defaultFloatingButtonRight;
}
return Math.min(
Config.settings.maxFloatingButtonRight,
Math.max(Config.settings.minFloatingButtonRight, Math.floor(number))
);
}
function normalize(source = {}) {
const base = cloneDefaults();
const general = source.general || {};
base.general.language = validLanguages.includes(general.language)
? general.language
: base.general.language;
base.general.theme = validThemes.includes(general.theme)
? general.theme
: base.general.theme;
base.general.floatingButtonRight = normalizeRight(
general.floatingButtonRight
);
base.modules.content = {
...(source.modules?.content || {}),
};
return base;
}
let current = normalize(Storage.loadSettings());
function save() {
Storage.saveSettings(current);
}
return {
validLanguages,
validThemes,
get() {
return JSON.parse(JSON.stringify(current));
},
updateGeneral(key, value) {
if (key === 'language' && validLanguages.includes(value)) {
current.general.language = value;
} else if (key === 'theme' && validThemes.includes(value)) {
current.general.theme = value;
} else if (key === 'floatingButtonRight') {
current.general.floatingButtonRight = normalizeRight(value);
} else {
return this.get();
}
save();
return this.get();
},
resolveLanguage(value = current.general.language) {
return value === 'zh-CN' ||
(value === 'system' && /^zh(?:-|$)/i.test(navigator.language || ''))
? 'zh-CN'
: 'en';
},
reset() {
current = cloneDefaults();
save();
},
};
})();
const localeMessages = {
en: {
settings: 'Confluence Plus settings',
configure: 'Configure Confluence Plus',
close: 'Close',
general: 'General',
contentModule: 'Content',
content: 'Content',
language: 'Language',
languageDescription: 'Choose the plugin display language.',
theme: 'Theme',
themeDescription: 'Light, dark, or follow system.',
floatingPosition: 'Floating button position',
floatingPositionDescription: 'Distance from the right edge of the screen.',
system: 'System',
english: 'English',
chinese: '中文',
light: 'Light',
dark: 'Dark',
pixels: 'px',
noSettings: 'No settings available yet.',
aiConfiguration: 'AI Configuration',
aiConfigurationDescription: 'Configure Azure OpenAI for Confluence content features.',
aiStatusNotConfigured: 'Not configured',
aiStatusConfigured: 'Configured',
aiStatusAvailable: 'Configured',
aiStatusUnavailable: 'Unavailable',
apiEndpoint: 'API endpoint',
apiEndpointDescription: 'For example: https://your-resource.openai.azure.com/',
apiKey: 'API Key',
apiVersion: 'API version',
apiVersionDescription: 'Azure OpenAI API version used for chat completions.',
apiKeyPlaceholder: 'Enter Azure OpenAI API Key',
show: 'Show',
hide: 'Hide',
modelDeployment: 'Deployment',
modelDeploymentDescription: 'Enter the deployment name used by Azure OpenAI.',
modelDeploymentPlaceholder: 'Enter a deployment name',
tokenParameter: 'Token parameter',
tokenParameterDescription: 'Newer reasoning models use max_completion_tokens; older models use max_tokens.',
maxCompletionTokens: 'max_completion_tokens',
maxTokens: 'max_tokens',
notTested: 'Connection has not been tested.',
connecting: 'Connecting to Azure OpenAI using the specified deployment…',
testConnection: 'Test connection',
testConnectionTooltip: 'Test the connection using the specified deployment.',
clear: 'Clear',
missingEndpointOrKey: 'Endpoint and API Key are required.',
deploymentRequired: 'Enter a deployment name first.',
deploymentVerificationFailed: 'Deployment verification returned an invalid response.',
deploymentVerified: 'Deployment connection succeeded.',
contentToolTitle: 'Content Management Tool',
contentToolSubtitle: 'Use Tool to manage the content',
spacePageTree: 'Space Page Tree',
spacePageTreeDescription: 'Browse and expand the complete page tree of the current Space.',
generateLabel: 'Generate Label',
generateLabelDescription: 'Analyze the current page and suggest labels using the configured AI.',
aiConfigurationRequired: 'AI configuration required:',
currentSpaceIcon: 'Current Space icon',
leftClick: 'Left-click: Open feature window',
rightClick: 'Right-click: Open configuration window',
maximizeWindow: 'Maximize window',
restoreWindow: 'Restore window',
closeWindow: 'Close window',
openPage: 'Open page',
loadingSpaceTree: 'Loading Space page tree…',
reloadSpaceTree: 'Reload the Space page tree',
collapseChildren: 'Collapse children',
expandChildren: 'Expand children',
childPages: '{{count}} child page(s)',
currentPageBadge: 'Current',
noPagesInSpace: 'No pages found in this Space.',
loadedPages: 'Loaded {{count}} page(s)',
loadedPagesFromCache: 'Loaded {{count}} page(s) from session cache',
generateSuggestionsForPage: 'Generate suggestions for the current page',
pageContentSentToAi: 'The page title and content will be sent to the configured Azure OpenAI endpoint.',
generateLabelSuggestions: 'Analyze the current page and generate label suggestions',
generateSuggestions: 'Generate Suggestions',
recommendedLabelsFor: 'Recommended Labels for {{title}}',
recommendedLabels: 'Recommended Labels',
addLabelManually: 'Add a label manually',
add: 'Add',
applySelectedLabels: 'Apply Selected Labels',
applySelectedLabelsTitle: 'Add the selected labels to the current Confluence page',
removeLabel: 'Remove label',
applyLabelsQuestion: 'Apply labels?',
cancel: 'Cancel',
apply: 'Apply',
selectNewValidLabel: 'Select at least one new valid label',
applyingLabels: 'Applying labels…',
addedLabelsRefreshing: 'Added {{count}} label(s). Refreshing page…',
loadingAndGenerating: 'Loading page content and generating suggestions…',
configureAiBeforeLabels: 'Configure AI before generating labels',
generatedSuggestions: 'Generated {{count}} suggestion(s)',
noNewLabelSuggestions: 'No new label suggestions were generated',
labelsCount: '{{count}} label(s): {{labels}}',
aiConfigurationRequiredError: 'Configure and save a valid AI configuration first.',
aiLabelResponseMissing: 'AI did not return recognizable Label suggestions.',
aiLabelResponseInvalid: 'AI returned invalid JSON for the Label suggestions.',
invalidPageId: 'The current page does not have a valid Confluence Page ID.',
missingSpaceId: 'The current page does not have a recognizable Space ID.',
missingSpaceInfo: 'The current Space does not have a recognizable name or Space Key.',
endpointHttpsRequired: 'Azure OpenAI endpoint must use HTTPS.',
unsupportedV1Endpoint: 'Azure OpenAI /openai/v1 endpoint is not supported yet; use an Azure resource endpoint.',
endpointMustNotContainCredentials: 'Endpoint must not contain credentials, query parameters, or a hash.',
endpointRequired: 'Enter an Azure OpenAI endpoint.',
apiKeyRequired: 'Enter an Azure OpenAI API key.',
apiVersionRequired: 'API version cannot be empty.',
azureNonJsonResponse: 'Azure OpenAI returned a non-JSON response.',
azureCrossOriginRequestFailed: 'Azure OpenAI cross-origin request failed.',
azureApiTimedOut: 'Azure OpenAI API request timed out.',
gmRequestUnavailable: 'The UserScript cross-origin request API is unavailable. Grant the script GM_xmlhttpRequest permission.',
confluenceNonJsonResponse: 'Confluence returned a non-JSON response; you may need to sign in again.',
confluenceApiTimedOut: 'Confluence API request timed out.',
},
'zh-CN': {
settings: 'Confluence Plus 设置',
configure: '配置 Confluence Plus',
close: '关闭',
general: '常规',
contentModule: '内容',
content: '内容',
language: '语言',
languageDescription: '选择插件显示语言。',
theme: '主题',
themeDescription: '浅色、深色或跟随系统。',
floatingPosition: '浮动按钮位置',
floatingPositionDescription: '距离屏幕右侧的距离。',
system: '系统',
english: 'English',
chinese: '中文',
light: '浅色',
dark: '深色',
pixels: 'px',
noSettings: '暂时没有可配置项。',
aiConfiguration: 'AI 配置',
aiConfigurationDescription: '配置 Azure OpenAI,以供 Confluence 内容功能调用。',
aiStatusNotConfigured: '未配置',
aiStatusConfigured: '已配置',
aiStatusAvailable: '已配置',
aiStatusUnavailable: '不可用',
apiEndpoint: 'API 端点',
apiEndpointDescription: '例如:https://your-resource.openai.azure.com/',
apiKey: 'API Key',
apiVersion: 'API 版本',
apiVersionDescription: '用于 Chat Completions 的 Azure OpenAI API 版本。',
apiKeyPlaceholder: '输入 Azure OpenAI API Key',
show: '显示',
hide: '隐藏',
modelDeployment: '部署',
modelDeploymentDescription: '输入 Azure OpenAI 使用的部署名称。',
modelDeploymentPlaceholder: '请输入部署名称',
tokenParameter: 'Token 数量参数',
tokenParameterDescription: '较新的推理模型使用 max_completion_tokens,旧模型使用 max_tokens。',
maxCompletionTokens: 'max_completion_tokens',
maxTokens: 'max_tokens',
notTested: '尚未测试连接。',
connecting: '正在使用指定的部署连接 Azure OpenAI…',
testConnection: '测试连接',
testConnectionTooltip: '使用指定的部署测试连接。',
clear: '清除',
missingEndpointOrKey: 'Endpoint 和 API Key 不能为空。',
deploymentRequired: '请先输入部署名称。',
deploymentVerificationFailed: '部署验证返回了无效响应。',
deploymentVerified: '部署连接成功。',
contentToolTitle: '内容管理工具',
contentToolSubtitle: '使用工具管理内容',
spacePageTree: 'Space 页面树',
spacePageTreeDescription: '浏览并展开当前 Space 的完整页面树。',
generateLabel: '生成 Label',
generateLabelDescription: '使用已配置的 AI 分析当前页面并生成标签建议。',
aiConfigurationRequired: '需要 AI 配置:',
currentSpaceIcon: '当前 Space 图标',
leftClick: '左键:打开功能窗口',
rightClick: '右键:打开配置窗口',
maximizeWindow: '最大化窗口',
restoreWindow: '还原窗口',
closeWindow: '关闭窗口',
openPage: '打开页面',
loadingSpaceTree: '正在加载 Space 页面树…',
reloadSpaceTree: '重新加载 Space 页面树',
collapseChildren: '收起子页面',
expandChildren: '展开子页面',
childPages: '{{count}} 个子页面',
currentPageBadge: '当前页面',
noPagesInSpace: '当前 Space 中没有找到页面。',
loadedPages: '已加载 {{count}} 个页面',
loadedPagesFromCache: '已从会话缓存加载 {{count}} 个页面',
generateSuggestionsForPage: '为当前页面生成建议',
pageContentSentToAi: '页面标题和内容将发送到已配置的 Azure OpenAI 端点。',
generateLabelSuggestions: '分析当前页面并生成标签建议',
generateSuggestions: '生成建议',
recommendedLabelsFor: '{{title}} 的推荐标签',
recommendedLabels: '推荐标签',
addLabelManually: '手动添加标签',
add: '添加',
applySelectedLabels: '应用选中的标签',
applySelectedLabelsTitle: '将选中的标签添加到当前 Confluence 页面',
removeLabel: '移除标签',
applyLabelsQuestion: '应用标签?',
cancel: '取消',
apply: '应用',
selectNewValidLabel: '至少选择一个有效的新标签',
applyingLabels: '正在应用标签…',
addedLabelsRefreshing: '已添加 {{count}} 个标签,正在刷新页面…',
loadingAndGenerating: '正在加载页面内容并生成建议…',
configureAiBeforeLabels: '生成标签前请先配置 AI',
generatedSuggestions: '已生成 {{count}} 条建议',
noNewLabelSuggestions: '未生成新的标签建议',
labelsCount: '{{count}} 个 Label:{{labels}}',
aiConfigurationRequiredError: '请先配置并保存可用的 AI 配置。',
aiLabelResponseMissing: 'AI 未返回可识别的 Label 建议。',
aiLabelResponseInvalid: 'AI 返回的 Label 建议不是有效 JSON。',
invalidPageId: '当前页面没有有效的 Confluence Page ID。',
missingSpaceId: '当前页面没有可识别的 Space ID。',
missingSpaceInfo: '当前 Space 没有可识别的名称或 Space Key。',
endpointHttpsRequired: 'Azure OpenAI 端点必须使用 HTTPS。',
unsupportedV1Endpoint: '暂不支持 Azure OpenAI /openai/v1 端点,请使用 Azure resource endpoint。',
endpointMustNotContainCredentials: 'Endpoint 不应包含账号、查询参数或 hash。',
endpointRequired: '请输入 Azure OpenAI Endpoint。',
apiKeyRequired: '请输入 Azure OpenAI API Key。',
apiVersionRequired: 'API Version 不能为空。',
azureNonJsonResponse: 'Azure OpenAI 返回了非 JSON 响应。',
azureCrossOriginRequestFailed: 'Azure OpenAI 跨域请求失败。',
azureApiTimedOut: 'Azure OpenAI API 请求超时。',
gmRequestUnavailable: 'UserScript 跨域请求接口不可用,请确认脚本已授予 GM_xmlhttpRequest 权限。',
confluenceNonJsonResponse: 'Confluence 返回了非 JSON 响应,可能需要重新登录。',
confluenceApiTimedOut: 'Confluence API 请求超时。',
},
};
const getLocaleText = (key, variables = {}) => {
const language = Settings.resolveLanguage();
const text = localeMessages[language]?.[key] || key;
return text.replace(/\{\{(\w+)\}\}/g, (_, name) => variables[name] ?? '');
};
/********************************************************
* AI CONFIGURATION
********************************************************/
const AIConfig = (() => {
const defaults = {
schemaVersion: 2,
endpoint: '',
apiKey: '',
apiVersion: Config.ai.defaultApiVersion,
modelsApiVersion: Config.ai.defaultModelsApiVersion,
deployment: '',
deployments: [],
tokenParameter: 'max_completion_tokens',
lastCheck: null,
};
// Start with the current schema intentionally; no legacy AI configuration is migrated.
const stored = Storage.loadAi();
let config = stored?.schemaVersion === defaults.schemaVersion
? {
...defaults,
...stored,
deployments: Array.isArray(stored.deployments)
? [...stored.deployments]
: [],
}
: JSON.parse(JSON.stringify(defaults));
if (stored?.schemaVersion !== defaults.schemaVersion) {
Storage.saveAi(config);
}
function normalizeEndpoint(endpoint) {
const value = endpoint.trim().replace(/\/+$/, '');
const url = new URL(value);
if (url.protocol !== 'https:') {
throw new Error(getLocaleText('endpointHttpsRequired'));
}
if (/\/openai\/v1\/?$/i.test(url.pathname)) {
throw new Error(getLocaleText('unsupportedV1Endpoint'));
}
if (url.username || url.password || url.search || url.hash) {
throw new Error(getLocaleText('endpointMustNotContainCredentials'));
}
return url.origin + url.pathname.replace(/\/+$/, '');
}
function validate(candidate) {
const next = {
...config,
...candidate,
};
if (!next.endpoint) {
throw new Error(getLocaleText('endpointRequired'));
}
if (!next.apiKey) {
throw new Error(getLocaleText('apiKeyRequired'));
}
next.endpoint = normalizeEndpoint(next.endpoint);
next.apiVersion = next.apiVersion.trim();
next.modelsApiVersion = next.modelsApiVersion.trim();
next.deployment = next.deployment.trim();
next.tokenParameter = ['max_tokens', 'max_completion_tokens'].includes(next.tokenParameter)
? next.tokenParameter
: defaults.tokenParameter;
if (!next.apiVersion || !next.modelsApiVersion) {
throw new Error(getLocaleText('apiVersionRequired'));
}
return next;
}
return {
get() {
return { ...config, deployments: [...config.deployments] };
},
validate,
save(candidate) {
const previous = config;
const next = validate(candidate);
const changed =
previous.endpoint !== next.endpoint ||
previous.apiKey !== next.apiKey ||
previous.apiVersion !== next.apiVersion ||
previous.deployment !== next.deployment ||
previous.tokenParameter !== next.tokenParameter;
const hasExplicitHealth =
Object.prototype.hasOwnProperty.call(
candidate,
'lastCheck'
);
config = {
...next,
lastCheck: hasExplicitHealth
? next.lastCheck
: changed
? null
: next.lastCheck || null,
};
Storage.saveAi(config);
return this.get();
},
updateDraft(candidate = {}) {
const next = {
...config,
...candidate,
endpoint: String(candidate.endpoint ?? config.endpoint ?? '').trim(),
apiKey: String(candidate.apiKey ?? config.apiKey ?? '').trim(),
apiVersion: String(candidate.apiVersion ?? config.apiVersion ?? defaults.apiVersion).trim(),
modelsApiVersion: String(candidate.modelsApiVersion ?? config.modelsApiVersion ?? defaults.modelsApiVersion).trim(),
deployment: String(candidate.deployment ?? config.deployment ?? '').trim(),
tokenParameter: ['max_tokens', 'max_completion_tokens'].includes(candidate.tokenParameter)
? candidate.tokenParameter
: config.tokenParameter || defaults.tokenParameter,
deployments: Array.isArray(candidate.deployments)
? [...candidate.deployments]
: [...config.deployments],
};
if (next.endpoint) {
try {
next.endpoint = normalizeEndpoint(next.endpoint);
} catch {
next.endpoint = '';
}
}
const healthChanged =
next.endpoint !== config.endpoint ||
next.apiKey !== config.apiKey ||
next.apiVersion !== config.apiVersion ||
next.deployment !== config.deployment ||
next.tokenParameter !== config.tokenParameter;
config = {
...next,
lastCheck: healthChanged ? null : next.lastCheck || null,
};
Storage.saveAi(config);
return this.get();
},
recordHealth(ok, message, metadata = {}) {
config = {
...config,
lastCheck: {
ok,
message,
endpoint: config.endpoint,
deployment: config.deployment,
apiVersion: config.apiVersion,
tokenParameter: config.tokenParameter,
checkedAt: Date.now(),
...metadata,
},
};
Storage.saveAi(config);
},
getStatus() {
if (!config.endpoint || !config.apiKey || !config.deployment) {
return {
key: 'not-configured',
label: getLocaleText('aiStatusNotConfigured'),
};
}
if (!config.lastCheck ||
config.lastCheck.endpoint !== config.endpoint ||
config.lastCheck.deployment !== config.deployment ||
config.lastCheck.apiVersion !== config.apiVersion ||
config.lastCheck.tokenParameter !== config.tokenParameter) {
return {
key: 'unverified',
label: getLocaleText('aiStatusConfigured'),
};
}
return config.lastCheck.ok
? {
key: 'available',
label: getLocaleText('aiStatusConfigured'),
}
: {
key: 'unavailable',
label: getLocaleText('aiStatusUnavailable'),
};
},
clear() {
config = { ...defaults };
Storage.clearAi();
},
isConfigured() {
return Boolean(config.endpoint && config.apiKey);
},
};
})();
/********************************************************
* STORE
********************************************************/
const Store = (() => {
const state = {
context: {
pageId: '',
url: location.href,
},
ui: {
x: 120,
y: 120,
width: Config.ui.defaultWidth,
height: Config.ui.defaultHeight,
minimized: true,
maximized: false,
},
};
const listeners = [];
const persistUi = Utils.debounce(() => {
Storage.saveUi(state.ui);
});
function notify(type, value) {
listeners.forEach((listener) => {
listener(type, value, state);
});
}
return {
init() {
Object.assign(state.ui, Storage.loadUi());
},
getContext() {
return state.context;
},
getUi() {
return state.ui;
},
setContext(context) {
state.context = {
...state.context,
...context,
};
notify('context', state.context);
},
updateUi(patch) {
Object.assign(state.ui, patch);
persistUi();
notify('ui', state.ui);
},
resetPanelSession() {
Object.assign(state.ui, {
x: 120,
y: 120,
minimized: true,
maximized: false,
});
notify('ui', state.ui);
},
subscribe(listener) {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
if (index >= 0) {
listeners.splice(index, 1);
}
};
},
};
})();
/********************************************************
* ROUTER / PAGE CONTEXT
********************************************************/
const Router = (() => {
let lastUrl = '';
function getContext() {
const url = location.href;
return {
url,
pageId: Utils.extractPageId(url),
};
}
function emitIfChanged() {
const context = getContext();
if (context.url === lastUrl) {
return;
}
lastUrl = context.url;
// Reset before publishing the new route so the panel cannot keep
// displaying tree data loaded for the previous Space.
Store.resetPanelSession();
Store.setContext(context);
Logger.info('route.changed', context);
}
function init() {
const rawPushState = history.pushState;
const rawReplaceState = history.replaceState;
history.pushState = function (...args) {
rawPushState.apply(this, args);
setTimeout(emitIfChanged, 50);
};
history.replaceState = function (...args) {
rawReplaceState.apply(this, args);
setTimeout(emitIfChanged, 50);
};
window.addEventListener('popstate', emitIfChanged);
emitIfChanged();
}
return {
init,
getContext,
};
})();
/********************************************************
* HTTP CLIENT
********************************************************/
const Http = {
requestViaManager(url, options = {}) {
const headers = {
Accept: 'application/json',
...(options.headers || {}),
};
const logRequest = options.logLabel
? {
method: options.method || 'GET',
url,
headers: Object.fromEntries(
Object.entries(headers).map(([key, value]) => [
key,
/api-key/i.test(key) ? '[REDACTED]' : value,
])
),
body: options.body,
}
: null;
if (logRequest) {
console.info(`[Confluence Plus] ${options.logLabel} request`, logRequest);
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: options.method || 'GET',
url,
data: options.body,
headers,
timeout: Config.request.timeout,
onload(response) {
if (logRequest) {
console.info(`[Confluence Plus] ${options.logLabel} response`, {
status: response.status,
responseText: response.responseText,
});
}
if (response.status < 200 || response.status >= 300) {
const detail = (response.responseText || '')
.replace(/\s+/g, ' ')
.slice(0, 500);
reject(new Error(
`HTTP ${response.status}: ${detail}`
));
return;
}
if (response.status === 204) {
resolve(null);
return;
}
try {
resolve(JSON.parse(response.responseText));
} catch {
reject(new Error(
getLocaleText('azureNonJsonResponse')
));
}
},
onerror() {
reject(new Error(getLocaleText('azureCrossOriginRequestFailed')));
},
ontimeout() {
reject(new Error(getLocaleText('azureApiTimedOut')));
},
});
});
},
async request(url, options = {}) {
if (options.crossOrigin) {
if (typeof GM_xmlhttpRequest !== 'function') {
throw new Error(getLocaleText('gmRequestUnavailable'));
}
// Never fall back to window.fetch for Azure OpenAI requests: that would
// reintroduce the browser CORS restriction this manager bypasses.
return this.requestViaManager(url, options);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, Config.request.timeout);
try {
const response = await fetch(url, {
...options,
credentials: options.credentials || 'same-origin',
signal: controller.signal,
headers: {
Accept: 'application/json',
...(options.headers || {}),
},
});
if (!response.ok) {
const text = await response.text();
const detail = text.replace(/\s+/g, ' ').slice(0, 500);
throw new Error(
`HTTP ${response.status}: ${detail}`
);
}
if (response.status === 204) {
return null;
}
const contentType =
response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
throw new Error(
getLocaleText('confluenceNonJsonResponse')
);
}
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
throw new Error(getLocaleText('confluenceApiTimedOut'));
}
throw error;
} finally {
clearTimeout(timeoutId);
}
},
get(url, options = {}) {
return this.request(url, {
method: 'GET',
...options,
});
},
post(url, body, options = {}) {
return this.request(url, {
...options,
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
},
};
/********************************************************
* AZURE OPENAI API SERVICES
********************************************************/
const AzureOpenAI = {
isV1Endpoint(endpoint) {
return /\/openai\/v1$/i.test(endpoint);
},
getUrl(endpoint, path, apiVersion) {
if (this.isV1Endpoint(endpoint)) {
return `${endpoint}${path}`;
}
const query = new URLSearchParams({
'api-version': apiVersion,
});
return `${endpoint}${path}?${query}`;
},
getHeaders(apiKey) {
return {
'api-key': apiKey,
Accept: 'application/json',
};
},
async verifyDeployment(config) {
const response = await Http.post(
this.getChatCompletionsUrl(config),
{
messages: [
{
role: 'user',
content: 'Return JSON exactly {"ok":true}.',
},
],
response_format: { type: 'json_object' },
[config.tokenParameter]: 16,
},
{
crossOrigin: true,
credentials: 'omit',
logLabel: 'AI Test Connection',
headers: this.getHeaders(config.apiKey),
}
);
if (!response || typeof response !== 'object') {
throw new Error(getLocaleText('deploymentVerificationFailed'));
}
return response;
},
async testConnection(candidate) {
const config = AIConfig.validate(candidate);
if (!config.deployment) {
throw new Error(getLocaleText('deploymentRequired'));
}
await this.verifyDeployment(config);
return {
deployments: [{ id: config.deployment, label: config.deployment }],
deploymentCount: 1,
message: getLocaleText('deploymentVerified'),
};
},
getChatCompletionsUrl(config) {
const path = this.isV1Endpoint(config.endpoint)
? '/chat/completions'
: `/openai/deployments/${encodeURIComponent(config.deployment)}/chat/completions`;
return this.getUrl(config.endpoint, path, config.apiVersion);
},
getResponsesUrl(config) {
const path = this.isV1Endpoint(config.endpoint)
? '/responses'
: `/openai/deployments/${encodeURIComponent(config.deployment)}/responses`;
return this.getUrl(config.endpoint, path, config.apiVersion);
},
extractChatOutputText(response) {
return response?.choices?.[0]?.message?.content || '';
},
extractOutputText(response) {
if (typeof response.output_text === 'string') {
return response.output_text;
}
const messages = response.output || [];
const texts = [];
messages.forEach((item) => {
(item.content || []).forEach((content) => {
if (content.type === 'output_text' && content.text) {
texts.push(content.text);
}
});
});
return texts.join('\n');
},
async suggestLabels({ title, content, existingLabels }) {
const config = AIConfig.get();
if (!AIConfig.isConfigured() || !config.deployment) {
throw new Error(getLocaleText('aiConfigurationRequiredError'));
}
const prompt = [
'Recommend 3 to 10 concise Confluence labels for this page.',
'Return JSON only in the shape {"labels":["label-a"]}.',
'Use lowercase letters, numbers, and hyphens only.',
'Do not include existing labels or explanations.',
`Existing labels: ${(existingLabels || []).join(', ') || '(none)'}`,
`Title: ${title || '(untitled)'}`,
'Page content:',
content.slice(0, 24000),
].join('\n\n');
const response = await Http.post(
this.getChatCompletionsUrl(config),
{
messages: [
{
role: 'user',
content: prompt,
},
],
response_format: { type: 'json_object' },
[config.tokenParameter]: 6000,
},
{
crossOrigin: true,
credentials: 'omit',
headers: {
...this.getHeaders(config.apiKey),
},
}
);
const output = this.extractChatOutputText(response);
const match = output.match(/\{[\s\S]*\}/);
if (!match) {
throw new Error(getLocaleText('aiLabelResponseMissing'));
}
let parsed;
try {
parsed = JSON.parse(match[0]);
} catch {
throw new Error(getLocaleText('aiLabelResponseInvalid'));
}
return Array.isArray(parsed.labels) ? parsed.labels : [];
},
};
/********************************************************
* CONFLUENCE API SERVICES
********************************************************/
const ConfluenceApi = {
getV1Url(path) {
return `${location.origin}${Config.api.v1Path}${path}`;
},
getV2Url(path) {
return `${location.origin}${Config.api.v2Path}${path}`;
},
folders: {
get(folderId) {
return Http.get(
ConfluenceApi.getV2Url(
`/folders/${encodeURIComponent(folderId)}`
)
);
},
},
spaces: {
get(spaceId) {
return Http.get(
ConfluenceApi.getV2Url(
`/spaces/${encodeURIComponent(spaceId)}`
)
);
},
async getByKey(spaceKey) {
const query = new URLSearchParams({
keys: spaceKey,
limit: '1',
});
const data = await Http.get(
`${ConfluenceApi.getV2Url('/spaces')}?${query}`
);
return Array.isArray(data.results) ? data.results[0] : null;
},
},
pages: {
get(pageId) {
return Http.get(
ConfluenceApi.getV2Url(`/pages/${pageId}`)
);
},
getForAi(pageId) {
const query = new URLSearchParams({
expand: 'body.storage,metadata.labels',
});
return Http.get(
`${ConfluenceApi.getV1Url(`/content/${pageId}`)}?${query}`
);
},
addLabels(pageId, labels) {
return Http.post(
ConfluenceApi.getV1Url(`/content/${pageId}/label`),
labels.map((name) => ({
prefix: 'global',
name,
}))
);
},
getChildren(pageId) {
return Http.get(
ConfluenceApi.getV2Url(
`/pages/${pageId}/children`
)
);
},
getSpacePages(spaceId, cursor = '') {
const query = new URLSearchParams({
limit: '250',
});
if (cursor) {
query.set('cursor', cursor);
}
return Http.get(
`${ConfluenceApi.getV2Url(
`/spaces/${encodeURIComponent(spaceId)}/pages`
)}?${query}`
);
},
},
pageStates: {
get(pageId) {
return Http.get(
ConfluenceApi.getV1Url(
`/content/${pageId}/state`
)
);
},
},
};
/********************************************************
* AI LABEL SERVICE
********************************************************/
const AILabelService = {
normalize(label) {
return String(label || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
},
getExistingLabels(page) {
return (page.metadata?.labels?.results || [])
.map((item) => item.name)
.filter(Boolean);
},
async generate(pageId) {
const page = await ConfluenceApi.pages.getForAi(pageId);
const existingLabels = this.getExistingLabels(page);
const body = page.body?.storage?.value || '';
const suggestions = await AzureOpenAI.suggestLabels({
title: page.title,
content: body,
existingLabels,
});
const existing = new Set(
existingLabels.map((label) => this.normalize(label))
);
const unique = [];
suggestions.forEach((label) => {
const normalized = this.normalize(label);
if (
normalized &&
!existing.has(normalized) &&
!unique.includes(normalized)
) {
unique.push(normalized);
}
});
return {
page,
existingLabels,
suggestions: unique.slice(0, 10),
};
},
};
/********************************************************
* SPACE TREE SERVICE
********************************************************/
const SpaceTreeService = {
getNextCursor(data) {
const directCursor =
data.meta?.cursor || data.cursor || '';
if (directCursor) {
return directCursor;
}
const next = data._links?.next || data.links?.next;
if (!next) {
return '';
}
try {
return new URL(next, location.origin)
.searchParams.get('cursor') || '';
} catch {
return '';
}
},
loadFromSidebar(currentPageId) {
const containers = [
...document.querySelectorAll(
'[role="tree"], nav, aside, [data-testid*="sidebar" i], [data-testid*="tree" i]'
),
];
let best = null;
let bestScore = 0;
containers.forEach((container) => {
const links = [...container.querySelectorAll(
'a[href*="/pages/"]'
)];
const pageLinks = links.filter((link) => {
const id = Utils.extractPageId(link.href);
return id && link.getClientRects().length;
});
const containsCurrent = pageLinks.some(
(link) => Utils.extractPageId(link.href) === String(currentPageId)
);
const score = pageLinks.length + (containsCurrent ? 1000 : 0);
if (score > bestScore) {
best = {
container,
links: pageLinks,
};
bestScore = score;
}
});
if (!best || !best.links.length) {
return [];
}
const items = [];
const seen = new Set();
const stack = [];
best.links.forEach((link) => {
const id = Utils.extractPageId(link.href);
if (!id || seen.has(id)) return;
seen.add(id);
const treeItem = link.closest('[role="treeitem"]');
const ariaLevel = treeItem?.getAttribute('aria-level');
let depth = ariaLevel ? Number(ariaLevel) - 1 : NaN;
if (!Number.isFinite(depth)) {
depth = 0;
let listItem = link.closest('li');
while (listItem && best.container.contains(listItem)) {
depth += 1;
listItem = listItem.parentElement?.closest('li');
}
depth = Math.max(0, depth - 1);
}
depth = Math.max(0, depth);
stack.length = Math.min(stack.length, depth);
const title = (
link.getAttribute('data-item-title') ||
link.getAttribute('aria-label') ||
link.textContent ||
'(Untitled)'
).trim();
const parent = depth > 0 ? stack[depth - 1] : null;
const item = {
id,
title,
parentId: parent ? parent.id : null,
sidebarDepth: depth,
};
items.push(item);
stack[depth] = item;
stack.length = depth + 1;
});
return items;
},
async load(spaceId) {
const pages = [];
let cursor = '';
do {
const data = await ConfluenceApi.pages.getSpacePages(
spaceId,
cursor
);
const results = Array.isArray(data.results)
? data.results
: [];
pages.push(...results);
cursor = this.getNextCursor(data);
} while (cursor);
// The pages endpoint does not include folder entities. Pages whose
// parentType is folder still expose the folder id, so load those
// folder records separately and merge them into the tree.
const folders = new Map();
const pendingFolderIds = new Set(
pages
.filter((page) => page.parentType === 'folder' && page.parentId)
.map((page) => String(page.parentId))
);
while (pendingFolderIds.size) {
const folderId = pendingFolderIds.values().next().value;
pendingFolderIds.delete(folderId);
if (folders.has(folderId)) continue;
try {
const folder = await ConfluenceApi.folders.get(folderId);
if (!folder?.id) continue;
const normalizedFolder = {
...folder,
type: 'folder',
id: String(folder.id),
parentId: folder.parentId || null,
parentType: folder.parentType || 'page',
};
folders.set(normalizedFolder.id, normalizedFolder);
if (
normalizedFolder.parentType === 'folder' &&
normalizedFolder.parentId
) {
pendingFolderIds.add(String(normalizedFolder.parentId));
}
} catch (error) {
Logger.warn('space-tree.folder-load-failed', {
folderId,
error,
});
}
}
return [...pages, ...folders.values()];
},
buildTree(pages, currentPageId, spaceRootTitle) {
const normalizedSpaceName = (spaceRootTitle || '').trim();
const spaceRoot = pages.find((page) => (
(page.title || '').trim() === normalizedSpaceName
));
const allowedIds = new Set();
if (spaceRoot) {
allowedIds.add(String(spaceRoot.id));
let changed = true;
while (changed) {
changed = false;
pages.forEach((page) => {
const parentId = page.parentId || page.parent?.id;
if (
parentId &&
allowedIds.has(String(parentId)) &&
!allowedIds.has(String(page.id))
) {
allowedIds.add(String(page.id));
changed = true;
}
});
}
}
const nodes = new Map();
pages.forEach((page, index) => {
const id = String(page.id);
if (!allowedIds.has(id)) return;
const parentId = page.id === spaceRoot?.id
? null
: page.parentId || page.parent?.id || null;
const rawPosition = page.position ??
page.positionInParent ??
page.order ??
page.ordinal ??
page.sortOrder;
const position = Number(rawPosition);
nodes.set(id, {
id,
title: page.title || '(Untitled)',
parentId: parentId ? String(parentId) : null,
children: [],
page,
position: Number.isFinite(position) ? position : null,
sourceIndex: index,
});
});
const roots = [];
nodes.forEach((node) => {
const parent = node.parentId
? nodes.get(node.parentId)
: null;
if (parent && parent.id !== node.id) {
parent.children.push(node);
} else {
roots.push(node);
}
});
const sortNodes = (items) => {
items.sort((a, b) => {
if (a.position !== null && b.position !== null) {
return a.position - b.position || a.sourceIndex - b.sourceIndex;
}
if (a.position !== null) return -1;
if (b.position !== null) return 1;
return a.sourceIndex - b.sourceIndex;
});
items.forEach((item) => sortNodes(item.children));
};
sortNodes(roots);
const expanded = new Set();
let current = nodes.get(String(currentPageId));
while (current) {
expanded.add(current.id);
current = current.parentId
? nodes.get(current.parentId)
: null;
}
return {
roots,
nodes,
expanded,
filteredCount: nodes.size,
};
},
};
/********************************************************
* CONTENT SESSION CACHE
********************************************************/
const ContentSessionCache = (() => {
const prefix = `confluence-plus:session:v1:${location.origin}:`;
function read(name) {
try {
const value = sessionStorage.getItem(`${prefix}${name}`);
return value ? JSON.parse(value) : null;
} catch (error) {
Logger.warn('session-cache.read-failed', { name, error });
return null;
}
}
function write(name, value) {
try {
sessionStorage.setItem(`${prefix}${name}`, JSON.stringify(value));
} catch (error) {
Logger.warn('session-cache.write-failed', { name, error });
}
}
function remove(name) {
try {
sessionStorage.removeItem(`${prefix}${name}`);
} catch (error) {
Logger.warn('session-cache.remove-failed', { name, error });
}
}
function scopedName(scope, id) {
return `${scope}:${encodeURIComponent(String(id))}`;
}
return {
getSpaceTree(spaceId) {
return read(scopedName('space-tree', spaceId));
},
saveSpaceTree(spaceId, value) {
write(scopedName('space-tree', spaceId), value);
},
clearSpaceTree(spaceId) {
remove(scopedName('space-tree', spaceId));
},
getLabels(pageId) {
return read(scopedName('labels', pageId));
},
saveLabels(pageId, value) {
write(scopedName('labels', pageId), value);
},
clearLabels(pageId) {
remove(scopedName('labels', pageId));
},
};
})();
/********************************************************
* UI PANEL
********************************************************/
const UI = (() => {
let root;
let unsubscribe;
let overlay;
let contextMenu;
let adoStyleFab;
let adoStyleConfig;
let themeMediaQuery;
let themeChangeHandler;
let contentFeatureUi = {
spaceTreeOpen: false,
aiLabelsOpen: false,
};
let lastViewport = {
width: window.innerWidth,
height: window.innerHeight,
};
function applyLayout() {
if (!root) return;
const ui = Store.getUi();
root.classList.toggle('confluence-plus-expanded', !ui.minimized);
root.style.position = 'fixed';
root.style.zIndex = '999999';
if (ui.minimized) {
root.style.left = '0px';
root.style.top = '0px';
root.style.right = 'auto';
root.style.bottom = 'auto';
return;
}
if (ui.maximized) {
// Use an explicit border-box size so the resize observer can tell
// the true fullscreen geometry from a user resize.
root.style.left = '0px';
root.style.top = '0px';
root.style.right = 'auto';
root.style.bottom = 'auto';
root.style.width = `${window.innerWidth}px`;
root.style.height = `${window.innerHeight}px`;
return;
}
root.style.width = `${ui.width}px`;
root.style.height = `${ui.height}px`;
root.style.right = 'auto';
root.style.bottom = 'auto';
root.style.left = `${ui.x}px`;
root.style.top = `${ui.y}px`;
}
function centerPanel() {
const ui = Store.getUi();
const width = Math.min(
ui.width,
Math.max(Config.ui.minWidth, window.innerWidth - 24)
);
const height = Math.min(
ui.height,
Math.max(Config.ui.minHeight, window.innerHeight - 24)
);
const x = Math.max(0, Math.round((window.innerWidth - width) / 2));
const y = Math.max(0, Math.round((window.innerHeight - height) / 2));
Store.updateUi({
x,
y,
width,
height,
});
}
function setMinimized(minimized) {
Store.updateUi({ minimized });
render();
}
function updateWindowChrome() {
if (!root) return;
applyLayout();
const button = root.querySelector('#confluence-plus-toggle-maximize');
if (!button) return;
const maximized = Store.getUi().maximized;
button.innerHTML = maximized ? windowIcons.restore : windowIcons.maximize;
button.title = getLocaleText(maximized ? 'restoreWindow' : 'maximizeWindow');
button.setAttribute(
'aria-label',
getLocaleText(maximized ? 'restoreWindow' : 'maximizeWindow')
);
}
function toggleMaximized() {
const ui = Store.getUi();
if (ui.maximized) {
Store.updateUi({
maximized: false,
width: ui._normalWidth || ui.width,
height: ui._normalHeight || ui.height,
x: ui._normalX ?? ui.x,
y: ui._normalY ?? ui.y,
});
} else {
Store.updateUi({
maximized: true,
_normalWidth: ui.width,
_normalHeight: ui.height,
_normalX: ui.x,
_normalY: ui.y,
});
}
updateWindowChrome();
}
function showMenuOverlay() {
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'confluence-plus-menu-overlay';
overlay.addEventListener('click', () => {
setMinimized(true);
});
document.body.appendChild(overlay);
}
overlay.style.display = 'block';
}
function hideMenuOverlay() {
if (overlay) {
overlay.style.display = 'none';
}
}
function getCurrentSpaceIconUrl() {
const icon = document.querySelector(
'img[data-testid="space-icon"], img[data-vc="space-header-icon-image"], [data-testid="space-icon"]'
);
const source = icon?.currentSrc || icon?.src || icon?.getAttribute?.('src') || '';
if (!source) return images.plugin;
try {
return new URL(source, location.href).href;
} catch {
return source;
}
}
function applyContentWindowSpaceIcon() {
const icon = root?.querySelector('[data-confluence-plus-space-icon]');
if (!icon) return;
const fallback = images.plugin;
icon.src = getCurrentSpaceIconUrl() || fallback;
icon.alt = getLocaleText('currentSpaceIcon');
icon.onerror = () => {
if (icon.src !== fallback) icon.src = fallback;
};
}
function renderFloatingButtonLocale() {
const tooltip = adoStyleFab?.querySelector('.confluence-plus-tooltip');
if (!tooltip) return;
tooltip.innerHTML = `${getLocaleText('leftClick')}
${getLocaleText('rightClick')}`;
}
function applyFloatingButtonPosition() {
if (!adoStyleFab) return;
adoStyleFab.style.right = `${Settings.get().general.floatingButtonRight}px`;
renderFloatingButtonLocale();
}
function applyConfigTheme() {
const settings = Settings.get();
const isDark = settings.general.theme === 'dark' ||
(settings.general.theme === 'system' &&
window.matchMedia?.('(prefers-color-scheme: dark)').matches);
const themeClass = isDark
? 'confluence-plus-theme-dark'
: 'confluence-plus-theme-light';
[root, adoStyleFab, adoStyleConfig].forEach((element) => {
element?.classList.remove(
'confluence-plus-theme',
'confluence-plus-theme-light',
'confluence-plus-theme-dark'
);
element?.classList.add('confluence-plus-theme', themeClass);
});
}
function bindThemeListener() {
if (!window.matchMedia || themeMediaQuery) return;
themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
themeChangeHandler = () => {
if (Settings.get().general.theme === 'system') {
applyConfigTheme();
}
};
if (themeMediaQuery.addEventListener) {
themeMediaQuery.addEventListener('change', themeChangeHandler);
} else {
themeMediaQuery.addListener(themeChangeHandler);
}
}
function unbindThemeListener() {
if (!themeMediaQuery || !themeChangeHandler) return;
if (themeMediaQuery.removeEventListener) {
themeMediaQuery.removeEventListener('change', themeChangeHandler);
} else {
themeMediaQuery.removeListener(themeChangeHandler);
}
themeMediaQuery = null;
themeChangeHandler = null;
}
function closeAdoStyleConfig() {
if (!adoStyleConfig) return;
adoStyleConfig.hidden = true;
adoStyleConfig.setAttribute('aria-hidden', 'true');
}
function getContentAiSummaryStatus() {
const config = AIConfig.get();
const configured = Boolean(
config.endpoint.trim() &&
config.apiKey.trim() &&
config.deployment.trim()
);
return {
key: configured ? 'ai-status-available' : 'ai-status-unconfigured',
label: configured
? getLocaleText('aiStatusAvailable')
: getLocaleText('aiStatusNotConfigured'),
};
}
function renderContentAiDeployments() {
const options = adoStyleConfig?.querySelector('#confluencePlusContentAiDeploymentOptions');
if (!options) return;
options.innerHTML = '';
AIConfig.get().deployments.forEach((deployment) => {
if (!deployment?.id) return;
const option = document.createElement('option');
option.value = deployment.id;
option.label = deployment.label || deployment.id;
options.appendChild(option);
});
}
function getContentAiConnectionMessage(check) {
if (!check) return getLocaleText('notTested');
if (check.status === 'success' && Number.isFinite(check.deploymentCount)) {
return getLocaleText('deploymentVerified');
}
return check.message || getLocaleText('notTested');
}
function refreshContentAiSummary() {
const summary = adoStyleConfig?.querySelector('#confluencePlusContentAiStatus');
if (!summary) return;
const status = getContentAiSummaryStatus();
summary.className = `confluence-plus-ai-status ${status.key}`;
summary.textContent = status.label;
}
function bindContentAiControls() {
if (!adoStyleConfig) return;
const endpoint = adoStyleConfig.querySelector('#confluencePlusContentAiEndpoint');
const apiKey = adoStyleConfig.querySelector('#confluencePlusContentAiKey');
const apiVersion = adoStyleConfig.querySelector('#confluencePlusContentAiApiVersion');
const deployment = adoStyleConfig.querySelector('#confluencePlusContentAiDeployment');
const tokenParameter = adoStyleConfig.querySelector('#confluencePlusContentAiTokenParameter');
const result = adoStyleConfig.querySelector('#confluencePlusContentAiResult');
const testButton = adoStyleConfig.querySelector('[data-content-ai-test]');
const clearButton = adoStyleConfig.querySelector('[data-content-ai-clear]');
const showKeyButton = adoStyleConfig.querySelector('[data-content-ai-show-key]');
const setResult = (message, status = '') => {
result.className = `confluence-plus-ai-result${status ? ` ai-result-${status}` : ''}`;
result.textContent = message;
};
const current = AIConfig.get();
endpoint.value = current.endpoint;
apiKey.value = current.apiKey;
apiVersion.value = current.apiVersion || '2025-03-01-preview';
deployment.value = current.deployment;
tokenParameter.value = current.tokenParameter || 'max_completion_tokens';
renderContentAiDeployments();
if (current.lastCheck) {
setResult(
getContentAiConnectionMessage({
...current.lastCheck,
status: current.lastCheck.ok ? 'success' : 'error',
}),
current.lastCheck.ok ? 'success' : 'error'
);
}
const updateDraft = () => {
AIConfig.updateDraft({
endpoint: endpoint.value,
apiKey: apiKey.value,
apiVersion: apiVersion.value,
deployment: deployment.value,
tokenParameter: tokenParameter.value,
});
refreshContentAiSummary();
};
[endpoint, apiKey, apiVersion, deployment, tokenParameter].forEach((field) => {
field.addEventListener('input', updateDraft);
field.addEventListener('change', updateDraft);
});
showKeyButton.addEventListener('click', () => {
const visible = apiKey.type === 'text';
apiKey.type = visible ? 'password' : 'text';
showKeyButton.textContent = getLocaleText(visible ? 'show' : 'hide');
});
clearButton.addEventListener('click', () => {
AIConfig.clear();
endpoint.value = '';
apiKey.value = '';
apiVersion.value = '2025-03-01-preview';
deployment.value = '';
tokenParameter.value = 'max_completion_tokens';
renderContentAiDeployments();
setResult(getLocaleText('notTested'));
refreshContentAiSummary();
});
testButton.addEventListener('click', async () => {
updateDraft();
const draft = AIConfig.get();
if (!draft.endpoint || !draft.apiKey) {
setResult(getLocaleText('missingEndpointOrKey'), 'error');
return;
}
testButton.disabled = true;
setResult(getLocaleText('connecting'));
try {
const candidate = AIConfig.validate(draft);
const connection = await AzureOpenAI.testConnection(candidate);
const selectedDeployment = deployment.value.trim() || connection.deployments[0]?.id || '';
AIConfig.updateDraft({
...candidate,
deployment: selectedDeployment,
deployments: connection.deployments,
});
AIConfig.recordHealth(true, connection.message, {
deploymentCount: connection.deploymentCount,
v1: AzureOpenAI.isV1Endpoint(candidate.endpoint),
});
deployment.value = selectedDeployment;
renderContentAiDeployments();
setResult(connection.message, 'success');
refreshContentAiSummary();
} catch (error) {
AIConfig.recordHealth(false, error.message);
setResult(error.message, 'error');
refreshContentAiSummary();
} finally {
testButton.disabled = false;
}
});
}
function applyConfigLocale() {
if (!adoStyleConfig) return;
adoStyleConfig.querySelectorAll('[data-confluence-plus-locale]').forEach((element) => {
const key = element.dataset.confluencePlusLocale;
const attribute = element.dataset.confluencePlusLocaleAttr;
if (attribute) {
element.setAttribute(attribute, getLocaleText(key));
} else {
element.textContent = getLocaleText(key);
}
});
const title = adoStyleConfig.querySelector('#confluencePlusConfigTitle');
if (title) {
title.textContent = `${getLocaleText('settings')} (v${GM_info.script.version})`;
}
adoStyleConfig.querySelectorAll('[data-confluence-plus-option-locale]').forEach((option) => {
option.textContent = getLocaleText(option.dataset.confluencePlusOptionLocale);
});
const language = adoStyleConfig.querySelector('#confluencePlusConfigLanguage');
const theme = adoStyleConfig.querySelector('#confluencePlusConfigTheme');
if (language) language.setAttribute('aria-label', getLocaleText('language'));
if (theme) theme.setAttribute('aria-label', getLocaleText('theme'));
const showKey = adoStyleConfig.querySelector('[data-content-ai-show-key]');
if (showKey) {
const keyInput = adoStyleConfig.querySelector('#confluencePlusContentAiKey');
showKey.textContent = getLocaleText(keyInput?.type === 'text' ? 'hide' : 'show');
}
const current = AIConfig.get();
const result = adoStyleConfig.querySelector('#confluencePlusContentAiResult');
if (result && current.lastCheck) {
result.textContent = getContentAiConnectionMessage({
...current.lastCheck,
status: current.lastCheck.ok ? 'success' : 'error',
});
result.removeAttribute('data-confluence-plus-locale');
}
}
function renderAdoStyleConfig() {
if (!adoStyleConfig) return;
const language = Settings.get().general.language;
const theme = Settings.get().general.theme;
const right = Settings.get().general.floatingButtonRight;
const aiConfig = AIConfig.get();
const aiStatus = getContentAiSummaryStatus();
const option = (value, labelKey) =>
``;
const themeOption = (value, labelKey) =>
``;
adoStyleConfig.innerHTML = `
${getLocaleText('notTested')}