// ==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 = `
Confluence Auth
`; 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 = `
Confluence Plus
Context
Modules
Logs
`; 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(); })();