diff --git a/.yarn/cache/framer-plugin-npm-3.9.0-37940aa6dc-2d8249cc82.zip b/.yarn/cache/framer-plugin-npm-3.9.0-37940aa6dc-2d8249cc82.zip new file mode 100644 index 000000000..c25e796ca Binary files /dev/null and b/.yarn/cache/framer-plugin-npm-3.9.0-37940aa6dc-2d8249cc82.zip differ diff --git a/.yarn/cache/framer-plugin-npm-3.9.0-beta.1-d4f15f8a6a-1f58b75683.zip b/.yarn/cache/framer-plugin-npm-3.9.0-beta.1-d4f15f8a6a-1f58b75683.zip deleted file mode 100644 index a513f930c..000000000 Binary files a/.yarn/cache/framer-plugin-npm-3.9.0-beta.1-d4f15f8a6a-1f58b75683.zip and /dev/null differ diff --git a/plugins/csv-import/package.json b/plugins/csv-import/package.json index ec0406eb4..d672275fe 100644 --- a/plugins/csv-import/package.json +++ b/plugins/csv-import/package.json @@ -10,11 +10,12 @@ "check-eslint": "run g:check-eslint", "preview": "run g:preview", "pack": "npx framer-plugin-tools@latest pack", - "check-typescript": "run g:check-typescript" + "check-typescript": "run g:check-typescript", + "autofix": "run check-biome --fix && run g:check-eslint --fix" }, "dependencies": { "csv-parse": "^6.1.0", - "framer-plugin": "3.9.0-beta.1", + "framer-plugin": "3.9.0", "react": "^18.3.1", "react-dom": "^18.3.1", "valibot": "^1.2.0" diff --git a/plugins/csv-import/src/App.css b/plugins/csv-import/src/App.css index 2dbf6932d..742de74a3 100644 --- a/plugins/csv-import/src/App.css +++ b/plugins/csv-import/src/App.css @@ -1,4 +1,3 @@ -.import-collection, .manage-conflicts { display: flex; flex-direction: column; @@ -9,15 +8,23 @@ } .import-collection { + display: flex; + flex-direction: column; + justify-content: space-between; + text-align: left; + height: 100%; + padding: 10px; align-items: center; + gap: 5px; } -.import-form { +.select-csv-file { display: flex; flex-direction: column; align-items: center; - flex: 1; + justify-content: center; width: 100%; + flex: 1; position: relative; } @@ -38,32 +45,6 @@ /* Main screen */ -.dropzone { - position: relative; - display: flex; - flex: 1; - flex-direction: column; - align-items: center; - justify-content: center; - margin: 0; - width: 100%; - min-height: 170px; - border-radius: 8px; - background-color: color-mix(in srgb, var(--framer-color-tint) 10%, transparent); - line-height: 1; - transition: all 0.2s ease; - border: 1px dashed transparent; -} - -.dropzone.dragging { - border-color: var(--framer-color-tint); -} - -.dropzone p { - color: var(--framer-color-tint); - font-weight: 600; -} - .file-input { visibility: hidden; position: absolute; @@ -93,8 +74,13 @@ width: 100%; flex: 1; text-align: center; - max-width: 160px; gap: 15px; + + padding: 20px; + aspect-ratio: 1 / 1; + border: 2px dashed rgba(255, 255, 255, 0.2); + border-radius: 12px; + background-color: rgba(255, 255, 255, 0.05); } .intro h2 { @@ -109,11 +95,29 @@ color: var(--framer-color-text-tertiary); } +.intro .content { + display: flex; + flex-direction: column; + flex: 1 1 auto; +} + +.dropzone { + border-color: var(--framer-color-tint); +} + +.dropzone p { + color: var(--framer-color-tint); + font-weight: 600; +} + +.no-border { + border: none; +} + .collection-selector { display: flex; gap: 8px; width: 100%; - padding-top: 15px; } .collection-select { @@ -202,3 +206,337 @@ width: fit-content; color: var(--framer-color-text-secondary); } + +select:disabled { + opacity: 0.5; +} + +select:not(:disabled) { + cursor: pointer; +} + +.field-name { + font-weight: 500; + /* color: var(--framer-color-text); */ + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.field-type { + font-size: 12px; + white-space: nowrap; +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--framer-color-text-tertiary); +} + +/* Field Mapper */ + +.field-mapper { + height: 100%; +} + +.field-mapper form { + height: 100%; + display: flex; + flex-direction: column; + gap: 20px; + + padding: 10px 15px 0; +} + +.field-mapper .sticky-divider { + position: sticky; + top: 0; + left: 0; + right: 0; + border: none; + border-top: 1px solid var(--framer-color-divider); +} + +.field-mapper .mapper-summary { + display: flex; + flex-wrap: wrap; + gap: 12px; + color: var(--framer-color-text-tertiary); +} + +.field-mapper .summary-stat { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; +} + +.field-mapper .summary-stat strong { + font-weight: 600; + color: var(--framer-color-text-secondary); +} + +.field-mapper .summary-stat.matched strong { + color: var(--framer-color-text-positive, #34c759); +} + +.field-mapper .summary-stat.creating strong { + color: var(--framer-color-tint, #0099ff); +} + +.field-mapper .summary-stat.mismatched strong { + color: var(--framer-color-warning, #ff9500); +} + +.field-mapper .summary-stat.ignored strong { + color: var(--framer-color-text-tertiary); +} + +.field-mapper .slug-field { + display: flex; + flex-direction: column; + width: 100%; + justify-content: space-between; + gap: 10px; + color: var(--framer-color-text-tertiary); +} + +.field-mapper .fields { + display: grid; + grid-template-columns: 1fr 8px 1fr 1fr; + gap: 10px; + margin-bottom: auto; + align-items: center; + color: var(--framer-color-text-tertiary); +} + +.field-mapper .fields-column { + grid-column: span 2 / span 2; +} + +.field-mapper .source-field { + display: flex; + flex-direction: row; + align-items: center; + justify-content: left; + white-space: nowrap; + font-weight: 500; + background-color: var(--framer-color-bg-tertiary); + gap: 8px; + padding: 0 10px; + height: 30px; + border-radius: 8px; + border: none; + cursor: pointer; + color: var(--framer-color-text); + width: 100%; + text-align: left; +} + +.field-mapper .source-field.ignored, +.field-mapper .source-field[aria-disabled="true"] { + opacity: 0.5; +} + +.field-mapper .source-field.is-slug { + box-shadow: inset 0 0 0 1px var(--framer-color-tint); +} + +.field-mapper .source-field:focus-visible { + outline: none; + box-shadow: inset 0 0 0 1px var(--framer-color-tint); +} + +.field-mapper .source-field input[type="checkbox"] { + cursor: pointer; + flex-shrink: 0; +} + +.field-mapper .source-field input[type="checkbox"]:focus { + box-shadow: none; +} + +[data-framer-theme="light"] .field-mapper .source-field input[type="checkbox"]:not(:checked) { + background: #ccc; +} + +[data-framer-theme="dark"] .field-mapper .source-field input[type="checkbox"]:not(:checked) { + background: #666; +} + +.field-mapper .source-field span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.field-mapper .source-field .inferred-type { + margin-left: auto; + font-size: 0.75em; + font-weight: 400; + color: var(--framer-color-text-tertiary); + flex-shrink: 0; +} + +.field-mapper .field-input { + width: 100%; + flex-shrink: 1; +} + +.field-mapper .field-input[disabled] { + opacity: 0.5; +} + +.field-mapper .field-type { + width: 100%; +} + +.field-mapper .mismatch-warning { + grid-column: 1 / -1; + font-size: 0.75em; + color: var(--framer-color-warning, #ff9500); + padding: 6px 10px; + background-color: rgba(255, 153, 0, 0.1); + border-radius: 6px; + margin-top: -5px; +} + +.field-mapper footer { + position: sticky; + bottom: 0; + left: 0; + width: 100%; + background-color: var(--framer-color-bg); + margin-top: auto; + padding-bottom: 15px; + display: flex; + flex-direction: column; + gap: 15px; + border-top: 1px solid var(--framer-color-divider); +} + +.field-mapper footer .sticky-top { + border: none; + border-top: 1px solid var(--framer-color-divider); +} + +/* Unmapped required fields warning */ + +.unmapped-required-section { + margin-top: 12px; + padding: 8px; + background-color: rgba(255, 153, 0, 0.08); + border: 1px solid rgba(255, 153, 0, 0.25); + border-radius: 6px; +} + +.unmapped-required-header { + display: flex; + align-items: center; + gap: 6px; + font-weight: 600; + font-size: 0.75em; + color: var(--framer-color-warning, #ff9500); + margin-bottom: 6px; +} + +.warning-icon { + font-size: 12px; +} + +.unmapped-required-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.unmapped-required-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 8px; + background-color: var(--framer-color-bg); + border-radius: 4px; + font-size: 0.75em; +} + +.unmapped-required-item .field-name { + font-weight: 500; + color: var(--framer-color-text); +} + +.unmapped-required-item .field-type { + color: var(--framer-color-text); + font-size: 1em; +} + +.unmapped-required-hint { + margin: 6px 0 0; + font-size: 0.6875em; + color: var(--framer-color-text-tertiary); +} + +/* Missing Fields Section - Notion-style */ + +.missing-fields-section { + display: grid; + grid-template-columns: 1fr 8px 1fr 1fr; + gap: 10px; + align-items: center; +} + +.missing-fields-header { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 5px; +} + +.missing-fields-header span:first-child { + color: var(--framer-color-text-tertiary); +} + +.missing-field-row { + display: contents; +} + +.missing-field-info { + display: flex; + flex-direction: row; + align-items: center; + background-color: var(--framer-color-bg-tertiary); + gap: 8px; + padding: 0 10px; + height: 30px; + border-radius: 8px; + min-width: 0; + overflow: hidden; +} + +.missing-field-info .field-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.missing-field-info .field-type { + margin-left: auto; + font-size: 0.75em; + color: var(--framer-color-text-tertiary); + white-space: nowrap; + width: auto; + flex-shrink: 0; +} + +.missing-field-action { + grid-column: span 2; + width: 100%; +} diff --git a/plugins/csv-import/src/App.tsx b/plugins/csv-import/src/App.tsx index c6270783e..9540be249 100644 --- a/plugins/csv-import/src/App.tsx +++ b/plugins/csv-import/src/App.tsx @@ -1,78 +1,44 @@ import type { Collection } from "framer-plugin" import { FramerPluginClosedError, framer, useIsAllowedTo } from "framer-plugin" -import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react" -import type { ImportResult } from "./csv" +import { useCallback, useState } from "react" import "./App.css" -import { CollectionSelector } from "./CollectionSelector" -import { ImportError, importCSV, parseCSV, processRecords } from "./csv" -import { ImportIcon } from "./ImportIcon" -import { ManageConflicts } from "./ManageConflicts" +import { useMiniRouter } from "./minirouter" +import { FieldMapper, type FieldMappingItem, type MissingFieldItem } from "./routes/FieldMapper" +import { Home } from "./routes/Home" +import { ManageConflicts } from "./routes/ManageConflicts" +import { + createNewFieldsInCms as applyFieldCreationsToCms, + removeFieldsFromCms as applyFieldRemovalsToCms, +} from "./utils/fieldReconciliation" +import { importCSV as loadDataToCms } from "./utils/importCSV" +import { parseCSV } from "./utils/parseCSV" +import { ImportError, type ImportItem, prepareImportPayload } from "./utils/prepareImportPayload" export function App({ initialCollection }: { initialCollection: Collection | null }) { const [collection, setCollection] = useState(initialCollection) + const hasAllPermissions = useIsAllowedTo("Collection.addItems", "Collection.addFields", "Collection.removeFields") - const isAllowedToAddItems = useIsAllowedTo("Collection.addItems") + const { currentRoute, navigate } = useMiniRouter() - const form = useRef(null) - const inputOpenedFromImportButton = useRef(false) - - const [result, setResult] = useState(null) - - const itemsWithConflict = useMemo(() => result?.items.filter(item => item.action === "conflict") ?? [], [result]) - - const [isDragging, setIsDragging] = useState(false) - - useEffect(() => { - void framer.showUI({ - width: 260, - height: 330, - resizable: false, - }) - }, []) - - useEffect(() => { - if (itemsWithConflict.length === 0) { - return - } - - void framer.showUI({ - width: 260, - height: 165, - resizable: false, - }) - }, [itemsWithConflict]) - - const importItems = useCallback( - async (result: ImportResult) => { + const handleFileSelected = useCallback( + async (csvContent: string) => { if (!collection) return - - await framer.hideUI() - await importCSV(collection, result) - }, - [collection] - ) - - const processAndImport = useCallback( - async (csv: string) => { - if (!collection) return - if (!isAllowedToAddItems) return + if (!hasAllPermissions) return try { - const csvRecords = await parseCSV(csv) + const csvRecords = await parseCSV(csvContent) if (csvRecords.length === 0) { throw new Error("No records found in CSV") } - const result = await processRecords(collection, csvRecords) - setResult(result) - - if (result.items.some(item => item.action === "conflict")) { - return - } - - await importItems(result) + await navigate({ + uid: "field-mapper", + opts: { collection, csvRecords }, + }) } catch (error) { - if (error instanceof FramerPluginClosedError) throw error + if (error instanceof FramerPluginClosedError) { + throw error + } console.error(error) @@ -81,215 +47,116 @@ export function App({ initialCollection }: { initialCollection: Collection | nul return } - framer.closePlugin("Error processing CSV file. Check console for details.", { + framer.notify("Error processing CSV file. Check console for details.", { variant: "error", }) } }, - [isAllowedToAddItems, collection, importItems] + [collection, hasAllPermissions, navigate] ) - useEffect(() => { - if (!collection) return - if (!isAllowedToAddItems) return + const handleFieldMapperSubmit = useCallback( + async (opts: { + collection: Collection + csvRecords: Record[] + mappings: FieldMappingItem[] + slugFieldName: string + missingFields: MissingFieldItem[] + }) => { + if (!hasAllPermissions) return - const formElement = form.current - if (!formElement) return - - const handleDragOver = (event: DragEvent) => { - event.preventDefault() - setIsDragging(true) - } - - const handleDragLeave = (event: DragEvent) => { - if (!event.relatedTarget) { - setIsDragging(false) - } - } - - const handleDrop = (event: DragEvent) => { - event.preventDefault() - setIsDragging(false) - - const file = event.dataTransfer?.files[0] - if (!file?.name.endsWith(".csv")) return - - const input = document.getElementById("file-input") as HTMLInputElement - const dataTransfer = new DataTransfer() - dataTransfer.items.add(file) - input.files = dataTransfer.files - formElement.requestSubmit() - } - - formElement.addEventListener("dragover", handleDragOver) - formElement.addEventListener("dragleave", handleDragLeave) - formElement.addEventListener("drop", handleDrop) - - return () => { - formElement.removeEventListener("dragover", handleDragOver) - formElement.removeEventListener("dragleave", handleDragLeave) - formElement.removeEventListener("drop", handleDrop) - } - }, [isAllowedToAddItems, collection]) - - useEffect(() => { - if (!collection) return - if (!isAllowedToAddItems) return - - const handlePaste = (event: ClipboardEvent) => { - const { clipboardData } = event - if (!clipboardData) return - - // Check if the paste event originated from within an input element - const target = event.target as HTMLElement - if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") { - return - } - - const task = async () => { - let csv = "" + try { + await applyFieldRemovalsToCms(opts.collection, opts.missingFields) + await applyFieldCreationsToCms(opts.collection, opts.mappings) + + // Process records with field mapping + const payload = await prepareImportPayload({ + collection: opts.collection, + csvRecords: opts.csvRecords, + slugFieldName: opts.slugFieldName, + mappings: opts.mappings, + }) - try { - csv = clipboardData.getData("text/plain") - if (!csv) return - } catch (error) { - console.error("Error accessing clipboard data:", error) - framer.notify("Unable to access clipboard content", { - variant: "error", + const itemsWithConflict = payload.items.filter(item => item.action === "conflict") + if (itemsWithConflict.length > 0) { + const resolutions = await new Promise(resolve => { + void navigate({ + uid: "manage-conflicts", + opts: { + conflicts: itemsWithConflict, + result: payload, + onComplete(items) { + resolve(items) + }, + }, + }) }) - return - } - try { - await processAndImport(csv) - } catch (error) { - if (error instanceof FramerPluginClosedError) return - - console.error("Error importing CSV:", error) - framer.notify("Error importing CSV", { - variant: "error", - }) + payload.items = payload.items.map( + item => resolutions.find(resolved => resolved.slug === item.slug) ?? item + ) } - } - - void task() - } - window.addEventListener("paste", handlePaste) + await navigate({ uid: "home", opts: undefined }) - return () => { - window.removeEventListener("paste", handlePaste) - } - }, [isAllowedToAddItems, processAndImport, collection]) - - const handleSubmit = useCallback( - (event: React.FormEvent) => { - if (!collection) return - if (!isAllowedToAddItems) return - event.preventDefault() - - if (!form.current) throw new Error("Form ref not set") - - const formData = new FormData(form.current) - const fileValue = formData.get("file") + await loadDataToCms(opts.collection, payload) - if (!fileValue || typeof fileValue === "string") return + await framer.hideUI() + } catch (error) { + if (error instanceof FramerPluginClosedError) { + throw error + } - const file = fileValue + console.error(error) - void file.text().then(processAndImport) - }, - [isAllowedToAddItems, processAndImport, collection] - ) + if (error instanceof ImportError || error instanceof Error) { + framer.notify(error.message, { variant: "error" }) + return + } - const handleFileChange = useCallback( - (event: ChangeEvent) => { - if (!collection) return - if (!event.currentTarget.files?.[0]) return - if (inputOpenedFromImportButton.current) { - form.current?.requestSubmit() + framer.notify("Error processing CSV file. Check console for details.", { + variant: "error", + }) } }, - [collection] + [hasAllPermissions, navigate] ) - if (result && itemsWithConflict.length > 0) { - return ( - { - const updatedItems = result.items.map( - item => resolvedItems.find(resolved => resolved.slug === item.slug) ?? item - ) - void importItems({ ...result, items: updatedItems }) - }} - /> - ) + switch (currentRoute.uid) { + case "home": { + return ( + + ) + } + case "field-mapper": + return ( + + handleFieldMapperSubmit({ + collection: currentRoute.opts.collection, + csvRecords: currentRoute.opts.csvRecords, + mappings: opts.mappings, + slugFieldName: opts.slugFieldName, + missingFields: opts.missingFields, + }) + } + onCancel={async () => { + await navigate({ uid: "home", opts: undefined }) + }} + /> + ) + case "manage-conflicts": { + return ( + + ) + } + default: + // @ts-expect-error -- exhaustive switch + return
Unknown route {currentRoute.uid}
} - - return ( - <> -
- - -
- - - {isDragging && ( -
-

Drop CSV file to import

-
- )} - - {!isDragging && ( - <> -
-
- -
-
-

Upload CSV

-

- Make sure your collection fields in Framer match the names of your CSV fields. -

-
-
- - - - )} -
-
- - ) } diff --git a/plugins/csv-import/src/CheckIcon.tsx b/plugins/csv-import/src/components/CheckIcon.tsx similarity index 100% rename from plugins/csv-import/src/CheckIcon.tsx rename to plugins/csv-import/src/components/CheckIcon.tsx diff --git a/plugins/csv-import/src/CollectionSelector.tsx b/plugins/csv-import/src/components/CollectionSelector.tsx similarity index 99% rename from plugins/csv-import/src/CollectionSelector.tsx rename to plugins/csv-import/src/components/CollectionSelector.tsx index 61137564a..64b67bf93 100644 --- a/plugins/csv-import/src/CollectionSelector.tsx +++ b/plugins/csv-import/src/components/CollectionSelector.tsx @@ -65,6 +65,7 @@ export function CollectionSelector({ collection, onCollectionChange }: Collectio ))} + {isAllowedToCreateCollection && ( + + + )} + + ) +} diff --git a/plugins/csv-import/src/csv.ts b/plugins/csv-import/src/csv.ts deleted file mode 100644 index cef0d16af..000000000 --- a/plugins/csv-import/src/csv.ts +++ /dev/null @@ -1,519 +0,0 @@ -import { - Collection, - CollectionItem, - type CollectionItemInput, - type Field, - type FieldDataEntryInput, - type FieldDataInput, - framer, -} from "framer-plugin" - -import * as v from "valibot" - -const CSVRecordSchema = v.record(v.string(), v.string()) - -type CSVRecord = v.InferOutput - -export type ImportResultItem = CollectionItemInput & { - action: "add" | "conflict" | "onConflictUpdate" | "onConflictSkip" -} - -export interface ImportResult { - warnings: { - missingSlugCount: number - doubleSlugCount: number - skippedValueCount: number - skippedValueKeys: Set - } - items: ImportResultItem[] -} - -/** - * Parses a string of CSV data. Does not do any type casting, because we want to - * apply that based on the fields the data will go into, not the data itself. - * - * @param data CSV data, separated by comma or tab. - * @returns Array of parsed records - */ -export async function parseCSV(data: string): Promise { - // Lazily import the parser - const { parse } = await import("csv-parse/browser/esm/sync") - - let records: CSVRecord[] = [] - let error - - // Delimiters to try - // , = pretty much the default - // \t = more common when copy-pasting (e.g. Google Sheets) - // ; = what spreadsheet apps (e.g. Numbers) use when you're using a locale - // that already uses , for decimal separation - // Check of , and \t will be combined as this will cover most cases, falls back to ; - const delimiters = [",", "\t", ";"] - const options = { columns: true, skipEmptyLines: true, skipRecordsWithEmptyValues: true } - - for (const delimiter of delimiters) { - try { - const parsed = parse(data, { ...options, delimiter }) as unknown - - // It can happen that parsing succeeds with the wrong delimiter. For example, a tab separated file could be parsed - // successfully with comma separators. If that's the case, we can find it by checking two things: - // 1. That the resulting records have more than one column - // 2. That if there's only one column, it does not contain delimiters - // If both of those conditions are met, we can assume there's a parsing error and should not import the records - const firstItemKeys = isArray(parsed) && parsed[0] && isObject(parsed[0]) ? Object.keys(parsed[0]) : [] - if (firstItemKeys.length < 2) { - const delimiterInKey = delimiters.some(del => firstItemKeys[0]?.includes(del)) - if (delimiterInKey) { - error = new Error("Parsed with incorrect delimiter") - continue - } - } - - error = undefined - records = v.parse(v.array(CSVRecordSchema), parsed) - break - } catch (innerError) { - error = innerError instanceof Error ? innerError : new Error(String(innerError)) - } - } - - if (error) { - throw error - } - - return records -} - -/** Error when importing fails, internal to `RecordImporter` */ -export class ImportError extends Error { - /** - * @param variant Notification variant to show the user - * @param message Message to show the user - */ - constructor( - readonly variant?: "error" | "warning", - message?: string - ) { - super(message) - } -} - -/** Used to indicated a value conversion failed, used by `RecordImporter` and `setValueForVariable` */ -class ConversionError extends Error {} - -function findRecordValue(record: CSVRecord, key: string) { - const value = Object.entries(record).find(([k]) => collator.compare(k, key) === 0)?.[1] - if (!value) { - return null - } - return value -} - -const collator = new Intl.Collator("en", { sensitivity: "base" }) -const BOOLEAN_TRUTHY_VALUES = /1|y(?:es)?|true/iu - -function getFieldDataEntryInputForField( - field: Field, - value: string | null, - allItemIdBySlug: Map>, - record: CSVRecord -): FieldDataEntryInput | ConversionError { - switch (field.type) { - case "string": - return { type: field.type, value: value ?? "" } - case "formattedText": - return { type: field.type, value: value ?? "", contentType: "auto" } - - case "color": - case "link": - case "file": - return { type: field.type, value: value ? value.trim() : null } - - case "image": { - const altText = findRecordValue(record, `${field.name}:alt`) - return { type: field.type, value: value ? value.trim() : null, alt: altText ?? undefined } - } - - case "number": { - const number = Number(value) - if (Number.isNaN(number)) { - return new ConversionError(`Invalid value for field “${field.name}” expected a number`) - } - return { type: "number", value: number } - } - - case "boolean": { - return { type: "boolean", value: value ? BOOLEAN_TRUTHY_VALUES.test(value) : false } - } - - case "date": { - if (value === null) { - return { type: "date", value: null } - } - const date = new Date(value) - if (!isValidDate(date)) { - return new ConversionError(`Invalid value for field “${field.name}” expected a valid date`) - } - return { type: "date", value: date.toJSON() } - } - - case "enum": { - if (value === null) { - const [firstCase] = field.cases - assert(firstCase, `No cases found for enum “${field.name}”`) - return { type: "enum", value: firstCase.id } - } - const matchingCase = field.cases.find( - enumCase => collator.compare(enumCase.name, value) === 0 || enumCase.id === value - ) - if (!matchingCase) { - return new ConversionError(`Invalid case “${value}” for enum “${field.name}”`) - } - return { type: "enum", value: matchingCase.id } - } - - case "collectionReference": { - if (value === null) { - return { type: "collectionReference", value: null } - } - - const referencedSlug = value.trim() - const referencedId = allItemIdBySlug.get(field.collectionId)?.get(referencedSlug) - if (!referencedId) { - return new ConversionError(`Invalid Collection reference “${value}”`) - } - - return { type: "collectionReference", value: referencedId } - } - - case "multiCollectionReference": { - if (value === null) { - return { type: "multiCollectionReference", value: null } - } - const referencedSlugs = value.split(",").map(slug => slug.trim()) - const referencedIds: string[] = [] - - for (const slug of referencedSlugs) { - const referencedId = allItemIdBySlug.get(field.collectionId)?.get(slug) - if (!referencedId) { - return new ConversionError(`Invalid Collection reference “${slug}”`) - } - referencedIds.push(referencedId) - } - - return { type: "multiCollectionReference", value: referencedIds } - } - - case "array": - case "divider": - case "unsupported": - return new ConversionError(`Unsupported field type “${field.type}”`) - - default: - field satisfies never - return new ConversionError("This should not happen") - } -} - -function getFirstMatchingIndex(values: string[], name: string | undefined) { - if (!name) { - return -1 - } - - for (const [index, value] of values.entries()) { - if (collator.compare(value, name) === 0) { - return index - } - } - - return -1 -} - -/** - * Find the index of the slug field in the CSV header - * Either matches the slug field directly or finds the field it's based on - */ -function findSlugFieldIndex( - csvHeader: string[], - slugField: { name: string; basedOn?: string | null }, - fields: Field[] -): { slugIndex: number; basedOnIndex: number } { - // Try direct match first - const slugIndex = getFirstMatchingIndex(csvHeader, slugField.name) - - // Find the based on field - const basedOnField = fields.find(field => field.id === slugField.basedOn) - const basedOnIndex = getFirstMatchingIndex(csvHeader, basedOnField?.name) - - // If neither field is found, throw error - if (slugIndex === -1 && basedOnIndex === -1) { - throw new ImportError("error", `Import failed. Ensure your CSV has a column named “${slugField.name}”.`) - } - - return { slugIndex, basedOnIndex } -} - -/** Importer for "records": string based values with named keys */ -export async function processRecords(collection: Collection, records: CSVRecord[]) { - if (!collection.slugFieldName) { - throw new ImportError("error", "Import failed. No slug field was found in your CMS Collection.") - } - - const existingItems = await collection.getItems() - - const result: ImportResult = { - warnings: { - missingSlugCount: 0, - doubleSlugCount: 0, - skippedValueCount: 0, - skippedValueKeys: new Set(), - }, - items: [], - } - - const fields = await collection.getFields() - const allItemIdBySlug = new Map>() - - const firstRecord = records[0] - assert(firstRecord, "No records were found in your CSV.") - - const csvHeader = Object.keys(firstRecord) - const { slugIndex, basedOnIndex } = findSlugFieldIndex( - csvHeader, - { - name: collection.slugFieldName, - basedOn: collection.slugFieldBasedOn, - }, - fields - ) - - // Check if CSV has a draft column - const hasDraftColumn = csvHeader.includes(":draft") - - for (const field of fields) { - if (field.type === "collectionReference" || field.type === "multiCollectionReference") { - const collectionIdBySlug = allItemIdBySlug.get(field.collectionId) ?? new Map() - - const collection = await framer.getCollection(field.collectionId) - if (!collection) { - throw new ImportError( - "error", - `Import failed. “${field.name}” references a Collection that doesn’t exist.` - ) - } - - const items = await collection.getItems() - for (const item of items) { - collectionIdBySlug.set(item.slug, item.id) - } - - allItemIdBySlug.set(field.collectionId, collectionIdBySlug) - } - } - - const newSlugValues = new Set() - const existingItemsBySlug = new Map() - for (const item of existingItems) { - existingItemsBySlug.set(item.slug, item) - } - - const fieldsToImport = fields.filter(field => csvHeader.find(header => collator.compare(header, field.name) === 0)) - - for (const record of records) { - let slug: string | undefined - const values = Object.values(record) - - // Try to get slug from the slug field first - if (slugIndex !== -1 && !isUndefined(values[slugIndex])) { - slug = slugify(values[slugIndex]) - } - - // If no slug and we have a basedOn field, try to get slug from that - if (!slug && basedOnIndex !== -1 && !isUndefined(values[basedOnIndex])) { - slug = slugify(values[basedOnIndex]) - } - - if (!slug) { - result.warnings.missingSlugCount++ - continue - } else if (newSlugValues.has(slug)) { - result.warnings.doubleSlugCount++ - continue - } - - // Parse draft status - let draft = false - if (hasDraftColumn) { - const draftValue = findRecordValue(record, ":draft") - if (draftValue && draftValue.trim() !== "") { - draft = BOOLEAN_TRUTHY_VALUES.test(draftValue.trim()) - } - } - - const fieldData: FieldDataInput = {} - for (const field of fieldsToImport) { - const value = findRecordValue(record, field.name) - const fieldDataEntry = getFieldDataEntryInputForField(field, value, allItemIdBySlug, record) - - if (fieldDataEntry instanceof ConversionError) { - result.warnings.skippedValueCount++ - result.warnings.skippedValueKeys.add(field.name) - continue - } - - fieldData[field.id] = fieldDataEntry - } - - const item: ImportResultItem = { - id: existingItemsBySlug.get(slug)?.id, - slug, - fieldData, - action: existingItemsBySlug.get(slug) ? "conflict" : "add", - draft, - } - - if (item.action === "add") { - newSlugValues.add(slug) - } - - result.items.push(item) - } - - return result -} - -export async function importCSV(collection: Collection, result: ImportResult) { - const totalItems = result.items.length - const totalAdded = result.items.filter(item => item.action === "add").length - const totalUpdated = result.items.filter(item => item.action === "onConflictUpdate").length - const totalSkipped = result.items.filter(item => item.action === "onConflictSkip").length - if (totalItems !== totalAdded + totalUpdated + totalSkipped) { - throw new Error("Total items mismatch") - } - - await collection.addItems( - result.items - .filter(item => item.action !== "onConflictSkip") - .map(item => { - if (item.action === "add") { - assert(item.slug !== undefined, "Item requires a slug") - return { - slug: item.slug, - fieldData: item.fieldData, - draft: item.draft, - } - } - - assert(item.id !== undefined, "Item requires an id") - return { - id: item.id, - fieldData: item.fieldData, - draft: item.draft, - } - }) - ) - - const messages: string[] = [] - if (totalAdded > 0) { - messages.push(`Added ${totalAdded} ${totalAdded === 1 ? "item" : "items"}`) - } - if (totalUpdated > 0) { - messages.push(`Updated ${totalUpdated} ${totalUpdated === 1 ? "item" : "items"}`) - } - if (totalSkipped > 0) { - messages.push(`Skipped ${totalSkipped} ${totalSkipped === 1 ? "item" : "items"}`) - } - - if (result.warnings.missingSlugCount > 0) { - messages.push( - `Skipped ${result.warnings.missingSlugCount} ${ - result.warnings.missingSlugCount === 1 ? "item" : "items" - } because of missing slug field` - ) - } - if (result.warnings.doubleSlugCount > 0) { - messages.push( - `Skipped ${result.warnings.doubleSlugCount} ${ - result.warnings.doubleSlugCount === 1 ? "item" : "items" - } because of duplicate slugs` - ) - } - - const { skippedValueCount, skippedValueKeys } = result.warnings - if (skippedValueCount > 0) { - messages.push( - `Skipped ${skippedValueCount} ${skippedValueCount === 1 ? "value" : "values"} for ${ - skippedValueKeys.size - } ${skippedValueKeys.size === 1 ? "field" : "fields"} (${summary([...skippedValueKeys], 3)})` - ) - } - - const finalMessage = messages.join(". ") - framer.closePlugin(messages.length > 1 ? finalMessage + "." : finalMessage || "Successfully imported Collection") -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -function isArray(value: unknown): value is unknown[] { - return Array.isArray(value) -} - -function isValidDate(date: Date): boolean { - return !Number.isNaN(date.getTime()) -} - -/** Helper to show a summary of items, truncating after `max` */ -function summary(items: string[], max: number) { - const summaryFormatter = new Intl.ListFormat("en", { style: "long", type: "conjunction" }) - - if (items.length === 0) { - return "none" - } - // Go one past the max, because we'll add a sentinel anyway - if (items.length > max + 1) { - items = items.slice(0, max).concat([`${items.length - max} more`]) - } - return summaryFormatter.format(items) -} - -// Match everything except for letters, numbers and parentheses. -const nonSlugCharactersRegExp = /[^\p{Letter}\p{Number}()]+/gu -// Match leading/trailing dashes, for trimming purposes. -const trimSlugRegExp = /^-+|-+$/gu - -/** - * Takes a freeform string and removes all characters except letters, numbers, - * and parentheses. Also makes it lower case, and separates words by dashes. - * This makes the value URL safe. - */ -export function slugify(value: string): string { - return value.toLowerCase().replace(nonSlugCharactersRegExp, "-").replace(trimSlugRegExp, "") -} - -function assert(condition: unknown, ...msg: unknown[]): asserts condition { - if (condition) return - - const e = Error("Assertion Error" + (msg.length > 0 ? ": " + msg.join(" ") : "")) - // Hack the stack so the assert call itself disappears. Works in jest and in chrome. - if (e.stack) { - try { - const lines = e.stack.split("\n") - if (lines[1]?.includes("assert")) { - lines.splice(1, 1) - e.stack = lines.join("\n") - } else if (lines[0]?.includes("assert")) { - lines.splice(0, 1) - e.stack = lines.join("\n") - } - } catch { - // nothing - } - } - throw e -} - -function isUndefined(value: unknown): value is undefined { - return value === undefined -} diff --git a/plugins/csv-import/src/main.tsx b/plugins/csv-import/src/main.tsx index 7cebef349..a83493dff 100644 --- a/plugins/csv-import/src/main.tsx +++ b/plugins/csv-import/src/main.tsx @@ -4,6 +4,7 @@ import { framer } from "framer-plugin" import React from "react" import ReactDOM from "react-dom/client" import { App } from "./App.tsx" +import { MiniRouterProvider } from "./minirouter.tsx" const root = document.getElementById("root") if (!root) throw new Error("Root element not found") @@ -15,6 +16,8 @@ if (collection && collection.managedBy !== "user") { ReactDOM.createRoot(root).render( - + + + ) diff --git a/plugins/csv-import/src/minirouter.tsx b/plugins/csv-import/src/minirouter.tsx new file mode 100644 index 000000000..5b15d0a4d --- /dev/null +++ b/plugins/csv-import/src/minirouter.tsx @@ -0,0 +1,75 @@ +import { type Collection, framer, type UIOptions } from "framer-plugin" +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react" +import type { ImportItem, ImportPayload } from "./utils/prepareImportPayload" + +type Route = + | { + uid: "home" + opts: undefined + } + | { + uid: "field-mapper" + opts: { + collection: Collection + csvRecords: Record[] + } + } + | { + uid: "manage-conflicts" + opts: { + conflicts: ImportPayload["items"] + result: ImportPayload + onComplete: (items: ImportItem[]) => void + } + } + +const fallbackUiOptions: UIOptions = { width: 260, height: 330, resizable: false } +const defaultUiOptions = { + home: fallbackUiOptions, + "field-mapper": { width: 550, height: 600, resizable: true }, + "manage-conflicts": { width: 260, height: 165, resizable: false }, +} as Record + +interface MiniRouterContextType { + // Define your context properties here + currentRoute: Route + navigate: (route: Route) => Promise +} + +const MiniRouterContext = createContext(undefined) + +interface MiniRouterProviderProps { + children: ReactNode +} + +export function MiniRouterProvider({ children }: MiniRouterProviderProps) { + const [currentRoute, setCurrentRoute] = useState({ uid: "home", opts: undefined }) + + useEffect(() => { + const uiOptions = defaultUiOptions[currentRoute.uid] ?? fallbackUiOptions + void framer.showUI(uiOptions) + }, [currentRoute.uid]) + + // eslint-disable-next-line @typescript-eslint/require-await -- async for forward compatibility + const navigate = useCallback(async (route: Route) => { + setCurrentRoute(route) + }, []) + + const value: MiniRouterContextType = useMemo(() => { + return { + currentRoute, + navigate, + } + }, [currentRoute, navigate]) + + return {children} +} + +// eslint-disable-next-line react-refresh/only-export-components +export const useMiniRouter = () => { + const context = useContext(MiniRouterContext) + if (context === undefined) { + throw new Error("useMiniRouter must be used within a MiniRouterProvider") + } + return context +} diff --git a/plugins/csv-import/src/routes/FieldMapper.tsx b/plugins/csv-import/src/routes/FieldMapper.tsx new file mode 100644 index 000000000..f9c2ef389 --- /dev/null +++ b/plugins/csv-import/src/routes/FieldMapper.tsx @@ -0,0 +1,630 @@ +import type { Collection, Field } from "framer-plugin" +import { framer } from "framer-plugin" +import { useCallback, useEffect, useMemo, useState } from "react" +import { type InferredField, inferFieldsFromCSV } from "../utils/typeInference" + +const labelByFieldType: Record = { + boolean: "Toggle", + date: "Date", + number: "Number", + formattedText: "Formatted Text", + color: "Color", + enum: "Option", + file: "File", + image: "Image", + link: "Link", + string: "Plain Text", + collectionReference: "Reference", + multiCollectionReference: "Multi-Reference", + array: "Gallery", + divider: "Divider", + unsupported: "Unsupported", +} + +/** + * Check if a CSV column's inferred type can be imported into a target field type. + * Some field types are more general and can accept values from other types. + * + * - Plain Text & Formatted Text: accept anything (most general) + * - Link: accepts link, string + * - Image: accepts image, link, string + * - File: accepts file, image, link, string + * - Number, Boolean, Date, Color: only accept their own type + */ +function isTypeCompatible(sourceType: Field["type"], targetType: Field["type"]): boolean { + // Same type is always compatible + if (sourceType === targetType) return true + + // Plain Text and Formatted Text can accept anything + if (targetType === "string" || targetType === "formattedText") return true + + // Link can accept strings (URLs are detected as strings sometimes) + if (targetType === "link" && sourceType === "string") return true + + // Image can accept link or string + if (targetType === "image" && (sourceType === "link" || sourceType === "string")) return true + + // File can accept image, link, or string + if (targetType === "file" && (sourceType === "image" || sourceType === "link" || sourceType === "string")) { + return true + } + + // Enum can accept strings + if (targetType === "enum" && sourceType === "string") return true + + // Strict types: number, boolean, date, color only accept their own type + return false +} + +export type MappingAction = "create" | "map" | "ignore" + +export interface FieldMappingItem { + inferredField: InferredField + action: MappingAction + /** The existing field ID to map to (when action is "map") */ + targetFieldId?: string + /** Whether types are compatible when mapping */ + hasTypeMismatch: boolean + /** Override the inferred type when creating a new field */ + overrideType?: Field["type"] +} + +export type MissingFieldAction = "ignore" | "remove" + +export interface MissingFieldItem { + field: Field + action: MissingFieldAction +} + +export interface FieldMapperSubmitOpts { + mappings: FieldMappingItem[] + slugFieldName: string + missingFields: MissingFieldItem[] +} + +interface FieldMapperProps { + collection: Collection + csvRecords: Record[] + onSubmit: (opts: FieldMapperSubmitOpts) => Promise + onCancel: () => Promise +} + +interface FieldMapperRowProps { + item: FieldMappingItem + existingFields: Field[] + isSlug: boolean + onToggleIgnored: () => void + onSetIgnored: (ignored: boolean) => void + onTargetChange: (targetFieldId: string | null) => void + onTypeChange: (type: Field["type"]) => void +} + +function FieldMapperRow({ + item, + existingFields, + isSlug, + onToggleIgnored, + onSetIgnored, + onTargetChange, + onTypeChange, +}: FieldMapperRowProps) { + const { inferredField, action, targetFieldId, hasTypeMismatch, overrideType } = item + const isIgnored = action === "ignore" + + // Find the target field when mapping to an existing field + const targetField = targetFieldId ? existingFields.find(f => f.id === targetFieldId) : null + + // Determine the type to display in the Type selector + const displayType = + action === "map" && targetField + ? targetField.type // Show existing field's type when mapping + : (overrideType ?? inferredField.inferredType) // Show inferred/override type when creating + + // Type selector is editable only when creating a new field with multiple allowed types + const canEditType = action === "create" && inferredField.allowedTypes.length > 1 + + return ( + <> + + + + + + + {hasTypeMismatch && !isIgnored && ( +
Type mismatch. Incompatible values will be skipped.
+ )} + + ) +} + +export function FieldMapper({ collection, csvRecords, onSubmit, onCancel }: FieldMapperProps) { + const [existingFields, setExistingFields] = useState([]) + const [mappings, setMappings] = useState([]) + const [missingFields, setMissingFields] = useState([]) + const [selectedSlugFieldName, setSelectedSlugFieldName] = useState(null) + const [loading, setLoading] = useState(true) + + // Load existing fields and create initial mappings + useEffect(() => { + async function loadFields() { + try { + const fields = await collection.getFields() + setExistingFields(fields) + + // Track which existing fields get mapped + const mappedFieldIds = new Set() + + const inferredFields = inferFieldsFromCSV(csvRecords) + + // Create initial mappings based on name matching + const initialMappings: FieldMappingItem[] = inferredFields.map(inferredField => { + // Try to find an existing field with matching name + const matchingField = fields.find(f => f.name.toLowerCase() === inferredField.name.toLowerCase()) + + if (matchingField) { + // Found a match - check type compatibility + const hasTypeMismatch = !isTypeCompatible(inferredField.inferredType, matchingField.type) + mappedFieldIds.add(matchingField.id) + return { + inferredField, + action: "map" as const, + targetFieldId: matchingField.id, + hasTypeMismatch, + } + } + + // No match - create new field + return { + inferredField, + action: "create" as const, + hasTypeMismatch: false, + } + }) + + setMappings(initialMappings) + + // Find fields that exist in collection but are not mapped from CSV + const initialMissingFields: MissingFieldItem[] = fields + .filter(field => !mappedFieldIds.has(field.id)) + .map(field => ({ + field, + action: "ignore" as const, + })) + + setMissingFields(initialMissingFields) + } catch (error) { + console.error("Error loading fields:", error) + framer.notify("Error loading collection fields", { variant: "error" }) + } finally { + setLoading(false) + } + } + + void loadFields() + }, [collection, csvRecords]) + + // Determine possible slug fields (fields that have values in every record) + const possibleSlugFields = useMemo(() => { + return mappings + .filter(m => m.action !== "ignore") + .filter(m => csvRecords.every(record => record[m.inferredField.columnName])) + .map(m => m.inferredField) + }, [csvRecords, mappings]) + + // Auto-select first possible slug field + useEffect(() => { + if (possibleSlugFields.length > 0 && !selectedSlugFieldName) { + setSelectedSlugFieldName(possibleSlugFields[0]?.columnName ?? null) + } + }, [possibleSlugFields, selectedSlugFieldName]) + + const toggleIgnored = useCallback( + (columnName: string) => { + setMappings(prev => { + const currentItem = prev.find(item => item.inferredField.columnName === columnName) + const willBeIgnored = currentItem?.action !== "ignore" + + const newMappings = prev.map(item => { + if (item.inferredField.columnName !== columnName) return item + + if (item.action === "ignore") { + // Un-ignore: restore to create mode + return { ...item, action: "create" as const, targetFieldId: undefined, hasTypeMismatch: false } + } else { + // Ignore + return { ...item, action: "ignore" as const, targetFieldId: undefined, hasTypeMismatch: false } + } + }) + + // If ignoring the current slug field, switch to another available one + if (willBeIgnored && columnName === selectedSlugFieldName) { + const newSlugField = newMappings + .filter(m => m.action !== "ignore") + .find(m => csvRecords.every(record => record[m.inferredField.columnName])) + + setSelectedSlugFieldName(newSlugField?.inferredField.columnName ?? null) + } + + return newMappings + }) + }, + [selectedSlugFieldName, csvRecords] + ) + + const setIgnored = useCallback( + (columnName: string, ignored: boolean) => { + setMappings(prev => { + const newMappings = prev.map(item => { + if (item.inferredField.columnName !== columnName) return item + + if (ignored) { + return { ...item, action: "ignore" as const, targetFieldId: undefined, hasTypeMismatch: false } + } else if (item.action === "ignore") { + // Un-ignore: restore to create mode + return { ...item, action: "create" as const, targetFieldId: undefined, hasTypeMismatch: false } + } + return item + }) + + // If ignoring the current slug field, switch to another available one + if (ignored && columnName === selectedSlugFieldName) { + const newSlugField = newMappings + .filter(m => m.action !== "ignore") + .find(m => csvRecords.every(record => record[m.inferredField.columnName])) + + setSelectedSlugFieldName(newSlugField?.inferredField.columnName ?? null) + } + + return newMappings + }) + }, + [selectedSlugFieldName, csvRecords] + ) + + const updateTarget = useCallback( + (columnName: string, targetFieldId: string | null) => { + setMappings(prev => { + const newMappings = prev.map(item => { + if (item.inferredField.columnName !== columnName) return item + + if (targetFieldId === null) { + // Create new field + return { + ...item, + action: "create" as const, + targetFieldId: undefined, + hasTypeMismatch: false, + } + } + + // Map to existing field + const targetField = existingFields.find(f => f.id === targetFieldId) + const hasTypeMismatch = targetField + ? !isTypeCompatible(item.inferredField.inferredType, targetField.type) + : false + + return { + ...item, + action: "map" as const, + targetFieldId, + hasTypeMismatch, + } + }) + + // Update missing fields based on new mappings + const mappedFieldIds = new Set( + newMappings.filter(m => m.action === "map" && m.targetFieldId).map(m => m.targetFieldId) + ) + + setMissingFields(prev => { + const prevActionMap = new Map(prev.map(item => [item.field.id, item.action])) + + return existingFields + .filter(field => !mappedFieldIds.has(field.id)) + .map(field => ({ + field, + action: prevActionMap.get(field.id) ?? ("ignore" as MissingFieldAction), + })) + }) + + return newMappings + }) + }, + [existingFields] + ) + + const updateMissingFieldAction = useCallback((fieldId: string, action: MissingFieldAction) => { + setMissingFields(prev => prev.map(item => (item.field.id === fieldId ? { ...item, action } : item))) + }, []) + + const updateType = useCallback((columnName: string, type: Field["type"]) => { + setMappings(prev => + prev.map(item => { + if (item.inferredField.columnName !== columnName) return item + return { ...item, overrideType: type } + }) + ) + }, []) + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + + if (!selectedSlugFieldName) { + framer.notify("Please select a slug field before importing.", { variant: "warning" }) + return + } + + // Check if the slug field is being ignored + const slugMapping = mappings.find(m => m.inferredField.columnName === selectedSlugFieldName) + if (slugMapping?.action === "ignore") { + framer.notify("The slug field cannot be ignored.", { variant: "warning" }) + return + } + + // Check if all required fields are mapped + if (unmappedRequiredFields.length > 0) { + framer.notify("All required fields must be mapped before importing.", { variant: "warning" }) + return + } + + await onSubmit({ mappings, slugFieldName: selectedSlugFieldName, missingFields }) + } + + // Find required fields that are not mapped to any CSV column + const unmappedRequiredFields = useMemo(() => { + const mappedFieldIds = new Set( + mappings.filter(m => m.action === "map" && m.targetFieldId).map(m => m.targetFieldId) + ) + return existingFields.filter(field => "required" in field && field.required && !mappedFieldIds.has(field.id)) + }, [existingFields, mappings]) + + const canSubmit = unmappedRequiredFields.length === 0 + + // Summary stats + const stats = useMemo(() => { + const active = mappings.filter(m => m.action !== "ignore") + return { + total: mappings.length, + ignored: mappings.filter(m => m.action === "ignore").length, + creating: mappings.filter(m => m.action === "create").length, + matched: active.filter(m => m.action === "map" && !m.hasTypeMismatch).length, + mismatched: active.filter(m => m.action === "map" && m.hasTypeMismatch).length, + } + }, [mappings]) + + if (loading) { + return ( +
+
Loading fields...
+
+ ) + } + + return ( +
+
+
void handleSubmit(e)}> +
+ + {stats.total} columns + + {stats.matched > 0 && ( + + {stats.matched} matched + + )} + {stats.creating > 0 && ( + + {stats.creating} new + + )} + {stats.mismatched > 0 && ( + + {stats.mismatched} type issues + + )} + {stats.ignored > 0 && ( + + {stats.ignored} ignored + + )} +
+ + + +
+ CSV Column + Target Field + Type + {mappings.map(item => ( + { + toggleIgnored(item.inferredField.columnName) + }} + onSetIgnored={ignored => { + setIgnored(item.inferredField.columnName, ignored) + }} + onTargetChange={targetId => { + updateTarget(item.inferredField.columnName, targetId) + }} + onTypeChange={type => { + updateType(item.inferredField.columnName, type) + }} + /> + ))} +
+ + {missingFields.length > 0 && ( +
+
+ Unmapped Fields +
+ {missingFields.map(item => ( +
+
+ {item.field.name} + {labelByFieldType[item.field.type]} +
+ + + + +
+ ))} +
+ )} + + {unmappedRequiredFields.length > 0 && ( +
+
+ + Required fields without data +
+
+ {unmappedRequiredFields.map(field => ( +
+ {field.name} + {labelByFieldType[field.type]} +
+ ))} +
+

+ Map a CSV column to these fields, or imported items will have empty values. +

+
+ )} + +
+
+ + + +
+
+
+
+ ) +} diff --git a/plugins/csv-import/src/routes/Home.tsx b/plugins/csv-import/src/routes/Home.tsx new file mode 100644 index 000000000..d9785530a --- /dev/null +++ b/plugins/csv-import/src/routes/Home.tsx @@ -0,0 +1,25 @@ +import type { Collection } from "framer-plugin" +import { CollectionSelector } from "../components/CollectionSelector" +import { SelectCSVFile } from "../components/SelectCSVFile" + +interface HomeProps { + collection: Collection | null + onCollectionChange: (collection: Collection) => void + onFileSelected: (csvContent: string) => Promise +} + +export function Home({ collection, onCollectionChange, onFileSelected }: HomeProps) { + return ( +
+ + + {collection ? ( + + ) : ( +
+

Select a collection to import CSV data into.

+
+ )} +
+ ) +} diff --git a/plugins/csv-import/src/ManageConflicts.tsx b/plugins/csv-import/src/routes/ManageConflicts.tsx similarity index 92% rename from plugins/csv-import/src/ManageConflicts.tsx rename to plugins/csv-import/src/routes/ManageConflicts.tsx index 4a58bf95f..eadcb55fa 100644 --- a/plugins/csv-import/src/ManageConflicts.tsx +++ b/plugins/csv-import/src/routes/ManageConflicts.tsx @@ -1,9 +1,9 @@ import { useCallback, useRef, useState } from "react" -import type { ImportResultItem } from "./csv" +import type { ImportItem } from "../utils/prepareImportPayload" interface ManageConflictsProps { - records: ImportResultItem[] - onAllConflictsResolved: (items: ImportResultItem[]) => void + records: ImportItem[] + onAllConflictsResolved: (items: ImportItem[]) => void } export function ManageConflicts({ records, onAllConflictsResolved }: ManageConflictsProps) { @@ -12,7 +12,7 @@ export function ManageConflicts({ records, onAllConflictsResolved }: ManageConfl const [applyToAll, setApplyToAll] = useState(false) - const fixedRecords = useRef(records) + const fixedRecords = useRef(records) const moveToNextRecord = useCallback(() => { const next = recordsIterator.next() @@ -24,7 +24,7 @@ export function ManageConflicts({ records, onAllConflictsResolved }: ManageConfl }, [recordsIterator, onAllConflictsResolved]) const setAction = useCallback( - (record: ImportResultItem, action: "onConflictUpdate" | "onConflictSkip") => { + (record: ImportItem, action: "onConflictUpdate" | "onConflictSkip") => { if (!currentRecord) return fixedRecords.current = fixedRecords.current.map(existingRecord => { diff --git a/plugins/csv-import/src/utils/assert.ts b/plugins/csv-import/src/utils/assert.ts new file mode 100644 index 000000000..b295ffd26 --- /dev/null +++ b/plugins/csv-import/src/utils/assert.ts @@ -0,0 +1,21 @@ +export function assert(condition: unknown, ...msg: unknown[]): asserts condition { + if (condition) return + + const e = Error("Assertion Error" + (msg.length > 0 ? ": " + msg.join(" ") : "")) + // Hack the stack so the assert call itself disappears. Works in jest and in chrome. + if (e.stack) { + try { + const lines = e.stack.split("\n") + if (lines[1]?.includes("assert")) { + lines.splice(1, 1) + e.stack = lines.join("\n") + } else if (lines[0]?.includes("assert")) { + lines.splice(0, 1) + e.stack = lines.join("\n") + } + } catch { + // nothing + } + } + throw e +} diff --git a/plugins/csv-import/src/utils/fieldReconciliation.ts b/plugins/csv-import/src/utils/fieldReconciliation.ts new file mode 100644 index 000000000..e374ce8f7 --- /dev/null +++ b/plugins/csv-import/src/utils/fieldReconciliation.ts @@ -0,0 +1,105 @@ +import type { Collection, CreateField, Field } from "framer-plugin" +import type { FieldMappingItem, MissingFieldItem } from "../routes/FieldMapper" + +export async function removeFieldsFromCms(collection: Collection, missingFields: MissingFieldItem[]): Promise { + const fieldsToRemove = missingFields.filter(m => m.action === "remove").map(m => m.field.id) + if (fieldsToRemove.length > 0) { + await collection.removeFields(fieldsToRemove) + } +} + +export async function createNewFieldsInCms(collection: Collection, mappings: FieldMappingItem[]): Promise { + const fieldsToAdd: CreateField[] = mappings + .filter(mapping => mapping.action === "create") + .map(mapping => { + // Use overrideType if user changed the type, otherwise use the inferred type + const fieldType = mapping.overrideType ?? mapping.inferredField.inferredType + return createFieldConfig(fieldType, mapping.inferredField.name) + }) + .filter((fieldConfig): fieldConfig is CreateField => fieldConfig !== null) + + // Add new fields using collection.addFields() + if (fieldsToAdd.length > 0) { + await collection.addFields(fieldsToAdd) + } +} + +/** + * Create a field configuration object for adding a new field + */ +function createFieldConfig(type: Field["type"], name: string): CreateField | null { + switch (type) { + case "string": + return { type: "string", name } + + case "formattedText": + return { type: "formattedText", name } + + case "number": + return { type: "number", name } + + case "boolean": + return { type: "boolean", name } + + case "date": + return { type: "date", name } + + case "color": + return { type: "color", name } + + case "link": + return { type: "link", name } + + case "file": + return { type: "file", name, allowedFileTypes: [] } + + case "image": + return { type: "image", name } + + case "enum": + // For enum fields, we need to provide at least one case + return { + type: "enum", + name, + cases: [{ name: "Default" }], + } + + case "collectionReference": + case "multiCollectionReference": + case "array": + case "divider": + case "unsupported": + // These types either require additional configuration we don't have, + // or aren't supported for CSV import. Return null to skip. + return null + + default: + return null + } +} + +export interface GetMappedFieldNameOpts { + csvColumnName: string + mappings: FieldMappingItem[] + collectionFields: Field[] +} + +/** + * Get the mapped field name for a CSV column based on mappings + */ +export function getMappedFieldName(opts: GetMappedFieldNameOpts): string | null { + const mapping = opts.mappings.find(m => m.inferredField.columnName === opts.csvColumnName) + + if (!mapping || mapping.action === "ignore") { + return null + } + + // If it's mapped to an existing field, return that field's name + if (mapping.action === "map" && mapping.targetFieldId) { + const mappedField = opts.collectionFields.find(f => f.id === mapping.targetFieldId) + return mappedField?.name ?? null + } + + // If it's a new field, return the inferred field name + return mapping.inferredField.name +} diff --git a/plugins/csv-import/src/utils/importCSV.ts b/plugins/csv-import/src/utils/importCSV.ts new file mode 100644 index 000000000..3f2b86be7 --- /dev/null +++ b/plugins/csv-import/src/utils/importCSV.ts @@ -0,0 +1,88 @@ +import { Collection, framer } from "framer-plugin" + +import { assert } from "./assert" +import type { ImportPayload } from "./prepareImportPayload" + +/** Helper to show a summary of items, truncating after `max` */ +function summary(items: string[], max: number) { + const summaryFormatter = new Intl.ListFormat("en", { style: "long", type: "conjunction" }) + + if (items.length === 0) { + return "none" + } + // Go one past the max, because we'll add a sentinel anyway + if (items.length > max + 1) { + items = items.slice(0, max).concat([`${items.length - max} more`]) + } + return summaryFormatter.format(items) +} + +export async function importCSV(collection: Collection, result: ImportPayload) { + const totalItems = result.items.length + const totalAdded = result.items.filter(item => item.action === "add").length + const totalUpdated = result.items.filter(item => item.action === "onConflictUpdate").length + const totalSkipped = result.items.filter(item => item.action === "onConflictSkip").length + if (totalItems !== totalAdded + totalUpdated + totalSkipped) { + throw new Error("Total items mismatch") + } + + await collection.addItems( + result.items + .filter(item => item.action !== "onConflictSkip") + .map(item => { + if (item.action === "add") { + assert(item.slug !== undefined, "Item requires a slug") + return { + slug: item.slug, + fieldData: item.fieldData, + draft: item.draft, + } + } + + assert(item.id !== undefined, "Item requires an id") + return { + id: item.id, + fieldData: item.fieldData, + draft: item.draft, + } + }) + ) + + const messages: string[] = [] + if (totalAdded > 0) { + messages.push(`Added ${totalAdded} ${totalAdded === 1 ? "item" : "items"}`) + } + if (totalUpdated > 0) { + messages.push(`Updated ${totalUpdated} ${totalUpdated === 1 ? "item" : "items"}`) + } + if (totalSkipped > 0) { + messages.push(`Skipped ${totalSkipped} ${totalSkipped === 1 ? "item" : "items"}`) + } + + if (result.warnings.missingSlugCount > 0) { + messages.push( + `Skipped ${result.warnings.missingSlugCount} ${ + result.warnings.missingSlugCount === 1 ? "item" : "items" + } because of missing slug field` + ) + } + if (result.warnings.doubleSlugCount > 0) { + messages.push( + `Skipped ${result.warnings.doubleSlugCount} ${ + result.warnings.doubleSlugCount === 1 ? "item" : "items" + } because of duplicate slugs in the CSV` + ) + } + + const { skippedValueCount, skippedValueKeys } = result.warnings + if (skippedValueCount > 0) { + messages.push( + `Skipped ${skippedValueCount} ${skippedValueCount === 1 ? "value" : "values"} for ${ + skippedValueKeys.size + } ${skippedValueKeys.size === 1 ? "field" : "fields"} (${summary([...skippedValueKeys], 3)})` + ) + } + + const finalMessage = messages.join(". ") + framer.closePlugin(messages.length > 1 ? finalMessage + "." : finalMessage || "Successfully imported Collection") +} diff --git a/plugins/csv-import/src/utils/parseCSV.ts b/plugins/csv-import/src/utils/parseCSV.ts new file mode 100644 index 000000000..e11af3df2 --- /dev/null +++ b/plugins/csv-import/src/utils/parseCSV.ts @@ -0,0 +1,69 @@ +import * as v from "valibot" + +const CSVRecordSchema = v.record(v.string(), v.string()) + +export type CSVRecord = v.InferOutput + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function isArray(value: unknown): value is unknown[] { + return Array.isArray(value) +} + +/** + * Parses a string of CSV data. Does not do any type casting, because we want to + * apply that based on the fields the data will go into, not the data itself. + * + * @param data CSV data, separated by comma or tab. + * @returns Array of parsed records + */ +export async function parseCSV(data: string): Promise { + // Lazily import the parser + const { parse } = await import("csv-parse/browser/esm/sync") + + let records: CSVRecord[] = [] + let error + + // Delimiters to try + // , = pretty much the default + // \t = more common when copy-pasting (e.g. Google Sheets) + // ; = what spreadsheet apps (e.g. Numbers) use when you're using a locale + // that already uses , for decimal separation + // Check of , and \t will be combined as this will cover most cases, falls back to ; + const delimiters = [",", "\t", ";"] + const options = { columns: true, skipEmptyLines: true, skipRecordsWithEmptyValues: true } + + for (const delimiter of delimiters) { + try { + const parsed = parse(data, { ...options, delimiter }) as unknown + + // It can happen that parsing succeeds with the wrong delimiter. For example, a tab separated file could be parsed + // successfully with comma separators. If that's the case, we can find it by checking two things: + // 1. That the resulting records have more than one column + // 2. That if there's only one column, it does not contain delimiters + // If both of those conditions are met, we can assume there's a parsing error and should not import the records + const firstItemKeys = isArray(parsed) && parsed[0] && isObject(parsed[0]) ? Object.keys(parsed[0]) : [] + if (firstItemKeys.length < 2) { + const delimiterInKey = delimiters.some(del => firstItemKeys[0]?.includes(del)) + if (delimiterInKey) { + error = new Error("Parsed with incorrect delimiter") + continue + } + } + + error = undefined + records = v.parse(v.array(CSVRecordSchema), parsed) + break + } catch (innerError) { + error = innerError instanceof Error ? innerError : new Error(String(innerError)) + } + } + + if (error) { + throw error + } + + return records +} diff --git a/plugins/csv-import/src/utils/prepareImportPayload.ts b/plugins/csv-import/src/utils/prepareImportPayload.ts new file mode 100644 index 000000000..3afc16f16 --- /dev/null +++ b/plugins/csv-import/src/utils/prepareImportPayload.ts @@ -0,0 +1,315 @@ +import { + Collection, + CollectionItem, + type CollectionItemInput, + type Field, + type FieldDataEntryInput, + type FieldDataInput, + framer, +} from "framer-plugin" + +import type { FieldMappingItem } from "../routes/FieldMapper" +import { assert } from "./assert" +import type { CSVRecord } from "./parseCSV" + +/** Error when importing fails, internal to `RecordImporter` */ +export class ImportError extends Error { + /** + * @param variant Notification variant to show the user + * @param message Message to show the user + */ + constructor( + readonly variant?: "error" | "warning", + message?: string + ) { + super(message) + } +} + +export type ImportItem = CollectionItemInput & { + action: "add" | "conflict" | "onConflictUpdate" | "onConflictSkip" +} + +export interface ImportPayload { + warnings: { + missingSlugCount: number + doubleSlugCount: number + skippedValueCount: number + skippedValueKeys: Set + } + items: ImportItem[] +} + +class ConversionError extends Error {} + +const collator = new Intl.Collator("en", { sensitivity: "base" }) +const BOOLEAN_TRUTHY_VALUES = /1|y(?:es)?|true/iu + +function findRecordValue(record: CSVRecord, key: string) { + const value = Object.entries(record).find(([k]) => collator.compare(k, key) === 0)?.[1] + if (!value) { + return null + } + return value +} + +function isValidDate(date: Date): boolean { + return !Number.isNaN(date.getTime()) +} + +// Match everything except for letters, numbers and parentheses. +const nonSlugCharactersRegExp = /[^\p{Letter}\p{Number}()]+/gu +// Match leading/trailing dashes, for trimming purposes. +const trimSlugRegExp = /^-+|-+$/gu + +/** + * Takes a freeform string and removes all characters except letters, numbers, + * and parentheses. Also makes it lower case, and separates words by dashes. + * This makes the value URL safe. + */ +function slugify(value: string): string { + return value.toLowerCase().replace(nonSlugCharactersRegExp, "-").replace(trimSlugRegExp, "") +} + +interface GetFieldDataEntryInputForFieldOpts { + field: Field + value: string | null + allItemIdBySlug: Map> + csvRecord: CSVRecord +} + +function getFieldDataEntryInputForField( + opts: GetFieldDataEntryInputForFieldOpts +): FieldDataEntryInput | ConversionError { + switch (opts.field.type) { + case "string": + return { type: opts.field.type, value: opts.value ?? "" } + case "formattedText": + return { type: opts.field.type, value: opts.value ?? "", contentType: "auto" } + + case "color": + case "link": + case "file": + return { type: opts.field.type, value: opts.value ? opts.value.trim() : null } + + case "image": { + const altText = findRecordValue(opts.csvRecord, `${opts.field.name}:alt`) + return { type: opts.field.type, value: opts.value ? opts.value.trim() : null, alt: altText ?? undefined } + } + + case "number": { + const number = Number(opts.value) + if (Number.isNaN(number)) { + return new ConversionError(`Invalid value for field "${opts.field.name}" expected a number`) + } + return { type: "number", value: number } + } + + case "boolean": { + return { type: "boolean", value: opts.value ? BOOLEAN_TRUTHY_VALUES.test(opts.value) : false } + } + + case "date": { + if (opts.value === null) { + return { type: "date", value: null } + } + const date = new Date(opts.value) + if (!isValidDate(date)) { + return new ConversionError(`Invalid value for field "${opts.field.name}" expected a valid date`) + } + return { type: "date", value: date.toJSON() } + } + + case "enum": { + if (opts.value === null) { + const [firstCase] = opts.field.cases + assert(firstCase, `No cases found for enum "${opts.field.name}"`) + return { type: "enum", value: firstCase.id } + } + const matchingCase = opts.field.cases.find( + enumCase => collator.compare(enumCase.name, opts.value ?? "") === 0 || enumCase.id === opts.value + ) + if (!matchingCase) { + return new ConversionError(`Invalid case "${opts.value}" for enum "${opts.field.name}"`) + } + return { type: "enum", value: matchingCase.id } + } + + case "collectionReference": { + if (opts.value === null) { + return { type: "collectionReference", value: null } + } + + const referencedSlug = opts.value.trim() + const referencedId = opts.allItemIdBySlug.get(opts.field.collectionId)?.get(referencedSlug) + if (!referencedId) { + return new ConversionError(`Invalid Collection reference "${opts.value}"`) + } + + return { type: "collectionReference", value: referencedId } + } + + case "multiCollectionReference": { + if (opts.value === null) { + return { type: "multiCollectionReference", value: null } + } + const referencedSlugs = opts.value.split(",").map(slug => slug.trim()) + const referencedIds: string[] = [] + + for (const slug of referencedSlugs) { + const referencedId = opts.allItemIdBySlug.get(opts.field.collectionId)?.get(slug) + if (!referencedId) { + return new ConversionError(`Invalid Collection reference "${slug}"`) + } + referencedIds.push(referencedId) + } + + return { type: "multiCollectionReference", value: referencedIds } + } + + case "array": + case "divider": + case "unsupported": + return new ConversionError(`Unsupported field type "${opts.field.type}"`) + + default: + opts.field satisfies never + return new ConversionError("This should not happen") + } +} + +/** + * Process CSV records with custom field mapping + * This version maps CSV columns to existing collection fields by name + */ +export interface ProcessRecordsWithFieldMappingOpts { + collection: Collection + csvRecords: CSVRecord[] + slugFieldName: string + mappings: FieldMappingItem[] +} + +export async function prepareImportPayload(opts: ProcessRecordsWithFieldMappingOpts): Promise { + if (!opts.collection.slugFieldName) { + throw new ImportError("error", "Import failed. No slug field was found in your CMS Collection.") + } + + const existingItems = await opts.collection.getItems() + const fields = await opts.collection.getFields() + + const result: ImportPayload = { + warnings: { + missingSlugCount: 0, + doubleSlugCount: 0, + skippedValueCount: 0, + skippedValueKeys: new Set(), + }, + items: [], + } + + const allItemIdBySlug = new Map>() + + // TODO: what's the significance of this? We can do joins between collections? Needs QA to ensure it still works + for (const field of fields) { + if (field.type === "collectionReference" || field.type === "multiCollectionReference") { + const collectionIdBySlug = allItemIdBySlug.get(field.collectionId) ?? new Map() + + const referencedCollection = await framer.getCollection(field.collectionId) + if (!referencedCollection) { + throw new ImportError( + "error", + `Import failed. "${field.name}" references a Collection that doesn't exist.` + ) + } + + const items = await referencedCollection.getItems() + for (const item of items) { + collectionIdBySlug.set(item.slug, item.id) + } + + allItemIdBySlug.set(field.collectionId, collectionIdBySlug) + } + } + + // TODO: QA draft functionality + // Check if CSV has a draft column + const firstRecord = opts.csvRecords[0] + assert(firstRecord, "No records were found in your CSV.") + const csvHeader = Object.keys(firstRecord) + const hasDraftColumn = csvHeader.includes(":draft") + + const newSlugValues = new Set() + const existingItemsBySlug = new Map() + for (const item of existingItems) { + existingItemsBySlug.set(item.slug, item) + } + + for (const record of opts.csvRecords) { + const slugValue = findRecordValue(record, opts.slugFieldName) + if (!slugValue || slugValue.trim() === "") { + result.warnings.missingSlugCount++ + continue + } + + const slug = slugify(slugValue) + if (newSlugValues.has(slug)) { + result.warnings.doubleSlugCount++ + continue + } + + let draft = false + if (hasDraftColumn) { + const draftValue = findRecordValue(record, ":draft") + if (draftValue && draftValue.trim() !== "") { + draft = BOOLEAN_TRUTHY_VALUES.test(draftValue.trim()) + } + } + + const fieldData: FieldDataInput = {} + for (const mapping of opts.mappings) { + if (mapping.action === "ignore") { + continue + } + + const csvColumnName = mapping.inferredField.columnName + + // Find the target field + let field: Field | undefined + if (mapping.action === "map" && mapping.targetFieldId) { + field = fields.find(f => f.id === mapping.targetFieldId) + } else if (mapping.action === "create") { + // For created fields, find by name (case-insensitive) + field = fields.find(f => collator.compare(f.name, mapping.inferredField.name) === 0) + } + + if (!field) { + continue + } + + const value = findRecordValue(record, csvColumnName) + const fieldDataEntry = getFieldDataEntryInputForField({ field, value, allItemIdBySlug, csvRecord: record }) + + if (fieldDataEntry instanceof ConversionError) { + result.warnings.skippedValueCount++ + result.warnings.skippedValueKeys.add(field.name) + continue + } + + fieldData[field.id] = fieldDataEntry + } + + const item: ImportItem = { + id: existingItemsBySlug.get(slug)?.id, + slug, + fieldData, + action: existingItemsBySlug.get(slug) ? "conflict" : "add", + draft, + } + + newSlugValues.add(slug) + + result.items.push(item) + } + + return result +} diff --git a/plugins/csv-import/src/utils/typeInference.ts b/plugins/csv-import/src/utils/typeInference.ts new file mode 100644 index 000000000..91977ca9b --- /dev/null +++ b/plugins/csv-import/src/utils/typeInference.ts @@ -0,0 +1,159 @@ +import type { Field } from "framer-plugin" + +export interface InferredField { + name: string + columnName: string + inferredType: Field["type"] + allowedTypes: Field["type"][] +} + +const BOOLEAN_TRUTHY_VALUES = /^(1|y(?:es)?|true)$/iu +const BOOLEAN_FALSY_VALUES = /^(0|n(?:o)?|false)$/iu +const URL_PATTERN = /^https?:\/\/.+/i +const COLOR_PATTERN = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i +const IMAGE_URL_PATTERN = /^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|svg)$/i + +/** + * Infer the field type from CSV data + */ +function inferFieldType(values: (string | null)[]): Field["type"] { + const nonNullValues = values.filter((v): v is string => v !== null && v.trim() !== "") + + if (nonNullValues.length === 0) { + return "string" + } + + // Check if all values are booleans + const allBoolean = nonNullValues.every(v => BOOLEAN_TRUTHY_VALUES.test(v) || BOOLEAN_FALSY_VALUES.test(v)) + if (allBoolean) { + return "boolean" + } + + // Check if all values are numbers + const allNumbers = nonNullValues.every(v => { + const num = Number(v) + return !Number.isNaN(num) && v.trim() !== "" + }) + if (allNumbers) { + return "number" + } + + // Check if all values are valid dates + const allDates = nonNullValues.every(v => { + const date = new Date(v) + return !Number.isNaN(date.getTime()) + }) + if (allDates && nonNullValues.length > 0) { + // Additional check: ensure it looks like a date string + const hasDateLikeFormat = nonNullValues.some( + v => + /\d{4}-\d{2}-\d{2}/.test(v) || // ISO format + /\d{1,2}\/\d{1,2}\/\d{2,4}/.test(v) || // Common date formats + /\d{1,2}-\d{1,2}-\d{2,4}/.test(v) + ) + if (hasDateLikeFormat) { + return "date" + } + } + + // Check if all values are colors + const allColors = nonNullValues.every(v => COLOR_PATTERN.test(v.trim())) + if (allColors) { + return "color" + } + + // Check if all values are image URLs + const allImageUrls = nonNullValues.every(v => IMAGE_URL_PATTERN.test(v.trim())) + if (allImageUrls) { + return "image" + } + + // Check if all values are URLs + const allUrls = nonNullValues.every(v => URL_PATTERN.test(v.trim())) + if (allUrls) { + return "link" + } + + // Check if values contain HTML or markdown (formatted text) + const hasFormatting = nonNullValues.some( + v => + /<[^>]+>/.test(v) || // HTML tags + /\*\*.*\*\*/.test(v) || // Bold markdown + /\*.*\*/.test(v) || // Italic markdown + /\[.*\]\(.*\)/.test(v) // Markdown links + ) + if (hasFormatting) { + return "formattedText" + } + + // Default to string + return "string" +} + +/** + * Get allowed types for a field based on its inferred type + */ +function getAllowedTypes(inferredType: Field["type"]): Field["type"][] { + // Define which types can be converted to which other types + const typeCompatibility: Record = { + string: ["string", "formattedText", "link", "color", "file", "image"], + formattedText: ["formattedText", "string"], + number: ["number", "string"], + boolean: ["boolean", "string"], + date: ["date", "string"], + color: ["color", "string"], + link: ["link", "string"], + image: ["image", "file", "link", "string"], + file: ["file", "link", "string"], + enum: ["enum", "string"], + collectionReference: ["collectionReference"], + multiCollectionReference: ["multiCollectionReference"], + array: ["array"], + divider: ["divider"], + unsupported: ["unsupported"], + } + + return typeCompatibility[inferredType] +} + +/** + * Infer field types from CSV records + */ +export function inferFieldsFromCSV(records: Record[]): InferredField[] { + if (records.length === 0) { + return [] + } + + const firstRecord = records[0] + if (!firstRecord) { + return [] + } + + const fieldNames = Object.keys(firstRecord) + const inferredFields: InferredField[] = [] + + for (const fieldName of fieldNames) { + // Skip special fields + if (fieldName.startsWith(":")) { + continue + } + + // Collect all values for this field + const values = records.map(record => { + const value = record[fieldName] + return value && value.trim() !== "" ? value : null + }) + + const inferredType = inferFieldType(values) + const allowedTypes = getAllowedTypes(inferredType) + + inferredFields.push({ + name: fieldName, + columnName: fieldName, + inferredType, + allowedTypes, + }) + } + + return inferredFields +} diff --git a/yarn.lock b/yarn.lock index f959f91a1..2ae00aec5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3713,7 +3713,7 @@ __metadata: "@types/react": "npm:^18.3.24" "@types/react-dom": "npm:^18.3.7" csv-parse: "npm:^6.1.0" - framer-plugin: "npm:3.9.0-beta.1" + framer-plugin: "npm:3.9.0" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" valibot: "npm:^1.2.0" @@ -4593,13 +4593,13 @@ __metadata: languageName: node linkType: hard -"framer-plugin@npm:3.9.0-beta.1": - version: 3.9.0-beta.1 - resolution: "framer-plugin@npm:3.9.0-beta.1" +"framer-plugin@npm:3.9.0": + version: 3.9.0 + resolution: "framer-plugin@npm:3.9.0" peerDependencies: react: ^18.2.0 react-dom: ^18.2.0 - checksum: 10/1f58b75683d5cfedb812422916bcf6e4a63c6141b3f0df49eb13551894929edecabe8cb9ebbcb0b5d693efb277412e08e32b7652c41bea58f14b8bd0949106fe + checksum: 10/2d8249cc82faadd57880e3fec97db7b3ed7aba5b8e80beb6c250ddf773e0d09265dc0990569bc898b193b790320b7ae89a71fa981c269ff685a3c465f8520713 languageName: node linkType: hard