diff --git a/src/components/Element/Classifications/Classification.vue b/src/components/Element/Classifications/Classification.vue index 1366605a427ed961b6a3c665ca61c66880cc0ff4..b18211f130bbb5de617047d1e21e87631196c157 100644 --- a/src/components/Element/Classifications/Classification.vue +++ b/src/components/Element/Classifications/Classification.vue @@ -1,5 +1,5 @@ <template> - <div class="columns mr-0"> + <div class="columns mt-0 mr-0"> <div class="column"> {{ name }} <i diff --git a/src/components/Element/Classifications/Classifications.vue b/src/components/Element/Classifications/Classifications.vue index 10bab75d4d02f42511bce2e5cb450ba3a2cd192f..dec112651a56b0039edc1ebfa109ff36e7834bd5 100644 --- a/src/components/Element/Classifications/Classifications.vue +++ b/src/components/Element/Classifications/Classifications.vue @@ -1,16 +1,44 @@ <template> <section class="mb-4"> + <div class="mt-2 pb-5" v-if="manualClassifications.length"> + <strong class="mb-4">Manual</strong> + <Classification + v-for="classification in manualClassifications" + :key="classification.id" + :classification="classification" + :disabled="!canWrite(corpus)" + :element-id="element.id" + /> + </div> + <div class="mt-2 pb-5" - v-for="(results, version) in groupedClassifications" - :key="version" + v-for="[workerRunId, classifications] in workerRunClassifications" + :key="workerRunId" + > + <WorkerRunSummary + class="mb-4" + :worker-run-details="workerRunSummaries[workerRunId]" + /> + <Classification + v-for="classification in classifications" + :key="classification.id" + :classification="classification" + :disabled="!canWrite(corpus)" + :element-id="element.id" + /> + </div> + + <div + class="mt-2 pb-5" + v-for="[versionId, classifications] in workerVersionClassifications" + :key="versionId" > <div class="mb-4"> - <strong v-if="version === MANUAL_WORKER_VERSION">Manual</strong> - <WorkerVersionDetails v-else :worker-version-id="version" has-outside-title /> + <WorkerVersionDetails :worker-version-id="versionId" has-outside-title /> </div> <Classification - v-for="classification in results" + v-for="classification in classifications" :key="classification.id" :classification="classification" :disabled="!canWrite(corpus)" @@ -22,41 +50,80 @@ <script> import { orderBy, groupBy } from 'lodash' -import Classification from './Classification' + import { corporaMixin } from '@/mixins.js' -import { MANUAL_WORKER_VERSION } from '@/config.js' + +import WorkerRunSummary from '@/components/Process/Workers/WorkerRunSummary' import WorkerVersionDetails from '@/components/Process/Workers/Versions/Details.vue' +import Classification from './Classification' + export default { mixins: [ corporaMixin ], components: { Classification, - WorkerVersionDetails + WorkerVersionDetails, + WorkerRunSummary }, props: { element: { type: Object, - required: true + required: true, + validator: element => Array.isArray(element.classifications) } }, - data: () => ({ - MANUAL_WORKER_VERSION - }), computed: { corpusId () { return this.element.corpus.id }, /** - * Group classifications by worker version and order by confidence. - * Returns { worker version ID: [classifications] } - * For manual classifications, since `null` cannot be set as an object's key, - * the key `__manual__` is used. + * Classifications sorted by descending confidence and ascending class name. + * This sorting is computed once and then re-used by other computed properties + * to perform the grouping. + */ + sortedClassifications () { + return orderBy( + this.element.classifications, + ['confidence', 'class_name'], + ['desc', 'asc'] + ) + }, + manualClassifications () { + return this.sortedClassifications.filter(classification => !classification.worker_run && !classification.worker_version) + }, + /** + * Classifications with worker runs, grouped by their worker run ID and sorted by their worker run summary. + * @returns {[string, object[]][]} + */ + workerRunClassifications () { + const grouped = groupBy( + this.sortedClassifications.filter(classification => classification.worker_run), + 'worker_run.id' + ) + return orderBy(Object.entries(grouped), ([id]) => this.workerRunSummaries[id]) + }, + /** + * Worker run summary serializers mapped to their IDs. + * @returns {{ [id: string]: { id: string, summary: string } }} + */ + workerRunSummaries () { + return Object.fromEntries( + this.sortedClassifications + .filter(classification => classification?.worker_run) + .map(classification => [classification.worker_run.id, classification.worker_run]) + ) + }, + /** + * Classifications with worker versions and no worker runs, grouped by their worker version ID and sorted only by ID. + * @returns {[string, object[]][]} */ - groupedClassifications () { - if (!this.element || !this.element.classifications) return {} - const orderedClassifications = orderBy(this.element.classifications, [c => c.confidence], ['desc']) - return groupBy(orderedClassifications, c => c.worker_version || MANUAL_WORKER_VERSION) + workerVersionClassifications () { + const grouped = groupBy( + this.sortedClassifications.filter(classification => classification.worker_version && !classification.worker_run), + 'worker_version' + ) + return orderBy(Object.entries(grouped), [0]) } } } diff --git a/src/components/Element/DetailsPanel.vue b/src/components/Element/DetailsPanel.vue index 3754d68ddc669fc1b835b0debfaa062ba8c84984..b00a7099b15578f0c59516df7862838cd2bc14ad 100644 --- a/src/components/Element/DetailsPanel.vue +++ b/src/components/Element/DetailsPanel.vue @@ -43,13 +43,7 @@ <template v-if="elementType.folder === false"> <DropdownContent id="transcriptions" title="Transcriptions"> - <GroupedTranscriptions - v-for="[workerId, transcriptions] in groupedTranscriptions" - :key="workerId" - :element="element" - :transcriptions="transcriptions" - :worker-id="workerId" - /> + <Transcriptions :element="element" /> <template v-if="canWriteElement(elementId)"> <div class="has-text-right"> <a v-on:click="transcriptionModal = true"> @@ -88,35 +82,33 @@ </template> <script> -import { mapState, mapActions, mapGetters } from 'vuex' +import { mapState, mapGetters } from 'vuex' import { corporaMixin } from '@/mixins.js' -import { MANUAL_WORKER_VERSION } from '@/config.js' -import { groupBy, orderBy } from 'lodash' -import GroupedTranscriptions from './Transcription' +import DropdownContent from '@/components/DropdownContent.vue' +import MLClassSelect from '@/components/MLClassSelect.vue' import EntityLinks from '@/components/Entity/Links.vue' import Classifications from './Classifications' -import MLClassSelect from '@/components/MLClassSelect.vue' import ElementMetadata from './Metadata' -import DropdownContent from '@/components/DropdownContent.vue' +import OrientationPanel from './OrientationPanel' +import Transcriptions from './Transcription' import TranscriptionsModal from './Transcription/Modal' import TranscriptionCreationForm from './Transcription/CreationForm' -import OrientationPanel from './OrientationPanel' export default { mixins: [ corporaMixin ], components: { - GroupedTranscriptions, - EntityLinks, Classifications, - MLClassSelect, - ElementMetadata, DropdownContent, - TranscriptionsModal, + ElementMetadata, + EntityLinks, + MLClassSelect, + OrientationPanel, TranscriptionCreationForm, - OrientationPanel + Transcriptions, + TranscriptionsModal }, props: { elementId: { @@ -169,31 +161,9 @@ export default { const neighbor = this.neighbors?.[this.elementId]?.results?.find(n => n.element?.id === this.elementId) // Prevent errors on null parents since ListElementNeighbors can return null parents for paths with 'ghost elements' return [...neighbor?.parents ?? []].reverse().find(parent => parent !== null)?.id - }, - groupedTranscriptions () { - // Find all transcriptions attached to this element - let transcriptions = Object.values(this.transcriptions[this.elementId] ?? {}) - if (!transcriptions.length) return [] - - /* - * Skip all transcriptions that have a worker version ID, but the worker version is not yet loaded. - * A watcher will handle the actual loading of worker versions, and this computed should update itself. - */ - transcriptions = transcriptions.filter(t => !t.worker_version_id || this.workerVersions[t.worker_version_id]) - - // Group transcriptions by worker - const grouped = groupBy(transcriptions, t => { - if (!t.worker_version_id) return MANUAL_WORKER_VERSION - return this.workerVersions[t.worker_version_id].worker.id - }) - - // Order by worker name - return orderBy(Object.entries(grouped), ([id]) => id === MANUAL_WORKER_VERSION ? '' : this.workers[id].name) } }, methods: { - ...mapActions('elements', ['listTranscriptions']), - ...mapActions('process', ['getWorkerVersion']), async createClassification () { if (!this.canCreateClassification) return this.isSavingNewClassification = true @@ -206,23 +176,12 @@ export default { this.$refs.newClassificationSelect.clear() this.isSavingNewClassification = false } - }, - async fetchTranscriptionWorkerVersions (elementId) { - if (!this.transcriptions[elementId]) return - [...new Set( - Object.values(this.transcriptions[elementId]) - // Get all the worker version IDs of all transcriptions on this element - .map(transcription => transcription.worker_version_id) - // If the worker version ID is not null (not manual) and the version ID was not loaded in the frontend - .filter(id => id && !(id in this.workerVersions)) - // Retrieve the worker version's information - )].forEach(id => this.getWorkerVersion(id)) } }, watch: { elementId: { immediate: true, - async handler (id) { + handler (id) { if (!id) return /* * Do not retrieve the element again if it already exists in the store, @@ -231,18 +190,7 @@ export default { * This ensures there are no strange behaviors where some actions are only sometimes disabled when they shouldn't, * or some element attributes are not displayed at all. */ - if (!this.element || this.element.id !== id || !this.element.rights || !this.element.classifications) await this.$store.dispatch('elements/get', { id }) - await this.listTranscriptions({ id }) - this.fetchTranscriptionWorkerVersions(id) - } - }, - elementType: { - async handler (type) { - // List transcriptions attached to this element - if (type.folder === false && this.transcriptions[this.elementId] === undefined) { - await this.listTranscriptions({ id: this.elementId }) - this.fetchTranscriptionWorkerVersions(this.elementId) - } + if (!this.element || this.element.id !== id || !this.element.rights || !this.element.classifications) this.$store.dispatch('elements/get', { id }) } } } diff --git a/src/components/Element/PanelHeader.vue b/src/components/Element/PanelHeader.vue index 7fb3b4c3e928e75c946d1a6ff5efff5b5c56664a..46f673127582ca37c765dc551f653c29d9cea468 100644 --- a/src/components/Element/PanelHeader.vue +++ b/src/components/Element/PanelHeader.vue @@ -1,56 +1,53 @@ <template> <div v-if="element" class="mb-3"> - <div class="columns is-flex-grow-1"> - <div class="column"> - <span class="subtitle is-5"> - <span :title="element.type" class="has-text-grey mr-1">{{ typeName(element.type) }}</span> - <strong :title="element.name">{{ element.name }}</strong> + <div class="is-pulled-right ml-2" v-if="isAnnotable"> + <button + v-if="!annotationEnabled" + class="button is-primary" + title="Open annotation panel" + v-on:click="toggle(true)" + > + <span class="icon"> + <i class="icon-plus"></i> </span> - <router-link - v-if="element && element.id !== mainElementId" - :to="{ name: 'element-details', params: { id: element.id } }" - > - <i class="icon-link" :title="`Navigate to ${element.name}`"></i> - </router-link> - <a - class="icon-trash" - :class="canAdminElement(elementId) ? 'has-text-danger' : 'has-text-grey-light'" - :title="canAdminElement(elementId) ? 'Delete this element' : 'Admin access on the project is required to delete this element'" - v-on:click="deleteModal = canAdminElement(elementId)" - ></a> - <p> - <!-- Allow both worker_version and worker_version_id because the list and retrieve endpoints are inconsistent --> - <WorkerVersionDetails - v-if="element.worker_version || element.worker_version_id" - :worker-version-id="element.worker_version || element.worker_version_id" - has-outside-title - /> - <span v-else-if="element.creator">Created by <strong>{{ element.creator }}</strong></span> - <ConfidenceTag v-if="Number.isFinite(element.confidence)" :value="element.confidence" /> - </p> - </div> - <div class="column is-narrow" v-if="isAnnotable"> - <button - v-if="!annotationEnabled" - class="button is-primary" - title="Open annotation panel" - v-on:click="toggle(true)" - > - <span class="icon"> - <i class="icon-plus"></i> - </span> - <span>annotate</span> - </button> - <button - v-else-if="annotationEnabled" - class="button is-danger" - title="Close annotation panel" - v-on:click="toggle(false)" - > - <span>close</span> - </button> - </div> + <span>annotate</span> + </button> + <button + v-else-if="annotationEnabled" + class="button is-danger" + title="Close annotation panel" + v-on:click="toggle(false)" + > + <span>close</span> + </button> </div> + <span class="subtitle is-5"> + <span :title="element.type" class="has-text-grey mr-1">{{ typeName(element.type) }}</span> + <strong :title="element.name">{{ element.name }}</strong> + </span> + <router-link + v-if="element && element.id !== mainElementId" + :to="{ name: 'element-details', params: { id: element.id } }" + > + <i class="icon-link" :title="`Navigate to ${element.name}`"></i> + </router-link> + <a + class="icon-trash" + :class="canAdminElement(elementId) ? 'has-text-danger' : 'has-text-grey-light'" + :title="canAdminElement(elementId) ? 'Delete this element' : 'Admin access on the project is required to delete this element'" + v-on:click="deleteModal = canAdminElement(elementId)" + ></a> + <p> + <WorkerRunSummary v-if="element.worker_run" :worker-run-details="element.worker_run" /> + <!-- Allow both worker_version and worker_version_id because the list and retrieve endpoints are inconsistent --> + <WorkerVersionDetails + v-else-if="element.worker_version || element.worker_version_id" + :worker-version-id="element.worker_version || element.worker_version_id" + has-outside-title + /> + <span v-else-if="element.creator">Created by <strong>{{ element.creator }}</strong></span> + <ConfidenceTag v-if="Number.isFinite(element.confidence)" :value="element.confidence" /> + </p> <Modal v-model="deleteModal" :title="'Delete ' + typeName(element.type) + ' ' + element.name"> <p> Are you sure you want to delete the {{ typeName(element.type) }} @@ -72,6 +69,7 @@ import { mapState, mapGetters, mapMutations } from 'vuex' import { corporaMixin } from '@/mixins.js' +import WorkerRunSummary from '@/components/Process/Workers/WorkerRunSummary.vue' import WorkerVersionDetails from '@/components/Process/Workers/Versions/Details.vue' import Modal from '@/components/Modal.vue' import ConfidenceTag from '@/components/ConfidenceTag.vue' @@ -83,7 +81,8 @@ export default { components: { WorkerVersionDetails, Modal, - ConfidenceTag + ConfidenceTag, + WorkerRunSummary }, props: { elementId: { diff --git a/src/components/Element/Transcription/Box.vue b/src/components/Element/Transcription/Box.vue index b5106341e0cc4727c924d5e13315c6cc2cfa0e7c..f282a6c6fbef5e5b10c0f957ccbc73349a20ad82 100644 --- a/src/components/Element/Transcription/Box.vue +++ b/src/components/Element/Transcription/Box.vue @@ -4,7 +4,7 @@ :style="orientationStyle(transcription.orientation)" > <blockquote class="transcription-box"> - <template v-if="entities.length"> + <template v-if="transcriptionEntities.length"> <Token v-for="(token, index) in tokens" v-bind="token" @@ -28,9 +28,24 @@ export default { type: Object, required: true }, + /** + * Filter TranscriptionEntities that do not have a WorkerRun by WorkerVersion. + * If both this parameter and the WorkerRun filter are unset, no TranscriptionEntities are displayed. + * To display entities created without a WorkerVersion or a WorkerRun, use the `MANUAL_WORKER_VERSION` const. + * Both parameters may not be set at once. + */ workerVersionFilter: { type: String, default: '' + }, + /** + * Filter TranscriptionEntities by WorkerRun UUID. + * If both this parameter and the WorkerVersion filter are unset, no TranscriptionEntities are displayed. + * Both parameters may not be set at once. + */ + workerRunFilter: { + type: String, + default: '' } }, components: { @@ -39,19 +54,26 @@ export default { computed: { ...mapState('entity', ['inTranscription']), loaded () { - if (!this.inTranscription || !this.inTranscription[this.transcription.id]) return false - const { results, count, loaded } = this.inTranscription[this.transcription.id] - return results && count === loaded + const { results, count, loaded } = this.inTranscription?.[this.transcription.id] ?? {} + return results && loaded >= count }, - entities () { - if (!this.loaded || !this.workerVersionFilter) return [] - return this - .inTranscription[this.transcription.id] - .results - .filter(transcriptionEntity => (transcriptionEntity.worker_version_id || MANUAL_WORKER_VERSION) === this.workerVersionFilter) + transcriptionEntities () { + let transcriptionEntities = this.inTranscription?.[this.transcription.id]?.results + + if (!this.loaded || !transcriptionEntities?.length || (!this.workerVersionFilter && !this.workerRunFilter)) return [] + if (this.workerVersionFilter && this.workerRunFilter) throw new Error('The WorkerVersion and WorkerRun filters cannot be used at the same time.') + + if (this.workerVersionFilter) { + transcriptionEntities = transcriptionEntities.filter(transcriptionEntity => + !transcriptionEntity.worker_run && + (transcriptionEntity.worker_version_id || MANUAL_WORKER_VERSION) === this.workerVersionFilter + ) + } else if (this.workerRunFilter) transcriptionEntities = transcriptionEntities.filter(transcriptionEntity => transcriptionEntity.worker_run?.id === this.workerRunFilter) + + return transcriptionEntities }, tokens () { - return parseEntities(this.transcription.text, this.entities) + return parseEntities(this.transcription.text, this.transcriptionEntities) } }, methods: { diff --git a/src/components/Element/Transcription/GroupedTranscriptions.vue b/src/components/Element/Transcription/GroupedTranscriptions.vue deleted file mode 100644 index 912b2cef3a7f0e20bf1e90210c65f5c0284e246c..0000000000000000000000000000000000000000 --- a/src/components/Element/Transcription/GroupedTranscriptions.vue +++ /dev/null @@ -1,65 +0,0 @@ -<template> - <section> - <div - class="pb-4" - v-for="(results, version) in groupedTranscriptions" - :key="version" - > - <div class="mb-2"> - <strong v-if="version === MANUAL_WORKER_VERSION">Manual</strong> - <WorkerVersionDetails v-else :worker-version-id="version" has-outside-title /> - </div> - <Transcription - v-for="transcription in results" - :key="transcription.id" - :element="element" - :transcription="transcription" - /> - </div> - </section> -</template> - -<script> -import { mapState } from 'vuex' -import { orderBy, groupBy } from 'lodash' -import { MANUAL_WORKER_VERSION } from '@/config.js' -import WorkerVersionDetails from '@/components/Process/Workers/Versions/Details.vue' -import Transcription from './Transcription' - -export default { - props: { - element: { - type: Object, - required: true - }, - transcriptions: { - type: Array, - required: true - }, - workerId: { - type: String, - required: true - } - }, - data: () => ({ - MANUAL_WORKER_VERSION - }), - components: { - Transcription, - WorkerVersionDetails - }, - methods: { - getCreatedDate (versionId) { - if (!versionId) return - return new Date(this.workerVersions[versionId].revision.created) - } - }, - computed: { - ...mapState('process', ['workerVersions']), - groupedTranscriptions () { - const orderedTranscriptions = orderBy(this.transcriptions, t => this.getCreatedDate(t.worker_version_id), 'desc') - return groupBy(orderedTranscriptions, t => t.worker_version_id || MANUAL_WORKER_VERSION) - } - } -} -</script> diff --git a/src/components/Element/Transcription/Transcription.vue b/src/components/Element/Transcription/Transcription.vue index e594468ebe045893985da346b9b0f6736e9d5ac4..a73b94905e707edfeea3966a1941dc8758e0b52e 100644 --- a/src/components/Element/Transcription/Transcription.vue +++ b/src/components/Element/Transcription/Transcription.vue @@ -13,26 +13,37 @@ :transcription="transcription" v-on:edit="editing = true" /> - <div class="select" v-if="entityVersions?.length"> - <select v-model="workerVersionFilter"> + <div class="select is-truncated" v-if="entityFilterValues.length"> + <select v-model="filterValue"> <option value="">No entities</option> - <option v-for="[id, name] in entityVersions" :key="id" :value="id">{{ name }}</option> + <option v-for="[id, name] in entityFilterValues" :key="id" :value="id">{{ truncateSelect(name) }}</option> </select> </div> <span class="is-clearfix"></span> - <Box :transcription="transcription" :worker-version-filter="workerVersionFilter" /> + <Box + :transcription="transcription" + :worker-version-filter="workerVersionFilter" + :worker-run-filter="workerRunFilter" + /> </template> </div> </template> <script> +import { sortBy } from 'lodash' import { mapState, mapActions } from 'vuex' + import { MANUAL_WORKER_VERSION } from '@/config.js' +import { truncateMixin } from '@/mixins' + import Actions from './Actions' import Box from './Box' import EditionForm from './EditionForm' export default { + mixins: [ + truncateMixin + ], props: { element: { type: Object, @@ -50,6 +61,7 @@ export default { }, data: () => ({ workerVersionFilter: '', + workerRunFilter: '', editing: false }), mounted () { @@ -58,20 +70,75 @@ export default { computed: { ...mapState('entity', ['inTranscription']), ...mapState('process', ['workerVersions']), + + /** + * Values and display names for the options of the entity worker versions/worker runs filter. + * @returns {[string, string][]} Array of IDs and display names. + * The IDs are UUIDs that start with a `run-` prefix for WorkerRuns and a `version-` prefix for WorkerVersions. + */ + entityFilterValues () { + const transcriptionEntities = this.inTranscription?.[this.transcription.id]?.results + if (!transcriptionEntities) return [] + + const values = [] + // If there are TranscriptionEntities with no worker run and no worker version, add the manual option as the very first item + if (transcriptionEntities.some(transcriptionEntity => !transcriptionEntity.worker_version_id && !transcriptionEntity.worker_run)) { + values.push([`version-${MANUAL_WORKER_VERSION}`, 'Manual']) + } + + values.push( + ...sortBy( + // Turn into an object to make the array unique by ID, but turn back into an array to allow sorting + Object.entries(Object.fromEntries( + transcriptionEntities + .filter(transcriptionEntity => transcriptionEntity.worker_run) + .map(transcriptionEntity => ([`run-${transcriptionEntity.worker_run.id}`, transcriptionEntity.worker_run.summary])), + [1, 0] + )) + ) + ) + + values.push( + ...sortBy( + Object.entries(Object.fromEntries( + transcriptionEntities + .filter(transcriptionEntity => !transcriptionEntity.worker_run && transcriptionEntity.worker_version_id) + .map(transcriptionEntity => this.workerVersions[transcriptionEntity.worker_version_id]) + // Ignore worker versions that were not yet loaded + .filter(version => version) + .map(version => ([`version-${version.id}`, `${version.worker.name} ${version.revision.hash.substring(0, 8)}`])) + )), + [1, 0] + ) + ) + + return values + }, + /** - * Values and display names for the options of the entity worker versions filter. - * @return {[string, string][]} Array of [worker version ID, worker version display name] + * Since it is not possible for the options of a <select> to use anything other than a single string + * as a value without causing an infinite stream of bugs, we use strings prefixed with "run-" or "version-" + * depending on whether we should filter by WorkerRun or by WorkerVersion. + * This computed property makes the link between the selected option value and the actual filter attributes. */ - entityVersions () { - if (!this.inTranscription?.[this.transcription.id]?.results) return [] - // Build a unique set of all worker version IDs - const ids = new Set(this.inTranscription[this.transcription.id].results.map(transcriptionEntity => transcriptionEntity.worker_version_id)) - // Ignore worker versions that are not yet loaded; a watcher loads those - return [...ids].filter(id => !id || this.workerVersions[id]).map(id => { - if (!id) return [MANUAL_WORKER_VERSION, 'Manual'] - const version = this.workerVersions[id] - return [id, version.worker.name + ' ' + version.revision.hash.substring(0, 8)] - }) + filterValue: { + get () { + if (this.workerRunFilter) return `run-${this.workerRunFilter}` + else if (this.workerVersionFilter) return `version-${this.workerVersionFilter}` + else return '' + }, + set (newValue) { + if (!newValue) { + this.workerVersionFilter = '' + this.workerRunFilter = '' + } else if (newValue.startsWith('run-')) { + this.workerVersionFilter = '' + this.workerRunFilter = newValue.slice(4) + } else if (newValue.startsWith('version-')) { + this.workerVersionFilter = newValue.slice(8) + this.workerRunFilter = '' + } else throw new Error(`Unsupported filter value ${newValue}`) + } } }, methods: { @@ -82,22 +149,30 @@ export default { const transcriptionEntities = newValue?.[this.transcription.id]?.results if (!transcriptionEntities?.length) return // Get every worker version ID of every TranscriptionEntity - [...new Set(transcriptionEntities.map(transcriptionEntity => transcriptionEntity.worker_version_id))] + [...new Set( + transcriptionEntities + // Ignore those with worker runs, as we will not use their version + .filter(transcriptionEntity => !transcriptionEntity.worker_run) + .map(transcriptionEntity => transcriptionEntity.worker_version_id) + )] // Only pick those that are not yet in the store .filter(id => id && !this.workerVersions[id]) // Fetch them .map(id => this.getWorkerVersion(id)) }, - versionIds (newValue) { - // Automatically select the first available worker version - if (newValue.length) this.workerVersionFilter = newValue[0][0] + entityFilterValues: { + handler (newValue) { + // Automatically select the first available filter option + if (newValue.length) this.filterValue = newValue[0][0] + }, + immediate: true } } } </script> <style scoped> -/* Add some spacing between two transcriptions with the same worker version */ +/* Add some spacing between two transcriptions */ .transcription:not(:last-child) { margin-bottom: .5rem; } diff --git a/src/components/Element/Transcription/Transcriptions.vue b/src/components/Element/Transcription/Transcriptions.vue new file mode 100644 index 0000000000000000000000000000000000000000..7813e82e3ac5f1c03aa6c7a878def14811d4c4bd --- /dev/null +++ b/src/components/Element/Transcription/Transcriptions.vue @@ -0,0 +1,161 @@ +<template> + <section v-if="transcriptions[element.id]"> + <div class="pb-4" v-if="manualTranscriptions.length"> + <strong class="mb-2">Manual</strong> + <Transcription + v-for="transcription in manualTranscriptions" + :key="transcription.id" + :element="element" + :transcription="transcription" + /> + </div> + + <div + class="pb-4" + v-for="[workerRunId, transcriptions] in workerRunTranscriptions" + :key="workerRunId" + > + <WorkerRunSummary + class="mb-2" + :worker-run-details="workerRunSummaries[workerRunId]" + /> + <Transcription + v-for="transcription in transcriptions" + :key="transcription.id" + :element="element" + :transcription="transcription" + /> + </div> + + <div + class="pb-4" + v-for="[versionId, transcriptions] in workerVersionTranscriptions" + :key="versionId" + > + <div class="mb-2"> + <WorkerVersionDetails :worker-version-id="versionId" has-outside-title /> + </div> + <Transcription + v-for="transcription in transcriptions" + :key="transcription.id" + :element="element" + :transcription="transcription" + /> + </div> + </section> +</template> + +<script> +import { mapActions, mapState } from 'vuex' +import { orderBy, groupBy } from 'lodash' + +import WorkerRunSummary from '@/components/Process/Workers/WorkerRunSummary.vue' +import WorkerVersionDetails from '@/components/Process/Workers/Versions/Details.vue' +import Transcription from './Transcription' + +export default { + props: { + element: { + type: Object, + required: true, + validator: value => value?.id + } + }, + components: { + Transcription, + WorkerVersionDetails, + WorkerRunSummary + }, + computed: { + ...mapState('elements', ['transcriptions']), + ...mapState('process', ['workerVersions']), + /** + * Transcriptions sorted by descending confidence and ascending text. + * This sorting is computed once and then re-used by other computed properties + * to perform the grouping. + */ + sortedTranscriptions () { + return orderBy( + this.transcriptions[this.element.id], + ['confidence', 'text'], + ['desc', 'asc'] + ) + }, + manualTranscriptions () { + return this.sortedTranscriptions.filter(transcription => !transcription.worker_run && !transcription.worker_version_id) + }, + /** + * Transcriptions with worker runs, grouped by their worker run ID and sorted by their worker run summary. + * @returns {[string, object[]][]} + */ + workerRunTranscriptions () { + const grouped = groupBy( + this.sortedTranscriptions.filter(transcription => transcription.worker_run), + 'worker_run.id' + ) + return orderBy(Object.entries(grouped), ([id]) => this.workerRunSummaries[id]) + }, + /** + * Worker run summary serializers mapped to their IDs. + * @returns {{ [id: string]: { id: string, summary: string } }} + */ + workerRunSummaries () { + return Object.fromEntries( + this.sortedTranscriptions + .filter(transcription => transcription?.worker_run) + .map(transcription => [transcription.worker_run.id, transcription.worker_run]) + ) + }, + /** + * Transcriptions with worker versions and no worker runs, grouped by their worker version ID + * and sorted by worker name, then revision creation date, then worker version ID. + * If the worker version is not yet loaded, this only sorts by worker version ID. + * @returns {[string, object[]][]} + */ + workerVersionTranscriptions () { + const grouped = groupBy( + this.sortedTranscriptions.filter(transcription => transcription.worker_version_id && !transcription.worker_run), + 'worker_version_id' + ) + return orderBy(Object.entries(grouped), [ + ([id]) => this.workerVersions[id]?.worker?.name, + ([id]) => this.workerVersions[id]?.revision?.created, + /* + * Fallback to sorting by worker version ID. When the worker version is not yet loaded, + * the two functions above will return `null`, and this will be the only sorting criterion. + */ + '0' + ]) + } + }, + methods: { + ...mapActions('elements', ['listTranscriptions']), + ...mapActions('process', ['getWorkerVersion']) + }, + watch: { + element: { + immediate: true, + async handler (newValue) { + if (!newValue) return + if (!this.transcriptions[newValue.id]) await this.listTranscriptions({ id: newValue.id }) + + /* + * Once transcriptions are loaded, we need to fetch all worker versions + * to properly sort the transcriptions that have no worker runs and preserve the older sorting method. + * TODO: Remove this whole fetching once we fully switch to worker runs + */ + if (!this.transcriptions[newValue.id]) return + [...new Set( + Object.values(this.transcriptions[newValue.id]) + // Get all the worker version IDs of all transcriptions on this element that have no workerRuns + .filter(transcription => !transcription.worker_run) + .map(transcription => transcription.worker_version_id) + // If the worker version ID is not null (not manual) and the version ID was not loaded in the frontend + .filter(id => id && !(id in this.workerVersions)) + // Retrieve the worker version's information + )].forEach(id => this.getWorkerVersion(id)) + } + } + } +} +</script> diff --git a/src/components/Element/Transcription/index.js b/src/components/Element/Transcription/index.js index 9d345fa1bb2b97c9c48efb354163e4040cb22fe0..93df33af45bef33c52c68aef23804f83b5241917 100644 --- a/src/components/Element/Transcription/index.js +++ b/src/components/Element/Transcription/index.js @@ -1 +1 @@ -export { default } from './GroupedTranscriptions' +export { default } from './Transcriptions' diff --git a/src/components/Process/Workers/WorkerRunSummary.vue b/src/components/Process/Workers/WorkerRunSummary.vue new file mode 100644 index 0000000000000000000000000000000000000000..0342dc4e3c2870504aa8833f9db134cb0b2d7883 --- /dev/null +++ b/src/components/Process/Workers/WorkerRunSummary.vue @@ -0,0 +1,26 @@ +<template> + <div :title="workerRunDetails.summary"> + <span class="summary">{{ workerRunDetails.summary }}</span> + </div> +</template> + +<script> +export default { + props: { + workerRunDetails: { + type: Object, + required: true, + validator: value => value?.id && value?.summary + } + } +} +</script> + +<style scoped> +.summary { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +</style>