452 lines
10 KiB
Text
452 lines
10 KiB
Text
|
|
/**
|
||
|
|
* Brainy Google Sheets Add-on
|
||
|
|
*
|
||
|
|
* Custom functions and sidebar for two-way sync with Brainy.
|
||
|
|
*
|
||
|
|
* Setup:
|
||
|
|
* 1. Open Script Editor: Extensions → Apps Script
|
||
|
|
* 2. Paste this code into Code.gs
|
||
|
|
* 3. Set your Brainy URL: Tools → Script properties → Add BRAINY_URL
|
||
|
|
* 4. Refresh your spreadsheet
|
||
|
|
*
|
||
|
|
* Custom Functions:
|
||
|
|
* - =BRAINY_QUERY(query, limit) - Query entities
|
||
|
|
* - =BRAINY_GET(id) - Get entity by ID
|
||
|
|
* - =BRAINY_SIMILAR(text, limit) - Semantic search
|
||
|
|
* - =BRAINY_RELATIONS(entityId, direction) - Get relationships
|
||
|
|
* - =BRAINY_TYPES() - List available types
|
||
|
|
*
|
||
|
|
* For full documentation: https://github.com/soulcraft/brainy
|
||
|
|
*/
|
||
|
|
|
||
|
|
// Configuration
|
||
|
|
const CONFIG_BRAINY_URL = 'BRAINY_URL';
|
||
|
|
const CONFIG_API_KEY = 'BRAINY_API_KEY';
|
||
|
|
const DEFAULT_LIMIT = 100;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get Brainy URL from script properties
|
||
|
|
*/
|
||
|
|
function getBrainyUrl() {
|
||
|
|
const props = PropertiesService.getScriptProperties();
|
||
|
|
const url = props.getProperty(CONFIG_BRAINY_URL);
|
||
|
|
if (!url) {
|
||
|
|
throw new Error('Brainy URL not configured. Go to Extensions → Apps Script → Project Settings → Script Properties and add BRAINY_URL');
|
||
|
|
}
|
||
|
|
return url.replace(/\/$/, ''); // Remove trailing slash
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get API key (optional)
|
||
|
|
*/
|
||
|
|
function getApiKey() {
|
||
|
|
const props = PropertiesService.getScriptProperties();
|
||
|
|
return props.getProperty(CONFIG_API_KEY) || '';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Make authenticated request to Brainy
|
||
|
|
*/
|
||
|
|
function brainyFetch(endpoint, options = {}) {
|
||
|
|
const baseUrl = getBrainyUrl();
|
||
|
|
const apiKey = getApiKey();
|
||
|
|
|
||
|
|
const url = `${baseUrl}/sheets${endpoint}`;
|
||
|
|
|
||
|
|
const fetchOptions = {
|
||
|
|
method: options.method || 'GET',
|
||
|
|
contentType: 'application/json',
|
||
|
|
muteHttpExceptions: true,
|
||
|
|
...options
|
||
|
|
};
|
||
|
|
|
||
|
|
if (apiKey) {
|
||
|
|
fetchOptions.headers = {
|
||
|
|
...fetchOptions.headers,
|
||
|
|
'Authorization': `Bearer ${apiKey}`
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (options.payload) {
|
||
|
|
fetchOptions.payload = JSON.stringify(options.payload);
|
||
|
|
}
|
||
|
|
|
||
|
|
const response = UrlFetchApp.fetch(url, fetchOptions);
|
||
|
|
const code = response.getResponseCode();
|
||
|
|
const text = response.getContentText();
|
||
|
|
|
||
|
|
if (code >= 400) {
|
||
|
|
const error = JSON.parse(text);
|
||
|
|
throw new Error(error.error || `HTTP ${code}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
return JSON.parse(text);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============= Custom Functions =============
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Query Brainy entities
|
||
|
|
*
|
||
|
|
* @param {string} query Semantic search query or type filter (e.g., "type:person")
|
||
|
|
* @param {number} limit Maximum results (default: 100)
|
||
|
|
* @return {Array} Results as rows with headers
|
||
|
|
* @customfunction
|
||
|
|
*/
|
||
|
|
function BRAINY_QUERY(query, limit) {
|
||
|
|
query = query || '';
|
||
|
|
limit = limit || DEFAULT_LIMIT;
|
||
|
|
|
||
|
|
const params = new URLSearchParams();
|
||
|
|
params.append('limit', limit);
|
||
|
|
|
||
|
|
if (query) {
|
||
|
|
// Check if it's a type filter
|
||
|
|
if (query.toLowerCase().startsWith('type:')) {
|
||
|
|
params.append('type', query.substring(5).trim());
|
||
|
|
} else {
|
||
|
|
params.append('q', query);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = brainyFetch(`/query?${params.toString()}`);
|
||
|
|
|
||
|
|
if (!result.rows || result.rows.length === 0) {
|
||
|
|
return [['No results found']];
|
||
|
|
}
|
||
|
|
|
||
|
|
return [result.headers, ...result.rows];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get a single entity by ID
|
||
|
|
*
|
||
|
|
* @param {string} id Entity ID
|
||
|
|
* @return {Array} Entity as rows with headers
|
||
|
|
* @customfunction
|
||
|
|
*/
|
||
|
|
function BRAINY_GET(id) {
|
||
|
|
if (!id) {
|
||
|
|
return [['Error: ID required']];
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = brainyFetch(`/entity/${id}`);
|
||
|
|
|
||
|
|
if (!result.rows || result.rows.length === 0) {
|
||
|
|
return [['Entity not found']];
|
||
|
|
}
|
||
|
|
|
||
|
|
return [result.headers, ...result.rows];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Semantic similarity search
|
||
|
|
*
|
||
|
|
* @param {string} text Text to find similar entities to
|
||
|
|
* @param {number} limit Maximum results (default: 10)
|
||
|
|
* @return {Array} Similar entities as rows with headers
|
||
|
|
* @customfunction
|
||
|
|
*/
|
||
|
|
function BRAINY_SIMILAR(text, limit) {
|
||
|
|
if (!text) {
|
||
|
|
return [['Error: Search text required']];
|
||
|
|
}
|
||
|
|
|
||
|
|
limit = limit || 10;
|
||
|
|
|
||
|
|
const params = new URLSearchParams();
|
||
|
|
params.append('q', text);
|
||
|
|
params.append('limit', limit);
|
||
|
|
|
||
|
|
const result = brainyFetch(`/similar?${params.toString()}`);
|
||
|
|
|
||
|
|
if (!result.rows || result.rows.length === 0) {
|
||
|
|
return [['No similar entities found']];
|
||
|
|
}
|
||
|
|
|
||
|
|
return [result.headers, ...result.rows];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get relationships for an entity
|
||
|
|
*
|
||
|
|
* @param {string} entityId Entity ID to get relationships for
|
||
|
|
* @param {string} direction Direction: "from", "to", or "both" (default: "from")
|
||
|
|
* @param {number} limit Maximum results (default: 100)
|
||
|
|
* @return {Array} Relationships as rows with headers
|
||
|
|
* @customfunction
|
||
|
|
*/
|
||
|
|
function BRAINY_RELATIONS(entityId, direction, limit) {
|
||
|
|
if (!entityId) {
|
||
|
|
return [['Error: Entity ID required']];
|
||
|
|
}
|
||
|
|
|
||
|
|
direction = direction || 'from';
|
||
|
|
limit = limit || DEFAULT_LIMIT;
|
||
|
|
|
||
|
|
const params = new URLSearchParams();
|
||
|
|
params.append('limit', limit);
|
||
|
|
|
||
|
|
if (direction === 'from' || direction === 'both') {
|
||
|
|
params.append('from', entityId);
|
||
|
|
}
|
||
|
|
if (direction === 'to' || direction === 'both') {
|
||
|
|
params.append('to', entityId);
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = brainyFetch(`/relations?${params.toString()}`);
|
||
|
|
|
||
|
|
if (!result.rows || result.rows.length === 0) {
|
||
|
|
return [['No relationships found']];
|
||
|
|
}
|
||
|
|
|
||
|
|
return [result.headers, ...result.rows];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* List available entity types
|
||
|
|
*
|
||
|
|
* @return {Array} List of noun types
|
||
|
|
* @customfunction
|
||
|
|
*/
|
||
|
|
function BRAINY_TYPES() {
|
||
|
|
const result = brainyFetch('/schema');
|
||
|
|
return [['Type'], ...result.nounTypes.map(t => [t])];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* List available relationship types
|
||
|
|
*
|
||
|
|
* @return {Array} List of verb types
|
||
|
|
* @customfunction
|
||
|
|
*/
|
||
|
|
function BRAINY_RELATION_TYPES() {
|
||
|
|
const result = brainyFetch('/schema');
|
||
|
|
return [['Type'], ...result.verbTypes.map(t => [t])];
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============= Menu & Sidebar =============
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Add menu on spreadsheet open
|
||
|
|
*/
|
||
|
|
function onOpen() {
|
||
|
|
const ui = SpreadsheetApp.getUi();
|
||
|
|
ui.createMenu('Brainy')
|
||
|
|
.addItem('Open Sidebar', 'showSidebar')
|
||
|
|
.addSeparator()
|
||
|
|
.addItem('Configure Connection', 'showConfig')
|
||
|
|
.addItem('Test Connection', 'testConnection')
|
||
|
|
.addToUi();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Show the Brainy sidebar
|
||
|
|
*/
|
||
|
|
function showSidebar() {
|
||
|
|
const html = HtmlService.createHtmlOutputFromFile('Sidebar')
|
||
|
|
.setTitle('Brainy')
|
||
|
|
.setWidth(300);
|
||
|
|
SpreadsheetApp.getUi().showSidebar(html);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Show configuration dialog
|
||
|
|
*/
|
||
|
|
function showConfig() {
|
||
|
|
const ui = SpreadsheetApp.getUi();
|
||
|
|
const props = PropertiesService.getScriptProperties();
|
||
|
|
const currentUrl = props.getProperty(CONFIG_BRAINY_URL) || '';
|
||
|
|
|
||
|
|
const response = ui.prompt(
|
||
|
|
'Configure Brainy Connection',
|
||
|
|
`Enter your Brainy server URL (current: ${currentUrl || 'not set'}):`,
|
||
|
|
ui.ButtonSet.OK_CANCEL
|
||
|
|
);
|
||
|
|
|
||
|
|
if (response.getSelectedButton() === ui.Button.OK) {
|
||
|
|
const url = response.getResponseText().trim();
|
||
|
|
if (url) {
|
||
|
|
props.setProperty(CONFIG_BRAINY_URL, url);
|
||
|
|
ui.alert('Configuration saved! URL: ' + url);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Test the connection
|
||
|
|
*/
|
||
|
|
function testConnection() {
|
||
|
|
const ui = SpreadsheetApp.getUi();
|
||
|
|
try {
|
||
|
|
const result = brainyFetch('/health');
|
||
|
|
ui.alert('Connection successful!\n\nStatus: ' + result.status + '\nUptime: ' + Math.round(result.uptime / 1000) + 's');
|
||
|
|
} catch (e) {
|
||
|
|
ui.alert('Connection failed!\n\n' + e.message);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============= Sidebar Functions =============
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get connection status for sidebar
|
||
|
|
*/
|
||
|
|
function getConnectionStatus() {
|
||
|
|
try {
|
||
|
|
const result = brainyFetch('/health');
|
||
|
|
return {
|
||
|
|
connected: true,
|
||
|
|
url: getBrainyUrl(),
|
||
|
|
status: result.status,
|
||
|
|
uptime: result.uptime
|
||
|
|
};
|
||
|
|
} catch (e) {
|
||
|
|
return {
|
||
|
|
connected: false,
|
||
|
|
error: e.message
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Query entities from sidebar
|
||
|
|
*/
|
||
|
|
function sidebarQuery(query, type, limit) {
|
||
|
|
const params = new URLSearchParams();
|
||
|
|
params.append('limit', limit || 50);
|
||
|
|
|
||
|
|
if (query) params.append('q', query);
|
||
|
|
if (type) params.append('type', type);
|
||
|
|
|
||
|
|
return brainyFetch(`/query?${params.toString()}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Add entity from sidebar
|
||
|
|
*/
|
||
|
|
function sidebarAddEntity(type, data, metadata) {
|
||
|
|
return brainyFetch('/add', {
|
||
|
|
method: 'POST',
|
||
|
|
payload: { type, data, metadata }
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update entity from sidebar
|
||
|
|
*/
|
||
|
|
function sidebarUpdateEntity(id, data, metadata) {
|
||
|
|
return brainyFetch('/update', {
|
||
|
|
method: 'POST',
|
||
|
|
payload: { id, data, metadata, merge: true }
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Delete entity from sidebar
|
||
|
|
*/
|
||
|
|
function sidebarDeleteEntity(id) {
|
||
|
|
return brainyFetch('/delete', {
|
||
|
|
method: 'POST',
|
||
|
|
payload: { id }
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get schema for sidebar dropdowns
|
||
|
|
*/
|
||
|
|
function sidebarGetSchema() {
|
||
|
|
return brainyFetch('/schema');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Insert query result into sheet at selection
|
||
|
|
*/
|
||
|
|
function insertQueryResult(query, type, limit) {
|
||
|
|
const sheet = SpreadsheetApp.getActiveSheet();
|
||
|
|
const selection = sheet.getActiveCell();
|
||
|
|
|
||
|
|
const result = sidebarQuery(query, type, limit);
|
||
|
|
|
||
|
|
if (!result.rows || result.rows.length === 0) {
|
||
|
|
selection.setValue('No results');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const data = [result.headers, ...result.rows];
|
||
|
|
const range = sheet.getRange(
|
||
|
|
selection.getRow(),
|
||
|
|
selection.getColumn(),
|
||
|
|
data.length,
|
||
|
|
data[0].length
|
||
|
|
);
|
||
|
|
|
||
|
|
range.setValues(data);
|
||
|
|
|
||
|
|
return {
|
||
|
|
inserted: result.rows.length,
|
||
|
|
columns: result.headers.length
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sync selected range to Brainy
|
||
|
|
*/
|
||
|
|
function syncRangeToBrainy(type) {
|
||
|
|
const sheet = SpreadsheetApp.getActiveSheet();
|
||
|
|
const range = sheet.getActiveRange();
|
||
|
|
const values = range.getValues();
|
||
|
|
|
||
|
|
if (values.length < 2) {
|
||
|
|
throw new Error('Need at least header row and one data row');
|
||
|
|
}
|
||
|
|
|
||
|
|
const headers = values[0];
|
||
|
|
const operations = [];
|
||
|
|
|
||
|
|
// Find ID column
|
||
|
|
const idCol = headers.findIndex(h => h.toLowerCase() === 'id');
|
||
|
|
|
||
|
|
for (let i = 1; i < values.length; i++) {
|
||
|
|
const row = values[i];
|
||
|
|
const metadata = {};
|
||
|
|
|
||
|
|
for (let j = 0; j < headers.length; j++) {
|
||
|
|
if (j !== idCol && row[j]) {
|
||
|
|
metadata[headers[j]] = row[j];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (idCol >= 0 && row[idCol]) {
|
||
|
|
// Update existing
|
||
|
|
operations.push({
|
||
|
|
action: 'update',
|
||
|
|
id: row[idCol],
|
||
|
|
metadata
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
// Add new
|
||
|
|
operations.push({
|
||
|
|
action: 'add',
|
||
|
|
type: type,
|
||
|
|
metadata
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = brainyFetch('/batch', {
|
||
|
|
method: 'POST',
|
||
|
|
payload: { operations }
|
||
|
|
});
|
||
|
|
|
||
|
|
// Update IDs in sheet for new entities
|
||
|
|
if (idCol >= 0) {
|
||
|
|
for (let i = 0; i < result.results.length; i++) {
|
||
|
|
if (result.results[i].action === 'add' && result.results[i].id) {
|
||
|
|
sheet.getRange(range.getRow() + 1 + i, range.getColumn() + idCol).setValue(result.results[i].id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|