diff --git a/plugins/google-sheets/src/App.tsx b/plugins/google-sheets/src/App.tsx index 47a8781ad..a632e4d34 100644 --- a/plugins/google-sheets/src/App.tsx +++ b/plugins/google-sheets/src/App.tsx @@ -190,7 +190,9 @@ export function App({ pluginContext }: AppProps) { const mode = framer.mode // Not a hook because we don't want to re-run the effect - const isAllowedToSync = framer.isAllowedTo(...syncMethods) + const hasEnumField = context.type === "update" && context.collectionFields.some(field => field.type === "enum") + const isAllowedToSync = + framer.isAllowedTo(...syncMethods) && (!hasEnumField || framer.isAllowedTo("ManagedCollection.setFields")) const shouldSyncOnly = mode === "syncManagedCollection" && shouldSyncImmediately(context) && isAllowedToSync useLayoutEffect(() => { @@ -222,6 +224,7 @@ export function App({ pluginContext }: AppProps) { spreadsheetId, sheetTitle, fields, + configureFields: false, // Determine if the field type is already configured, otherwise default to "string" colFieldTypes: headerRow.map(colName => { const field = fields.find(field => field.name === colName) diff --git a/plugins/google-sheets/src/pages/MapSheetFields.tsx b/plugins/google-sheets/src/pages/MapSheetFields.tsx index 04d2ef8ac..9dd8adaae 100644 --- a/plugins/google-sheets/src/pages/MapSheetFields.tsx +++ b/plugins/google-sheets/src/pages/MapSheetFields.tsx @@ -29,6 +29,7 @@ const fieldTypeOptions: FieldTypeOption[] = [ { type: "color", label: "Color" }, { type: "boolean", label: "Toggle" }, { type: "number", label: "Number" }, + { type: "enum", label: "Option" }, { type: "file", label: "File" }, ] @@ -322,7 +323,7 @@ export function MapSheetFieldsPage({ diff --git a/plugins/google-sheets/src/sheets.ts b/plugins/google-sheets/src/sheets.ts index cc449ee90..bfa28dbfc 100644 --- a/plugins/google-sheets/src/sheets.ts +++ b/plugins/google-sheets/src/sheets.ts @@ -10,7 +10,16 @@ import * as v from "valibot" import auth from "./auth" import { logSyncResult } from "./debug.ts" import { queryClient } from "./main.tsx" -import { assert, columnToLetter, generateHashId, generateUniqueNames, isDefined, listFormatter, slugify } from "./utils" +import { + assert, + columnToLetter, + generateHashId, + generateUniqueNames, + hashStringToEnumCaseId, + isDefined, + listFormatter, + slugify, +} from "./utils" const USER_INFO_API_URL = "https://www.googleapis.com/oauth2/v1" const SHEETS_API_URL = "https://sheets.googleapis.com/v4" @@ -306,6 +315,11 @@ export interface SyncMutationOptions { ignoredColumns: string[] colFieldTypes: VirtualFieldType[] lastSyncedTime: string | null + /** + * When false (e.g. sync-only mode), schema is only applied when enum fields need refreshed cases. + * When true (default), collection fields are always updated from the mapping. + */ + configureFields?: boolean } const BASE_DATE_1900 = new Date(Date.UTC(1899, 11, 30)) @@ -348,6 +362,54 @@ function isValidISODate(dateString: string): boolean { } } +function isEnumCellValueEmpty(cell: CellValue | undefined): boolean { + if (!isDefined(cell)) return true + if (typeof cell === "string" && cell.trim() === "") return true + return false +} + +function buildEnumCasesForColumn(rows: Row[], colIndex: number): { id: string; name: string }[] { + const unique = new Set() + let hasEmptyCell = false + + for (const row of rows) { + const cell = row[colIndex] + if (isEnumCellValueEmpty(cell)) { + hasEmptyCell = true + continue + } + unique.add(String(cell)) + } + + const sorted = Array.from(unique).sort((a, b) => a.localeCompare(b)) + const valueCases = sorted.map(text => ({ + id: hashStringToEnumCaseId(text), + name: text, + })) + + if (hasEmptyCell) { + return [{ id: "null", name: "None" }, ...valueCases] + } + + return valueCases +} + +function enrichFieldsWithEnumCases( + fields: SheetCollectionFieldInput[], + uniqueHeaderRowNames: string[], + rows: Row[] +): SheetCollectionFieldInput[] { + return fields.map(field => { + if (field.type !== "enum") return field + + const colIndex = uniqueHeaderRowNames.indexOf(field.id) + if (colIndex === -1) return field + + const cases = buildEnumCasesForColumn(rows, colIndex) + return { ...field, type: "enum", cases } + }) +} + function getFieldDataEntryInput(type: VirtualFieldType, cellValue: CellValue): FieldDataEntryInput | null { switch (type) { case "number": { @@ -389,6 +451,10 @@ function getFieldDataEntryInput(type: VirtualFieldType, cellValue: CellValue): F if (!isDefined(cellValue)) return null return { type, value: String(cellValue) } } + case "enum": { + if (isEnumCellValueEmpty(cellValue)) return null + return { type: "enum", value: hashStringToEnumCaseId(String(cellValue)) } + } default: return null } @@ -460,6 +526,12 @@ function processSheetRow({ type: "date", } break + case "enum": + fieldDataEntryInput = { + value: "null", + type: "enum", + } + break } } @@ -565,6 +637,7 @@ export async function syncSheet({ ignoredColumns, slugColumn, colFieldTypes, + configureFields, }: SyncMutationOptions) { if (fields.length === 0) { throw new Error("Expected to have at least one field selected to sync.") @@ -576,6 +649,13 @@ export async function syncSheet({ const [headerRow, ...rows] = sheet.values const uniqueHeaderRowNames = generateUniqueNames(headerRow) + const enrichedFields = enrichFieldsWithEnumCases(fields, uniqueHeaderRowNames, rows) + const needsFieldSchemaUpdate = !configureFields || enrichedFields.some(field => field.type === "enum") + + if (needsFieldSchemaUpdate) { + await collection.setFields(enrichedFields.map(mapFieldToFramer)) + } + const headerRowHash = generateHeaderRowHash(headerRow, ignoredColumns) // Find the longest row length to check if any sheet rows are longer than the header row @@ -798,13 +878,7 @@ export const useSyncSheetMutation = ({ }) => { return useMutation({ mutationFn: async (args: SyncMutationOptions) => { - const collection = await framer.getActiveManagedCollection() - const fields = args.fields.map(mapFieldToFramer) - await collection.setFields(fields) - return await syncSheet({ - ...args, - fields, - }) + return await syncSheet(args) }, onSuccess, onError, diff --git a/plugins/google-sheets/src/utils.ts b/plugins/google-sheets/src/utils.ts index 6f614f398..2a8a995be 100644 --- a/plugins/google-sheets/src/utils.ts +++ b/plugins/google-sheets/src/utils.ts @@ -73,6 +73,29 @@ export function generateHashId(text: string): string { return unsignedHash.toString(16).padStart(8, "0") } +/** + * Stable 32-character lowercase hex id derived from text (enum case ids from cell values). + */ +export function hashStringToEnumCaseId(text: string): string { + let h0 = 5381 + let h1 = 52711 + let h2 = 0 + let h3 = 0 + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i) + h0 = (Math.imul(h0, 33) ^ c) >>> 0 + h1 = (Math.imul(h1, 33) ^ (c + i)) >>> 0 + h2 = (Math.imul(h2, 33) + c) >>> 0 + h3 = (h3 + Math.imul(c, i + 1)) >>> 0 + } + return [ + h0.toString(16).padStart(8, "0"), + h1.toString(16).padStart(8, "0"), + h2.toString(16).padStart(8, "0"), + h3.toString(16).padStart(8, "0"), + ].join("") +} + /** * Generates unique names by appending a suffix if the name is already used. */