feat: Integration Hub for external tool connectivity
- Add native config option: `new Brainy({ integrations: true })`
- OData integration for Excel Power Query and Power BI
- Google Sheets integration with Apps Script
- Server-Sent Events (SSE) for real-time streaming
- Webhooks for push notifications
- Zero-config with sensible defaults
- Full tree-shaking when disabled
This commit is contained in:
parent
24039e8a1a
commit
b5bc9000cf
28 changed files with 8186 additions and 1 deletions
350
integrations/README.md
Normal file
350
integrations/README.md
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
# Brainy Integrations
|
||||
|
||||
Connect Brainy to spreadsheets, BI tools, and external systems with zero configuration.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { Brainy } from '@soulcraft/brainy'
|
||||
|
||||
const brain = new Brainy({ integrations: true })
|
||||
await brain.init()
|
||||
|
||||
// That's it! Your endpoints are ready:
|
||||
console.log(brain.hub.endpoints)
|
||||
// { odata: '/odata', sheets: '/sheets', sse: '/events', webhooks: '/webhooks' }
|
||||
```
|
||||
|
||||
## Supported Tools
|
||||
|
||||
| Tool | Protocol | How to Connect |
|
||||
|------|----------|----------------|
|
||||
| Excel | OData | Data → Get Data → From OData Feed → `http://your-server/odata` |
|
||||
| Power BI | OData | Get Data → OData Feed → `http://your-server/odata` |
|
||||
| Tableau | OData | Connect → OData → `http://your-server/odata` |
|
||||
| Google Sheets | REST | Install Apps Script add-on (see below) |
|
||||
| Any SSE Client | SSE | `new EventSource('http://your-server/events')` |
|
||||
|
||||
---
|
||||
|
||||
## Excel (Power Query)
|
||||
|
||||
### Connect in 3 clicks:
|
||||
|
||||
1. **Data** tab → **Get Data** → **From Other Sources** → **From OData Feed**
|
||||
2. Enter URL: `http://your-server/odata`
|
||||
3. Click **OK** → **Load**
|
||||
|
||||
### Query Options
|
||||
|
||||
Excel Power Query supports OData query parameters:
|
||||
|
||||
```
|
||||
/odata/Entities?$filter=Type eq 'person'&$top=100
|
||||
/odata/Entities?$select=Id,Type,Metadata_name
|
||||
/odata/Entities?$orderby=CreatedAt desc
|
||||
/odata/Entities?$search=machine learning
|
||||
```
|
||||
|
||||
### Refresh Data
|
||||
|
||||
- Manual: **Data** → **Refresh All**
|
||||
- Automatic: **Query Properties** → Set refresh interval
|
||||
|
||||
---
|
||||
|
||||
## Power BI
|
||||
|
||||
### Connect:
|
||||
|
||||
1. **Get Data** → **OData Feed**
|
||||
2. Enter URL: `http://your-server/odata`
|
||||
3. Select tables: `Entities`, `Relationships`
|
||||
4. Click **Load**
|
||||
|
||||
### Advanced
|
||||
|
||||
Power BI DirectQuery is supported for real-time data.
|
||||
|
||||
---
|
||||
|
||||
## Tableau
|
||||
|
||||
### Connect:
|
||||
|
||||
1. **Connect** → **To a Server** → **OData**
|
||||
2. Enter: `http://your-server/odata`
|
||||
3. Drag tables to canvas
|
||||
|
||||
---
|
||||
|
||||
## Google Sheets
|
||||
|
||||
### Setup (5 minutes):
|
||||
|
||||
1. Open your Google Sheet
|
||||
2. **Extensions** → **Apps Script**
|
||||
3. Delete existing code
|
||||
4. Copy `integrations/google-sheets/Code.gs` into the editor
|
||||
5. Create new file `Sidebar.html`, paste from `integrations/google-sheets/Sidebar.html`
|
||||
6. **Project Settings** → **Script properties** → Add:
|
||||
- `BRAINY_URL` = `http://your-server`
|
||||
7. Save and refresh spreadsheet
|
||||
|
||||
### Custom Functions:
|
||||
|
||||
```
|
||||
=BRAINY_QUERY("type:person", 100) // Query entities
|
||||
=BRAINY_GET("entity-id") // Get one entity
|
||||
=BRAINY_SIMILAR("machine learning", 5) // Semantic search
|
||||
=BRAINY_RELATIONS("entity-id", "from") // Get relationships
|
||||
```
|
||||
|
||||
### Sidebar:
|
||||
|
||||
**Extensions** → **Brainy** → **Open Sidebar**
|
||||
|
||||
- Search and insert results
|
||||
- Add new entities
|
||||
- Sync selected range to Brainy
|
||||
|
||||
---
|
||||
|
||||
## Real-Time Streaming (SSE)
|
||||
|
||||
### JavaScript:
|
||||
|
||||
```javascript
|
||||
const source = new EventSource('http://your-server/events')
|
||||
|
||||
source.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
console.log('Change:', data)
|
||||
}
|
||||
|
||||
// Filter by type
|
||||
const filtered = new EventSource('http://your-server/events?types=noun&operations=create,update')
|
||||
```
|
||||
|
||||
### Query Parameters:
|
||||
|
||||
- `types` - Entity types: `noun`, `verb`, `vfs`
|
||||
- `operations` - Operations: `create`, `update`, `delete`
|
||||
- `nounTypes` - Filter by noun type: `person`, `document`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Webhooks
|
||||
|
||||
Push events to external URLs:
|
||||
|
||||
```typescript
|
||||
const brain = new Brainy({ integrations: true })
|
||||
await brain.init()
|
||||
|
||||
// Register a webhook
|
||||
await brain.hub.webhooks?.register({
|
||||
url: 'https://your-app.com/webhook',
|
||||
events: { entityTypes: ['noun'], operations: ['create', 'update'] },
|
||||
secret: 'your-hmac-secret' // optional, for signature verification
|
||||
})
|
||||
```
|
||||
|
||||
### Webhook Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"id": "event-123",
|
||||
"type": "noun",
|
||||
"operation": "create",
|
||||
"entityId": "entity-456",
|
||||
"timestamp": 1704067200000
|
||||
}
|
||||
],
|
||||
"deliveredAt": 1704067201000
|
||||
}
|
||||
```
|
||||
|
||||
### Signature Verification:
|
||||
|
||||
Webhooks include `X-Brainy-Signature` header with HMAC-SHA256 signature.
|
||||
|
||||
---
|
||||
|
||||
## Server Examples
|
||||
|
||||
### Minimal (in-memory):
|
||||
|
||||
```typescript
|
||||
import { Brainy } from '@soulcraft/brainy'
|
||||
|
||||
const brain = new Brainy({ integrations: true })
|
||||
await brain.init()
|
||||
|
||||
// Add some data
|
||||
await brain.add({ type: 'Person', metadata: { name: 'Alice' } })
|
||||
|
||||
// Get connection instructions
|
||||
console.log(brain.hub.getInstructions())
|
||||
```
|
||||
|
||||
### Express:
|
||||
|
||||
```typescript
|
||||
import express from 'express'
|
||||
import { Brainy } from '@soulcraft/brainy'
|
||||
|
||||
const app = express()
|
||||
const brain = new Brainy({
|
||||
storage: { type: 'filesystem', options: { basePath: './data' } },
|
||||
integrations: true
|
||||
})
|
||||
await brain.init()
|
||||
|
||||
app.use(express.json())
|
||||
|
||||
// Route all integrations
|
||||
app.all(['/odata/*', '/sheets/*', '/events/*'], async (req, res) => {
|
||||
const response = await brain.hub.handleRequest({
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
query: req.query as Record<string, string>,
|
||||
headers: req.headers as Record<string, string>,
|
||||
body: req.body
|
||||
})
|
||||
|
||||
res.status(response.status).set(response.headers)
|
||||
if (response.body) res.json(response.body)
|
||||
else res.end()
|
||||
})
|
||||
|
||||
app.listen(3000, () => {
|
||||
console.log('Brainy server ready!')
|
||||
console.log('Excel/Power BI: http://localhost:3000/odata')
|
||||
console.log('Google Sheets: http://localhost:3000/sheets')
|
||||
console.log('SSE Stream: http://localhost:3000/events')
|
||||
})
|
||||
```
|
||||
|
||||
### Hono (Cloudflare Workers):
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono'
|
||||
import { Brainy } from '@soulcraft/brainy'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
const brain = new Brainy({
|
||||
storage: { type: 'memory' },
|
||||
integrations: true
|
||||
})
|
||||
await brain.init()
|
||||
|
||||
app.all('/odata/*', async (c) => {
|
||||
const response = await brain.hub.handleRequest({
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
query: Object.fromEntries(new URL(c.req.url).searchParams),
|
||||
headers: Object.fromEntries(c.req.raw.headers),
|
||||
body: await c.req.json().catch(() => undefined)
|
||||
})
|
||||
return c.json(response.body, response.status)
|
||||
})
|
||||
|
||||
export default app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Custom Base Path:
|
||||
|
||||
```typescript
|
||||
const brain = new Brainy({
|
||||
integrations: { basePath: '/api/v1' }
|
||||
})
|
||||
await brain.init()
|
||||
|
||||
// Endpoints become:
|
||||
// /api/v1/odata
|
||||
// /api/v1/sheets
|
||||
// /api/v1/events
|
||||
```
|
||||
|
||||
### Select Integrations:
|
||||
|
||||
```typescript
|
||||
// Only OData and Sheets
|
||||
const brain = new Brainy({
|
||||
integrations: { enable: ['odata', 'sheets'] }
|
||||
})
|
||||
await brain.init()
|
||||
```
|
||||
|
||||
### Per-Integration Config:
|
||||
|
||||
```typescript
|
||||
const brain = new Brainy({
|
||||
integrations: {
|
||||
config: {
|
||||
odata: { basePath: '/data' },
|
||||
sse: { heartbeatInterval: 15000 }
|
||||
}
|
||||
}
|
||||
})
|
||||
await brain.init()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Excel: "Can't connect to OData feed"
|
||||
- Ensure server is running and accessible
|
||||
- Check firewall settings
|
||||
- Try opening URL in browser first
|
||||
|
||||
### Google Sheets: "Brainy URL not configured"
|
||||
- Add `BRAINY_URL` in Apps Script → Project Settings → Script properties
|
||||
|
||||
### Power BI: "Invalid OData"
|
||||
- Ensure `$metadata` endpoint returns valid XML
|
||||
- Try: `http://your-server/odata/$metadata`
|
||||
|
||||
### SSE: "Connection dropped"
|
||||
- Check network/proxy timeout settings
|
||||
- The integration sends heartbeats every 30 seconds by default
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### OData Endpoints
|
||||
|
||||
```
|
||||
GET /odata/$metadata - Schema (XML)
|
||||
GET /odata/Entities - List entities
|
||||
GET /odata/Entities('id') - Get entity
|
||||
GET /odata/Relationships - List relationships
|
||||
POST /odata/Entities - Create entity
|
||||
```
|
||||
|
||||
### Sheets Endpoints
|
||||
|
||||
```
|
||||
GET /sheets/query?q=...&limit=100 - Query entities
|
||||
GET /sheets/entity/:id - Get entity
|
||||
GET /sheets/similar?text=...&k=10 - Semantic search
|
||||
POST /sheets/add - Add entity
|
||||
```
|
||||
|
||||
### SSE Endpoint
|
||||
|
||||
```
|
||||
GET /events - All events
|
||||
GET /events?types=noun - Filter by type
|
||||
GET /events?operations=create,update - Filter by operation
|
||||
```
|
||||
451
integrations/google-sheets/Code.gs
Normal file
451
integrations/google-sheets/Code.gs
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
142
integrations/google-sheets/README.md
Normal file
142
integrations/google-sheets/README.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# Brainy Google Sheets Add-on
|
||||
|
||||
Two-way sync between Brainy and Google Sheets.
|
||||
|
||||
## Quick Setup
|
||||
|
||||
1. Open your Google Sheet
|
||||
2. Go to **Extensions → Apps Script**
|
||||
3. Delete any existing code
|
||||
4. Copy and paste `Code.gs` into the editor
|
||||
5. Create a new HTML file called `Sidebar.html` and paste the content
|
||||
6. Go to **Project Settings** (gear icon) → **Script properties**
|
||||
7. Add property: `BRAINY_URL` = your Brainy server URL (e.g., `http://localhost:3000`)
|
||||
8. Save and refresh your spreadsheet
|
||||
|
||||
## Custom Functions
|
||||
|
||||
Use these directly in cells:
|
||||
|
||||
### `=BRAINY_QUERY(query, limit)`
|
||||
Query entities using semantic search or type filter.
|
||||
|
||||
```
|
||||
=BRAINY_QUERY("machine learning", 10)
|
||||
=BRAINY_QUERY("type:person", 100)
|
||||
```
|
||||
|
||||
### `=BRAINY_GET(id)`
|
||||
Get a single entity by ID.
|
||||
|
||||
```
|
||||
=BRAINY_GET("entity-uuid-here")
|
||||
```
|
||||
|
||||
### `=BRAINY_SIMILAR(text, limit)`
|
||||
Find semantically similar entities.
|
||||
|
||||
```
|
||||
=BRAINY_SIMILAR("artificial intelligence research", 5)
|
||||
```
|
||||
|
||||
### `=BRAINY_RELATIONS(entityId, direction)`
|
||||
Get relationships for an entity.
|
||||
|
||||
```
|
||||
=BRAINY_RELATIONS("entity-id", "from")
|
||||
=BRAINY_RELATIONS("entity-id", "to")
|
||||
=BRAINY_RELATIONS("entity-id", "both")
|
||||
```
|
||||
|
||||
### `=BRAINY_TYPES()`
|
||||
List all available entity types.
|
||||
|
||||
### `=BRAINY_RELATION_TYPES()`
|
||||
List all available relationship types.
|
||||
|
||||
## Sidebar
|
||||
|
||||
Open via **Brainy → Open Sidebar** menu:
|
||||
|
||||
- **Search**: Query entities and insert results into the sheet
|
||||
- **Add**: Create new entities with a form
|
||||
- **Sync**: Sync selected range to Brainy (add or update)
|
||||
|
||||
## Authentication
|
||||
|
||||
If your Brainy server requires authentication:
|
||||
|
||||
1. Go to **Project Settings → Script properties**
|
||||
2. Add property: `BRAINY_API_KEY` = your API key
|
||||
|
||||
## Real-Time Sync
|
||||
|
||||
For real-time updates, enable SSE in your Brainy server:
|
||||
|
||||
```javascript
|
||||
brain.augmentations.register(new SSEIntegration())
|
||||
```
|
||||
|
||||
The sidebar will automatically show changes as they happen.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Brainy URL not configured"
|
||||
Add the `BRAINY_URL` script property in Apps Script settings.
|
||||
|
||||
### "Connection failed"
|
||||
- Check that your Brainy server is running
|
||||
- Ensure the URL is correct (include port if needed)
|
||||
- For local development, use ngrok or similar to expose localhost
|
||||
|
||||
### Custom functions not appearing
|
||||
- Refresh the spreadsheet
|
||||
- Wait a few seconds (Apps Script can be slow to register)
|
||||
- Check the Apps Script execution log for errors
|
||||
|
||||
## Server Setup
|
||||
|
||||
The simplest way to enable all integrations:
|
||||
|
||||
```javascript
|
||||
import { Brainy } from '@soulcraft/brainy'
|
||||
|
||||
const brain = new Brainy({ integrations: true })
|
||||
await brain.init()
|
||||
|
||||
console.log(brain.hub.endpoints)
|
||||
// { odata: '/odata', sheets: '/sheets', sse: '/events', webhooks: '/webhooks' }
|
||||
```
|
||||
|
||||
With Express:
|
||||
|
||||
```javascript
|
||||
import express from 'express'
|
||||
import { Brainy } from '@soulcraft/brainy'
|
||||
|
||||
const app = express()
|
||||
const brain = new Brainy({ integrations: true })
|
||||
await brain.init()
|
||||
|
||||
// Route all integration requests
|
||||
app.use(express.json())
|
||||
app.all(['/odata/*', '/sheets/*', '/events/*'], async (req, res) => {
|
||||
const response = await brain.hub.handleRequest({
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
query: req.query,
|
||||
headers: req.headers,
|
||||
body: req.body
|
||||
})
|
||||
|
||||
if (response.isSSE) {
|
||||
// Handle SSE stream
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
// ... streaming logic
|
||||
} else {
|
||||
res.status(response.status).set(response.headers).json(response.body)
|
||||
}
|
||||
})
|
||||
|
||||
app.listen(3000)
|
||||
```
|
||||
421
integrations/google-sheets/Sidebar.html
Normal file
421
integrations/google-sheets/Sidebar.html
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<base target="_top">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status {
|
||||
margin-left: auto;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #ccc;
|
||||
}
|
||||
.status.connected {
|
||||
background: #34a853;
|
||||
}
|
||||
.status.disconnected {
|
||||
background: #ea4335;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.section-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: #5f6368;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #dadce0;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4285f4;
|
||||
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
|
||||
}
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
button.primary {
|
||||
background: #4285f4;
|
||||
color: white;
|
||||
}
|
||||
button.primary:hover {
|
||||
background: #3367d6;
|
||||
}
|
||||
button.secondary {
|
||||
background: #f1f3f4;
|
||||
color: #5f6368;
|
||||
}
|
||||
button.secondary:hover {
|
||||
background: #e8eaed;
|
||||
}
|
||||
button.danger {
|
||||
background: #ea4335;
|
||||
color: white;
|
||||
}
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.results {
|
||||
margin-top: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.result-item {
|
||||
padding: 10px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.result-item:hover {
|
||||
background: #e8f0fe;
|
||||
}
|
||||
.result-item .type {
|
||||
font-size: 10px;
|
||||
color: #5f6368;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.result-item .title {
|
||||
font-weight: 500;
|
||||
color: #1967d2;
|
||||
}
|
||||
.result-item .id {
|
||||
font-size: 11px;
|
||||
color: #80868b;
|
||||
font-family: monospace;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
color: #5f6368;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab.active {
|
||||
color: #4285f4;
|
||||
border-bottom-color: #4285f4;
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #5f6368;
|
||||
padding: 20px;
|
||||
}
|
||||
.error {
|
||||
background: #fce8e6;
|
||||
color: #c5221f;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.success {
|
||||
background: #e6f4ea;
|
||||
color: #137333;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🧠 Brainy</h1>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="search">Search</div>
|
||||
<div class="tab" data-tab="add">Add</div>
|
||||
<div class="tab" data-tab="sync">Sync</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Tab -->
|
||||
<div class="tab-content active" id="tab-search">
|
||||
<div class="section">
|
||||
<input type="text" id="searchQuery" placeholder="Search entities...">
|
||||
<select id="searchType">
|
||||
<option value="">All Types</option>
|
||||
</select>
|
||||
<div class="btn-row">
|
||||
<button class="primary" onclick="search()">Search</button>
|
||||
<button class="secondary" onclick="insertResults()">Insert to Sheet</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="results" id="searchResults">
|
||||
<div class="loading">Enter a search query</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Tab -->
|
||||
<div class="tab-content" id="tab-add">
|
||||
<div class="section">
|
||||
<div class="section-title">Entity Type</div>
|
||||
<select id="addType"></select>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Name</div>
|
||||
<input type="text" id="addName" placeholder="Entity name">
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Description</div>
|
||||
<textarea id="addDescription" rows="3" placeholder="Description (optional)"></textarea>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Additional Fields (JSON)</div>
|
||||
<textarea id="addMetadata" rows="3" placeholder='{"email": "john@example.com"}'></textarea>
|
||||
</div>
|
||||
<button class="primary" onclick="addEntity()">Add Entity</button>
|
||||
</div>
|
||||
|
||||
<!-- Sync Tab -->
|
||||
<div class="tab-content" id="tab-sync">
|
||||
<div class="section">
|
||||
<p>Select a range with entity data and sync it to Brainy.</p>
|
||||
<p style="font-size: 11px; color: #5f6368;">
|
||||
First row should be headers. Include an "Id" column for updates.
|
||||
</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Type for New Entities</div>
|
||||
<select id="syncType"></select>
|
||||
</div>
|
||||
<button class="primary" onclick="syncSelection()">Sync Selection</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// State
|
||||
let schema = { nounTypes: [], verbTypes: [] };
|
||||
let searchResults = [];
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkConnection();
|
||||
loadSchema();
|
||||
setupTabs();
|
||||
});
|
||||
|
||||
function setupTabs() {
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function checkConnection() {
|
||||
google.script.run
|
||||
.withSuccessHandler(result => {
|
||||
const status = document.getElementById('status');
|
||||
status.className = 'status ' + (result.connected ? 'connected' : 'disconnected');
|
||||
})
|
||||
.withFailureHandler(err => {
|
||||
document.getElementById('status').className = 'status disconnected';
|
||||
})
|
||||
.getConnectionStatus();
|
||||
}
|
||||
|
||||
function loadSchema() {
|
||||
google.script.run
|
||||
.withSuccessHandler(result => {
|
||||
schema = result;
|
||||
populateTypeDropdowns();
|
||||
})
|
||||
.sidebarGetSchema();
|
||||
}
|
||||
|
||||
function populateTypeDropdowns() {
|
||||
const selects = ['searchType', 'addType', 'syncType'];
|
||||
selects.forEach(id => {
|
||||
const select = document.getElementById(id);
|
||||
const currentValue = select.value;
|
||||
select.innerHTML = id === 'searchType' ? '<option value="">All Types</option>' : '';
|
||||
schema.nounTypes.forEach(type => {
|
||||
const option = document.createElement('option');
|
||||
option.value = type;
|
||||
option.textContent = type.charAt(0).toUpperCase() + type.slice(1);
|
||||
select.appendChild(option);
|
||||
});
|
||||
if (currentValue) select.value = currentValue;
|
||||
});
|
||||
}
|
||||
|
||||
function showMessage(text, type) {
|
||||
const el = document.getElementById('message');
|
||||
el.innerHTML = `<div class="${type}">${text}</div>`;
|
||||
setTimeout(() => el.innerHTML = '', 3000);
|
||||
}
|
||||
|
||||
function search() {
|
||||
const query = document.getElementById('searchQuery').value;
|
||||
const type = document.getElementById('searchType').value;
|
||||
const resultsEl = document.getElementById('searchResults');
|
||||
|
||||
resultsEl.innerHTML = '<div class="loading">Searching...</div>';
|
||||
|
||||
google.script.run
|
||||
.withSuccessHandler(result => {
|
||||
searchResults = result;
|
||||
renderResults(result);
|
||||
})
|
||||
.withFailureHandler(err => {
|
||||
resultsEl.innerHTML = `<div class="error">${err.message}</div>`;
|
||||
})
|
||||
.sidebarQuery(query, type, 50);
|
||||
}
|
||||
|
||||
function renderResults(result) {
|
||||
const resultsEl = document.getElementById('searchResults');
|
||||
|
||||
if (!result.rows || result.rows.length === 0) {
|
||||
resultsEl.innerHTML = '<div class="loading">No results found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Find column indices
|
||||
const headers = result.headers;
|
||||
const idCol = headers.indexOf('Id');
|
||||
const typeCol = headers.indexOf('Type');
|
||||
const nameCol = headers.findIndex(h => h.toLowerCase().includes('name'));
|
||||
|
||||
resultsEl.innerHTML = result.rows.map((row, i) => `
|
||||
<div class="result-item" onclick="selectResult(${i})">
|
||||
<div class="type">${row[typeCol] || 'unknown'}</div>
|
||||
<div class="title">${row[nameCol] || row[idCol] || 'Untitled'}</div>
|
||||
<div class="id">${row[idCol]}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function selectResult(index) {
|
||||
const row = searchResults.rows[index];
|
||||
const headers = searchResults.headers;
|
||||
|
||||
// Show details or insert into sheet
|
||||
console.log('Selected:', row);
|
||||
}
|
||||
|
||||
function insertResults() {
|
||||
const query = document.getElementById('searchQuery').value;
|
||||
const type = document.getElementById('searchType').value;
|
||||
|
||||
google.script.run
|
||||
.withSuccessHandler(result => {
|
||||
showMessage(`Inserted ${result.inserted} rows`, 'success');
|
||||
})
|
||||
.withFailureHandler(err => {
|
||||
showMessage(err.message, 'error');
|
||||
})
|
||||
.insertQueryResult(query, type, 100);
|
||||
}
|
||||
|
||||
function addEntity() {
|
||||
const type = document.getElementById('addType').value;
|
||||
const name = document.getElementById('addName').value;
|
||||
const description = document.getElementById('addDescription').value;
|
||||
const metadataStr = document.getElementById('addMetadata').value;
|
||||
|
||||
if (!type) {
|
||||
showMessage('Please select a type', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let metadata = { name };
|
||||
if (description) metadata.description = description;
|
||||
|
||||
try {
|
||||
if (metadataStr) {
|
||||
const extra = JSON.parse(metadataStr);
|
||||
metadata = { ...metadata, ...extra };
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('Invalid JSON in additional fields', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
google.script.run
|
||||
.withSuccessHandler(result => {
|
||||
showMessage('Entity created!', 'success');
|
||||
document.getElementById('addName').value = '';
|
||||
document.getElementById('addDescription').value = '';
|
||||
document.getElementById('addMetadata').value = '';
|
||||
})
|
||||
.withFailureHandler(err => {
|
||||
showMessage(err.message, 'error');
|
||||
})
|
||||
.sidebarAddEntity(type, null, metadata);
|
||||
}
|
||||
|
||||
function syncSelection() {
|
||||
const type = document.getElementById('syncType').value;
|
||||
|
||||
if (!type) {
|
||||
showMessage('Please select a type for new entities', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
google.script.run
|
||||
.withSuccessHandler(result => {
|
||||
showMessage(`Synced ${result.successful} entities (${result.failed} failed)`, 'success');
|
||||
})
|
||||
.withFailureHandler(err => {
|
||||
showMessage(err.message, 'error');
|
||||
})
|
||||
.syncRangeToBrainy(type);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
11
integrations/google-sheets/appsscript.json
Normal file
11
integrations/google-sheets/appsscript.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"timeZone": "America/New_York",
|
||||
"dependencies": {},
|
||||
"exceptionLogging": "STACKDRIVER",
|
||||
"runtimeVersion": "V8",
|
||||
"oauthScopes": [
|
||||
"https://www.googleapis.com/auth/spreadsheets.currentonly",
|
||||
"https://www.googleapis.com/auth/script.container.ui",
|
||||
"https://www.googleapis.com/auth/script.external_request"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue