import type { IExecuteFunctions, INodeExecutionData, INodeType, INodeTypeDescription, IHttpRequestMethods, IDataObject, } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; import { buildRequest, type ActionParams } from './actions'; /** * KBDB node(薄殼)。 * * 詞彙依 leo 2026-08-15 定案(InkStoneCo `system-dev/wiki/kbdb-詞彙表.md`): * 對外只有三個字——sheet(一張表=一個看法)/field(一欄)/record(一筆記錄, * 有自己的 ID,同一筆可以出現在好幾張表裡)。 * * 動作集=動作對照表標 ✅ 的那半。標 🔴(關係整組、Archive 兩個)與 ◐ 裡 * 語意不成立的(Add a field),**刻意不在這裡**——API 沒有的能力,薄殼不拼裝。 */ export class Kbdb implements INodeType { description: INodeTypeDescription = { displayName: 'KBDB', name: 'kbdb', icon: 'file:kbdb.svg', group: ['transform'], version: 1, subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', description: 'KBDB:把東西和「怎麼看東西」拆開的資料庫。先把 record 丟進去,再用 sheet 決定怎麼看它——不建表、不用 SQL、不用工程師。', defaults: { name: 'KBDB' }, inputs: ['main'], outputs: ['main'], credentials: [{ name: 'kbdbApi', required: true }], properties: [ { displayName: 'Resource', name: 'resource', type: 'options', noDataExpression: true, options: [ { name: 'Record', value: 'record', description: '一筆記錄。有自己的 ID' }, { name: 'Sheet', value: 'sheet', description: '一張表=一個看法。收起它,記錄不會消失' }, { name: 'Search', value: 'search', description: '搜尋你存進 KBDB 的內容' }, ], default: 'record', }, // ── record operations ──────────────────────────────────────────── { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, displayOptions: { show: { resource: ['record'] } }, options: [ { name: 'Append Record', value: 'append', description: '在一張 sheet 裡新增一筆記錄', action: 'Append a record to a sheet', }, { name: 'Get a Record', value: 'get', description: '用 record ID 取回一筆記錄', action: 'Get a record', }, { name: 'Get Many Records', value: 'getMany', description: '列出一張 sheet 裡(你 namespace 底下)的所有記錄', action: 'Get many records from a sheet', }, { name: 'Update a Record', value: 'update', description: '改一筆既有記錄的欄位值(只改你給的欄,其他欄不動)', action: 'Update a record', }, ], default: 'append', }, // ── sheet operations ───────────────────────────────────────────── { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, displayOptions: { show: { resource: ['sheet'] } }, options: [ { name: 'Create a Sheet', value: 'create', description: '開一張新表=寫下你想看哪幾個欄位。不建實體表、不用等人', action: 'Create a sheet', }, { name: 'Get Sheet Schema', value: 'getSchema', description: '看一張 sheet 有哪些欄位', action: 'Get sheet schema', }, { name: 'List Sheets', value: 'list', description: '列出所有 sheet', action: 'List sheets', }, ], default: 'list', }, // ── search operations ──────────────────────────────────────────── { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, displayOptions: { show: { resource: ['search'] } }, options: [ { name: 'Search', value: 'search', description: '全文/語意搜尋。搜的是內容本身:命中回「那一格的內容」(含 entry id),不是整筆 record,也不含同筆的其他欄——目前沒有從命中反查所屬 record 的能力', action: 'Search KBDB', }, ], default: 'search', }, // ── shared fields ──────────────────────────────────────────────── { displayName: 'Sheet', name: 'sheet', type: 'string', default: '', required: true, displayOptions: { show: { resource: ['record', 'sheet'], operation: ['append', 'getMany', 'create', 'getSchema'], }, }, description: 'Sheet 名稱(或 ID)', }, { displayName: 'Record ID', name: 'recordId', type: 'string', default: '', required: true, displayOptions: { show: { resource: ['record'], operation: ['get', 'update'] } }, description: '記錄自己的 ID(rec_…)', }, { displayName: 'Values', name: 'values', type: 'json', default: '{}', required: true, displayOptions: { show: { resource: ['record'], operation: ['append', 'update'] } }, description: '欄位值,JSON 物件:{"欄名": "內容"}。Update 時只需給要改的欄', }, { displayName: 'Fields', name: 'fields', type: 'string', default: '', required: true, displayOptions: { show: { resource: ['sheet'], operation: ['create'] } }, placeholder: 'name, phone, email', description: '這張表要看哪幾欄,逗號分隔', }, { displayName: 'Description', name: 'description', type: 'string', default: '', displayOptions: { show: { resource: ['sheet'], operation: ['create'] } }, description: '這張 sheet 是做什麼的(選填)', }, { displayName: 'Query', name: 'query', type: 'string', default: '', required: true, displayOptions: { show: { resource: ['search'] } }, description: '要找什麼', }, { displayName: 'Mode', name: 'mode', type: 'options', options: [ { name: 'Keyword', value: 'keyword' }, { name: 'Semantic', value: 'semantic' }, ], default: 'keyword', displayOptions: { show: { resource: ['search'] } }, description: 'Keyword=字面命中;Semantic=語意相近(實例沒開語意模組時會誠實降級成 keyword)', }, { displayName: 'Filters', name: 'filters', type: 'collection', placeholder: 'Add Filter', default: {}, displayOptions: { show: { resource: ['search'] } }, options: [ { displayName: 'Library', name: 'library', type: 'string', default: '' }, { displayName: 'Source', name: 'source', type: 'string', default: '' }, { displayName: 'Entry Type', name: 'entry_type', type: 'string', default: '' }, ], }, ], }; async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; const credentials = await this.getCredentials('kbdbApi'); const baseUrl = String(credentials.baseUrl).replace(/\/$/, ''); for (let i = 0; i < items.length; i++) { try { const resource = this.getNodeParameter('resource', i) as string; const operation = this.getNodeParameter('operation', i) as string; const params: ActionParams = {}; if (resource === 'record') { if (operation === 'append' || operation === 'getMany') { params.sheet = this.getNodeParameter('sheet', i) as string; } if (operation === 'get' || operation === 'update') { params.recordId = this.getNodeParameter('recordId', i) as string; } if (operation === 'append' || operation === 'update') { const raw = this.getNodeParameter('values', i); params.values = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Record; } } else if (resource === 'sheet') { if (operation !== 'list') { params.sheet = this.getNodeParameter('sheet', i) as string; } if (operation === 'create') { params.slots = (this.getNodeParameter('fields', i) as string) .split(',') .map((s) => s.trim()) .filter(Boolean); params.description = this.getNodeParameter('description', i, '') as string; } } else if (resource === 'search') { params.query = this.getNodeParameter('query', i) as string; params.mode = this.getNodeParameter('mode', i, 'keyword') as 'keyword' | 'semantic'; params.filters = this.getNodeParameter('filters', i, {}) as ActionParams['filters']; } const req = buildRequest(resource, operation, params); const response = await this.helpers.httpRequestWithAuthentication.call(this, 'kbdbApi', { method: req.method as IHttpRequestMethods, url: `${baseUrl}${req.path}`, qs: req.qs, body: req.body, json: true, }); const asArray = Array.isArray(response) ? response : [response]; for (const entry of asArray) { returnData.push({ json: entry as IDataObject, pairedItem: { item: i } }); } } catch (error) { if (this.continueOnFail()) { returnData.push({ json: { error: (error as Error).message }, pairedItem: { item: i } }); continue; } throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i }); } } return [returnData]; } }