import React from 'react'; import { KintoneFormFieldProperty, KintoneRestAPIClient } from '@kintone/rest-api-client'; import { KintoneRecord } from '@shin-chan/kypes/types/page'; import dayjs from 'dayjs'; import Docxtemplater from 'docxtemplater'; import expressionParser from 'docxtemplater/expressions'; import { saveAs } from 'file-saver'; import moize from 'moize'; import PizZip from 'pizzip'; import { useTranslation } from 'react-i18next'; import invariant from 'tiny-invariant'; import { DOCX_CONTENT_TYPE } from '../common/constants'; import { KintoneFormFieldProperties } from '../common/types'; import KintonePluginAlert from '../common/ui/KintonePluginAlert'; import KintonePluginButton from '../common/ui/KintonePluginButton'; interface TemplateData { [key: string]: TemplateData | TemplateData[] | string | string[]; } interface MenuPanelProps { pluginId: string; event: kintone.events.AppRecordDetailShowEvent | kintone.events.MobileAppRecordDetailShowEvent; } const cachedFormFieldsProperties = moize.promise(async (appId: number): Promise => { const client = new KintoneRestAPIClient(); const { properties } = await client.app.getFormFields({ app: appId, preview: false }); return properties; }); const formatNumberValue = (value: string, digit: boolean, scale?: number): string => { if (value !== '' && !isNaN(Number(value))) { const numberValue = Number(value); const options: Intl.NumberFormatOptions = { useGrouping: digit, }; if (scale != null && scale >= 0) { options.minimumFractionDigits = scale; options.maximumFractionDigits = scale; } return new Intl.NumberFormat('en-US', options).format(numberValue); } return value; }; const formatValueWithUnit = ( value: string, property: KintoneFormFieldProperty.Number | KintoneFormFieldProperty.Calc, ): string => { if (value !== '' && property.unit !== '') { if (property.unitPosition === 'BEFORE') { return `${property.unit} ${value}`; } else if (property.unitPosition === 'AFTER') { return `${value} ${property.unit}`; } } return value; }; const formatNumberRecordValue = ( value: string, property: KintoneFormFieldProperty.Number | KintoneFormFieldProperty.Lookup, ): string => { if ('lookup' in property) { return value; } const scale = property.displayScale !== '' ? Number(property.displayScale) : undefined; const digit = property.digit ?? false; const formattedValue = formatNumberValue(value, digit, scale); return formatValueWithUnit(formattedValue, property); }; const formatCalculatedRecordValue = ( value: string, property: KintoneFormFieldProperty.Calc, language: string, ): string => { const { format } = property; if (value === '') { return ''; } const scale = property.displayScale !== '' ? Number(property.displayScale) : undefined; let formattedValue: string; if (format === 'NUMBER') { formattedValue = formatNumberValue(value, false, scale); } else if (format === 'NUMBER_DIGIT') { formattedValue = formatNumberValue(value, true, scale); } else if (format === 'DATETIME') { // "2025-05-30T00:00:00Z" -> "2025-05-30 9:00" (browser local timezone) const date = new Date(value); invariant(!isNaN(date.getTime()), `Expected value to be a valid date string, but got ${value}`); formattedValue = dayjs(date).format('YYYY-MM-DD h:mm'); } else if (format === 'DATE') { // "2025-05-30T00:00:00Z" -> "2025-05-30" (browser local timezone) const date = new Date(value); invariant(!isNaN(date.getTime()), `Expected value to be a valid date string, but got ${value}`); formattedValue = dayjs(date).format('YYYY-MM-DD'); } else if (format === 'TIME') { // "00:30" -> "0:30" const [hours, minutes] = value.split(':').map((v) => Number(v)); invariant( !isNaN(hours) && !isNaN(minutes), `Expected hours and minutes to be numbers, but got hours: ${hours}, minutes: ${minutes}`, ); formattedValue = `${hours}:${String(minutes).padStart(2, '0')}`; } else if (format === 'HOUR_MINUTE') { // "49:30" -> en:"49 hours 30 minutes", ja: "49時間30分" const [hours, minutes] = value.split(':').map((v) => Number(v)); invariant( !isNaN(hours) && !isNaN(minutes), `Expected hours and minutes to be numbers, but got hours: ${hours}, minutes: ${minutes}`, ); formattedValue = language === 'ja' ? `${hours}時間${minutes}分` : `${hours} hours ${minutes} minutes`; } else if (format === 'DAY_HOUR_MINUTE') { // "49:30" -> en: "2 days 1 hour 30 minutes", ja: "2日1時間30分 const [hours, minutes] = value.split(':').map((v) => Number(v)); invariant( !isNaN(hours) && !isNaN(minutes), `Expected hours and minutes to be numbers, but got hours: ${hours}, minutes: ${minutes}`, ); const days = Math.floor(Number(hours) / 24); const remainingHours = Number(hours) % 24; formattedValue = language === 'ja' ? `${days}日${remainingHours}時間${minutes}分` : `${days} days ${remainingHours} hours ${minutes} minutes`; } else { // DATETIME, DATE, TIME formattedValue = value; } return formatValueWithUnit(formattedValue, property); }; const record2data = ( properties: KintoneFormFieldProperties, record: Partial, language: string, ): TemplateData => { const data: TemplateData = {}; for (const key in record) { if (Object.prototype.hasOwnProperty.call(record, key) && Object.prototype.hasOwnProperty.call(properties, key)) { const item = record[key]; const property = properties[key]; if (item == null) continue; const { type, value } = item; if (value == null) { data[key] = ''; } else if (type === 'CREATOR' || type === 'MODIFIER') { data[key] = { name: value.name, code: value.code, }; } else if (type === 'NUMBER') { invariant(property.type === 'NUMBER', `Expected property type to be NUMBER, but got ${property.type}`); data[key] = formatNumberRecordValue(value, property); } else if (type === 'CALC') { invariant(property.type === 'CALC', `Expected property type to be CALC, but got ${property.type}`); data[key] = formatCalculatedRecordValue(value, property, language); } else if (type === 'CHECK_BOX' || type === 'MULTI_SELECT' || type === 'CATEGORY') { data[key] = value.map((v) => v); } else if ( type === 'USER_SELECT' || type === 'ORGANIZATION_SELECT' || type === 'GROUP_SELECT' || type === 'STATUS_ASSIGNEE' ) { data[key] = value.map((v) => { return { name: v.name, code: v.code }; }); } else if (type === 'SUBTABLE') { invariant(property.type === 'SUBTABLE', `Expected property type to be SUBTABLE, but got ${property.type}`); data[key] = value.map((subRecord) => record2data(property.fields, subRecord.value, language)); } else if (type === 'FILE') { invariant(property.type === 'FILE', `Expected property type to be FILE, but got ${property.type}`); data[key] = value.map((file) => ({ name: file.name, size: file.size, contentType: file.contentType, fileKey: file.fileKey, })); } else { // RECORD_NUMBER, __ID__, __REVISION__, CREATED_TIME, UPDATED_TIME // SINGLE_LINE_TEXT, MULTI_LINE_TEXT, RICH_TEXT, RADIO_BUTTON, DROP_DOWN, DATE, TIME, DATETIME, LINK, STATUS data[key] = value; } } } return data; }; const MenuPanel: React.FC = (props) => { const { pluginId, event } = props; const { t } = useTranslation(); const appId = event.appId; const properties = React.use(cachedFormFieldsProperties(appId)); const language = kintone.getLoginUser().language; const config = kintone.plugin.app.getConfig(pluginId); const template: string = config.template ?? ''; if (template === '') { return ( {t('name')}: {t('errors.template-field-is-not-set')} ); } const record = event.record[template]; if (record == null) { return ( {t('name')}: {t('errors.template-field-is-not-available')} ); } if (record.type !== 'FILE') { return ( {t('name')}: {t('errors.template-field-must-be-an-attachment-field')} ); } if (record.value.length === 0) { return ( {t('name')}: {t('errors.template-field-does-not-contain-any-files')} ); } if (record.value.length > 1) { return ( {t('name')}: {t('errors.template-field-contains-multiple-files')} ); } const { fileKey, contentType } = record.value[0]; if (contentType !== DOCX_CONTENT_TYPE) { return ( {t('name')}: {t('errors.template-file-must-be-a-docx', { contentType })} ); } const handleOnClickOutputButton = (e: React.MouseEvent) => { e.preventDefault(); const client = new KintoneRestAPIClient(); client.file .downloadFile({ fileKey }) .then((content) => { const zip = new PizZip(content); const doc = new Docxtemplater(zip, { paragraphLoop: true, linebreaks: true, parser: expressionParser, nullGetter: () => '', }); doc.render(record2data(properties, event.record, language)); const out = doc.getZip().generate({ type: 'blob', mimeType: DOCX_CONTENT_TYPE }); saveAs(out, 'output.docx'); }) .catch((error) => { console.error('Error downloading file:', error); alert('Failed to download the Word template file.'); }); }; return ( {t('buttons.output')} ); }; export default MenuPanel;