diff --git a/src/components/modals/ManageShotsModal.vue b/src/components/modals/ManageShotsModal.vue
index ef9356f780..d6f0940940 100644
--- a/src/components/modals/ManageShotsModal.vue
+++ b/src/components/modals/ManageShotsModal.vue
@@ -18,6 +18,7 @@
:options="shotPaddingOptions"
class="shot-padding flexrow-item"
v-model="shotPadding"
+ v-show="shotMode === 'single'"
/>
@@ -104,39 +105,127 @@
-
{{ $t('shots.title') }}
-
-
+
-
-
-
-
-
+ {{ $t('shots.single_tab') }}
+
+
+
{{ $t('shots.title') }}
+
+
+
+
+ {{ shot.name }}
+
+
+
+
+ {{ name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('shots.bulk_invalid_start') }}
+
+
+
+
+
@@ -170,7 +259,13 @@ const props = defineProps({
active: { type: Boolean, default: true }
})
-const emit = defineEmits(['add-episode', 'add-sequence', 'add-shot', 'cancel'])
+const emit = defineEmits([
+ 'add-episode',
+ 'add-sequence',
+ 'add-shot',
+ 'add-shots-bulk',
+ 'cancel'
+])
useModal(toRef(props, 'active'), emit)
@@ -188,13 +283,16 @@ const names = reactive({ episode: '', sequence: '', shot: '' })
const loading = reactive({
addEpisode: false,
addSequence: false,
- addShot: false
+ addShot: false,
+ bulkGenerate: false
})
const sequences = ref([])
const displayedShots = ref([])
const selectedEpisodeId = ref(null)
const selectedSequenceId = ref(null)
const shotPadding = ref('1')
+const shotMode = ref('single')
+const bulk = reactive({ start: '', count: 20, step: 10 })
const currentProduction = computed(() => store.getters.currentProduction)
const displayedEpisodes = computed(() => store.getters.displayedEpisodes)
@@ -218,6 +316,26 @@ const isAddSequenceAllowed = computed(() => {
return !exists && (selectedEpisodeId.value || !isTVShow.value)
})
+const bulkStartError = computed(
+ () => bulk.start.length > 0 && !/\d+$/.test(bulk.start)
+)
+
+const bulkPreviewNames = computed(() => {
+ if (!bulk.start || bulkStartError.value || !bulk.count || !bulk.step) {
+ return []
+ }
+ return stringHelpers.generateBulkShotNames(bulk.start, bulk.count, bulk.step)
+})
+
+const isBulkGenerateAllowed = computed(
+ () =>
+ !bulkStartError.value &&
+ bulkPreviewNames.value.length > 0 &&
+ !!selectedSequenceId.value &&
+ !bulkPreviewNames.value.some(name => isBulkNameCollision(name)) &&
+ !loading.bulkGenerate
+)
+
const isAddShotAllowed = computed(() => {
if (!names.shot) return false
const exists = displayedShots.value.find(shot => names.shot === shot.name)
@@ -254,6 +372,33 @@ const selectEpisode = episodeId => {
}
}
+const isBulkNameCollision = name =>
+ !!displayedShots.value.find(shot => shot.name === name)
+
+const generateShots = () => {
+ if (!isBulkGenerateAllowed.value) return
+ loading.bulkGenerate = true
+ const sequence = displayedSequences.value.find(
+ s => s.id === selectedSequenceId.value
+ )
+ const episode = isTVShow.value
+ ? displayedEpisodes.value.find(e => e.id === selectedEpisodeId.value)
+ : null
+ emit(
+ 'add-shots-bulk',
+ {
+ shotNames: bulkPreviewNames.value,
+ sequenceName: sequence?.name,
+ episodeName: episode?.name ?? null
+ },
+ () => {
+ loading.bulkGenerate = false
+ selectSequence(selectedSequenceId.value)
+ bulk.start = ''
+ }
+ )
+}
+
const addEpisode = () => {
if (!isAddEpisodeAllowed.value) return
loading.addEpisode = true
@@ -413,4 +558,47 @@ input::placeholder {
.shot-padding {
margin-right: 1em;
}
+
+.shot-tabs {
+ display: flex;
+ margin-bottom: 4px;
+}
+
+.tab-button {
+ flex: 1;
+ border: 1px solid $light-grey;
+ background: transparent;
+ cursor: pointer;
+ padding: 4px 0;
+
+ &.active {
+ background: var(--background-selected);
+ }
+
+ &:first-child {
+ border-radius: 4px 0 0 4px;
+ }
+
+ &:last-child {
+ border-radius: 0 4px 4px 0;
+ border-left: 0;
+ }
+}
+
+.bulk-fields {
+ display: flex;
+ gap: 4px;
+ margin-right: 10px;
+ margin-bottom: 0;
+}
+
+.bulk-field {
+ flex: 1;
+ min-width: 0;
+}
+
+.entity-line.collision {
+ color: $red;
+ text-decoration: line-through;
+}
diff --git a/src/components/pages/Shots.vue b/src/components/pages/Shots.vue
index ec1570e6a3..4955b6ad1a 100644
--- a/src/components/pages/Shots.vue
+++ b/src/components/pages/Shots.vue
@@ -143,6 +143,7 @@
@add-episode="addEpisode"
@add-sequence="addSequence"
@add-shot="addShot"
+ @add-shots-bulk="addShotsBulk"
@cancel="hideManageShots"
/>
@@ -578,6 +579,7 @@ export default {
methods: {
...mapActions([
'addMetadataDescriptor',
+ 'bulkCreateShots',
'createTasks',
'changeShotSort',
'clearSelectedShots',
@@ -651,6 +653,18 @@ export default {
this.newShot(shot).then(callback).catch(console.error)
},
+ addShotsBulk(payload, callback) {
+ this.bulkCreateShots(payload)
+ .then(() => this.loadShots())
+ .then(() => {
+ if (callback) callback()
+ })
+ .catch(err => {
+ console.error(err)
+ if (callback) callback()
+ })
+ },
+
onDeleteClicked(shot) {
this.shotToDelete = shot
this.modals.isDeleteDisplayed = true
diff --git a/src/lib/string.js b/src/lib/string.js
index 0c2120e115..afebde5d9b 100644
--- a/src/lib/string.js
+++ b/src/lib/string.js
@@ -18,6 +18,22 @@ export default {
}
},
+ generateBulkShotNames(startName, count, step) {
+ const matches = startName.match(/\d+$/)
+ if (!matches) return []
+ const numStr = matches[0]
+ const prefix = startName.slice(0, startName.length - numStr.length)
+ const padLen = numStr.length
+ const startNum = parseInt(numStr, 10)
+ const cappedCount = Math.min(count, 500)
+ const names = []
+ for (let i = 0; i < cappedCount; i++) {
+ const n = startNum + i * step
+ names.push(prefix + String(n).padStart(padLen, '0'))
+ }
+ return names
+ },
+
shortenText(text, maxLength) {
let result = text || ''
if (text?.length > maxLength) {
diff --git a/src/locales/en.js b/src/locales/en.js
index 9775c17bad..2c11f539c4 100644
--- a/src/locales/en.js
+++ b/src/locales/en.js
@@ -1905,6 +1905,10 @@ export default {
manage: 'Create shots',
new_success: 'Shot {name} successfully created.',
padding: 'Shot Padding',
+ single_tab: 'Single',
+ bulk_tab: 'Bulk',
+ bulk_generate: 'Generate',
+ bulk_invalid_start: 'Start name must end with a number.',
restore_text: 'Are you sure you want to restore {name} from your archive?',
restore_error: 'An error occurred while restoring this shot.',
sequences: 'Sequences',
@@ -1924,6 +1928,9 @@ export default {
name: 'Name',
person: 'Modifier',
placeholder: 'SH01',
+ start: 'Start',
+ count: 'Count',
+ step: 'Step',
production: 'Prod',
resolution: 'Resolution',
sequence: 'Sequence',
diff --git a/src/store/modules/shots.js b/src/store/modules/shots.js
index 54601305b3..8061967c08 100644
--- a/src/store/modules/shots.js
+++ b/src/store/modules/shots.js
@@ -566,6 +566,27 @@ const actions = {
})
},
+ bulkCreateShots(
+ { commit, dispatch, rootGetters },
+ { shotNames, sequenceName, episodeName }
+ ) {
+ const isTVShow = rootGetters.isTVShow
+ const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`
+ const header = isTVShow ? 'Episode,Sequence,Name' : 'Sequence,Name'
+ const rows = shotNames.map(name =>
+ isTVShow
+ ? `${q(episodeName)},${q(sequenceName)},${q(name)}`
+ : `${q(sequenceName)},${q(name)}`
+ )
+ const csv = [header, ...rows].join('\n')
+ const blob = new Blob([csv], { type: 'text/csv' })
+ const file = new File([blob], 'shots.csv', { type: 'text/csv' })
+ const formData = new FormData()
+ formData.append('file', file)
+ commit(SHOT_CSV_FILE_SELECTED, formData)
+ return dispatch('uploadShotFile', false)
+ },
+
uploadEdlFile({ rootGetters }, { edl_file, namingConvention, matchCase }) {
const production = rootGetters.currentProduction
const episode = rootGetters.isTVShow ? rootGetters.currentEpisode : null
diff --git a/tests/unit/lib/string.spec.js b/tests/unit/lib/string.spec.js
index 2ecfe9652b..c595160f8f 100644
--- a/tests/unit/lib/string.spec.js
+++ b/tests/unit/lib/string.spec.js
@@ -19,6 +19,15 @@ describe('lib/string', () => {
expect(stringHelpers.generateNextName('SH001', 2)).toEqual('SH003')
})
+ it('generateBulkShotNames', () => {
+ expect(stringHelpers.generateBulkShotNames('SH010', 3, 10)).toEqual(['SH010', 'SH020', 'SH030'])
+ expect(stringHelpers.generateBulkShotNames('SH001', 3, 1)).toEqual(['SH001', 'SH002', 'SH003'])
+ expect(stringHelpers.generateBulkShotNames('SC0010', 3, 10)).toEqual(['SC0010', 'SC0020', 'SC0030'])
+ expect(stringHelpers.generateBulkShotNames('SH090', 2, 10)).toEqual(['SH090', 'SH100'])
+ expect(stringHelpers.generateBulkShotNames('MAIN', 3, 10)).toEqual([])
+ expect(stringHelpers.generateBulkShotNames('SH010', 501, 1)).toHaveLength(500)
+ })
+
it('shortenText', () => {
expect(stringHelpers.shortenText('long text', 0)).toEqual('...')
expect(stringHelpers.shortenText('short text', 10)).toEqual('short text')