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>
577 lines
18 KiB
JavaScript
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');
|
|
|
|
})();
|