kintone-as-code
Version:
A CLI tool for managing kintone applications as code with type-safe TypeScript schemas
402 lines (389 loc) • 16.2 kB
JavaScript
import { Schema } from 'effect';
import { GetFormFieldsResponseSchema as FormFieldsSchema } from 'kintone-effect-schema';
function hasErrorsProperty(e) {
return typeof e === 'object' && e !== null && 'errors' in e;
}
export const convertKintoneFieldsToSchema = (rawFields, appConstantName, appName) => {
try {
const parsed = Schema.decodeUnknownSync(FormFieldsSchema)(rawFields);
// READMEスタイル: 個別フィールド定義 + appFieldsConfig(固定)
const classicCode = generateFieldsConfigCode(parsed.properties);
// 安全な式生成(コードインジェクション回避)
const safeNameExpr = JSON.stringify(appName ?? 'Exported App');
const appIdExpr = appConstantName
? /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(appConstantName)
? `.${appConstantName}`
: `[${JSON.stringify(appConstantName)}]`
: `.MY_APP`;
return `${classicCode}
import { defineAppSchema } from 'kintone-as-code';
import { APP_IDS } from '../utils/app-ids.js';
export default defineAppSchema({
appId: APP_IDS${appIdExpr},
name: ${safeNameExpr},
description: 'This schema was exported from kintone.',
fieldsConfig: appFieldsConfig
});`;
}
catch (error) {
// バリデーションに失敗した場合でも、最低限の `properties` があれば寛容にフォールバック
console.error('Failed to parse kintone fields (will try fallback):', error);
try {
if (typeof rawFields === 'object' &&
rawFields !== null &&
'properties' in rawFields &&
typeof rawFields.properties === 'object') {
const props = rawFields.properties;
// プロパティの最低限バリデーション: 各フィールドが type:string を持つこと
const allFieldsHaveType = Object.values(props).every((v) => typeof v === 'object' &&
v !== null &&
typeof v.type === 'string');
if (!allFieldsHaveType) {
throw new Error('Schema validation failed during export.');
}
const classicCode = generateFieldsConfigCode(props);
const safeNameExpr = JSON.stringify(appName ?? 'Exported App');
const appIdExpr = appConstantName
? /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(appConstantName)
? `.${appConstantName}`
: `[${JSON.stringify(appConstantName)}]`
: `.MY_APP`;
return `${classicCode}
import { defineAppSchema } from 'kintone-as-code';
import { APP_IDS } from '../utils/app-ids.js';
export default defineAppSchema({
appId: APP_IDS${appIdExpr},
name: ${safeNameExpr},
description: 'This schema was exported from kintone.',
fieldsConfig: appFieldsConfig
});`;
}
}
catch (fallbackError) {
// フォールバックも失敗した場合は従来どおりエラー
console.error('Fallback schema generation also failed:', fallbackError);
}
if (hasErrorsProperty(error)) {
console.error('Validation errors:', JSON.stringify(error.errors, null, 2));
}
throw new Error('Schema validation failed during export.');
}
};
export const generateRecordSchemaCode = (schemaName) => {
// Generate TypeScript code for record schema that imports from the field schema
const baseName = schemaName.replace(/\.schema$/, '');
return `import { Schema } from 'effect';
import {
convertFormFieldsToRecordSchema,
createRecordSchemaFromForm,
decodeKintoneRecord
} from 'kintone-effect-schema';
import { appFieldsConfig } from './${baseName}.schema.js';
// Generate record schema from form field definitions (README style)
const recordSchemas = convertFormFieldsToRecordSchema(appFieldsConfig.properties as any);
// Export the record schema for type-safe record validation
export const RecordSchema = Schema.Struct(recordSchemas);
// Helper function for record validation with normalization
// Normalizes empty values for both REST API and JavaScript API
export const validateRecord = (record: unknown) => {
// First normalize the record (handle undefined, empty strings, etc.)
const normalizedRecord = decodeKintoneRecord(record);
// Then validate with schema
return Schema.decodeUnknownSync(RecordSchema)(normalizedRecord);
};
// Optional: Export with custom validations
// You can customize this by adding validation rules
export const CustomRecordSchema = createRecordSchemaFromForm(appFieldsConfig.properties as any, {
// Example: Add custom validation for specific fields
// price: (schema) => Schema.filter(
// schema,
// (field) => field.value !== null && Number(field.value) > 0,
// { message: () => "Price must be positive" }
// )
});
// Helper function for custom validation with normalization
export const validateRecordWithCustomRules = (record: unknown) => {
const normalizedRecord = decodeKintoneRecord(record);
return Schema.decodeUnknownSync(CustomRecordSchema)(normalizedRecord);
};
// Type inference helpers
export type AppRecord = Schema.Schema.Type<typeof RecordSchema>;
export type AppRecordEncoded = Schema.Schema.Encoded<typeof RecordSchema>;
`;
};
// Generate a static record schema (no runtime converters in the output)
export function generateStaticRecordSchemaCode(_schemaName, properties) {
const FIELD_TYPE_TO_RECORD_SCHEMA = {
SINGLE_LINE_TEXT: 'SingleLineTextFieldSchema',
MULTI_LINE_TEXT: 'MultiLineTextFieldSchema',
RICH_TEXT: 'RichTextFieldSchema',
NUMBER: 'NumberFieldSchema',
CALC: 'CalcFieldSchema',
RADIO_BUTTON: 'RadioButtonFieldSchema',
CHECK_BOX: 'CheckBoxFieldSchema',
MULTI_SELECT: 'MultiSelectFieldSchema',
DROP_DOWN: 'DropDownFieldSchema',
DATE: 'DateFieldSchema',
TIME: 'TimeFieldSchema',
DATETIME: 'DateTimeFieldSchema',
LINK: 'LinkFieldSchema',
USER_SELECT: 'UserSelectFieldSchema',
ORGANIZATION_SELECT: 'OrganizationSelectFieldSchema',
GROUP_SELECT: 'GroupSelectFieldSchema',
FILE: 'FileFieldSchema',
RECORD_NUMBER: 'RecordNumberFieldSchema',
CREATOR: 'CreatorFieldSchema',
CREATED_TIME: 'CreatedTimeFieldSchema',
MODIFIER: 'ModifierFieldSchema',
UPDATED_TIME: 'UpdatedTimeFieldSchema',
STATUS: 'StatusFieldSchema',
STATUS_ASSIGNEE: 'StatusAssigneeFieldSchema',
CATEGORY: 'CategoryFieldSchema',
RECORD_ID: 'RecordIdFieldSchema',
REVISION: 'RevisionFieldSchema',
};
const importSet = new Set(['decodeKintoneRecord']);
const entries = [];
for (const [code, field] of Object.entries(properties)) {
const f = field;
if (f.type === 'REFERENCE_TABLE' ||
f.type === 'GROUP' ||
f.type === 'SPACER' ||
f.type === 'LABEL') {
continue;
}
if (f.type === 'SUBTABLE' && f.fields) {
const innerTypes = new Set();
for (const sub of Object.values(f.fields)) {
const schemaConst = FIELD_TYPE_TO_RECORD_SCHEMA[sub.type];
if (schemaConst)
innerTypes.add(schemaConst);
}
for (const t of innerTypes)
importSet.add(t);
const unionExpr = innerTypes.size > 0
? `Schema.Union(${Array.from(innerTypes).join(', ')})`
: 'Schema.Unknown';
entries.push(` ${JSON.stringify(code)}: Schema.Struct({
type: Schema.Literal('SUBTABLE'),
value: Schema.Array(Schema.Struct({
id: Schema.String,
value: Schema.Record({ key: Schema.String, value: ${unionExpr} })
}))
})`);
continue;
}
const schemaConst = FIELD_TYPE_TO_RECORD_SCHEMA[f.type];
if (!schemaConst)
continue;
importSet.add(schemaConst);
const needsQuotes = code.startsWith('$') || !/^[a-zA-Z_][a-zA-Z0-9_$]*$/.test(code);
const key = needsQuotes ? JSON.stringify(code) : code;
entries.push(` ${key}: ${schemaConst}`);
}
const importList = Array.from(importSet).sort((a, b) => a.localeCompare(b));
const imports = `import { ${importList.join(', ')} } from 'kintone-effect-schema';`;
return `import { Schema } from 'effect';
${imports}
// Static record schema generated from form definition
export const RecordSchema = Schema.Struct({
${entries.join(',\n')}
});
export type AppRecord = Schema.Schema.Type<typeof RecordSchema>;
export type AppRecordEncoded = Schema.Schema.Encoded<typeof RecordSchema>;
export const validateRecord = (record: Record<string, unknown>): AppRecord => {
const normalizedRecord = decodeKintoneRecord(record);
return Schema.decodeUnknownSync(RecordSchema)(normalizedRecord);
};
`;
}
// --- Local generator removed in favor of official implementation ---
function generateFieldsConfigCode(properties) {
const FIELD_TYPE_TO_TS_TYPE = {
SINGLE_LINE_TEXT: 'SingleLineTextFieldProperties',
MULTI_LINE_TEXT: 'MultiLineTextFieldProperties',
RICH_TEXT: 'RichTextFieldProperties',
NUMBER: 'NumberFieldProperties',
CALC: 'CalcFieldProperties',
RADIO_BUTTON: 'RadioButtonFieldProperties',
CHECK_BOX: 'CheckBoxFieldProperties',
MULTI_SELECT: 'MultiSelectFieldProperties',
DROP_DOWN: 'DropDownFieldProperties',
DATE: 'DateFieldProperties',
TIME: 'TimeFieldProperties',
DATETIME: 'DateTimeFieldProperties',
LINK: 'LinkFieldProperties',
USER_SELECT: 'UserSelectFieldProperties',
ORGANIZATION_SELECT: 'OrganizationSelectFieldProperties',
GROUP_SELECT: 'GroupSelectFieldProperties',
FILE: 'FileFieldProperties',
REFERENCE_TABLE: 'ReferenceTableFieldProperties',
RECORD_NUMBER: 'RecordNumberFieldProperties',
CREATOR: 'CreatorFieldProperties',
CREATED_TIME: 'CreatedTimeFieldProperties',
MODIFIER: 'ModifierFieldProperties',
UPDATED_TIME: 'UpdatedTimeFieldProperties',
STATUS: 'StatusFieldProperties',
STATUS_ASSIGNEE: 'StatusAssigneeFieldProperties',
CATEGORY: 'CategoryFieldProperties',
SUBTABLE: 'SubtableFieldProperties',
GROUP: 'GroupFieldProperties',
RECORD_ID: 'RecordIdFieldProperties',
REVISION: 'RevisionFieldProperties',
__ID__: 'SystemIdFieldProperties',
__REVISION__: 'SystemRevisionFieldProperties',
SPACER: 'SpacerFieldProperties',
LABEL: 'LabelFieldProperties',
};
const typeNames = new Set();
const fieldDefinitions = [];
const fieldVariables = [];
// helper: convert field code to variable name
function toVariableName(code) {
if (code.startsWith('$'))
return code + 'Field';
if (/^[0-9]/.test(code))
return '_' + code + 'Field';
if (/^[\p{L}\p{Nl}$_][\p{L}\p{Nl}\p{Mn}\p{Mc}\p{Nd}\p{Pc}$]*$/u.test(code))
return code + 'Field';
let varName = code.replace(/[^\p{L}\p{Nl}\p{Mn}\p{Mc}\p{Nd}\p{Pc}$_]/gu, '_');
varName = varName.replace(/_+/g, '_').replace(/^_+|_+$/g, '');
if (!varName || /^[0-9]/.test(varName))
varName = 'field_' + (varName || 'generated');
return varName + 'Field';
}
function isReservedWord(word) {
const reserved = new Set([
'break',
'case',
'catch',
'class',
'const',
'continue',
'debugger',
'default',
'delete',
'do',
'else',
'export',
'extends',
'finally',
'for',
'function',
'if',
'import',
'in',
'instanceof',
'new',
'return',
'super',
'switch',
'this',
'throw',
'try',
'typeof',
'var',
'void',
'while',
'with',
'yield',
'enum',
'implements',
'interface',
'let',
'package',
'private',
'protected',
'public',
'static',
'await',
'abstract',
'boolean',
'byte',
'char',
'double',
'final',
'float',
'goto',
'int',
'long',
'native',
'short',
'synchronized',
'throws',
'transient',
'volatile',
]);
return reserved.has(word);
}
function valueToCode(value, indent = 0) {
const spaces = ' '.repeat(indent);
if (value === null)
return 'null';
if (value === undefined)
return 'undefined';
if (typeof value === 'string')
return JSON.stringify(value);
if (typeof value === 'number' || typeof value === 'boolean')
return String(value);
if (Array.isArray(value)) {
if (value.length === 0)
return '[]';
const items = value.map((v) => valueToCode(v, indent + 1));
if (items.every((i) => i.length < 40) && items.join(', ').length < 60)
return `[${items.join(', ')}]`;
return `[\n${spaces} ${items.join(`,\n${spaces} `)}\n${spaces}]`;
}
if (typeof value === 'object' && value !== null) {
const entries = Object.entries(value).filter(([, v]) => v !== undefined);
if (entries.length === 0)
return '{}';
const props = entries.map(([key, val]) => {
const needsQuotes = !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) || isReservedWord(key);
const keyStr = needsQuotes ? JSON.stringify(key) : key;
const valStr = valueToCode(val, indent + 1);
return `${spaces} ${keyStr}: ${valStr}`;
});
return `{\n${props.join(',\n')}\n${spaces}}`;
}
return JSON.stringify(value);
}
function isSubtable(f) {
return f.type === 'SUBTABLE' && 'fields' in f;
}
for (const [code, cfg] of Object.entries(properties)) {
const field = cfg;
const tsType = FIELD_TYPE_TO_TS_TYPE[field.type];
if (!tsType)
continue;
typeNames.add(tsType);
if (isSubtable(field)) {
const sub = (field.fields ?? {});
for (const child of Object.values(sub)) {
const childTs = FIELD_TYPE_TO_TS_TYPE[child.type];
if (childTs)
typeNames.add(childTs);
}
}
const varName = toVariableName(code);
// For subtable, ensure label/noLabel are kept if provided by source
const toSerialize = field;
const defCode = valueToCode(toSerialize, 0);
fieldDefinitions.push(`export const ${varName}: ${tsType} = ${defCode};`);
fieldVariables.push({ code, varName });
}
const sortedTypes = Array.from(typeNames).sort();
const imports = `import type {\n ${sortedTypes.join(',\n ')}\n} from 'kintone-effect-schema';`;
const configEntries = fieldVariables.map(({ code, varName }) => {
const needsQuotes = code.startsWith('$') ||
!/^[a-zA-Z_][a-zA-Z0-9_$]*$/.test(code) ||
isReservedWord(code);
const key = needsQuotes ? JSON.stringify(code) : code;
return ` ${key}: ${varName}`;
});
const appConfig = `export const appFieldsConfig = {\n properties: {\n${configEntries.join(',\n')}\n }\n};`;
return [imports, '', ...fieldDefinitions, '', appConfig].join('\n');
}
// Temporary: keep function referenced to satisfy noUnusedLocals
export const __keep = generateFieldsConfigCode;
//# sourceMappingURL=converter.js.map