diff --git a/src/desktop/MenuPanel.tsx b/src/desktop/MenuPanel.tsx index ce18b66..2e983f3 100644 --- a/src/desktop/MenuPanel.tsx +++ b/src/desktop/MenuPanel.tsx @@ -1,23 +1,15 @@ -import { type KintoneFormFieldProperty, KintoneRestAPIClient } from '@kintone/rest-api-client'; -import dayjs from 'dayjs'; -import Docxtemplater from 'docxtemplater'; -import expressionParser from 'docxtemplater/expressions'; -import { saveAs } from 'file-saver'; +import { KintoneRestAPIClient } from '@kintone/rest-api-client'; +import saveAs from 'file-saver'; import moize from 'moize'; -import PizZip from 'pizzip'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import invariant from 'tiny-invariant'; import { DOCX_CONTENT_TYPE } from '../common/constants'; import type { KintoneFormFieldProperties } from '../common/types'; import KintonePluginAlert from '../common/ui/KintonePluginAlert'; import KintonePluginButton from '../common/ui/KintonePluginButton'; +import generateWordFileData from './generateWordFileData'; -interface TemplateData { - [key: string]: TemplateData | TemplateData[] | string | string[]; -} - -interface MenuPanelProps { +export interface MenuPanelProps { pluginId: string; event: kintone.events.AppRecordDetailShowEvent | kintone.events.MobileAppRecordDetailShowEvent; } @@ -28,167 +20,6 @@ const cachedFormFieldsProperties = moize.promise(async (appId: number): Promise< return properties; }); -const formatNumberValue = (value: string, digit: boolean, scale?: number): string => { - if (value !== '' && !Number.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(!Number.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(!Number.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( - !Number.isNaN(hours) && !Number.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( - !Number.isNaN(hours) && !Number.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( - !Number.isNaN(hours) && !Number.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) { - // biome-ignore lint/suspicious/noPrototypeBuiltins: avoid prototype pollution - 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(); @@ -249,15 +80,7 @@ const MenuPanel: React.FC = (props) => { 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 }); + const out = generateWordFileData(content, properties, event.record, language); saveAs(out, 'output.docx'); }) .catch((error) => { diff --git a/src/desktop/generateWordFileData.ts b/src/desktop/generateWordFileData.ts new file mode 100644 index 0000000..d5788ef --- /dev/null +++ b/src/desktop/generateWordFileData.ts @@ -0,0 +1,192 @@ +import type { KintoneFormFieldProperty } from '@kintone/rest-api-client'; +import dayjs from 'dayjs'; +import Docxtemplater from 'docxtemplater'; +import expressionParser from 'docxtemplater/expressions'; +import PizZip from 'pizzip'; +import invariant from 'tiny-invariant'; +import { DOCX_CONTENT_TYPE } from '../common/constants'; +import type { KintoneFormFieldProperties } from '../common/types'; + +interface TemplateData { + [key: string]: TemplateData | TemplateData[] | string | string[]; +} + +const formatNumberValue = (value: string, digit: boolean, scale?: number): string => { + if (value !== '' && !Number.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(!Number.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(!Number.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( + !Number.isNaN(hours) && !Number.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( + !Number.isNaN(hours) && !Number.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( + !Number.isNaN(hours) && !Number.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 record2templateData = ( + properties: KintoneFormFieldProperties, + record: Partial, + language: string, +): TemplateData => { + const data: TemplateData = {}; + for (const key in record) { + // biome-ignore lint/suspicious/noPrototypeBuiltins: avoid prototype pollution + 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) => record2templateData(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 generateWordFileData = ( + content: ArrayBuffer, + properties: KintoneFormFieldProperties, + record: kintone.types.GenericFields, + language: string, +) => { + const zip = new PizZip(content); + const doc = new Docxtemplater(zip, { + paragraphLoop: true, + linebreaks: true, + parser: expressionParser, + nullGetter: () => '', + }); + doc.render(record2templateData(properties, record, language)); + return doc.getZip().generate({ type: 'blob', mimeType: DOCX_CONTENT_TYPE }); +}; + +export default generateWordFileData;