Files
library-desk/static/wikijs-integration.js
T
jpmschweitzerandClaude Fable 5 c5f90cdb4f
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m12s
feat(auth): session/proxy auth for Wiki.js buttons; drop browser API key
The wikijs-integration.js embedded a full-privilege API key that was
served to every wiki visitor — it unlocked all 66 authenticated
endpoints, including page/vector deletes and index purges. That key
has been rotated out of service.

The two browser endpoints (/ingest/page, /entity-linking/link-page)
now authenticate via the NPM /library-desk/ proxy location instead of a
key: Authentik forward-auth for external users, LAN bypass for internal,
verified by a trusted proxy marker header. This is safe because
library-desk binds loopback-only, so NPM is the sole path that can set
that header. The browser holds no secret; the script calls same-origin
with credentials. Machine callers (the Scheduler) keep the Bearer key
on the container-network endpoints. verify_api_key now compares in
constant time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:02:02 +02:00

577 lines
18 KiB
JavaScript

/**
* Library Desk Integration for Wiki.js
* Combined re-index and entity linking buttons
*
* Usage: Add to Wiki.js Code Injection (served same-origin behind Authentik):
* <script src="/library-desk/static/wikijs-integration.js"></script>
*
* Auth: none in the browser. Requests go same-origin through the NPM
* /library-desk/ location, which is gated by Authentik forward-auth with the
* LAN bypass — external users are authenticated, LAN users pass through, and
* library-desk trusts the proxy marker header. No API key is embedded here.
*/
(function() {
'use strict';
// Same-origin base: the script is served from <origin>/library-desk/static/...,
// so strip '/static/...' to get the library-desk mount point on this origin.
const scriptTag = document.currentScript;
const scriptUrl = scriptTag ? scriptTag.src : '';
const libraryDeskUrl = scriptUrl
? scriptUrl.replace(/^https?:\/\/[^/]+/, '').split('/static/')[0]
: '/library-desk';
// Shared configuration
const CONFIG = window.LIBRARY_DESK_CONFIG || {
libraryDeskUrl: libraryDeskUrl,
user: 'jpmschweitzer',
buttonPosition: 'toolbar', // 'toolbar' or 'floating'
debug: true
};
function log() {
if (CONFIG.debug) {
console.log('[Library Desk]', ...arguments);
}
}
// Store page ID globally once fetched
let CACHED_PAGE_ID = null;
let CACHED_PAGE_PATH = null;
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
function init() {
log('Initializing Library Desk integration...');
log('Library Desk URL:', CONFIG.libraryDeskUrl);
// Wait for page to be ready, then add buttons
setTimeout(addButtons, 1500);
}
async function addButtons() {
const pagePath = getPagePath();
if (!pagePath) {
log('Skipping buttons - not on a content page');
return;
}
log('Page path:', pagePath);
// Check if buttons already exist
if (document.getElementById('library-desk-buttons')) {
log('Buttons already exist');
return;
}
// Fetch and cache page ID during initialization
CACHED_PAGE_PATH = pagePath;
CACHED_PAGE_ID = await getPageIdFromPath(pagePath);
log('Cached page ID for this session:', CACHED_PAGE_ID);
if (CONFIG.buttonPosition === 'floating') {
addFloatingButtons(pagePath);
} else {
addToolbarButtons(pagePath);
}
}
function addToolbarButtons(pagePath) {
const toolbar = findToolbar();
if (!toolbar) {
log('Toolbar not found, falling back to floating buttons');
addFloatingButtons(pagePath);
return;
}
// Container for both buttons
const btnContainer = document.createElement('div');
btnContainer.id = 'library-desk-buttons';
btnContainer.style.cssText = 'display: inline-flex; align-items: center; gap: 4px; margin-left: 8px;';
// Re-index button
const reindexBtn = createToolbarButton(
'library-desk-reindex-btn',
'Re-index this page in Library Desk (vectors + knowledge graph)',
'mdi-database-sync',
() => reindexPage(pagePath, reindexBtn)
);
// Entity link button
const entityLinkBtn = createToolbarButton(
'library-desk-entitylink-btn',
'Link entities mentioned in this page to the knowledge graph',
'mdi-graph-outline',
() => linkEntities(pagePath, entityLinkBtn)
);
btnContainer.appendChild(reindexBtn);
btnContainer.appendChild(entityLinkBtn);
toolbar.appendChild(btnContainer);
log('Toolbar buttons added');
}
function createToolbarButton(id, title, icon, onClick) {
const button = document.createElement('button');
button.id = id;
button.className = 'v-btn v-btn--icon v-btn--round theme--dark v-size--default';
button.type = 'button';
button.title = title;
button.setAttribute('aria-label', title);
button.innerHTML = `<span class="v-btn__content"><i class="v-icon notranslate mdi ${icon} theme--dark" style="font-size: 20px;"></i></span>`;
button.addEventListener('mouseenter', function() {
this.style.backgroundColor = 'rgba(255, 255, 255, 0.08)';
});
button.addEventListener('mouseleave', function() {
this.style.backgroundColor = '';
});
button.onclick = function(e) {
e.preventDefault();
e.stopPropagation();
onClick();
};
return button;
}
function addFloatingButtons(pagePath) {
const container = document.createElement('div');
container.id = 'library-desk-buttons';
container.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 9999;
`;
// Re-index button
const reindexBtn = createFloatingButton(
'library-desk-reindex-btn',
'🔄 Re-index',
'Re-index this page in Library Desk',
'#1976d2',
'#1565c0',
() => reindexPage(pagePath, reindexBtn)
);
// Entity link button
const entityLinkBtn = createFloatingButton(
'library-desk-entitylink-btn',
'🔗 Link Entities',
'Link entities in this page',
'#43a047',
'#388e3c',
() => linkEntities(pagePath, entityLinkBtn)
);
container.appendChild(reindexBtn);
container.appendChild(entityLinkBtn);
document.body.appendChild(container);
log('Floating buttons added');
}
function createFloatingButton(id, text, title, bgColor, hoverColor, onClick) {
const button = document.createElement('button');
button.id = id;
button.innerHTML = text;
button.title = title;
button.style.cssText = `
padding: 10px 15px;
background: ${bgColor};
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
transition: all 0.3s;
`;
button.onmouseover = function() { this.style.background = hoverColor; };
button.onmouseout = function() { this.style.background = bgColor; };
button.onclick = onClick;
return button;
}
// ============================================================================
// RE-INDEX FUNCTIONALITY
// ============================================================================
async function reindexPage(pagePath, button) {
const isFloating = button.id === 'library-desk-reindex-btn' && button.innerHTML.includes('Re-index');
const icon = isFloating ? null : button.querySelector('.v-icon');
const originalContent = isFloating ? button.innerHTML : null;
const originalIcon = icon ? icon.className : null;
button.disabled = true;
if (isFloating) {
button.innerHTML = '⏳ Loading...';
button.style.background = '#757575';
} else {
icon.className = 'v-icon notranslate mdi mdi-loading mdi-spin theme--dark';
}
try {
// Use cached page ID from initialization
if (!CACHED_PAGE_ID) {
throw new Error('Page ID not available - was not fetched during initialization');
}
log('Re-indexing page', CACHED_PAGE_ID, '...');
// Re-index directly
const response = await fetch(CONFIG.libraryDeskUrl + '/ingest/page', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
user: CONFIG.user,
page_id: CACHED_PAGE_ID,
force_refresh: true
})
});
const result = await response.json();
if (response.ok && result.success) {
if (isFloating) {
button.innerHTML = '✓ Done!';
button.style.background = '#4caf50';
} else {
icon.className = 'v-icon notranslate mdi mdi-check-circle theme--dark';
icon.style.color = '#4caf50';
}
const message = 'Re-indexed: ' + result.page_title + '\n\n' +
'✓ Vectors: ' + result.vector_chunks_created + ' chunks\n' +
'✓ Entities: ' + result.graph_entities_extracted + '\n' +
'✓ Relationships: ' + result.graph_relationships_created + '\n' +
'⏱ Time: ' + Math.round(result.processing_time_ms) + 'ms';
log('Success:', message);
// Show notification
showNotification('success', 'Page Re-indexed', message);
// Reset button
setTimeout(function() {
if (isFloating) {
button.innerHTML = originalContent;
button.style.background = '#1976d2';
} else {
icon.className = originalIcon;
icon.style.color = '';
}
button.disabled = false;
}, 3000);
} else {
throw new Error(result.detail || result.error || 'Re-indexing failed');
}
} catch (error) {
console.error('[Library Desk] Error:', error);
if (isFloating) {
button.innerHTML = '✗ Failed';
button.style.background = '#f44336';
} else {
icon.className = 'v-icon notranslate mdi mdi-alert-circle theme--dark';
icon.style.color = '#f44336';
}
showNotification('error', 'Re-indexing Failed', error.message + '\n\nCheck browser console for details.');
// Reset button
setTimeout(function() {
if (isFloating) {
button.innerHTML = originalContent;
button.style.background = '#1976d2';
} else {
icon.className = originalIcon;
icon.style.color = '';
}
button.disabled = false;
}, 4000);
}
}
// ============================================================================
// ENTITY LINKING FUNCTIONALITY
// ============================================================================
async function linkEntities(pagePath, button) {
const isFloating = button.id === 'library-desk-entitylink-btn' && button.innerHTML.includes('Link');
const icon = isFloating ? null : button.querySelector('.v-icon');
const originalContent = isFloating ? button.innerHTML : null;
const originalIcon = icon ? icon.className : null;
button.disabled = true;
if (isFloating) {
button.innerHTML = '⏳ Finding...';
button.style.background = '#757575';
} else {
icon.className = 'v-icon notranslate mdi mdi-loading mdi-spin theme--dark';
}
try {
// Get page ID from Wiki.js GraphQL API
const pageId = await getPageIdFromPath(pagePath);
if (!pageId) {
throw new Error('Could not get page ID from Wiki.js');
}
log('Linking entities for page', pageId, '...');
// Call entity linking endpoint
const response = await fetch(CONFIG.libraryDeskUrl + '/entity-linking/link-page', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
user: CONFIG.user,
page_id: pageId,
create_relationships: true,
re_index_if_changed: true
})
});
const result = await response.json();
if (response.ok) {
if (isFloating) {
button.innerHTML = '✓ Linked!';
button.style.background = '#4caf50';
} else {
icon.className = 'v-icon notranslate mdi mdi-check-circle theme--dark';
icon.style.color = '#4caf50';
}
const entityList = result.entities_found
.slice(0, 5)
.map(e => `- ${e.name} (${e.mentions} mentions)${e.path ? ' ✓ linked' : ''}`)
.join('\n');
const message = 'Entity Linking Complete!\n\n' +
'Found: ' + result.entities_found.length + ' entities\n' +
'Wiki links added: ' + result.content_links_added + '\n' +
'Graph links added: ' + result.new_graph_links_created + '\n' +
'Content updated: ' + (result.content_updated ? 'Yes' : 'No') + '\n' +
'Re-indexed: ' + (result.re_indexed ? 'Yes' : 'No') + '\n\n' +
'Top entities:\n' + (entityList || 'None');
log('Success:', result);
alert(message);
// Reload page if content was updated
if (result.content_updated) {
log('Reloading page to show updated content...');
setTimeout(function() {
window.location.reload();
}, 1500);
} else {
setTimeout(function() {
if (isFloating) {
button.innerHTML = originalContent;
button.style.background = '#43a047';
} else {
icon.className = originalIcon;
icon.style.color = '';
}
button.disabled = false;
}, 3000);
}
} else {
throw new Error(result.detail || 'Entity linking failed');
}
} catch (error) {
console.error('[Library Desk] Error:', error);
if (isFloating) {
button.innerHTML = '✗ Failed';
button.style.background = '#f44336';
} else {
icon.className = 'v-icon notranslate mdi mdi-alert-circle theme--dark';
icon.style.color = '#f44336';
}
alert('Entity linking failed:\n\n' + error.message);
setTimeout(function() {
if (isFloating) {
button.innerHTML = originalContent;
button.style.background = '#43a047';
} else {
icon.className = originalIcon;
icon.style.color = '';
}
button.disabled = false;
}, 4000);
}
}
// ============================================================================
// SHARED UTILITIES
// ============================================================================
async function getPageIdFromPath(pagePath) {
/**
* Query Wiki.js GraphQL API to get page ID from path.
* Uses singleByPath query which requires path and locale.
*/
try {
// Get locale from URL or default to 'en'
const urlLocaleMatch = window.location.pathname.match(/^\/([a-z]{2})\//);
const locale = urlLocaleMatch ? urlLocaleMatch[1] : 'en';
log('Querying Wiki.js GraphQL for page:', pagePath, 'locale:', locale);
const query = `
query ($path: String!, $locale: String!) {
pages {
singleByPath(path: $path, locale: $locale) {
id
}
}
}
`;
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
query: query,
variables: { path: pagePath, locale: locale }
})
});
const result = await response.json();
log('GraphQL response:', result);
if (result.data && result.data.pages && result.data.pages.singleByPath) {
const pageId = result.data.pages.singleByPath.id;
log('Found page ID from GraphQL:', pageId);
return parseInt(pageId, 10);
}
if (result.errors) {
log('GraphQL errors:', result.errors.map(e => e.message).join(', '));
}
log('GraphQL query returned no page');
return null;
} catch (error) {
log('GraphQL query failed:', error.message);
return null;
}
}
function getPageId() {
/**
* Synchronous wrapper - returns null and triggers async fetch.
* Actual buttons will need to call getPageIdFromPath() directly.
*/
log('getPageId() called - page ID must be fetched asynchronously via getPageIdFromPath()');
return null;
}
function waitForPageElement(callback, maxAttempts = 10, interval = 500) {
/**
* Wait for Vue store to be available, then call callback.
*/
let attempts = 0;
function check() {
attempts++;
// Check if Vue store or page data is available
const hasVueStore = window.$store && (window.$store.state || window.$store.get);
const hasPageData = window.$page || window.pageId;
if (hasVueStore || hasPageData) {
log('Vue/page data available after', attempts, 'attempts');
callback();
} else if (attempts < maxAttempts) {
log('Waiting for Vue/page data... attempt', attempts);
setTimeout(check, interval);
} else {
log('Vue/page data never appeared after', maxAttempts, 'attempts - adding buttons anyway');
callback(); // Add buttons anyway, they just won't work
}
}
check();
}
function findToolbar() {
const selectors = [
'header.v-toolbar .v-toolbar__content',
'.v-app-bar .v-toolbar__content',
'nav.v-toolbar .v-toolbar__content',
'header .v-toolbar__content',
'.v-app-bar__content'
];
for (var i = 0; i < selectors.length; i++) {
var elem = document.querySelector(selectors[i]);
if (elem) return elem;
}
return null;
}
function getPagePath() {
var path = window.location.pathname;
path = path.replace(/^\//, '').replace(/\/$/, '');
path = path.replace(/^[a-z]{2}\//, ''); // Remove language code
// Don't add buttons to home page or special pages
if (path === '' || path === 'home' || path.startsWith('_') || path.startsWith('a/')) {
return null;
}
return path;
}
function showNotification(type, title, message) {
// Try Wiki.js notifications if available
if (window.$store && typeof window.$store.commit === 'function') {
try {
window.$store.commit('showNotification', {
message: title + ': ' + message,
style: type,
icon: type === 'success' ? 'check' : 'alert'
});
return;
} catch (e) {
// Fall through to alert
}
}
// Fallback to browser alert
alert(title + '\n\n' + message);
}
log('Library Desk integration loaded successfully');
log('Version: 2.0.0 - Combined re-index and entity linking');
})();