87f6de7c9f
leo 2026-08-15「有效才正式開發」下一格:紙上考卷驗過的動作集做成真的 n8n 節點。 詞彙照定案(sheet/field/record,InkStoneCo system-dev/wiki/kbdb-詞彙表.md)。 - 動作 ↔ API 映射收斂在 nodes/Kbdb/actions.ts(純函式),20 個 vitest 釘死 ——就是動作對照表 ✅ 那半的機器版;🔴/◐ 的動作刻意不存在,呼叫丟 「沒有這個能力」(07-thin-shell §3.1:API 沒有的能力不拼裝) - 對外走 cypher-executor /kbdb/* proxy(X-Arcrun-API-Key),不直連 kbdb worker - Add a field 不掛:proxy 無 PATCH /kbdb/templates(實測 404);base 端 PATCH /templates/:id 語意是 slots 整組覆蓋(本地實測),不是加一欄 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
284 lines
9.4 KiB
TypeScript
284 lines
9.4 KiB
TypeScript
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<INodeExecutionData[][]> {
|
||
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<string, string>;
|
||
}
|
||
} 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];
|
||
}
|
||
}
|