├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .npmignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .releaserc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── img ├── cdd-action.png └── cdd-tool.png ├── lint-staged.config.js ├── package-lock.json ├── package.config.ts ├── package.json ├── renovate.json ├── sanity.json ├── src ├── actions │ └── DuplicateToAction.tsx ├── components │ ├── CrossDatasetDuplicator.tsx │ ├── CrossDatasetDuplicatorAction.tsx │ ├── CrossDatasetDuplicatorTool.tsx │ ├── Duplicator.tsx │ ├── DuplicatorQuery.tsx │ ├── DuplicatorWrapper.tsx │ ├── Feedback.tsx │ ├── ResetSecret.tsx │ ├── SelectButtons.tsx │ └── StatusBadge.tsx ├── context │ └── ConfigProvider.tsx ├── helpers │ ├── constants.ts │ ├── getDocumentsInArray.ts │ └── index.ts ├── index.ts ├── plugin.tsx ├── tool │ └── index.ts └── types │ └── index.ts ├── tsconfig.dist.json ├── tsconfig.json ├── tsconfig.settings.json └── v2-incompatible.js /.editorconfig: -------------------------------------------------------------------------------- 1 | ; editorconfig.org 2 | root = true 3 | charset= utf8 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.js 2 | .eslintrc.js 3 | commitlint.config.js 4 | dist 5 | lint-staged.config.js 6 | package.config.ts 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true, 5 | "browser": true 6 | }, 7 | "extends": [ 8 | "sanity", 9 | "sanity/typescript", 10 | "sanity/react", 11 | "plugin:react-hooks/recommended", 12 | "plugin:prettier/recommended", 13 | "plugin:react/jsx-runtime" 14 | ], 15 | "rules": { 16 | "react/no-unused-prop-types": "off" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI & Release 3 | 4 | # Workflow name based on selected inputs. Fallback to default Github naming when expression evaluates to empty string 5 | run-name: >- 6 | ${{ 7 | inputs.release && inputs.test && format('Build {0} ➤ Test ➤ Publish to NPM', github.ref_name) || 8 | inputs.release && !inputs.test && format('Build {0} ➤ Skip Tests ➤ Publish to NPM', github.ref_name) || 9 | github.event_name == 'workflow_dispatch' && inputs.test && format('Build {0} ➤ Test', github.ref_name) || 10 | github.event_name == 'workflow_dispatch' && !inputs.test && format('Build {0} ➤ Skip Tests', github.ref_name) || 11 | '' 12 | }} 13 | 14 | on: 15 | # Build on pushes branches that have a PR (including drafts) 16 | pull_request: 17 | # Build on commits pushed to branches without a PR if it's in the allowlist 18 | push: 19 | branches: [main] 20 | # https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow 21 | workflow_dispatch: 22 | inputs: 23 | test: 24 | description: Run tests 25 | required: true 26 | default: true 27 | type: boolean 28 | release: 29 | description: Release new version 30 | required: true 31 | default: false 32 | type: boolean 33 | 34 | concurrency: 35 | # On PRs builds will cancel if new pushes happen before the CI completes, as it defines `github.head_ref` and gives it the name of the branch the PR wants to merge into 36 | # Otherwise `github.run_id` ensures that you can quickly merge a queue of PRs without causing tests to auto cancel on any of the commits pushed to main. 37 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 38 | cancel-in-progress: true 39 | 40 | permissions: 41 | contents: read # for checkout 42 | 43 | jobs: 44 | build: 45 | runs-on: ubuntu-latest 46 | name: Lint & Build 47 | steps: 48 | - uses: actions/checkout@v3 49 | - uses: actions/setup-node@v3 50 | with: 51 | cache: npm 52 | node-version: lts/* 53 | - run: npm clean-install 54 | # Linting can be skipped 55 | - run: npm run lint --if-present 56 | if: github.event.inputs.test != 'false' 57 | # But not the build script, as semantic-release will crash if this command fails so it makes sense to test it early 58 | - run: npm run prepublishOnly --if-present 59 | 60 | test: 61 | needs: build 62 | # The test matrix can be skipped, in case a new release needs to be fast-tracked and tests are already passing on main 63 | if: github.event.inputs.test != 'false' 64 | runs-on: ${{ matrix.os }} 65 | name: Node.js ${{ matrix.node }} / ${{ matrix.os }} 66 | strategy: 67 | # A test failing on windows doesn't mean it'll fail on macos. It's useful to let all tests run to its completion to get the full picture 68 | fail-fast: false 69 | matrix: 70 | # Run the testing suite on each major OS with the latest LTS release of Node.js 71 | os: [macos-latest, ubuntu-latest, windows-latest] 72 | node: [lts/*] 73 | # It makes sense to also test the oldest, and latest, versions of Node.js, on ubuntu-only since it's the fastest CI runner 74 | include: 75 | - os: ubuntu-latest 76 | # Test the oldest LTS release of Node that's still receiving bugfixes and security patches, versions older than that have reached End-of-Life 77 | node: lts/-1 78 | - os: ubuntu-latest 79 | # Test the actively developed version that will become the latest LTS release next October 80 | node: current 81 | steps: 82 | # It's only necessary to do this for windows, as mac and ubuntu are sane OS's that already use LF 83 | - name: Set git to use LF 84 | if: matrix.os == 'windows-latest' 85 | run: | 86 | git config --global core.autocrlf false 87 | git config --global core.eol lf 88 | - uses: actions/checkout@v3 89 | - uses: actions/setup-node@v3 90 | with: 91 | cache: npm 92 | node-version: ${{ matrix.node }} 93 | - run: npm install 94 | - run: npm test --if-present 95 | 96 | release: 97 | permissions: 98 | contents: write # to be able to publish a GitHub release 99 | issues: write # to be able to comment on released issues 100 | pull-requests: write # to be able to comment on released pull requests 101 | id-token: write # to enable use of OIDC for npm provenance 102 | needs: [build, test] 103 | # only run if opt-in during workflow_dispatch 104 | if: always() && github.event.inputs.release == 'true' && needs.build.result != 'failure' && needs.test.result != 'failure' && needs.test.result != 'cancelled' 105 | runs-on: ubuntu-latest 106 | name: Semantic release 107 | steps: 108 | - uses: actions/checkout@v3 109 | with: 110 | # Need to fetch entire commit history to 111 | # analyze every commit since last release 112 | fetch-depth: 0 113 | - uses: actions/setup-node@v3 114 | with: 115 | cache: npm 116 | node-version: lts/* 117 | - run: npm clean-install 118 | - run: npm audit signatures 119 | # Branches that will release new versions are defined in .releaserc.json 120 | - run: npx semantic-release 121 | # Don't allow interrupting the release step if the job is cancelled, as it can lead to an inconsistent state 122 | # e.g. git tags were pushed but it exited before `npm publish` 123 | if: always() 124 | env: 125 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 126 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 127 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # macOS finder cache file 40 | .DS_Store 41 | 42 | # VS Code settings 43 | .vscode 44 | 45 | # IntelliJ 46 | .idea 47 | *.iml 48 | 49 | # Cache 50 | .cache 51 | 52 | # Yalc 53 | .yalc 54 | yalc.lock 55 | 56 | # npm package zips 57 | *.tgz 58 | 59 | # Compiled plugin 60 | dist 61 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /test 2 | /coverage 3 | .editorconfig 4 | .eslintrc 5 | .gitignore 6 | .github 7 | .prettierrc 8 | .travis.yml 9 | .nyc_output 10 | img -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | pnpm-lock.yaml 3 | yarn.lock 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "printWidth": 100, 4 | "bracketSpacing": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sanity/semantic-release-preset", 3 | "branches": ["main"] 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 📓 Changelog 4 | 5 | All notable changes to this project will be documented in this file. See 6 | [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 7 | 8 | ## [1.4.2](https://github.com/sanity-io/cross-dataset-duplicator/compare/v1.4.1...v1.4.2) (2025-05-23) 9 | 10 | ### Bug Fixes 11 | 12 | - configurable apiVersion ([#59](https://github.com/sanity-io/cross-dataset-duplicator/issues/59)) ([e38eddd](https://github.com/sanity-io/cross-dataset-duplicator/commit/e38edddb92fcaeba8e20c3fb4539ffdcff4628fb)) 13 | 14 | ## [1.4.1](https://github.com/sanity-io/cross-dataset-duplicator/compare/v1.4.0...v1.4.1) (2025-01-03) 15 | 16 | ### Bug Fixes 17 | 18 | - multiproject disabled, add check for projectId ([#55](https://github.com/sanity-io/cross-dataset-duplicator/issues/55)) ([bf6e6ba](https://github.com/sanity-io/cross-dataset-duplicator/commit/bf6e6ba2d15a099d1198a2f30eba4335ff5ce0f8)) 19 | 20 | ## [1.4.0](https://github.com/sanity-io/cross-dataset-duplicator/compare/v1.3.0...v1.4.0) (2024-11-21) 21 | 22 | ### Features 23 | 24 | - pre defined queires in config, ui tweaks ([e102993](https://github.com/sanity-io/cross-dataset-duplicator/commit/e102993a016b5b5b8a3c0a9c945f10e266c495ff)) 25 | 26 | ## [1.3.0](https://github.com/sanity-io/cross-dataset-duplicator/compare/v1.2.4...v1.3.0) (2024-01-23) 27 | 28 | ### Features 29 | 30 | - fix dependencies, ui tweaks, readme update ([#42](https://github.com/sanity-io/cross-dataset-duplicator/issues/42)) ([a1274a5](https://github.com/sanity-io/cross-dataset-duplicator/commit/a1274a5a53fea3ad2ab859f8f4203cb712ad933b)) 31 | 32 | ## [1.2.4](https://github.com/sanity-io/cross-dataset-duplicator/compare/v1.2.3...v1.2.4) (2024-01-02) 33 | 34 | ### Bug Fixes 35 | 36 | - use new 'url' and 'path' keys when uploading assets ([#39](https://github.com/sanity-io/cross-dataset-duplicator/issues/39)) ([6b52a5a](https://github.com/sanity-io/cross-dataset-duplicator/commit/6b52a5a20981449918095d1c88fd6ce965bd0383)) 37 | 38 | ## [1.2.3](https://github.com/sanity-io/cross-dataset-duplicator/compare/v1.2.2...v1.2.3) (2023-11-02) 39 | 40 | ### Bug Fixes 41 | 42 | - update semantic release ([#38](https://github.com/sanity-io/cross-dataset-duplicator/issues/38)) ([ba23802](https://github.com/sanity-io/cross-dataset-duplicator/commit/ba23802f438a150664c3e7f26d2c2d3e91d75ad7)) 43 | 44 | ## [1.2.2](https://github.com/sanity-io/cross-dataset-duplicator/compare/v1.2.1...v1.2.2) (2023-11-02) 45 | 46 | ### Bug Fixes 47 | 48 | - add node exports ([#36](https://github.com/sanity-io/cross-dataset-duplicator/issues/36)) ([f5a0cac](https://github.com/sanity-io/cross-dataset-duplicator/commit/f5a0cac06f89e0c40e542f8b151a8ec6ea37f253)) 49 | - update semantic release ([#37](https://github.com/sanity-io/cross-dataset-duplicator/issues/37)) ([7e451b5](https://github.com/sanity-io/cross-dataset-duplicator/commit/7e451b52e7a08587d9751abc7d9a1e6a1a2187ef)) 50 | 51 | ## [1.2.1](https://github.com/sanity-io/cross-dataset-duplicator/compare/v1.2.0...v1.2.1) (2023-09-06) 52 | 53 | ### Bug Fixes 54 | 55 | - add asset metadata document to transaction ([#34](https://github.com/sanity-io/cross-dataset-duplicator/issues/34)) ([f9728e1](https://github.com/sanity-io/cross-dataset-duplicator/commit/f9728e138c3614a33a2ed6531cd0bd82e4ffae9e)) 56 | 57 | ## [1.2.0](https://github.com/sanity-io/cross-dataset-duplicator/compare/v1.1.0...v1.2.0) (2023-04-27) 58 | 59 | ### Features 60 | 61 | - **DocumentAction:** add `onDuplicated` prop ([#30](https://github.com/sanity-io/cross-dataset-duplicator/issues/30)) ([f553aca](https://github.com/sanity-io/cross-dataset-duplicator/commit/f553aca7ef35e2ec54f2f62e7f9e46c9067f6e29)) 62 | 63 | ### Bug Fixes 64 | 65 | - update useClient to use api, remove React default imports ([#31](https://github.com/sanity-io/cross-dataset-duplicator/issues/31)) ([f81c7c0](https://github.com/sanity-io/cross-dataset-duplicator/commit/f81c7c0eb48e67f9a840a83f96075716dd8f60df)) 66 | 67 | ## [1.1.0](https://github.com/sanity-io/cross-dataset-duplicator/compare/v1.0.0...v1.1.0) (2023-03-31) 68 | 69 | ### Features 70 | 71 | - add migration component to exports ([1019151](https://github.com/sanity-io/cross-dataset-duplicator/commit/10191513643a22f02d0517c009a7b5084eb030d0)) 72 | - export Document Action and Config Provider ([8e4c82a](https://github.com/sanity-io/cross-dataset-duplicator/commit/8e4c82a388e49c4da47c8908c898cb514325cdda)) 73 | 74 | ### Bug Fixes 75 | 76 | - give action an identifier ([896b9fa](https://github.com/sanity-io/cross-dataset-duplicator/commit/896b9fa2f3cbcfc207732cacb0255bc1534ad913)) 77 | - import comments ([33a2eb8](https://github.com/sanity-io/cross-dataset-duplicator/commit/33a2eb8a64d093eae9e9719d457c1d81b704a100)) 78 | - tsdoc errors ([876dbc0](https://github.com/sanity-io/cross-dataset-duplicator/commit/876dbc00c46c21d15992651af6760177f04acb99)) 79 | - tsdoc errors ([52d34da](https://github.com/sanity-io/cross-dataset-duplicator/commit/52d34da5f9bcbce79595c0c24d79935a98fddc27)) 80 | - tsdoc errors ([16acc2d](https://github.com/sanity-io/cross-dataset-duplicator/commit/16acc2d2f39434b8aa9a854dfcc038e2e3a7af0c)) 81 | 82 | ## 1.0.0 (2022-12-14) 83 | 84 | ### Features 85 | 86 | - plugin kit, v3 compat, satisfy types ([179d68f](https://github.com/sanity-io/cross-dataset-duplicator/commit/179d68fe6cc1cb23a993407e5e3266b798c89143)) 87 | - prepare for release ([7e06c42](https://github.com/sanity-io/cross-dataset-duplicator/commit/7e06c42e0735179ea43117ac797df2aa3625f63b)) 88 | 89 | ### Bug Fixes 90 | 91 | - lint ([afde566](https://github.com/sanity-io/cross-dataset-duplicator/commit/afde566b988a56ce6f3a2a287db4544f08dd91d8)) 92 | - test build of plugin ([f404100](https://github.com/sanity-io/cross-dataset-duplicator/commit/f404100d9f11ea235b634f079985e972b2936dac)) 93 | - update readme ([72e9df3](https://github.com/sanity-io/cross-dataset-duplicator/commit/72e9df322c392f61b6a417f8a81ab94bc29d5fb5)) 94 | - update readme, improve inbound/outbound ([f2d5ba4](https://github.com/sanity-io/cross-dataset-duplicator/commit/f2d5ba490af3f48837da74529a967e444fbafdc2)) 95 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sanity.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cross Dataset Duplicator 2 | 3 | Sanity Studio v3 Tool and Document Action for empowering content editors to migrate Documents and Assets between Sanity Datasets and Projects from inside the Studio. 4 | 5 | > [!IMPORTANT] 6 | > You may not need this plugin. It was developed long before Sanity had fully-featured [live preview, visual editing](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing), [perspectives](https://www.sanity.io/docs/content-lake/perspectives) and [content releases](https://www.sanity.io/docs/user-guides/content-releases) which are more seamless ways to stage and preview content before publishing into production. It is recommended you investigate these features first before using this plugin. 7 | 8 | ## Installation 9 | 10 | ``` 11 | npm install --save @sanity/cross-dataset-duplicator 12 | ``` 13 | 14 | or 15 | 16 | ``` 17 | yarn add @sanity/cross-dataset-duplicator 18 | ``` 19 | 20 | ### Important Notes 21 | 22 | This plugin is designed as a convenience for Authors to make small, infrequent content migrations between Datasets. 23 | 24 | - This plugin should be used in conjunction with a reliable backup strategy. 25 | - Proceed with caution as this plugin can instantly write changes to Datasets. 26 | - Larger migrations may take more time, especially with Assets. Trying to upload them all at once could result in a rate-limiting issue, so the plugin mitigates this by limiting simultaneous asset uploads to 3. 27 | - If an Asset is already present at the destination, there's no need to duplicate it again. 28 | - Before starting a Duplication you can select which Documents and Assets to include. Migrations will fail if every Referenced Document or Asset is not included in the transaction or is already present at the destination Dataset. 29 | 30 | ## Tool 31 | 32 | The **Duplicate** Tool allows you to migrate Documents that are returned from any GROQ query. 33 | 34 | ![Cross Dataset Duplicator Tool in Sanity Studio v3](./img/cdd-tool.png) 35 | 36 | ## Document Action 37 | 38 | The **Duplicate to...** Document Action allows you to migrate an individual Document. 39 | 40 | ![Cross Dataset Duplicator Action in Sanity Studio v3](./img/cdd-action.png) 41 | 42 | ## Required Setup 43 | 44 | ### 1. Workspaces 45 | 46 | You must have more than one [Workspace configured](https://www.sanity.io/docs/config-api-reference#37c85e3072b2) to use this plugin. 47 | 48 | All Datasets and Project IDs set up as Workspaces will become selectable "destinations" for Migrations. 49 | 50 | Once set up, you will see a dropdown menu next to the Search bar in the Studio with the Datasets you have configured. 51 | 52 | ### 2. Configuration 53 | 54 | The plugin has some configuration options. These can be set by adding a config file to your Studio 55 | 56 | ```ts 57 | // ./sanity.config.ts 58 | 59 | import {defineConfig} from 'sanity' 60 | import {crossDatasetDuplicator} from '@sanity/cross-dataset-duplicator' 61 | 62 | export const defineConfig({ 63 | // all other settings... 64 | plugins: [ 65 | // all other plugins... 66 | crossDatasetDuplicator({ 67 | // Required settings to show document action 68 | types: ['article', 'page'], 69 | // Optional settings 70 | apiVersion: '2025-02-19', 71 | tool: true, 72 | filter: '_type != "product"', 73 | follow: [], 74 | queries:[ 75 | { 76 | label: "All articles", 77 | query: '_type == "article"' 78 | } 79 | ] 80 | }) 81 | ] 82 | }) 83 | ``` 84 | 85 | #### Options: 86 | 87 | - `tool` (boolean, default: true) – Set whether the Migration **Tool** is enabled. 88 | - `types` (Array[String], default: []) – Set which Schema Types the Migration Action should be enabled in. 89 | - `filter` (String, default: undefined) - Set a predicate for documents when gathering dependencies. 90 | - `follow` (("inbound" | "outbound")[], default: ["outbound"]) – Add buttons to allow the user to begin with just the existing document or first fetch all inbound references. 91 | - `queries`(Array[{label: string, query: string}], default: []) - Add button to allow the query to be populate with predefined useful queries. 92 | 93 | #### Action Options 94 | 95 | The Document Action has additional config options: 96 | 97 | - `onDuplicated` (`() => Promise`, default: undefined) - fire a callback after documents have been duplicated. 98 | 99 | The `onDuplicated` callback could be used to update update metadata after documents have been synced, or to perform arbitrary cleanup tasks like closing the dialog: 100 | 101 | ```tsx 102 | const DuplicatorAction = ({published, onComplete}: DocumentActionProps) => { 103 | const [dialogOpen, setDialogOpen] = useState(false) 104 | const [submitting, setSubmitting] = useState(false) 105 | const [duplicated, setDuplicated] = useState(false) 106 | 107 | return { 108 | label: 'Duplicate', 109 | title: 'Duplicate', 110 | tone: 'positive', 111 | disabled: submitting || duplicated, 112 | loading: submitting, 113 | icon: PublishIcon, 114 | dialog: dialogOpen && 115 | published && { 116 | type: 'popover', 117 | title: 'Cross Dataset Duplicator', 118 | content: ( 119 | { 122 | alert('data migrated') 123 | await new Promise((resolve) => { 124 | setTimeout(() => { 125 | setDialogOpen(false) 126 | setDuplicated(true) 127 | resolve() 128 | }, 1000) 129 | }) 130 | }} 131 | /> 132 | ), 133 | onHandle: () => setDialogOpen(true), 134 | onClose: () => { 135 | onComplete() 136 | setDialogOpen(false) 137 | setSubmitting(false) 138 | }, 139 | }, 140 | } 141 | } 142 | ``` 143 | 144 | ### 3. Authentication Key 145 | 146 | To Duplicate the original files of Assets, an API Token with Viewer permissions is required. You will be prompted for this the first time you attempt to use either the Tool or Document Action on any Dataset. 147 | 148 | This plugin uses [Sanity Secrets](https://github.com/sanity-io/sanity-studio-secrets/) to store the token in the Dataset itself. 149 | 150 | You can [create API tokens in Manage](https://sanity.io/manage) 151 | 152 | ### 4. CORS origins 153 | 154 | If you want to duplicate data across different projects, you need to enable CORS for the different hosts. This allows different projects to connect through the project API. CORS origins configuration can be found on your project page, under the API tab. 155 | 156 | ## Future feature ideas 157 | 158 | - Save predefined GROQ queries in the Tool to make bulk repeated Migrations simpler 159 | - Config options for allowed migrations (eg Dev -> Staging but not Dev -> Live) 160 | - Config options for permissions/user role checks 161 | 162 | ## License 163 | 164 | MIT-licensed. See LICENSE. 165 | 166 | ## Develop & test 167 | 168 | This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit) 169 | with default configuration for build & watch scripts. 170 | 171 | See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio) 172 | on how to run this plugin with hotreload in the studio. 173 | 174 | ### Release new version 175 | 176 | Run ["CI & Release" workflow](https://github.com/sanity-io/cross-dataset-duplicator/actions/workflows/main.yml). 177 | Make sure to select the main branch and check "Release new version". 178 | 179 | Semantic release will only release on configured branches, so it is safe to run release on any branch. 180 | 181 | ## License 182 | 183 | [MIT](LICENSE) © Sanity.io 184 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | } 4 | -------------------------------------------------------------------------------- /img/cdd-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/cross-dataset-duplicator/43799810f9d07193b6435acce459184a40e17c5a/img/cdd-action.png -------------------------------------------------------------------------------- /img/cdd-tool.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/cross-dataset-duplicator/43799810f9d07193b6435acce459184a40e17c5a/img/cdd-tool.png -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '**/*.{js,jsx}': ['eslint'], 3 | '**/*.{ts,tsx}': ['eslint', () => 'tsc --build'], 4 | } 5 | -------------------------------------------------------------------------------- /package.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from '@sanity/pkg-utils' 2 | 3 | export default defineConfig({ 4 | legacyExports: true, 5 | dist: 'dist', 6 | tsconfig: 'tsconfig.dist.json', 7 | 8 | // Remove this block to enable strict export validation 9 | extract: { 10 | rules: { 11 | 'ae-forgotten-export': 'off', 12 | 'ae-incompatible-release-tags': 'off', 13 | 'ae-internal-missing-underscore': 'off', 14 | 'ae-missing-release-tag': 'off', 15 | }, 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sanity/cross-dataset-duplicator", 3 | "version": "1.4.2", 4 | "description": "Empower content editors to migrate Documents and Assets between Sanity Projects and Datasets from inside Sanity Studio", 5 | "keywords": [ 6 | "sanity", 7 | "sanity-plugin" 8 | ], 9 | "homepage": "https://github.com/sanity-io/cross-dataset-duplicator#readme", 10 | "bugs": { 11 | "url": "https://github.com/sanity-io/cross-dataset-duplicator/issues" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git@github.com:sanity-io/cross-dataset-duplicator.git" 16 | }, 17 | "license": "MIT", 18 | "author": "Sanity.io ", 19 | "exports": { 20 | ".": { 21 | "types": "./dist/index.d.ts", 22 | "source": "./src/index.ts", 23 | "require": "./dist/index.js", 24 | "node": { 25 | "module": "./dist/index.esm.js", 26 | "import": "./dist/index.cjs.mjs" 27 | }, 28 | "import": "./dist/index.esm.js", 29 | "default": "./dist/index.esm.js" 30 | }, 31 | "./package.json": "./package.json" 32 | }, 33 | "main": "./dist/index.js", 34 | "module": "./dist/index.esm.js", 35 | "source": "./src/index.ts", 36 | "types": "./dist/index.d.ts", 37 | "files": [ 38 | "dist", 39 | "sanity.json", 40 | "src", 41 | "v2-incompatible.js" 42 | ], 43 | "scripts": { 44 | "build": "run-s clean && plugin-kit verify-package --silent && pkg-utils build --strict && pkg-utils --strict", 45 | "clean": "rimraf dist", 46 | "format": "prettier --write --cache --ignore-unknown .", 47 | "link-watch": "plugin-kit link-watch", 48 | "lint": "eslint .", 49 | "prepublishOnly": "run-s build", 50 | "watch": "pkg-utils watch --strict", 51 | "prepare": "husky install" 52 | }, 53 | "husky": { 54 | "hooks": { 55 | "pre-commit": "npm run lint:fix" 56 | } 57 | }, 58 | "dependencies": { 59 | "@sanity/asset-utils": "^1.3.0", 60 | "@sanity/icons": "^2.1.0", 61 | "@sanity/incompatible-plugin": "^1.0.4", 62 | "@sanity/mutator": "^3.0.6", 63 | "@sanity/studio-secrets": "^2.0.2", 64 | "@sanity/ui": "^1.9.3", 65 | "async": "^3.2.1", 66 | "dset": "^3.1.0", 67 | "semantic-release": "^22.0.12" 68 | }, 69 | "devDependencies": { 70 | "@commitlint/cli": "^18.2.0", 71 | "@commitlint/config-conventional": "^18.1.0", 72 | "@sanity/pkg-utils": "^2.4.10", 73 | "@sanity/plugin-kit": "^3.1.10", 74 | "@sanity/semantic-release-preset": "^4.1.6", 75 | "@types/react": "^18.0.31", 76 | "@typescript-eslint/eslint-plugin": "^5.57.0", 77 | "@typescript-eslint/parser": "^5.57.0", 78 | "eslint": "^8.37.0", 79 | "eslint-config-prettier": "^8.8.0", 80 | "eslint-config-sanity": "^6.0.0", 81 | "eslint-plugin-prettier": "^4.2.1", 82 | "eslint-plugin-react": "^7.32.2", 83 | "eslint-plugin-react-hooks": "^4.6.0", 84 | "husky": "^8.0.3", 85 | "lint-staged": "^15.0.2", 86 | "npm-run-all": "^4.1.5", 87 | "prettier": "^2.8.7", 88 | "prettier-plugin-packagejson": "^2.4.3", 89 | "react": "^18.2.0", 90 | "react-dom": "^18.2.0", 91 | "react-is": "^18.2.0", 92 | "rimraf": "^4.4.1", 93 | "sanity": "^3.79.0", 94 | "styled-components": "^6.1.8", 95 | "typescript": "^5.0.3" 96 | }, 97 | "peerDependencies": { 98 | "@sanity/ui": "^2.0.0", 99 | "react": "^18", 100 | "react-dom": "^18", 101 | "sanity": "^3.79.0", 102 | "styled-components": "^6.0.0" 103 | }, 104 | "engines": { 105 | "node": ">=14.0.0" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>sanity-io/renovate-presets//ecosystem/auto", 5 | "github>sanity-io/renovate-presets//ecosystem/studio-v3" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /sanity.json: -------------------------------------------------------------------------------- 1 | { 2 | "parts": [ 3 | { 4 | "implements": "part:@sanity/base/sanity-root", 5 | "path": "./v2-incompatible.js" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/actions/DuplicateToAction.tsx: -------------------------------------------------------------------------------- 1 | import {useState} from 'react' 2 | import {LaunchIcon} from '@sanity/icons' 3 | import {DocumentActionProps} from 'sanity' 4 | 5 | import {CrossDatasetDuplicatorAction} from '../components/CrossDatasetDuplicatorAction' 6 | 7 | /** 8 | * Document action from the Cross Dataset Duplicator plugin 9 | * @public 10 | */ 11 | export const DuplicateToAction = (props: DocumentActionProps) => { 12 | const {draft, published, onComplete} = props 13 | const [dialogOpen, setDialogOpen] = useState(false) 14 | 15 | return { 16 | disabled: draft, 17 | title: draft ? `Document must be Published to begin` : null, 18 | label: 'Duplicate to...', 19 | dialog: dialogOpen && 20 | published && { 21 | type: 'modal', 22 | title: 'Cross Dataset Duplicator', 23 | content: , 24 | onClose: () => { 25 | onComplete() 26 | setDialogOpen(false) 27 | }, 28 | }, 29 | onHandle: () => setDialogOpen(true), 30 | icon: LaunchIcon, 31 | } 32 | } 33 | 34 | DuplicateToAction.action = 'duplicateTo' 35 | -------------------------------------------------------------------------------- /src/components/CrossDatasetDuplicator.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react' 2 | import {useSecrets, SettingsView} from '@sanity/studio-secrets' 3 | import {Flex, Box, Spinner} from '@sanity/ui' 4 | import {SanityDocument} from 'sanity' 5 | 6 | import DuplicatorQuery from './DuplicatorQuery' 7 | import DuplicatorWrapper from './DuplicatorWrapper' 8 | import ResetSecret from './ResetSecret' 9 | import Feedback from './Feedback' 10 | import {SECRET_NAMESPACE} from '../helpers/constants' 11 | import {useCrossDatasetDuplicatorConfig} from '../context/ConfigProvider' 12 | 13 | // Check for auth secret (required for asset uploads) 14 | const secretConfigKeys = [ 15 | { 16 | key: 'bearerToken', 17 | title: 18 | 'An API token with Viewer permissions is required to duplicate the original files of assets, and will be used for all Duplications. Create one at sanity.io/manage', 19 | description: '', 20 | }, 21 | ] 22 | 23 | type Secrets = { 24 | bearerToken?: string 25 | } 26 | 27 | type CrossDatasetDuplicatorProps = { 28 | mode: 'tool' | 'action' 29 | docs: SanityDocument[] 30 | onDuplicated?: () => Promise 31 | } 32 | 33 | export default function CrossDatasetDuplicator(props: CrossDatasetDuplicatorProps) { 34 | const {mode = `tool`, docs = [], onDuplicated} = props ?? {} 35 | const pluginConfig = useCrossDatasetDuplicatorConfig() 36 | 37 | const {loading, secrets} = useSecrets(SECRET_NAMESPACE) 38 | const [showSecretsPrompt, setShowSecretsPrompt] = useState(false) 39 | 40 | useEffect(() => { 41 | if (secrets) { 42 | setShowSecretsPrompt(!secrets?.bearerToken) 43 | } 44 | }, [secrets]) 45 | 46 | if (loading) { 47 | return ( 48 | 49 | 50 | 51 | 52 | 53 | ) 54 | } 55 | 56 | if ((!loading && showSecretsPrompt) || !secrets?.bearerToken) { 57 | return ( 58 | setShowSecretsPrompt(false)} 64 | /> 65 | ) 66 | } 67 | 68 | if (mode === 'tool' && pluginConfig) { 69 | return ( 70 | <> 71 | 72 | 73 | 74 | ) 75 | } 76 | 77 | if (!docs?.length) { 78 | return No docs passed into Duplicator Tool 79 | } 80 | 81 | if (!pluginConfig) { 82 | return No plugin config 83 | } 84 | 85 | return ( 86 | 92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /src/components/CrossDatasetDuplicatorAction.tsx: -------------------------------------------------------------------------------- 1 | import {CrossDatasetDuplicatorActionProps} from '../types' 2 | 3 | import CrossDatasetDuplicator from './CrossDatasetDuplicator' 4 | 5 | /** 6 | * Component to perform a migration from the Cross Dataset Duplicator plugin 7 | * @public 8 | */ 9 | export function CrossDatasetDuplicatorAction(props: CrossDatasetDuplicatorActionProps) { 10 | const {docs = [], onDuplicated} = props 11 | 12 | return 13 | } 14 | -------------------------------------------------------------------------------- /src/components/CrossDatasetDuplicatorTool.tsx: -------------------------------------------------------------------------------- 1 | import {SanityDocument, Tool} from 'sanity' 2 | 3 | import CrossDatasetDuplicator from './CrossDatasetDuplicator' 4 | 5 | export type MultiToolConfig = { 6 | docs: SanityDocument[] 7 | } 8 | 9 | type CrossDatasetDuplicatorProps = { 10 | tool: Tool 11 | } 12 | 13 | export function CrossDatasetDuplicatorTool(props: CrossDatasetDuplicatorProps) { 14 | const {docs = []} = props.tool.options ?? {} 15 | 16 | return 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Duplicator.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-no-bind */ 2 | import React, {useState, useEffect} from 'react' 3 | import { 4 | useClient, 5 | Preview, 6 | useSchema, 7 | useWorkspaces, 8 | WorkspaceSummary, 9 | SanityDocument, 10 | } from 'sanity' 11 | // @ts-ignore 12 | import mapLimit from 'async/mapLimit' 13 | // @ts-ignore 14 | import asyncify from 'async/asyncify' 15 | import {extractWithPath} from '@sanity/mutator' 16 | import {dset} from 'dset' 17 | import { 18 | Card, 19 | Container, 20 | Text, 21 | Box, 22 | Button, 23 | Label, 24 | Stack, 25 | Select, 26 | Flex, 27 | Checkbox, 28 | CardTone, 29 | useTheme, 30 | Spinner, 31 | } from '@sanity/ui' 32 | import {ArrowRightIcon, SearchIcon, LaunchIcon} from '@sanity/icons' 33 | import {SanityAssetDocument} from '@sanity/client' 34 | import {isAssetId, isSanityFileAsset} from '@sanity/asset-utils' 35 | 36 | import {stickyStyles, createInitialMessage} from '../helpers' 37 | import {getDocumentsInArray} from '../helpers/getDocumentsInArray' 38 | import SelectButtons from './SelectButtons' 39 | import StatusBadge, {MessageTypes} from './StatusBadge' 40 | import Feedback from './Feedback' 41 | import {PluginConfig} from '../types' 42 | 43 | export type DuplicatorProps = { 44 | docs: SanityDocument[] 45 | // TODO: Find out if this is even used? 46 | // draftIds: string[] 47 | token: string 48 | pluginConfig: Required 49 | onDuplicated?: () => Promise 50 | } 51 | 52 | export type PayloadItem = { 53 | doc: SanityDocument 54 | include: boolean 55 | status?: keyof MessageTypes 56 | hasDraft?: boolean 57 | } 58 | 59 | type WorkspaceOption = WorkspaceSummary & { 60 | disabled: boolean 61 | } 62 | 63 | type Message = { 64 | text: string 65 | tone: CardTone 66 | } 67 | 68 | export default function Duplicator(props: DuplicatorProps) { 69 | const {docs, token, pluginConfig, onDuplicated} = props 70 | const isDarkMode = useTheme().sanity.color.dark 71 | 72 | // Prepare origin (this Studio) client 73 | const originClient = useClient({apiVersion: pluginConfig.apiVersion}) 74 | 75 | const schema = useSchema() 76 | 77 | // Create list of dataset options 78 | // and set initial value of dropdown 79 | const workspaces = useWorkspaces() 80 | const workspacesOptions: WorkspaceOption[] = workspaces.map((workspace) => ({ 81 | ...workspace, 82 | disabled: 83 | workspace.dataset === originClient.config().dataset && 84 | workspace.projectId === originClient.config().projectId, 85 | })) 86 | 87 | const [destination, setDestination] = useState( 88 | workspaces.length ? workspacesOptions.find((space) => !space.disabled) ?? null : null 89 | ) 90 | const [message, setMessage] = useState(null) 91 | const [payload, setPayload] = useState([]) 92 | 93 | const [hasReferences, setHasReferences] = useState(false) 94 | const [isDuplicating, setIsDuplicating] = useState(false) 95 | const [isGathering, setIsGathering] = useState(false) 96 | const [progress, setProgress] = useState([0, 0]) 97 | 98 | // Check for References and update message 99 | useEffect(() => { 100 | const expr = `.._ref` 101 | const initialRefs = [] 102 | const initialPayload: PayloadItem[] = [] 103 | 104 | docs.forEach((doc) => { 105 | const refs = extractWithPath(expr, doc).map((ref) => ref.value) 106 | initialRefs.push(...refs) 107 | initialPayload.push({include: true, doc}) 108 | }) 109 | 110 | updatePayloadStatuses(initialPayload) 111 | 112 | const docCount = docs.length 113 | const refsCount = initialRefs.length 114 | 115 | if (initialRefs.length) { 116 | setHasReferences(true) 117 | 118 | setMessage({ 119 | tone: `caution`, 120 | text: createInitialMessage(docCount, refsCount), 121 | }) 122 | } 123 | }, [docs]) 124 | 125 | // Re-check payload on destination when value changes 126 | // (On initial render + select change) 127 | useEffect(() => { 128 | updatePayloadStatuses() 129 | }, [destination]) 130 | 131 | // Check if payload documents exist at destination 132 | async function updatePayloadStatuses(newPayload: PayloadItem[] = []) { 133 | const payloadActual = newPayload.length ? newPayload : payload 134 | 135 | if (!payloadActual.length || !destination?.name) { 136 | return 137 | } 138 | 139 | const payloadIds = payloadActual.map(({doc}) => doc._id) 140 | const destinationClient = originClient.withConfig({ 141 | dataset: destination.dataset, 142 | projectId: destination.projectId, 143 | }) 144 | const destinationData: SanityDocument[] = await destinationClient.fetch( 145 | `*[_id in $payloadIds]{ _id, _updatedAt }`, 146 | {payloadIds} 147 | ) 148 | 149 | const updatedPayload = payloadActual.map((item) => { 150 | const existingDoc = destinationData.find((doc) => doc._id === item.doc._id) 151 | 152 | if (existingDoc?._updatedAt && item?.doc?._updatedAt) { 153 | if (existingDoc._updatedAt === item.doc._updatedAt) { 154 | // Exact same document exists at destination 155 | // We don't compare by _rev because that is updated in a transaction 156 | item.status = `EXISTS` 157 | } else if (existingDoc._updatedAt && item.doc._updatedAt) { 158 | item.status = 159 | new Date(existingDoc._updatedAt) > new Date(item.doc._updatedAt) 160 | ? // Document at destination is newer 161 | `OVERWRITE` 162 | : // Document at destination is older 163 | `UPDATE` 164 | } 165 | } else { 166 | item.status = 'CREATE' 167 | } 168 | 169 | return item 170 | }) 171 | 172 | setPayload(updatedPayload) 173 | } 174 | 175 | function handleCheckbox(_id: string) { 176 | const updatedPayload = payload.map((item) => { 177 | if (item.doc._id === _id) { 178 | item.include = !item.include 179 | } 180 | 181 | return item 182 | }) 183 | 184 | setPayload(updatedPayload) 185 | } 186 | 187 | // Find and recursively follow references beginning with this document 188 | async function handleReferences() { 189 | setIsGathering(true) 190 | const docIds = docs.map((doc) => doc._id) 191 | 192 | const payloadDocs = await getDocumentsInArray({ 193 | fetchIds: docIds, 194 | client: originClient, 195 | pluginConfig, 196 | }) 197 | const draftDocs = await getDocumentsInArray({ 198 | fetchIds: docIds.map((id) => `drafts.${id}`), 199 | client: originClient, 200 | projection: `{_id}`, 201 | pluginConfig, 202 | }) 203 | const draftDocsIds = new Set(draftDocs.map(({_id}) => _id)) 204 | 205 | // Shape it up 206 | const payloadShaped = payloadDocs.map((doc) => ({ 207 | doc, 208 | // Include this in the transaction? 209 | include: true, 210 | // Does it exist at the destination? 211 | status: undefined, 212 | // Does it have any drafts? 213 | hasDraft: draftDocsIds.has(`drafts.${doc._id}`), 214 | })) 215 | 216 | setPayload(payloadShaped) 217 | updatePayloadStatuses(payloadShaped) 218 | setIsGathering(false) 219 | } 220 | 221 | // Duplicate payload to destination dataset 222 | async function handleDuplicate() { 223 | if (!destination) { 224 | return 225 | } 226 | 227 | setIsDuplicating(true) 228 | 229 | const assetsCount = payload.filter(({doc, include}) => include && isAssetId(doc._id)).length 230 | let currentProgress = 0 231 | setProgress([currentProgress, assetsCount]) 232 | 233 | setMessage({text: 'Duplicating...', tone: `transparent`}) 234 | 235 | const destinationClient = originClient.withConfig({ 236 | apiVersion: pluginConfig.apiVersion, 237 | dataset: destination.dataset, 238 | projectId: destination.projectId, 239 | }) 240 | 241 | const transactionDocs: SanityDocument[] = [] 242 | const svgMaps: {old: string; new: string}[] = [] 243 | 244 | // Upload assets and then add to transaction 245 | async function fetchDoc(doc: SanityAssetDocument) { 246 | if (isAssetId(doc._id)) { 247 | // Download and upload asset 248 | // Get the *original* image with this dlRaw param to create the same deterministic _id 249 | const typeIsFile = isSanityFileAsset(doc) 250 | const downloadUrl = typeIsFile ? doc.url : `${doc.url}?dlRaw=true` 251 | const downloadConfig = typeIsFile ? {} : {headers: {Authorization: `Bearer ${token}`}} 252 | 253 | await fetch(downloadUrl, downloadConfig).then(async (res) => { 254 | const assetData = await res.blob() 255 | 256 | const options = {filename: doc.originalFilename} 257 | const assetDoc = await destinationClient.assets.upload( 258 | typeIsFile ? `file` : `image`, 259 | assetData, 260 | options 261 | ) 262 | 263 | // SVG _id's need remapping before transaction 264 | if (doc?.extension === 'svg') { 265 | svgMaps.push({old: doc._id, new: assetDoc._id}) 266 | } 267 | 268 | // This adds the newly created asset document to the transaction but ... 269 | // it doesn't have some of the original asset's metadata like `altText` or `title` 270 | transactionDocs.push(assetDoc) 271 | 272 | // So the original `doc` is added to the transaction as well below 273 | // However, we don't want to retain `url` or `path` keys 274 | // because these strings contain the origin's dataset name 275 | doc.url = assetDoc.url 276 | doc.path = assetDoc.path 277 | }) 278 | 279 | currentProgress += 1 280 | setMessage({ 281 | text: `Duplicating ${currentProgress}/${assetsCount} ${ 282 | assetsCount === 1 ? `Assets` : `Assets` 283 | }`, 284 | tone: 'default', 285 | }) 286 | 287 | setProgress([currentProgress, assetsCount]) 288 | } 289 | 290 | return transactionDocs.push(doc) 291 | } 292 | 293 | // Promises are limited to three at once 294 | const result = new Promise((resolve, reject) => { 295 | const payloadIncludedDocs = payload.filter((item) => item.include).map((item) => item.doc) 296 | 297 | mapLimit(payloadIncludedDocs, 3, asyncify(fetchDoc), (err: Error) => { 298 | if (err) { 299 | setIsDuplicating(false) 300 | setMessage({tone: 'critical', text: `Duplication Failed`}) 301 | console.error(err) 302 | reject(new Error('Duplication Failed')) 303 | } 304 | 305 | // @ts-ignore 306 | resolve() 307 | }) 308 | }) 309 | 310 | await result 311 | 312 | // Remap SVG references to new _id's 313 | const transactionDocsMapped = transactionDocs.map((doc) => { 314 | const expr = `.._ref` 315 | const references = extractWithPath(expr, doc) 316 | 317 | if (!references.length) { 318 | return doc 319 | } 320 | 321 | // For every found _ref, search for an SVG asset _id and update 322 | references.forEach((ref) => { 323 | const newRefValue = svgMaps.find((asset) => asset.old === ref.value)?.new 324 | 325 | if (newRefValue) { 326 | const refPath = ref.path.join('.') 327 | 328 | dset(doc, refPath, newRefValue) 329 | } 330 | }) 331 | 332 | return doc 333 | }) 334 | 335 | // Create transaction 336 | const transaction = destinationClient.transaction() 337 | 338 | transactionDocsMapped.forEach((doc) => { 339 | transaction.createOrReplace(doc) 340 | }) 341 | 342 | await transaction 343 | .commit() 344 | .then((res) => { 345 | setMessage({tone: 'positive', text: 'Duplication complete!'}) 346 | 347 | updatePayloadStatuses() 348 | }) 349 | .catch((err) => { 350 | setMessage({tone: 'critical', text: err.details.description}) 351 | }) 352 | 353 | setIsDuplicating(false) 354 | setProgress([0, 0]) 355 | if (onDuplicated) { 356 | try { 357 | await onDuplicated() 358 | } catch (error) { 359 | setMessage({tone: 'critical', text: `Error in onDuplicated hook: ${error}`}) 360 | } 361 | } 362 | } 363 | 364 | function handleChange(e: React.ChangeEvent) { 365 | if (!workspacesOptions.length) { 366 | return 367 | } 368 | 369 | const targeted = workspacesOptions.find((space) => space.name === e.currentTarget.value) 370 | 371 | if (targeted) { 372 | setDestination(targeted) 373 | } 374 | } 375 | 376 | const payloadCount = payload.length 377 | const firstSvgIndex = payload.findIndex(({doc}) => doc.extension === 'svg') 378 | const selectedDocumentsCount = payload.filter( 379 | (item) => item.include && !isAssetId(item.doc._id) 380 | ).length 381 | const selectedAssetsCount = payload.filter( 382 | (item) => item.include && isAssetId(item.doc._id) 383 | ).length 384 | const selectedTotal = selectedDocumentsCount + selectedAssetsCount 385 | const destinationTitle = destination?.title ?? destination?.name 386 | const hasMultipleProjectIds = 387 | new Set(workspacesOptions.map((space) => space?.projectId).filter(Boolean)).size > 1 388 | 389 | const headingText = [selectedTotal, `/`, payloadCount, `Documents and Assets selected`].join(` `) 390 | 391 | const buttonText = React.useMemo(() => { 392 | const text = [`Duplicate`] 393 | 394 | if (selectedDocumentsCount > 1) { 395 | text.push( 396 | String(selectedDocumentsCount), 397 | selectedDocumentsCount === 1 ? `Document` : `Documents` 398 | ) 399 | } 400 | 401 | if (selectedAssetsCount > 1) { 402 | text.push(`and`, String(selectedAssetsCount), selectedAssetsCount === 1 ? `Asset` : `Assets`) 403 | } 404 | 405 | if (originClient.config().projectId !== destination?.projectId) { 406 | text.push(`between Projects`) 407 | } 408 | 409 | text.push(`to`, String(destinationTitle)) 410 | 411 | return text.join(` `) 412 | }, [ 413 | selectedDocumentsCount, 414 | selectedAssetsCount, 415 | originClient, 416 | destination?.projectId, 417 | destinationTitle, 418 | ]) 419 | 420 | if (workspacesOptions.length < 2) { 421 | return ( 422 | 423 | sanity.config.ts must contain at least two Workspaces to use this plugin. 424 | 425 | ) 426 | } 427 | 428 | return ( 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 464 | 465 | 466 | 467 | {isDuplicating && ( 468 | 469 | 480 | 481 | )} 482 | {payload.length > 0 && ( 483 | <> 484 | 485 | 486 | 487 | )} 488 | 489 | 490 | 491 | 492 | {message && ( 493 | 494 | {message.text} 495 | 496 | )} 497 | {payload.length > 0 ? ( 498 | 499 | {payload.map(({doc, include, status, hasDraft}, index) => { 500 | const schemaType = schema.get(doc._type) 501 | 502 | return ( 503 | 504 | 505 | handleCheckbox(doc._id)} /> 506 | 507 | {schemaType ? ( 508 | 509 | ) : ( 510 | Invalid schema type 511 | )} 512 | 513 | 514 | {hasDraft ? : null} 515 | 516 | 517 | 518 | {doc?.extension === 'svg' && index === firstSvgIndex && ( 519 | 520 | 521 | Due to how SVGs are sanitized after first uploaded, duplicated SVG 522 | assets may have new _id's at the destination. The newly 523 | generated _id will be the same in each duplication, but 524 | it will never be the same _id as the first time this 525 | Asset was uploaded. References to the asset will be updated to use the 526 | new _id. 527 | 528 | 529 | )} 530 | 531 | ) 532 | })} 533 | 534 | ) : ( 535 | 536 | 537 | 538 | )} 539 | 540 | {hasReferences && ( 541 |