diff --git a/.yarn/cache/framer-motion-npm-12.29.2-abca95e353-13ea43efa8.zip b/.yarn/cache/framer-motion-npm-12.29.2-abca95e353-13ea43efa8.zip new file mode 100644 index 000000000..242aa8b78 Binary files /dev/null and b/.yarn/cache/framer-motion-npm-12.29.2-abca95e353-13ea43efa8.zip differ diff --git a/.yarn/cache/motion-dom-npm-12.29.2-d660ade015-d1eeb68403.zip b/.yarn/cache/motion-dom-npm-12.29.2-d660ade015-d1eeb68403.zip new file mode 100644 index 000000000..2b2ebd492 Binary files /dev/null and b/.yarn/cache/motion-dom-npm-12.29.2-d660ade015-d1eeb68403.zip differ diff --git a/.yarn/cache/motion-npm-12.29.2-4faf5724b4-0480a98419.zip b/.yarn/cache/motion-npm-12.29.2-4faf5724b4-0480a98419.zip new file mode 100644 index 000000000..df1458ac1 Binary files /dev/null and b/.yarn/cache/motion-npm-12.29.2-4faf5724b4-0480a98419.zip differ diff --git a/.yarn/cache/motion-utils-npm-12.29.2-868aec7208-ae5f9be58c.zip b/.yarn/cache/motion-utils-npm-12.29.2-868aec7208-ae5f9be58c.zip new file mode 100644 index 000000000..84bc8b54b Binary files /dev/null and b/.yarn/cache/motion-utils-npm-12.29.2-868aec7208-ae5f9be58c.zip differ diff --git a/plugins/notion/package.json b/plugins/notion/package.json index b818bb1ee..ad0fd513a 100644 --- a/plugins/notion/package.json +++ b/plugins/notion/package.json @@ -15,6 +15,7 @@ "dependencies": { "@notionhq/client": "^3.1.3", "framer-plugin": "3.10.2-alpha.0", + "motion": "^12.29.2", "react": "^18.3.1", "react-dom": "^18.3.1", "valibot": "^1.2.0" diff --git a/plugins/notion/src/App.css b/plugins/notion/src/App.css index 7eaa940dd..ac65a9bc2 100644 --- a/plugins/notion/src/App.css +++ b/plugins/notion/src/App.css @@ -37,6 +37,10 @@ form { gap: 10px; } +p a { + cursor: pointer; +} + .sticky-divider { position: sticky; top: 0; @@ -249,6 +253,8 @@ select:not(:disabled) { height: 100%; padding: 0px 15px 15px 15px; gap: 15px; + user-select: none; + -webkit-user-select: none; } .login-image { @@ -296,7 +302,45 @@ select:not(:disabled) { width: 100%; } +.actions a { + display: contents; +} + .action-button { flex: 1; width: 100%; } + +/* Progress State */ + +.progress-bar-text { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + width: 100%; + color: var(--framer-color-text-tertiary); +} + +.progress-bar-percent { + font-weight: 600; + color: var(--framer-color-text); +} + +.progress-bar { + height: 3px; + width: 100%; + flex-shrink: 0; + border-radius: 10px; + background-color: var(--framer-color-bg-tertiary); + position: relative; +} + +.progress-bar-fill { + position: absolute; + top: 0; + bottom: 0; + left: 0; + border-radius: 10px; + background-color: var(--framer-color-tint); +} diff --git a/plugins/notion/src/App.tsx b/plugins/notion/src/App.tsx index 9ed6dd418..5b214bb10 100644 --- a/plugins/notion/src/App.tsx +++ b/plugins/notion/src/App.tsx @@ -1,14 +1,22 @@ import "./App.css" import { APIErrorCode, APIResponseError } from "@notionhq/client" -import { framer, type ManagedCollection } from "framer-plugin" -import { useEffect, useLayoutEffect, useMemo, useState } from "react" +import { FramerPluginClosedError, framer, type ManagedCollection } from "framer-plugin" +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import auth from "./auth" -import { type DatabaseIdMap, type DataSource, getDataSource } from "./data" +import { + type DatabaseIdMap, + type DataSource, + getDataSource, + type SyncProgress, + shouldSyncExistingCollection, + syncExistingCollection, +} from "./data" import { FieldMapping } from "./FieldMapping" import { NoTableAccess } from "./NoAccess" +import { Progress } from "./Progress" import { SelectDataSource } from "./SelectDataSource" -import { showAccessErrorUI, showFieldMappingUI, showLoginUI } from "./ui" +import { showAccessErrorUI, showFieldMappingUI, showLoginUI, showProgressUI } from "./ui" interface AppProps { collection: ManagedCollection @@ -28,10 +36,103 @@ export function App({ previousIgnoredFieldIds, previousDatabaseName, existingCollectionDatabaseIdMap, +}: AppProps) { + const [isSyncMode, setIsSyncMode] = useState( + shouldSyncExistingCollection({ + previousSlugFieldId, + previousDatabaseId, + }) + ) + const [progress, setProgress] = useState({ current: 0, total: 0 }) + const hasRunSyncRef = useRef(false) + + useEffect(() => { + if (!isSyncMode || hasRunSyncRef.current) return + + hasRunSyncRef.current = true + + const task = async () => { + void showProgressUI() + + try { + const { didSync } = await syncExistingCollection( + collection, + previousDatabaseId, + previousSlugFieldId, + previousIgnoredFieldIds, + previousLastSynced, + previousDatabaseName, + existingCollectionDatabaseIdMap, + setProgress + ) + + if (didSync) { + framer.closePlugin("Synchronization successful", { + variant: "success", + }) + } else { + setIsSyncMode(false) + } + } catch (error) { + if (error instanceof FramerPluginClosedError) return + + console.error(error) + setIsSyncMode(false) + framer.notify(error instanceof Error ? error.message : "Failed to sync collection", { + variant: "error", + durationMs: 10000, + }) + } + } + + void task() + }, [ + isSyncMode, + collection, + previousDatabaseId, + previousSlugFieldId, + previousIgnoredFieldIds, + previousLastSynced, + previousDatabaseName, + existingCollectionDatabaseIdMap, + ]) + + if (isSyncMode) { + return ( + + ) + } + + return ( + + ) +} + +function ManageApp({ + collection, + previousDatabaseId, + previousSlugFieldId, + previousLastSynced, + previousIgnoredFieldIds, + previousDatabaseName, + existingCollectionDatabaseIdMap, }: AppProps) { const [dataSource, setDataSource] = useState(null) const [isLoadingDataSource, setIsLoadingDataSource] = useState(Boolean(previousDatabaseId)) const [hasAccessError, setHasAccessError] = useState(false) + const [isSyncing, setIsSyncing] = useState(false) // Support self-referencing databases by allowing the current collection to be referenced. const databaseIdMap = useMemo(() => { @@ -46,6 +147,8 @@ export function App({ try { if (hasAccessError) { await showAccessErrorUI() + } else if (isSyncing) { + await showProgressUI() } else if (dataSource || isLoadingDataSource) { await showFieldMappingUI() } else { @@ -60,7 +163,7 @@ export function App({ } void showUI() - }, [dataSource, isLoadingDataSource, hasAccessError]) + }, [dataSource, isLoadingDataSource, hasAccessError, isSyncing]) useEffect(() => { if (!previousDatabaseId) { @@ -149,6 +252,7 @@ export function App({ previousLastSynced={previousLastSynced} previousIgnoredFieldIds={previousIgnoredFieldIds} databaseIdMap={databaseIdMap} + setIsSyncing={setIsSyncing} /> ) } diff --git a/plugins/notion/src/FieldMapping.tsx b/plugins/notion/src/FieldMapping.tsx index 1fc4d6e7d..5bd19d9b5 100644 --- a/plugins/notion/src/FieldMapping.tsx +++ b/plugins/notion/src/FieldMapping.tsx @@ -23,6 +23,7 @@ import { type SyncProgress, syncCollection, } from "./data" +import { Progress } from "./Progress" import { assert, syncMethods } from "./utils" const labelByFieldTypeOption: Record = { @@ -144,6 +145,7 @@ interface FieldMappingProps { previousLastSynced: string | null previousIgnoredFieldIds: string | null databaseIdMap: DatabaseIdMap + setIsSyncing: (isSyncing: boolean) => void } export function FieldMapping({ @@ -153,6 +155,7 @@ export function FieldMapping({ previousLastSynced, previousIgnoredFieldIds, databaseIdMap, + setIsSyncing, }: FieldMappingProps) { const isAllowedToManage = useIsAllowedTo("ManagedCollection.setFields", ...syncMethods) @@ -250,6 +253,7 @@ export function FieldMapping({ const task = async () => { try { setStatus("syncing-collection") + setIsSyncing(true) setSyncProgress(null) await framer.setCloseWarning("Synchronization in progress. Closing will cancel the sync.") @@ -286,6 +290,7 @@ export function FieldMapping({ } finally { await framer.setCloseWarning(false) setStatus("mapping-fields") + setIsSyncing(false) setSyncProgress(null) } } @@ -301,7 +306,15 @@ export function FieldMapping({ ) } - const progressPercent = syncProgress ? ((syncProgress.current / syncProgress.total) * 100).toFixed(1) : null + if (isSyncing) { + return ( + + ) + } return (
@@ -352,15 +365,11 @@ export function FieldMapping({

diff --git a/plugins/notion/src/Progress.tsx b/plugins/notion/src/Progress.tsx new file mode 100644 index 000000000..1b717f848 --- /dev/null +++ b/plugins/notion/src/Progress.tsx @@ -0,0 +1,75 @@ +import { framer } from "framer-plugin" +import { animate, motion, useMotionValue, useTransform } from "motion/react" +import { useEffect } from "react" + +const LOADING_PHASE_MAX = 20 +const LOADING_PHASE_K = 150 + +export function Progress({ + current, + total, + contentFieldEnabled = true, +}: { + current: number + total: number + /** When false, loading phase spans 0–100% (no per-page content fetch). */ + contentFieldEnabled?: boolean +}) { + const percent = getProgressPercent(current, total, contentFieldEnabled) + const formatter = new Intl.NumberFormat("en-US") + const formattedCurrent = formatter.format(current) + const formattedTotal = formatter.format(total) + + const animatedValue = useMotionValue(0) + + useEffect(() => { + // Clear menu while syncing + void framer.setMenu([]) + }, []) + + useEffect(() => { + void animate(animatedValue, percent, { type: "tween" }) + }, [percent, animatedValue]) + + return ( +
+
+ {percent.toFixed(1).replace(".0", "")}% + + {formattedCurrent} / {formattedTotal} + +
+
+ `${animatedValue.get()}%`), + }} + /> +
+

+ {current > 0 ? "Syncing" : "Loading data"}… please keep the plugin open until the process is complete. +

+
+ ) +} + +function getProgressPercent(current: number, total: number, contentFieldEnabled: boolean): number { + if (total > 0 && contentFieldEnabled) { + if (current > 0) { + // Processing phase: base 20%, remaining 80% from current/total + return LOADING_PHASE_MAX + 80 * (current / total) + } + // Loading phase: 0–20% with total/(total+k) so we approach but never reach 20% + return LOADING_PHASE_MAX * (total / (total + LOADING_PHASE_K)) + } + if (total > 0 && !contentFieldEnabled) { + if (current > 0) { + // No per-page fetch: loading is done, show 100% + return 100 + } + // Loading phase: 0–100% with total/(total+k) + return 100 * (total / (total + LOADING_PHASE_K)) + } + return 0 +} diff --git a/plugins/notion/src/api.ts b/plugins/notion/src/api.ts index f3c8d3062..195567517 100644 --- a/plugins/notion/src/api.ts +++ b/plugins/notion/src/api.ts @@ -394,15 +394,28 @@ export async function getPageBlocksAsRichText(pageId: string) { return blocksToHtml(blocks) } -export async function getDatabaseItems(database: GetDatabaseResponse): Promise { +export async function getDatabaseItems( + database: GetDatabaseResponse, + onProgress?: (progress: { current: number; total: number }) => void +): Promise { const notion = getNotionClient() - const data = await collectPaginatedAPI(notion.databases.query, { + const data: PageObjectResponse[] = [] + let itemCount = 0 + + const databaseIterator = iteratePaginatedAPI(notion.databases.query, { database_id: database.id, }) - assert(data.every(isFullPage), "Response is not a full page") - return data + for await (const item of databaseIterator) { + data.push(item as PageObjectResponse) + itemCount++ + onProgress?.({ current: 0, total: itemCount }) + } + + const pages = data.filter(isFullPage) + + return pages } export function isUnchangedSinceLastSync(lastEditedTime: string, lastSyncedTime: string | null): boolean { diff --git a/plugins/notion/src/data.ts b/plugins/notion/src/data.ts index a5bebc5e3..442f35938 100644 --- a/plugins/notion/src/data.ts +++ b/plugins/notion/src/data.ts @@ -85,6 +85,8 @@ export function mergeFieldsInfoWithExistingFields( export interface SyncProgress { current: number total: number + /** When false, loading phase uses 100% of the bar (no per-page content fetch). */ + contentFieldEnabled?: boolean } export interface CollectionItem { @@ -111,6 +113,8 @@ export async function syncCollection( onProgress?: (progress: SyncProgress) => void ) { const fieldsById = new Map(fields.map(field => [field.id, field])) + const contentFieldEnabled = fieldsById.has(pageContentProperty.id) + const reportProgress = (p: { current: number; total: number }) => onProgress?.({ ...p, contentFieldEnabled }) // Track which fields have had their type changed const updatedFieldIds = new Set() @@ -127,7 +131,7 @@ export async function syncCollection( const seenItemIds = new Set() - const databaseItems = await getDatabaseItems(dataSource.database) + const databaseItems = await getDatabaseItems(dataSource.database, reportProgress) const limit = pLimit(CONCURRENCY_LIMIT) // Progress tracking @@ -224,7 +228,7 @@ export async function syncCollection( if (!slugValue) { console.warn(`Skipping item at index ${index} because it doesn't have a valid slug`) processedCount++ - onProgress?.({ current: processedCount, total: totalItems }) + reportProgress({ current: processedCount, total: totalItems }) return null } @@ -255,7 +259,7 @@ export async function syncCollection( } processedCount++ - onProgress?.({ current: processedCount, total: totalItems }) + reportProgress({ current: processedCount, total: totalItems }) return { id: item.id, @@ -341,6 +345,21 @@ export function parseIgnoredFieldIds(ignoredFieldIdsStringified: string | null): : new Set() } +export function shouldSyncExistingCollection({ + previousSlugFieldId, + previousDatabaseId, +}: { + previousSlugFieldId: string | null + previousDatabaseId: string | null +}): boolean { + const isAllowedToSync = framer.isAllowedTo(...syncMethods) + if (framer.mode !== "syncManagedCollection" || !previousSlugFieldId || !previousDatabaseId || !isAllowedToSync) { + return false + } + + return true +} + export async function syncExistingCollection( collection: ManagedCollection, previousDatabaseId: string | null, @@ -348,10 +367,14 @@ export async function syncExistingCollection( previousIgnoredFieldIds: string | null, previousLastSynced: string | null, previousDatabaseName: string | null, - databaseIdMap: DatabaseIdMap + databaseIdMap: DatabaseIdMap, + onProgress?: (progress: SyncProgress) => void ): Promise<{ didSync: boolean }> { - const isAllowedToSync = framer.isAllowedTo(...syncMethods) - if (framer.mode !== "syncManagedCollection" || !previousSlugFieldId || !previousDatabaseId || !isAllowedToSync) { + if ( + !shouldSyncExistingCollection({ previousSlugFieldId, previousDatabaseId }) || + !previousSlugFieldId || + !previousDatabaseId + ) { return { didSync: false } } @@ -387,7 +410,8 @@ export async function syncExistingCollection( slugField, ignoredFieldIds, previousLastSynced, - existingFields + existingFields, + onProgress ) return { didSync: true } } catch (error) { diff --git a/plugins/notion/src/main.tsx b/plugins/notion/src/main.tsx index 88a85d893..b3ddfa63f 100644 --- a/plugins/notion/src/main.tsx +++ b/plugins/notion/src/main.tsx @@ -4,11 +4,11 @@ import { framer } from "framer-plugin" import React from "react" import ReactDOM from "react-dom/client" -import { App } from "./App.tsx" +import { App } from "./App" import { PLUGIN_KEYS } from "./api" import auth from "./auth" -import { getExistingCollectionDatabaseIdMap, syncExistingCollection } from "./data" -import { Authenticate } from "./Login.tsx" +import { getExistingCollectionDatabaseIdMap } from "./data" +import { Authenticate } from "./Login" const activeCollection = await framer.getActiveManagedCollection() @@ -47,32 +47,16 @@ const [ getExistingCollectionDatabaseIdMap(), ]) -const { didSync } = await syncExistingCollection( - activeCollection, - previousDatabaseId, - previousSlugFieldId, - previousIgnoredFieldIds, - previousLastSynced, - previousDatabaseName, - existingCollectionDatabaseIdMap +ReactDOM.createRoot(root).render( + + + ) - -if (didSync) { - framer.closePlugin("Synchronization successful", { - variant: "success", - }) -} else { - ReactDOM.createRoot(root).render( - - - - ) -} diff --git a/plugins/notion/src/ui.ts b/plugins/notion/src/ui.ts index 53cdd3aa0..7f75524c9 100644 --- a/plugins/notion/src/ui.ts +++ b/plugins/notion/src/ui.ts @@ -27,3 +27,13 @@ export async function showLoginUI() { resizable: false, }) } + +export async function showProgressUI() { + await framer.showUI({ + width: 260, + height: 102, + minWidth: 260, + minHeight: 102, + resizable: false, + }) +} diff --git a/yarn.lock b/yarn.lock index 427bba63e..6b7350c33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4563,6 +4563,28 @@ __metadata: languageName: node linkType: hard +"framer-motion@npm:^12.29.2": + version: 12.29.2 + resolution: "framer-motion@npm:12.29.2" + dependencies: + motion-dom: "npm:^12.29.2" + motion-utils: "npm:^12.29.2" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10/13ea43efa814e4df1f3a0bf7ee6c1736f3f01b96cb677e560b62d58db8768f00a53c17156bb5bf6bf06b786370468495dbb6b0a83e3b5b5289e5235046ccb03b + languageName: node + linkType: hard + "framer-plugin-tools@npm:^1.0.0": version: 1.0.0 resolution: "framer-plugin-tools@npm:1.0.0" @@ -5538,6 +5560,15 @@ __metadata: languageName: node linkType: hard +"motion-dom@npm:^12.29.2": + version: 12.29.2 + resolution: "motion-dom@npm:12.29.2" + dependencies: + motion-utils: "npm:^12.29.2" + checksum: 10/d1eeb6840363cc2b4d1e1c2d9a91becc117bb64747482b1a99f7f2d4da709f049c9544c3a566c641cada368037019c0fafd3e7cf56f0477e88d65e9886bcdc40 + languageName: node + linkType: hard + "motion-utils@npm:^12.23.6": version: 12.23.6 resolution: "motion-utils@npm:12.23.6" @@ -5545,6 +5576,13 @@ __metadata: languageName: node linkType: hard +"motion-utils@npm:^12.29.2": + version: 12.29.2 + resolution: "motion-utils@npm:12.29.2" + checksum: 10/ae5f9be58c07939af72334894ed1a18653d724946182a718dc3d11268ef26e63804c3f16dee5a6110596d4406b539c4513822b74f86adebef9488601c34b18b7 + languageName: node + linkType: hard + "motion@npm:^12.23.12": version: 12.23.12 resolution: "motion@npm:12.23.12" @@ -5566,6 +5604,27 @@ __metadata: languageName: node linkType: hard +"motion@npm:^12.29.2": + version: 12.29.2 + resolution: "motion@npm:12.29.2" + dependencies: + framer-motion: "npm:^12.29.2" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10/0480a984192dd0f884709a007c1d28f31ff5fcdecb6674517edbe11ac0e318efc845373f35800e0606899613e6617ec87b1c09d5ecf3f5a5aeb3eb25fef22c7d + languageName: node + linkType: hard + "mrmime@npm:^2.0.0": version: 2.0.1 resolution: "mrmime@npm:2.0.1" @@ -5664,6 +5723,7 @@ __metadata: "@types/react-dom": "npm:^18.3.7" framer-plugin: "npm:3.10.2-alpha.0" framer-plugin-tools: "npm:^1.0.0" + motion: "npm:^12.29.2" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" valibot: "npm:^1.2.0"