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:
David Snelling 2026-01-20 16:21:11 -08:00
parent 24039e8a1a
commit b5bc9000cf
28 changed files with 8186 additions and 1 deletions

350
integrations/README.md Normal file
View 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
```

View 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;
}

View 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)
```

View 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>

View 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"
]
}