import type { sheets_v4 } from "@googleapis/sheets" import type * as z from "zod" import { BANDED_RANGE_SCHEMA, BANDING_PROPERTIES_SCHEMA, BANDING_PROPERTIES_UPDATE_SCHEMA, BORDER_STYLE_SCHEMA, BORDERS_SCHEMA, CELL_FORMAT_SCHEMA, CLEAR_FORMAT_PROPERTY_SCHEMA, COLOR_SCHEMA, CONDITIONAL_FORMAT_CONDITION_SCHEMA, CONDITIONAL_FORMAT_RULE_INPUT_SCHEMA, CONDITIONAL_FORMAT_SCHEMA, NUMBER_FORMAT_SCHEMA, RELATIVE_DATE_SCHEMA, TABLE_STYLE_SCHEMA, TABLE_STYLE_UPDATE_SCHEMA, THEME_COLOR_SCHEMA, } from "./schemas" const THEME_COLORS = { accent1: "ACCENT1", accent2: "ACCENT2", accent3: "ACCENT3", accent4: "ACCENT4", accent5: "ACCENT5", accent6: "ACCENT6", background: "BACKGROUND", link: "LINK", text: "TEXT", } as const satisfies Record, string> const BORDER_STYLES = { dashed: "DASHED", dotted: "DOTTED", double: "DOUBLE", solid: "SOLID", solidMedium: "SOLID_MEDIUM", solidThick: "SOLID_THICK", } as const satisfies Record, string> const NUMBER_FORMAT_TYPES = { currency: "CURRENCY", date: "DATE", dateTime: "DATE_TIME", number: "NUMBER", percent: "PERCENT", scientific: "SCIENTIFIC", text: "TEXT", time: "TIME", } as const satisfies Record< z.infer["type"], string > const CONDITION_TYPES = { blank: "BLANK", customFormula: "CUSTOM_FORMULA", dateAfter: "DATE_AFTER", dateBefore: "DATE_BEFORE", dateEqual: "DATE_EQ", notBlank: "NOT_BLANK", numberBetween: "NUMBER_BETWEEN", numberEqual: "NUMBER_EQ", numberGreaterThan: "NUMBER_GREATER", numberGreaterThanOrEqual: "NUMBER_GREATER_THAN_EQ", numberLessThan: "NUMBER_LESS", numberLessThanOrEqual: "NUMBER_LESS_THAN_EQ", numberNotBetween: "NUMBER_NOT_BETWEEN", numberNotEqual: "NUMBER_NOT_EQ", textContains: "TEXT_CONTAINS", textEndsWith: "TEXT_ENDS_WITH", textEqual: "TEXT_EQ", textNotContains: "TEXT_NOT_CONTAINS", textStartsWith: "TEXT_STARTS_WITH", } as const satisfies Record< z.infer["type"], string > const RELATIVE_DATES = { pastMonth: "PAST_MONTH", pastWeek: "PAST_WEEK", pastYear: "PAST_YEAR", today: "TODAY", tomorrow: "TOMORROW", yesterday: "YESTERDAY", } as const satisfies Record, string> const INTERPOLATION_POINT_TYPES = { max: "MAX", min: "MIN", number: "NUMBER", percent: "PERCENT", percentile: "PERCENTILE", } as const satisfies Record type GradientRuleInput = Extract< z.infer, { type: "gradient" } > type GradientInterpolationPoint = | GradientRuleInput["max"] | GradientRuleInput["min"] const FORMAT_PROPERTY_FIELDS = { backgroundColor: "backgroundColorStyle", borders: "borders", horizontalAlignment: "horizontalAlignment", hyperlinkDisplay: "hyperlinkDisplayType", numberFormat: "numberFormat", padding: "padding", rotation: "textRotation", text: "textFormat", "text.bold": "textFormat.bold", "text.color": "textFormat.foregroundColorStyle", "text.family": "textFormat.fontFamily", "text.italic": "textFormat.italic", "text.link": "textFormat.link", "text.size": "textFormat.fontSize", "text.strikethrough": "textFormat.strikethrough", "text.underline": "textFormat.underline", textDirection: "textDirection", verticalAlignment: "verticalAlignment", wrap: "wrapStrategy", } as const satisfies Record< z.infer, string > /** * Converts a public color into a provider ColorStyle oneof. * * @param color - Public RGB or theme color. */ export function toGoogleColorStyle( color: z.infer, ): sheets_v4.Schema$ColorStyle { if (typeof color !== "string") { return { themeColor: THEME_COLORS[color.theme] } } return { rgbColor: { blue: Number.parseInt(color.slice(5, 7), 16) / 255, green: Number.parseInt(color.slice(3, 5), 16) / 255, red: Number.parseInt(color.slice(1, 3), 16) / 255, }, } } /** * Normalizes a provider ColorStyle while rejecting malformed oneofs. * * @param style - Provider RGB or theme color. * @throws When the provider color is malformed or unknown. */ export function normalizeGoogleColorStyle( style: sheets_v4.Schema$ColorStyle, ): z.infer { const rgbColor = style.rgbColor const hasRgbColor = rgbColor != null const hasThemeColor = style.themeColor != null if (hasThemeColor && hasRgbColor) { throw new Error("Google returned a color with both RGB and theme values.") } if (hasThemeColor) { const theme = Object.entries(THEME_COLORS).find( ([, providerTheme]) => providerTheme === style.themeColor, )?.[0] if (!theme) { throw new Error( `Google returned unknown theme color "${style.themeColor}".`, ) } return COLOR_SCHEMA.parse({ theme }) } if (!hasRgbColor) { throw new Error("Google returned a color without an RGB or theme value.") } const channels = [rgbColor.red ?? 0, rgbColor.green ?? 0, rgbColor.blue ?? 0] if (channels.some((channel) => channel < 0 || channel > 1)) { throw new Error("Google returned an RGB color channel outside 0 through 1.") } return COLOR_SCHEMA.parse( `#${channels .map((channel) => Math.round(channel * 255) .toString(16) .padStart(2, "0"), ) .join("")}`, ) } /** * Builds the exact update mask for clearing selected or all cell formatting. * * @param properties - Public formatting properties, or undefined for all. */ export function toGoogleClearFormatFields( properties: z.infer[] | undefined, ) { if (!properties) return "userEnteredFormat" return `userEnteredFormat(${[ ...new Set(properties.map((property) => FORMAT_PROPERTY_FIELDS[property])), ].join(",")})` } /** * Builds the provider requests for a range-format update. * * @param format - Public cell-format update. * @param range - Resolved provider grid range. */ export function toGoogleFormatRequests( format: z.infer, range: sheets_v4.Schema$GridRange, ): sheets_v4.Schema$Request[] { const cellFormat = toGoogleCellFormat(format) return [ ...(cellFormat.fields.length ? [ { repeatCell: { cell: { userEnteredFormat: cellFormat.format }, fields: `userEnteredFormat(${cellFormat.fields.join(",")})`, range, }, }, ] : []), ...(format.borders ? [ { updateBorders: { ...toGoogleBorders(format.borders), range, }, }, ] : []), ] } /** * Builds repeat-cell formatting and its exact relative field-mask paths. * * @param format - Public cell-format update. */ export function toGoogleCellFormat(format: z.infer) { const text = format.text return { fields: [ format.backgroundColor === undefined ? undefined : "backgroundColorStyle", format.horizontalAlignment === undefined ? undefined : "horizontalAlignment", format.hyperlinkDisplay === undefined ? undefined : "hyperlinkDisplayType", format.numberFormat === undefined ? undefined : "numberFormat", format.padding === undefined ? undefined : "padding", format.rotation === undefined ? undefined : "textRotation", format.textDirection === undefined ? undefined : "textDirection", text?.bold === undefined ? undefined : "textFormat.bold", text?.color === undefined ? undefined : "textFormat.foregroundColorStyle", text?.family === undefined ? undefined : "textFormat.fontFamily", text?.italic === undefined ? undefined : "textFormat.italic", text?.link === undefined ? undefined : "textFormat.link", text?.size === undefined ? undefined : "textFormat.fontSize", text?.strikethrough === undefined ? undefined : "textFormat.strikethrough", text?.underline === undefined ? undefined : "textFormat.underline", format.verticalAlignment === undefined ? undefined : "verticalAlignment", format.wrap === undefined ? undefined : "wrapStrategy", ].filter((field) => field !== undefined), format: { backgroundColorStyle: format.backgroundColor === undefined ? undefined : toGoogleColorStyle(format.backgroundColor), horizontalAlignment: format.horizontalAlignment?.toUpperCase(), hyperlinkDisplayType: format.hyperlinkDisplay === "plainText" ? "PLAIN_TEXT" : format.hyperlinkDisplay?.toUpperCase(), numberFormat: format.numberFormat ? { pattern: format.numberFormat.pattern, type: NUMBER_FORMAT_TYPES[format.numberFormat.type], } : undefined, padding: format.padding, textDirection: format.textDirection === "leftToRight" ? "LEFT_TO_RIGHT" : format.textDirection === "rightToLeft" ? "RIGHT_TO_LEFT" : undefined, textFormat: text ? { bold: text.bold, fontFamily: text.family, fontSize: text.size, foregroundColorStyle: text.color === undefined ? undefined : toGoogleColorStyle(text.color), italic: text.italic, link: text.link === undefined ? undefined : { uri: text.link }, strikethrough: text.strikethrough, underline: text.underline, } : undefined, textRotation: format.rotation?.type === "angle" ? { angle: format.rotation.angle } : format.rotation?.type === "vertical" ? { vertical: format.rotation.vertical } : undefined, verticalAlignment: format.verticalAlignment?.toUpperCase(), wrapStrategy: format.wrap === "overflow" ? "OVERFLOW_CELL" : format.wrap?.toUpperCase(), } satisfies sheets_v4.Schema$CellFormat, } } /** * Builds a provider range-border update, including explicit clears. * * @param borders - Public range-border update. */ export function toGoogleBorders( borders: z.infer, ): Omit { return { bottom: toGoogleBorder(borders.bottom), innerHorizontal: toGoogleBorder(borders.innerHorizontal), innerVertical: toGoogleBorder(borders.innerVertical), left: toGoogleBorder(borders.left), right: toGoogleBorder(borders.right), top: toGoogleBorder(borders.top), } } /** * Converts native-table header and band-color updates. * * @param style - Public native-table style update. */ export function toGoogleTableRowsPropertiesUpdate( style: z.infer, ): sheets_v4.Schema$TableRowsProperties { return { firstBandColorStyle: style.firstBandColor == null ? undefined : toGoogleColorStyle(style.firstBandColor), headerColorStyle: style.headerColor == null ? undefined : toGoogleColorStyle(style.headerColor), secondBandColorStyle: style.secondBandColor == null ? undefined : toGoogleColorStyle(style.secondBandColor), } } /** * Normalizes provider native-table row colors. * * @param style - Provider native-table row properties. */ export function normalizeGoogleTableStyle( style: sheets_v4.Schema$TableRowsProperties | null | undefined, ) { return TABLE_STYLE_SCHEMA.parse({ firstBandColor: style?.firstBandColorStyle ? normalizeGoogleColorStyle(style.firstBandColorStyle) : undefined, footerColor: style?.footerColorStyle ? normalizeGoogleColorStyle(style.footerColorStyle) : undefined, headerColor: style?.headerColorStyle ? normalizeGoogleColorStyle(style.headerColorStyle) : undefined, secondBandColor: style?.secondBandColorStyle ? normalizeGoogleColorStyle(style.secondBandColorStyle) : undefined, }) } /** * Converts public row or column band colors into provider properties. * * @param properties - Public row or column band colors. */ export function toGoogleBandingProperties( properties: | z.infer | z.infer, ): sheets_v4.Schema$BandingProperties { return { firstBandColorStyle: properties.firstBandColor == null ? undefined : toGoogleColorStyle(properties.firstBandColor), footerColorStyle: properties.footerColor == null ? undefined : toGoogleColorStyle(properties.footerColor), headerColorStyle: properties.headerColor == null ? undefined : toGoogleColorStyle(properties.headerColor), secondBandColorStyle: properties.secondBandColor == null ? undefined : toGoogleColorStyle(properties.secondBandColor), } } /** * Normalizes complete provider row or column banding properties. * * @param properties - Provider row or column band colors. * @throws When required provider band colors are absent. */ export function normalizeGoogleBandingProperties( properties: sheets_v4.Schema$BandingProperties, ) { const firstBandColor = properties.firstBandColorStyle ?? (properties.firstBandColor ? { rgbColor: properties.firstBandColor } : undefined) const secondBandColor = properties.secondBandColorStyle ?? (properties.secondBandColor ? { rgbColor: properties.secondBandColor } : undefined) if (!firstBandColor || !secondBandColor) { throw new Error("Google returned incomplete banding colors.") } const footerColor = properties.footerColorStyle ?? (properties.footerColor ? { rgbColor: properties.footerColor } : undefined) const headerColor = properties.headerColorStyle ?? (properties.headerColor ? { rgbColor: properties.headerColor } : undefined) return BANDING_PROPERTIES_SCHEMA.parse({ firstBandColor: normalizeGoogleColorStyle(firstBandColor), footerColor: footerColor ? normalizeGoogleColorStyle(footerColor) : undefined, headerColor: headerColor ? normalizeGoogleColorStyle(headerColor) : undefined, secondBandColor: normalizeGoogleColorStyle(secondBandColor), }) } /** * Converts a public conditional-format rule into a provider rule. * * @param rule - Public boolean or gradient rule. * @param ranges - Resolved provider grid ranges. */ export function toGoogleConditionalFormatRule( rule: z.infer, ranges: sheets_v4.Schema$GridRange[], ): sheets_v4.Schema$ConditionalFormatRule { return rule.type === "boolean" ? { booleanRule: { condition: toGoogleCondition(rule.condition), format: toGoogleConditionalFormat(rule.format), }, ranges, } : { gradientRule: { maxpoint: toGoogleInterpolationPoint(rule.max), midpoint: rule.midpoint ? toGoogleInterpolationPoint(rule.midpoint) : undefined, minpoint: toGoogleInterpolationPoint(rule.min), }, ranges, } } /** * Normalizes a provider conditional rule body while rejecting unknown values. * * @param rule - Provider conditional-format rule. * @throws When the provider rule is malformed or contains unknown values. */ export function normalizeGoogleConditionalRule( rule: sheets_v4.Schema$ConditionalFormatRule, ) { if (rule.booleanRule && rule.gradientRule) { throw new Error("Google returned both boolean and gradient rules.") } if (rule.booleanRule) { if (!rule.booleanRule.condition || !rule.booleanRule.format) { throw new Error("Google returned an incomplete boolean format rule.") } return CONDITIONAL_FORMAT_RULE_INPUT_SCHEMA.parse({ condition: normalizeGoogleCondition(rule.booleanRule.condition), format: normalizeGoogleConditionalFormat(rule.booleanRule.format), type: "boolean", }) } const gradient = rule.gradientRule if (!gradient?.minpoint || !gradient.maxpoint) { throw new Error("Google returned an incomplete gradient format rule.") } return CONDITIONAL_FORMAT_RULE_INPUT_SCHEMA.parse({ max: normalizeGoogleInterpolationPoint(gradient.maxpoint), midpoint: gradient.midpoint ? normalizeGoogleInterpolationPoint(gradient.midpoint) : undefined, min: normalizeGoogleInterpolationPoint(gradient.minpoint), type: "gradient", }) } /** * Converts the supported conditional-format subset. * * @param format - Public conditional cell format. */ function toGoogleConditionalFormat( format: z.infer, ): sheets_v4.Schema$CellFormat { return { backgroundColorStyle: format.backgroundColor === undefined ? undefined : toGoogleColorStyle(format.backgroundColor), textFormat: format.bold !== undefined || format.italic !== undefined || format.strikethrough !== undefined || format.textColor !== undefined ? { bold: format.bold, foregroundColorStyle: format.textColor === undefined ? undefined : toGoogleColorStyle(format.textColor), italic: format.italic, strikethrough: format.strikethrough, } : undefined, } } /** * Normalizes the supported conditional-format subset. * * @param format - Provider conditional cell format. */ function normalizeGoogleConditionalFormat(format: sheets_v4.Schema$CellFormat) { const backgroundColor = format.backgroundColorStyle ?? (format.backgroundColor ? { rgbColor: format.backgroundColor } : undefined) const textColor = format.textFormat?.foregroundColorStyle ?? (format.textFormat?.foregroundColor ? { rgbColor: format.textFormat.foregroundColor } : undefined) return CONDITIONAL_FORMAT_SCHEMA.parse({ backgroundColor: backgroundColor ? normalizeGoogleColorStyle(backgroundColor) : undefined, bold: format.textFormat?.bold ?? undefined, italic: format.textFormat?.italic ?? undefined, strikethrough: format.textFormat?.strikethrough ?? undefined, textColor: textColor ? normalizeGoogleColorStyle(textColor) : undefined, }) } /** * Converts a typed public condition into its provider form. * * @param condition - Public conditional-format condition. * @throws When an impossible condition variant reaches the mapper. */ function toGoogleCondition( condition: z.infer, ): sheets_v4.Schema$BooleanCondition { if (condition.type === "blank" || condition.type === "notBlank") { return { type: CONDITION_TYPES[condition.type], values: [] } } if (condition.type === "customFormula") { return { type: CONDITION_TYPES[condition.type], values: [{ userEnteredValue: condition.formula }], } } if ( condition.type === "numberBetween" || condition.type === "numberNotBetween" ) { return { type: CONDITION_TYPES[condition.type], values: condition.values.map((userEnteredValue) => ({ userEnteredValue, })), } } if (condition.type === "dateBefore" || condition.type === "dateAfter") { return { type: CONDITION_TYPES[condition.type], values: [ typeof condition.value === "string" ? { userEnteredValue: condition.value } : { relativeDate: RELATIVE_DATES[condition.value.relativeDate] }, ], } } if ("value" in condition && typeof condition.value === "string") { return { type: CONDITION_TYPES[condition.type], values: [{ userEnteredValue: condition.value }], } } throw new Error(`Unsupported conditional format type "${condition.type}".`) } /** * Normalizes a provider conditional-format condition. * * @param condition - Provider boolean condition. * @throws When the condition type, value, or arity is invalid. */ function normalizeGoogleCondition( condition: sheets_v4.Schema$BooleanCondition, ) { const type = Object.entries(CONDITION_TYPES).find( ([, providerType]) => providerType === condition.type, )?.[0] if (!type) { throw new Error( `Google returned unknown condition type "${condition.type}".`, ) } const values = condition.values ?? [] const expectedValues = type === "blank" || type === "notBlank" ? 0 : type === "numberBetween" || type === "numberNotBetween" ? 2 : 1 if (values.length !== expectedValues) { throw new Error( `Google returned ${values.length} values for condition type "${condition.type}"; expected ${expectedValues}.`, ) } if (type === "blank" || type === "notBlank") { return CONDITIONAL_FORMAT_CONDITION_SCHEMA.parse({ type }) } if (type === "customFormula") { return CONDITIONAL_FORMAT_CONDITION_SCHEMA.parse({ formula: normalizeUserEnteredValue(values[0]), type, }) } if (type === "numberBetween" || type === "numberNotBetween") { return CONDITIONAL_FORMAT_CONDITION_SCHEMA.parse({ type, values: [ normalizeUserEnteredValue(values[0]), normalizeUserEnteredValue(values[1]), ], }) } if (type === "dateBefore" || type === "dateAfter") { const value = values[0] if (value?.relativeDate != null) { if (value.userEnteredValue != null) { throw new Error( "Google returned a conditional-format value with both relative and user-entered values.", ) } const relativeDate = Object.entries(RELATIVE_DATES).find( ([, providerDate]) => providerDate === value.relativeDate, )?.[0] if (!relativeDate) { throw new Error( `Google returned unknown relative date "${value.relativeDate}".`, ) } return CONDITIONAL_FORMAT_CONDITION_SCHEMA.parse({ type, value: { relativeDate }, }) } } return CONDITIONAL_FORMAT_CONDITION_SCHEMA.parse({ type, value: normalizeUserEnteredValue(values[0]), }) } /** * Extracts an exact user-entered condition value. * * @param value - Provider condition value. * @throws When the value is missing or uses the relative-date branch. */ function normalizeUserEnteredValue( value: sheets_v4.Schema$ConditionValue | undefined, ) { if (value?.userEnteredValue == null || value.relativeDate != null) { throw new Error("Google returned an invalid conditional-format value.") } return value.userEnteredValue } /** * Converts one public gradient interpolation point. * * @param point - Public gradient point. */ function toGoogleInterpolationPoint( point: GradientInterpolationPoint, ): sheets_v4.Schema$InterpolationPoint { return { colorStyle: toGoogleColorStyle(point.color), type: INTERPOLATION_POINT_TYPES[point.type], value: "value" in point ? point.value : undefined, } } /** * Normalizes one provider gradient interpolation point. * * @param point - Provider gradient point. * @throws When the point type, color, or value is invalid. */ function normalizeGoogleInterpolationPoint( point: sheets_v4.Schema$InterpolationPoint, ) { const type = Object.entries(INTERPOLATION_POINT_TYPES).find( ([, providerType]) => providerType === point.type, )?.[0] const colorStyle = point.colorStyle ?? (point.color ? { rgbColor: point.color } : undefined) if (!type || !colorStyle) { throw new Error("Google returned an invalid gradient interpolation point.") } return type === "min" || type === "max" ? { color: normalizeGoogleColorStyle(colorStyle), type } : { color: normalizeGoogleColorStyle(colorStyle), type, value: point.value, } } export type GoogleSheetsBandedRange = z.infer /** * Converts one range-border side or explicit clear. * * @param border - Public border side. */ function toGoogleBorder( border: z.infer[keyof z.infer], ): sheets_v4.Schema$Border | undefined { if (border === undefined) return undefined if (border === null) return { style: "NONE" } return { colorStyle: border.color === undefined ? undefined : toGoogleColorStyle(border.color), style: BORDER_STYLES[border.style], } }