import { ButtonActionsType, type ColumnType, FieldNameFromUITypes, UITypes, UITypesName } from 'nocodb-sdk' import isURL from 'validator/lib/isURL' import { pluralize } from 'inflection' // This regex pattern matches email addresses by looking for sequences that start with characters before the "@" symbol, followed by the domain. // It's designed to capture most email formats, including those with periods and "+" symbols in the local part. const validateEmail = (v: string) => /^(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/i.test(v) export const extractEmail = (v: string) => { const matches = v.match( /(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})/i, ) return matches ? matches[0] : null } const booleanOptions = [ { checked: true, unchecked: false }, { 'x': true, '': false }, { yes: true, no: false }, { y: true, n: false }, { 1: true, 0: false }, { '[x]': true, '[]': false, '[ ]': false }, { '☑': true, '': false }, { '✅': true, '': false }, { '✓': true, '': false }, { '✔': true, '': false }, { enabled: true, disabled: false }, { on: true, off: false }, { 'done': true, '': false }, { true: true, false: false }, ] const aggBooleanOptions: any = booleanOptions.reduce((obj, o) => ({ ...obj, ...o }), {}) const getColVal = (row: any, col?: number) => { return row && col !== undefined ? row[col] : row } export const isCheckboxType: any = (values: [], col?: number) => { let options = booleanOptions for (let i = 0; i < values.length; i++) { const val = getColVal(values[i], col) if (val === null || val === undefined || val.toString().trim() === '') { continue } options = options.filter((v) => val in v) if (!options.length) { return false } } return true } export const getCheckboxValue = (value: any) => { return value && aggBooleanOptions[value] } export const isMultiLineTextType = (values: [], col?: number) => { return values.some( (r) => (getColVal(r, col) || '').toString().match(/[\r\n]/) || (getColVal(r, col) || '').toString().length > 255, ) } export const extractMultiOrSingleSelectProps = (colData: []) => { const maxSelectOptionsAllowed = 64 const colProps: any = {} if (colData.some((v: any) => v && (v || '').toString().includes(','))) { const flattenedVals = colData.flatMap((v: any) => v ? v .toString() .trim() .split(/\s*,\s*/) : [], ) const uniqueVals = [ ...new Set(flattenedVals.filter((v) => v !== null && v !== undefined).map((v: any) => v.toString().trim())), ] if (uniqueVals.length > maxSelectOptionsAllowed) { // too many options are detected, convert the column to SingleLineText instead colProps.uidt = UITypes.SingleLineText // _disableSelect is used to disable the in TemplateEditor colProps._disableSelect = true } else { // assume the column type is multiple select if there are repeated values if (flattenedVals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(flattenedVals.length / 2)) { colProps.uidt = UITypes.MultiSelect } // set dtxp here so that users can have the options even they switch the type from other types to MultiSelect // once it's set, dtxp needs to be reset if the final column type is not MultiSelect colProps.dtxp = `${uniqueVals.map((v) => `'${v.replace(/'/gi, "''")}'`).join(',')}` } } else { const uniqueVals = [...new Set(colData.filter((v) => v !== null && v !== undefined).map((v: any) => v.toString().trim()))] if (uniqueVals.length > maxSelectOptionsAllowed) { // too many options are detected, convert the column to SingleLineText instead colProps.uidt = UITypes.SingleLineText // _disableSelect is used to disable the in TemplateEditor colProps._disableSelect = true } else { // assume the column type is single select if there are repeated values // once it's set, dtxp needs to be reset if the final column type is not Single Select if (colData.length > uniqueVals.length && uniqueVals.length <= Math.ceil(colData.length / 2)) { colProps.uidt = UITypes.SingleSelect } // set dtxp here so that users can have the options even they switch the type from other types to SingleSelect // once it's set, dtxp needs to be reset if the final column type is not SingleSelect colProps.dtxp = `${uniqueVals.map((v) => `'${v.replace(/'/gi, "''")}'`).join(',')}` } return colProps } } export const extractSelectOptions = (colData: [], type: UITypes.SingleSelect | UITypes.MultiSelect): { dtxp: string } => { const colProps: any = {} if (type === UITypes.MultiSelect) { const flattenedVals = colData.flatMap((v: any) => v ? v .toString() .trim() .split(/\s*,\s*/) : [], ) const uniqueVals = [...new Set(flattenedVals.map((v: any) => v.toString().trim()))] colProps.uidt = UITypes.MultiSelect colProps.dtxp = `${uniqueVals.map((v) => `'${v.replace(/'/gi, "''")}'`).join(',')}` } else { const uniqueVals = [...new Set(colData.map((v: any) => v.toString().trim()))] colProps.uidt = UITypes.SingleSelect colProps.dtxp = `${uniqueVals.map((v) => `'${v.replace(/'/gi, "''")}'`).join(',')}` } return colProps } export const isDecimalType = (colData: []) => colData.some((v: any) => { return v && parseInt(v) !== +v }) export const isEmailType = (colData: [], col?: number) => colData.some((r: any) => { const v = getColVal(r, col) return v && validateEmail(v) }) export const isUrlType = (colData: [], col?: number) => colData.some((r: any) => { const v = getColVal(r, col) // convert to string since isURL only accepts string // and cell data value can be number or any other types return v && isURL(v.toString()) }) export const getColumnUIDTAndMetas = (colData: [], defaultType: string) => { const colProps = { uidt: defaultType } if (colProps.uidt === UITypes.SingleLineText) { // check for long text if (isMultiLineTextType(colData)) { colProps.uidt = UITypes.LongText } if (isEmailType(colData)) { colProps.uidt = UITypes.Email } if (isUrlType(colData)) { colProps.uidt = UITypes.URL } else { if (isCheckboxType(colData)) { colProps.uidt = UITypes.Checkbox } else { Object.assign(colProps, extractMultiOrSingleSelectProps(colData)) } } } else if (colProps.uidt === UITypes.Number) { if (isDecimalType(colData)) { colProps.uidt = UITypes.Decimal } } // TODO(import): currency // TODO(import): date / datetime return colProps } export const filterNullOrUndefinedObjectProperties = >(obj: T): T => { return Object.keys(obj).reduce((result, propName) => { const value = obj[propName] if (value !== null && value !== undefined) { if (!Array.isArray(value) && typeof value === 'object') { // Recursively filter nested objects result[propName] = filterNullOrUndefinedObjectProperties(value) } else { result[propName] = value } } return result }, {} as Record) as T } /** * Extracts the next default name based on the provided namesData, defaultName, and splitOperator. * * @param namesData - An array of strings containing existing names data. * @param defaultName - The default name to extract and generate the next name from. * @param splitOperator - The separator used to split the defaultName and numbers in existing namesData. * Defaults to '-'. Example: If defaultName is 'Token' and splitOperator is '-', * existing names like 'Token-1', 'Token-2', etc., will be considered. * @returns The next default name with an incremented number based on existing namesData. */ export const extractNextDefaultName = (namesData: string[], defaultName: string, splitOperator: string = '-'): string => { // Extract and sort numbers associated with the provided defaultName const extractedSortedNumbers = (namesData .map((name) => { const [_defaultName, number] = name.split(splitOperator) if (_defaultName === defaultName && !isNaN(Number(number?.trim()))) { return Number(number?.trim()) } return undefined }) .filter((e) => e) .sort((a, b) => { if (a !== undefined && b !== undefined) { return a - b } return 0 }) as number[]) || [] return extractedSortedNumbers.length ? `${defaultName}${splitOperator}${extractedSortedNumbers[extractedSortedNumbers.length - 1] + 1}` : `${defaultName}${splitOperator}1` } export const getFormattedViewTabTitle = ({ viewName, tableName, baseName, isDefaultView = false, charLimit = 20, isSharedView = false, }: { viewName: string tableName: string baseName: string isDefaultView?: boolean charLimit?: number isSharedView?: boolean }) => { if (isSharedView) { return viewName || 'NocoDB' } let title = `${viewName} | ${tableName} | ${baseName}` if (isDefaultView) { charLimit = 30 title = `${tableName} | ${baseName}` } if (title.length <= 60) { return title } // Function to truncate text and add ellipsis if needed const truncateText = (text: string) => { return text.length > charLimit ? `${text.substring(0, charLimit - 3)}...` : text } if (isDefaultView) { title = `${truncateText(tableName)} | ${truncateText(baseName)}` } else { title = `${truncateText(viewName)} | ${truncateText(tableName)} | ${truncateText(baseName)}` } return title } export const generateUniqueColumnSuffix = ({ tableExplorerColumns, metaColumns, }: { tableExplorerColumns?: ColumnType[] metaColumns: ColumnType[] }) => { let suffix = (metaColumns?.length || 0) + 1 let columnName = `title${suffix}` while ( (tableExplorerColumns || metaColumns)?.some( (c) => (c.column_name || '').toLowerCase() === columnName.toLowerCase() || (c.title || '').toLowerCase() === columnName.toLowerCase(), ) ) { suffix++ columnName = `title${suffix}` } return suffix } const extractNextDefaultColumnName = ({ tableExplorerColumns, metaColumns, defaultColumnName, newFieldTitles, formState, }: { tableExplorerColumns?: ColumnType[] metaColumns: ColumnType[] defaultColumnName: string newFieldTitles: string[] formState: Record }): string => { // Extract and sort numbers associated with the provided defaultName const namesData = ((tableExplorerColumns || metaColumns) ?.flatMap((c) => { if (formState?.temp_id && c?.temp_id && formState?.temp_id === c?.temp_id) { return [] } if (c.title !== c.column_name) { return [c.title?.toLowerCase(), c.column_name?.toLowerCase()] } return [c.title?.toLowerCase()] }) .filter((t) => t && t.startsWith(defaultColumnName.toLowerCase())) || []) as string[] if (![...namesData, ...newFieldTitles].includes(defaultColumnName.toLowerCase())) { return defaultColumnName } const extractedSortedNumbers = (namesData .map((name) => { const [_defaultName, number] = name.split(/ (?!.* )/) if (_defaultName === defaultColumnName.toLowerCase() && !isNaN(Number(number?.trim()))) { return Number(number?.trim()) } return undefined }) .filter((e) => e) .sort((a, b) => { if (a !== undefined && b !== undefined) { return a - b } return 0 }) as number[]) || [] return extractedSortedNumbers.length ? `${defaultColumnName} ${extractedSortedNumbers[extractedSortedNumbers.length - 1] + 1}` : `${defaultColumnName} 1` } export const generateUniqueColumnName = ({ tableExplorerColumns, metaColumns, formState, newFieldTitles, }: { tableExplorerColumns?: ColumnType[] metaColumns: ColumnType[] formState: Record newFieldTitles?: string[] }) => { let defaultColumnName = FieldNameFromUITypes[formState.uidt as UITypes] if (!defaultColumnName) { return `title${generateUniqueColumnSuffix({ tableExplorerColumns, metaColumns })}` } switch (formState.uidt) { case UITypes.User: { if (formState.meta.is_multi) { defaultColumnName = `${defaultColumnName}s` } break } case UITypes.Links: case UITypes.LinkToAnotherRecord: { if (!formState.childTableTitle) { return `title${generateUniqueColumnSuffix({ tableExplorerColumns, metaColumns })}` } let childTableTitle = formState.childTableTitle // Use plural for links except oo relation type if (formState.uidt === UITypes.Links && formState?.type !== 'oo') { childTableTitle = pluralize(childTableTitle) } // Calculate the remaining length available for childTableTitle const maxLength = 255 - (defaultColumnName.length - 11 + '{TableName}'.length) // Truncate childTableTitle if it exceeds the maxLength if (childTableTitle.length > maxLength) { childTableTitle = `${childTableTitle.slice(0, maxLength - 3)}...` } // Replace {TableName} with the potentially truncated childTableTitle defaultColumnName = defaultColumnName.replace('{TableName}', childTableTitle) // Ensure the final defaultColumnName is less than 255 characters if (defaultColumnName.length >= 255) { defaultColumnName = `${defaultColumnName.slice(0, 252)}...` } break } case UITypes.Lookup: { if (!formState.lookupTableTitle || !formState.lookupColumnTitle) { return `title${generateUniqueColumnSuffix({ tableExplorerColumns, metaColumns })}` } let lookupTableTitle = formState.lookupTableTitle let lookupColumnTitle = formState.lookupColumnTitle // Calculate the lengths of the placeholders const placeholderLength = '{TableName}'.length + '{FieldName}'.length const baseLength = defaultColumnName.length - placeholderLength // Calculate the maximum length allowed for both titles combined const maxTotalLength = 255 - baseLength const maxLengthPerTitle = Math.floor(maxTotalLength / 2) // Truncate the titles if necessary if (lookupTableTitle.length > maxLengthPerTitle) { lookupTableTitle = `${lookupTableTitle.slice(0, maxLengthPerTitle - 3)}...` } if (lookupColumnTitle.length > maxLengthPerTitle) { lookupColumnTitle = `${lookupColumnTitle.slice(0, maxLengthPerTitle - 3)}...` } // Replace placeholders defaultColumnName = defaultColumnName.replace('{TableName}', lookupTableTitle).replace('{FieldName}', lookupColumnTitle) break } case UITypes.Rollup: { if (!formState.rollupTableTitle || !formState.rollupColumnTitle || !formState?.rollup_function_name) { return `title${generateUniqueColumnSuffix({ tableExplorerColumns, metaColumns })}` } let rollupTableTitle = formState.rollupTableTitle let rollupColumnTitle = formState.rollupColumnTitle // Update rollup function name defaultColumnName = defaultColumnName.replace('{RollupFunction}', formState.rollup_function_name) // Calculate the lengths of the placeholders const placeholderLength = '{TableName}'.length + '{FieldName}'.length const baseLength = defaultColumnName.length - placeholderLength // Calculate the maximum length allowed for both titles combined const maxTotalLength = 255 - baseLength const maxLengthPerTitle = Math.floor(maxTotalLength / 2) // Truncate the titles if necessary if (rollupTableTitle.length > maxLengthPerTitle) { rollupTableTitle = `${rollupTableTitle.slice(0, maxLengthPerTitle - 3)}...` } if (rollupColumnTitle.length > maxLengthPerTitle) { rollupColumnTitle = `${rollupColumnTitle.slice(0, maxLengthPerTitle - 3)}...` } // Replace placeholders defaultColumnName = defaultColumnName.replace('{TableName}', rollupTableTitle).replace('{FieldName}', rollupColumnTitle) break } case UITypes.Button: { if (formState?.type === ButtonActionsType.Ai) { defaultColumnName = UITypesName.AIButton } break } case UITypes.LongText: { if (formState?.meta?.[LongTextAiMetaProp] === true) { defaultColumnName = UITypesName.AIPrompt } break } } return extractNextDefaultColumnName({ tableExplorerColumns, metaColumns, defaultColumnName, newFieldTitles: newFieldTitles || [], formState, }) }