// ==UserScript==
// @name Confluence Plus
// @namespace https://docs.scriptcat.org/
// @version 0.2.0
// @description Confluence Plus
// @author Yekindar Wang
// @match https://*.atlassian.net/wiki/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @grant GM_info
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
/********************************************************
* STORAGE
********************************************************/
const STORAGE_KEY = 'confluence_plus_state';
function loadStorage() {
try {
return JSON.parse(GM_getValue(STORAGE_KEY, '{}'));
} catch (e) {
console.log(e);
log(e);
return {};
}
}
function saveStorage(data) {
GM_setValue(STORAGE_KEY, JSON.stringify(data));
}
function resetStorage() {
GM_deleteValue(STORAGE_KEY);
}
/********************************************************
* STATE
********************************************************/
const watchers = [];
let isHydrating = false;
function watch(fn) {
watchers.push(fn);
}
const reactiveCache = new WeakMap();
function createReactive(obj, callback, path = '') {
if (
!obj ||
typeof obj !== 'object'
) {
return obj;
}
if (reactiveCache.has(obj)) {
return reactiveCache.get(obj);
}
const proxy = new Proxy(obj, {
get(target, key) {
const value = target[key];
if (
value &&
typeof value === 'object'
) {
return createReactive(
value,
callback,
path
? `${path}.${String(key)}`
: String(key)
);
}
return value;
},
set(target, key, value) {
if (target[key] === value) {
return true;
}
target[key] = value;
const fullPath = path
? `${path}.${String(key)}`
: String(key);
callback(fullPath, value);
watchers.forEach((fn) => {
fn(fullPath, value);
});
return true;
},
deleteProperty(target, key) {
delete target[key];
const fullPath = path
? `${path}.${String(key)}`
: String(key);
callback(fullPath, undefined);
watchers.forEach((fn) => {
fn(fullPath, undefined);
});
return true;
},
});
reactiveCache.set(obj, proxy);
return proxy;
}
const rawState = {
auth: {
email: '',
token: '',
baseUrl: '',
},
context: {
pageId: '',
currentUrl: location.href,
},
ui: {
x: 120,
y: 120,
width: 420,
height: 700,
},
modules: [],
};
const state = createReactive(
rawState,
(path, value) => {
console.log(
'[STATE]',
path,
value
);
if (
!isHydrating &&
(
path.startsWith('auth') ||
path.startsWith('ui')
)
) {
persistState();
}
}
);
function hydrateState() {
isHydrating = true;
const local = loadStorage();
if (local.auth) {
Object.assign(state.auth, local.auth);
}
if (local.ui) {
Object.assign(state.ui, local.ui);
}
state.context.pageId = extractPageId();
isHydrating = false;
}
const persistState = debounce(() => {
saveStorage({
auth: state.auth,
ui: state.ui,
});
}, 300);
/********************************************************
* UTILS
********************************************************/
function extractPageId(url = location.href) {
const match = url.match(/\/pages\/(\d+)/);
return match?.[1] || '';
}
function toast(message, type = 'info') {
const el = document.createElement('div');
el.className = `cft-toast ${type}`;
el.innerText = message;
document.body.appendChild(el);
setTimeout(() => {
el.remove();
}, 2500);
}
function log(content) {
const logEl = document.getElementById('cft-log');
if (!logEl) return;
const item = document.createElement('div');
item.className = 'cft-log-item';
item.innerText =
typeof content === 'string'
? content
: JSON.stringify(content, null, 2);
logEl.prepend(item);
}
function debounce(fn, delay = 300) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
/********************************************************
* AUTH
********************************************************/
async function ensureAuth() {
const { email, token, baseUrl } = state.auth;
if (email && token && baseUrl) {
return true;
}
return openAuthModal();
}
async function withAuth(action) {
const ok = await ensureAuth();
if (!ok) return;
return action();
}
function openAuthModal() {
return new Promise((resolve) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = `
`;
document.body.appendChild(wrapper);
wrapper.querySelector('#cft-auth-save').onclick = () => {
state.auth.email =
wrapper.querySelector('#cft-auth-email').value.trim();
state.auth.token =
wrapper.querySelector('#cft-auth-token').value.trim();
state.auth.baseUrl =
wrapper.querySelector('#cft-auth-baseurl').value.trim();
wrapper.remove();
toast('Auth saved');
resolve(true);
};
wrapper.querySelector('#cft-auth-cancel').onclick = () => {
wrapper.remove();
resolve(false);
};
});
}
/********************************************************
* API
********************************************************/
const api = {
getBaseUrl() {
return `${state.auth.baseUrl}/wiki/rest/api`;
},
getHeaders() {
const auth = btoa(
`${state.auth.email}:${state.auth.token}`
);
return {
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`,
};
},
async request(path, options = {}) {
const url = `${this.getBaseUrl()}${path}`;
try {
const res = await fetch(url, {
...options,
headers: {
...this.getHeaders(),
...(options.headers || {}),
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(
`API Error ${res.status}: ${text}`
);
}
return await res.json();
} catch (err) {
console.error(err);
toast(err.message, 'error');
throw err;
}
},
get(path) {
return this.request(path);
},
post(path, body) {
return this.request(path, {
method: 'POST',
body: JSON.stringify(body),
});
},
put(path, body) {
return this.request(path, {
method: 'PUT',
body: JSON.stringify(body),
});
},
delete(path) {
return this.request(path, {
method: 'DELETE',
});
},
};
/********************************************************
* ROUTE LISTENER
********************************************************/
function listenRouteChange() {
const emitChange = () => {
const pageId = extractPageId();
if (
pageId &&
pageId !== state.context.pageId
) {
state.context.pageId = pageId;
state.context.currentUrl = location.href;
console.log(
'[Confluence Plus] page changed:',
pageId
);
}
};
const rawPushState = history.pushState;
const rawReplaceState = history.replaceState;
history.pushState = function (...args) {
rawPushState.apply(this, args);
setTimeout(emitChange, 50);
};
history.replaceState = function (...args) {
rawReplaceState.apply(this, args);
setTimeout(emitChange, 50);
};
window.addEventListener(
'popstate',
() => {
setTimeout(emitChange, 50);
}
);
emitChange();
}
/********************************************************
* MODULE SYSTEM
********************************************************/
function registerModule(module) {
state.modules.push(module);
}
/********************************************************
* UI
********************************************************/
let root;
function createRoot() {
root = document.createElement('div');
root.id = 'cft-root';
root.style.left = state.ui.x + 'px';
root.style.top = state.ui.y + 'px';
root.style.width = state.ui.width + 'px';
root.style.height = state.ui.height + 'px';
document.body.appendChild(root);
render();
enableDrag(root);
}
function render() {
root.innerHTML = `
`;
bindEvents();
renderModules();
}
function bindEvents() {
document
.getElementById('cft-reset-btn')
.addEventListener('click', () => {
resetStorage();
state.auth.email = '';
state.auth.token = '';
state.auth.baseUrl = '';
toast('Auth reset success');
});
}
function renderModules() {
const container = document.getElementById('cft-modules');
container.innerHTML = '';
state.modules.forEach((module) => {
const btn = document.createElement('button');
btn.className = 'cft-module-btn';
btn.innerText = module.name;
btn.addEventListener('click', async () => {
try {
await module.action();
} catch (err) {
console.error(err);
}
});
container.appendChild(btn);
});
}
/********************************************************
* DRAG
********************************************************/
function enableDrag(el) {
const header = el.querySelector('.cft-header');
let isDragging = false;
let offsetX = 0;
let offsetY = 0;
header.addEventListener('mousedown', (e) => {
isDragging = true;
offsetX = e.clientX - el.offsetLeft;
offsetY = e.clientY - el.offsetTop;
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const x = e.clientX - offsetX;
const y = e.clientY - offsetY;
el.style.left = x + 'px';
el.style.top = y + 'px';
state.ui.x = x;
state.ui.y = y;
});
document.addEventListener('mouseup', () => {
isDragging = false;
});
}
/********************************************************
* MODULES
********************************************************/
registerModule({
name: 'Get Current Page',
async action() {
await withAuth(async () => {
const pageId = state.context.pageId;
const data = await api.get(`/pages/${pageId}`);
console.log(data);
log(data);
toast('Loaded current page');
});
},
});
registerModule({
name: 'Get Child Pages',
async action() {
await withAuth(async () => {
const pageId = state.context.pageId;
const data = await api.get(
`/pages/${pageId}/children`
);
console.log(data);
log(data);
toast('Loaded child pages');
});
},
});
registerModule({
name: 'Get Space Pages',
async action() {
await withAuth(async () => {
const pageId = state.context.pageId;
const page = await api.get(`/pages/${pageId}`);
const spaceId = page.spaceId;
const pages = await api.get(
`/spaces/${spaceId}/pages`
);
console.log(pages);
log(pages);
toast('Loaded space pages');
});
},
});
registerModule({
name: 'Get Page Status',
async action() {
await withAuth(async () => {
const pageId = state.context.pageId;
const pageStatus = await api.get(`/content/${pageId}/state`);
console.log(pageStatus);
log(pageStatus);
toast('Get Page Status');
});
},
});
/********************************************************
* STYLE
********************************************************/
GM_addStyle(`
#cft-root {
position: fixed;
z-index: 999999;
background: white;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,.15);
overflow: hidden;
resize: both;
min-width: 320px;
min-height: 400px;
border: 1px solid #ddd;
font-size: 14px;
color: #333;
}
.cft-header {
height: 48px;
background: #0052cc;
color: white;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
cursor: move;
user-select: none;
}
.cft-header button {
border: none;
background: rgba(255,255,255,.2);
color: white;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
}
.cft-body {
height: calc(100% - 48px);
overflow: auto;
padding: 12px;
box-sizing: border-box;
}
.cft-section {
margin-bottom: 20px;
}
.cft-title {
font-weight: bold;
margin-bottom: 12px;
}
.cft-row {
display: flex;
flex-direction: column;
margin-bottom: 12px;
}
.cft-row label {
margin-bottom: 4px;
font-size: 12px;
color: #666;
}
.cft-row input {
border: 1px solid #ddd;
border-radius: 6px;
padding: 8px;
}
.cft-module-btn {
width: 100%;
border: none;
background: #0052cc;
color: white;
padding: 10px;
border-radius: 8px;
cursor: pointer;
margin-bottom: 10px;
}
.cft-module-btn:hover {
opacity: .9;
}
#cft-log {
max-height: 240px;
overflow: auto;
background: #f5f5f5;
border-radius: 8px;
padding: 8px;
}
.cft-log-item {
background: white;
border-radius: 6px;
padding: 8px;
margin-bottom: 8px;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.cft-toast {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 9999999;
background: #333;
color: white;
padding: 10px 14px;
border-radius: 8px;
}
.cft-toast.error {
background: #d93025;
}
.cft-mask {
position: fixed;
inset: 0;
background: rgba(0,0,0,.4);
z-index: 99999999;
display: flex;
align-items: center;
justify-content: center;
}
.cft-modal {
width: 400px;
background: white;
border-radius: 12px;
padding: 20px;
box-sizing: border-box;
}
.cft-modal-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 20px;
}
.cft-modal-row {
display: flex;
flex-direction: column;
margin-bottom: 14px;
}
.cft-modal-row label {
font-size: 12px;
margin-bottom: 4px;
color: #666;
}
.cft-modal-row input {
border: 1px solid #ddd;
border-radius: 6px;
padding: 10px;
}
.cft-modal-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.cft-modal-actions button {
flex: 1;
border: none;
background: #0052cc;
color: white;
padding: 10px;
border-radius: 8px;
cursor: pointer;
}
`);
/********************************************************
* WATCHERS
********************************************************/
watch((path, value) => {
if (path === 'context.pageId') {
const input = document.querySelector(
'#cft-current-page-id'
);
if (input) {
input.value = value || '';
}
}
});
/********************************************************
* INIT
********************************************************/
function init() {
hydrateState();
createRoot();
listenRouteChange();
console.log(`Script ${GM_info.script.name} is loaded and version is ${GM_info.script.version}`);
log(`Script ${GM_info.script.name} is loaded and version is ${GM_info.script.version}`);
}
init();
})();