From cf063a0914f06dc56362dc3f0e9dcb8e758bf43c Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 13 Oct 2026 12:18:14 -0400 Subject: [PATCH 01/13] add launch config model --- client/src/ConfigurationManager.ts | 13 +++++++++++++ server/dive_server/crud_dataset.py | 4 ++++ server/dive_utils/models.py | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/client/src/ConfigurationManager.ts b/client/src/ConfigurationManager.ts index 213b38a6..5b83ecfb 100644 --- a/client/src/ConfigurationManager.ts +++ b/client/src/ConfigurationManager.ts @@ -7,6 +7,8 @@ import type { CustomStyle } from './StyleManager'; import type { Feature } from './track'; export interface DiveConfiguration { + /** launchConfigs of the write-base folder for this folder's hierarchy (not merged) */ + launchConfigs?: LaunchConfig[]; prevNext?: { previous?: { id: string; name: string}; next?: { id: string; name: string}; @@ -195,6 +197,16 @@ export interface VisualMaskConfiguration { style?: CustomStyle; } +export interface LaunchConfig { + id: string; + label: string; + tooltip?: string; + color?: string; + /** Girder folder whose configuration is applied for the launched session */ + configurationFolderId: string; + order?: number; +} + export interface Configuration { general?: { configurationMerge? : 'merge up' | 'merge down' | 'disabled'; @@ -209,6 +221,7 @@ export interface Configuration { timelineConfigs?: TimelineConfiguration[]; customUI?: CustomUISettings; visualMasks?: Record; + launchConfigs?: LaunchConfig[]; } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/server/dive_server/crud_dataset.py b/server/dive_server/crud_dataset.py index ffe5bd18..902ba4f4 100644 --- a/server/dive_server/crud_dataset.py +++ b/server/dive_server/crud_dataset.py @@ -678,7 +678,11 @@ def get_configuration(folder: Folder, user: User): "name": next.get('name'), } + # Launch configs live only on the write base (never merged), so return them + # explicitly; browsing any descendant folder then resolves the base's buttons. + launchConfigs = (baseMetaData.get('configuration', {}) or {}).get('launchConfigs', []) returnVal = { + 'launchConfigs': launchConfigs if isinstance(launchConfigs, list) else [], 'configOwners': configOwners, "prevNext": prevNext, 'hierarchy': hierarchy, diff --git a/server/dive_utils/models.py b/server/dive_utils/models.py index d9bc9fe5..6dec160d 100644 --- a/server/dive_utils/models.py +++ b/server/dive_utils/models.py @@ -488,7 +488,7 @@ class GoToFrameAction(BaseModel): class CreateTrackAction(BaseModel): trackType: Optional[str] - geometryType: Literal['Point', 'rectangle', 'Polygon', 'LineString', 'Time'] + geometryType: Literal['Point', 'rectangle', 'Polygon', 'LineString', 'Time', 'Mask'] editableType: bool editableTitle: Optional[str] editableText: Optional[str] From bf302b212871dcd81820ac28c34ef127ce7cff13 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 13 Oct 2026 12:20:22 -0400 Subject: [PATCH 02/13] client side tools for getting launch configs --- .../web-girder/use/useLaunchConfigs.ts | 34 +++++++++++++++++++ client/src/launchConfigs.spec.ts | 19 +++++++++++ client/src/launchConfigs.ts | 20 +++++++++++ client/src/use/configFolderParam.spec.ts | 10 ++++++ client/src/use/configFolderParam.ts | 21 ++++++++++++ client/src/use/useURLParameters.ts | 6 ++++ 6 files changed, 110 insertions(+) create mode 100644 client/platform/web-girder/use/useLaunchConfigs.ts create mode 100644 client/src/launchConfigs.spec.ts create mode 100644 client/src/launchConfigs.ts create mode 100644 client/src/use/configFolderParam.spec.ts create mode 100644 client/src/use/configFolderParam.ts diff --git a/client/platform/web-girder/use/useLaunchConfigs.ts b/client/platform/web-girder/use/useLaunchConfigs.ts new file mode 100644 index 00000000..4cb673fa --- /dev/null +++ b/client/platform/web-girder/use/useLaunchConfigs.ts @@ -0,0 +1,34 @@ +import { ref, Ref } from 'vue'; +import { LaunchConfig } from 'vue-media-annotator/ConfigurationManager'; +import { normalizeLaunchConfigs } from 'vue-media-annotator/launchConfigs'; +import { getDiveConfiguration } from '../api/dataset.service'; + +// Cache per browse context (folder id) so list views never fetch per row +const cache = new Map>(); + +export function resolveLaunchConfigs(folderId: string): Promise { + let pending = cache.get(folderId); + if (!pending) { + pending = getDiveConfiguration(folderId) + .then((resp) => normalizeLaunchConfigs(resp.data?.launchConfigs)) + .catch(() => [] as LaunchConfig[]); + cache.set(folderId, pending); + } + return pending; +} + +export function invalidateLaunchConfigs(folderId?: string) { + if (folderId) { + cache.delete(folderId); + } else { + cache.clear(); + } +} + +export function useLaunchConfigs() { + const launchConfigs: Ref = ref([]); + const load = async (id?: string | null) => { + launchConfigs.value = id ? await resolveLaunchConfigs(id) : []; + }; + return { launchConfigs, load }; +} diff --git a/client/src/launchConfigs.spec.ts b/client/src/launchConfigs.spec.ts new file mode 100644 index 00000000..65cbfcd2 --- /dev/null +++ b/client/src/launchConfigs.spec.ts @@ -0,0 +1,19 @@ +import { normalizeLaunchConfigs, splitLaunchConfigs } from './launchConfigs'; + +const mk = (id: string, order?: number, folder = 'f') => ({ + id, label: id, configurationFolderId: folder, order, +}); + +describe('launchConfigs', () => { + it('normalizes and sorts', () => { + expect(normalizeLaunchConfigs(undefined)).toEqual([]); + const out = normalizeLaunchConfigs([mk('b', 2), mk('bad', 0, ''), mk('a', 1), mk('c')]); + expect(out.map((c) => c.id)).toEqual(['c', 'a', 'b']); + }); + it('splits inline vs overflow', () => { + const list = ['a', 'b', 'c', 'd', 'e'].map((i) => mk(i)); + const { inline, overflow } = splitLaunchConfigs(list); + expect(inline).toHaveLength(3); + expect(overflow).toHaveLength(2); + }); +}); diff --git a/client/src/launchConfigs.ts b/client/src/launchConfigs.ts new file mode 100644 index 00000000..27b6196a --- /dev/null +++ b/client/src/launchConfigs.ts @@ -0,0 +1,20 @@ +import { LaunchConfig } from './ConfigurationManager'; + +export const MAX_INLINE_LAUNCH_BUTTONS = 3; + +/** Drop invalid entries and sort by order (stable) */ +export function normalizeLaunchConfigs(raw: unknown): LaunchConfig[] { + if (!Array.isArray(raw)) { + return []; + } + return raw + .filter((c): c is LaunchConfig => !!c && typeof c.configurationFolderId === 'string' && !!c.configurationFolderId) + .map((c, index) => ({ c, index })) + .sort((a, b) => ((a.c.order ?? 0) - (b.c.order ?? 0)) || (a.index - b.index)) + .map(({ c }) => c); +} + +/** Split into inline buttons and overflow-menu entries */ +export function splitLaunchConfigs(configs: LaunchConfig[], maxInline = MAX_INLINE_LAUNCH_BUTTONS) { + return { inline: configs.slice(0, maxInline), overflow: configs.slice(maxInline) }; +} diff --git a/client/src/use/configFolderParam.spec.ts b/client/src/use/configFolderParam.spec.ts new file mode 100644 index 00000000..1ff0aa73 --- /dev/null +++ b/client/src/use/configFolderParam.spec.ts @@ -0,0 +1,10 @@ +import { readUrlParam, readConfigFolderIdFromUrl } from './configFolderParam'; + +describe('configFolderParam', () => { + it('reads params', () => { + expect(readUrlParam('http://x/viewer/1?frame=3&configFolderId=abc', 'configFolderId')).toBe('abc'); + expect(readConfigFolderIdFromUrl('http://x/viewer/1?diveMetadataRootId=z')).toBeNull(); + expect(readConfigFolderIdFromUrl('http://x/viewer/1')).toBeNull(); + expect(readConfigFolderIdFromUrl('http://x/viewer/1?configFolderId=')).toBeNull(); + }); +}); diff --git a/client/src/use/configFolderParam.ts b/client/src/use/configFolderParam.ts new file mode 100644 index 00000000..5050884e --- /dev/null +++ b/client/src/use/configFolderParam.ts @@ -0,0 +1,21 @@ +/** URL query param that overrides the hierarchy base UI configuration for a viewer session */ +export const CONFIG_FOLDER_PARAM = 'configFolderId'; + +/** Read a query parameter from a URL string (works with hash-less and vue-router history URLs) */ +export function readUrlParam(href: string, name: string): string | null { + const queryStart = href.indexOf('?'); + if (queryStart === -1) { + return null; + } + const query = href.slice(queryStart + 1).split('#')[0]; + const found = query.split('&').find((item) => item.split('=')[0] === name); + if (!found) { + return null; + } + const value = found.split('=')[1]; + return value ? decodeURIComponent(value) : null; +} + +export function readConfigFolderIdFromUrl(href: string = window.location.href): string | null { + return readUrlParam(href, CONFIG_FOLDER_PARAM); +} diff --git a/client/src/use/useURLParameters.ts b/client/src/use/useURLParameters.ts index 52bf7fdb..aa71b8fe 100644 --- a/client/src/use/useURLParameters.ts +++ b/client/src/use/useURLParameters.ts @@ -1,5 +1,6 @@ import { ref, Ref, watch } from 'vue'; import { AnnotationId } from 'vue-media-annotator/BaseAnnotation'; +import { CONFIG_FOLDER_PARAM, readConfigFolderIdFromUrl } from './configFolderParam'; export default function useURLParameters( frame: Ref, @@ -43,6 +44,11 @@ export default function useURLParameters( }); } } + // Keep the session config override across frame / track changes + const configFolderId = readConfigFolderIdFromUrl(); + if (configFolderId !== null) { + values.push(`${CONFIG_FOLDER_PARAM}=${encodeURIComponent(configFolderId)}`); + } const currentLocation = window.location.href; if (values.length === 0) { window.history.replaceState(null, '', currentLocation.replace(/\?.*/, '')); // Override history From 9fe60917a8d3863403a162ca09e77b8ab677d368 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 13 Oct 2026 12:29:31 -0400 Subject: [PATCH 03/13] UI to handle Launch configs --- .../components/ConfigurationEditor.vue | 5 + client/dive-common/components/PrevNext.vue | 11 +- client/dive-common/components/Viewer.vue | 36 ++- .../configurationEditors/LaunchConfigs.vue | 209 ++++++++++++++++++ .../views/AnnotationDataBrowser.vue | 23 +- .../views/DIVEMetadata/DIVEMetadataSearch.vue | 16 +- .../platform/web-girder/views/DataBrowser.vue | 26 ++- .../web-girder/views/LaunchButtons.vue | 100 +++++++++ docs/UI-Configuration.md | 9 + 9 files changed, 411 insertions(+), 24 deletions(-) create mode 100644 client/dive-common/components/configurationEditors/LaunchConfigs.vue create mode 100644 client/platform/web-girder/views/LaunchButtons.vue diff --git a/client/dive-common/components/ConfigurationEditor.vue b/client/dive-common/components/ConfigurationEditor.vue index 71dabb23..76081a4a 100644 --- a/client/dive-common/components/ConfigurationEditor.vue +++ b/client/dive-common/components/ConfigurationEditor.vue @@ -8,6 +8,7 @@ import ActionShortcuts from './ActionEditors/ActionShortcuts.vue'; import TrackFilter from './ActionEditors/TrackFilter.vue'; import ConfigurationSettings from './configurationEditors/configurationSettings.vue'; import GeneralConfiguration from './configurationEditors/generalConfiguration.vue'; +import LaunchConfigs from './configurationEditors/LaunchConfigs.vue'; import TimelineSettings from './configurationEditors/TimelineSettings.vue'; import UiSettings from './configurationEditors/uiSettings.vue'; @@ -21,6 +22,7 @@ export default defineComponent({ ActionEditorSettings, ActionShortcuts, TimelineSettings, + LaunchConfigs, }, props: { buttonOptions: { @@ -116,6 +118,9 @@ export default defineComponent({ + + + diff --git a/client/dive-common/components/PrevNext.vue b/client/dive-common/components/PrevNext.vue index fb62f2f4..ab24d576 100644 --- a/client/dive-common/components/PrevNext.vue +++ b/client/dive-common/components/PrevNext.vue @@ -5,6 +5,7 @@ import { import TooltipButton from 'vue-media-annotator/components/TooltipButton.vue'; import { useConfiguration, useHandler } from 'vue-media-annotator/provides'; import { useRouter } from 'vue-router/composables'; +import { readConfigFolderIdFromUrl } from 'vue-media-annotator/use/configFolderParam'; export default defineComponent({ name: 'PrevNext', @@ -48,11 +49,15 @@ export default defineComponent({ const queryStringParams = ref(''); watch(getDiveMetadataRootId, (newval) => { diveMetadataRootId.value = newval; + const params: string[] = []; if (diveMetadataRootId.value) { - queryStringParams.value = `?diveMetadataRootId=${diveMetadataRootId.value}`; - } else { - queryStringParams.value = ''; + params.push(`diveMetadataRootId=${diveMetadataRootId.value}`); } + const configFolderId = readConfigFolderIdFromUrl(); + if (configFolderId) { + params.push(`configFolderId=${encodeURIComponent(configFolderId)}`); + } + queryStringParams.value = params.length ? `?${params.join('&')}` : ''; }, { immediate: true }); return { diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index 99423268..f4c70220 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -61,6 +61,8 @@ import SlicerTaskRunnerVue from 'platform/web-girder/views/SlicerTaskRunner.vue' import useURLParameters from 'vue-media-annotator/use/useURLParameters'; import useUINotifications from 'platform/web-girder/UIControls'; import { getLatestRevision } from 'platform/web-girder/api/annotation.service'; +import { getDiveConfiguration } from 'platform/web-girder/api/dataset.service'; +import { readConfigFolderIdFromUrl } from 'vue-media-annotator/use/configFolderParam'; import { putDiveMetadataLastModified } from 'platform/web-girder/api/divemetadata.service'; import { OverlayPreferences } from 'vue-media-annotator/types'; import { useRoute } from 'vue-router/composables'; @@ -653,6 +655,8 @@ export default defineComponent({ ctx.emit('change-camera', camera); }; + /** Folder id from ?configFolderId= that overrides the hierarchy base config for this session */ + const configFolderOverride: Ref = ref(null); const initializeConfig = (config: { metadata: DatasetMeta & DatasetMetaMutable; diveConfig: DiveConfiguration & {metadata: DatasetMetaMutable}; }) => { const meta = config.metadata; @@ -672,7 +676,10 @@ export default defineComponent({ ); visualMaskManager.load(config.diveConfig.metadata.configuration.visualMasks); - if (config.diveConfig.metadata.configuration.general?.baseConfiguration) { + if (configFolderOverride.value) { + // Session-only override: edits target the override folder, stored base is untouched + configurationManager.setConfigurationId(configFolderOverride.value); + } else if (config.diveConfig.metadata.configuration.general?.baseConfiguration) { configurationManager.setConfigurationId( config.diveConfig.metadata.configuration.general.baseConfiguration, ); @@ -693,6 +700,33 @@ export default defineComponent({ // Close and reset sideBar context.resetActive(); let config = await loadMetadata(datasetId.value); + configFolderOverride.value = readConfigFolderIdFromUrl(); + if (configFolderOverride.value) { + try { + const override = (await getDiveConfiguration(configFolderOverride.value)).data; + if (!override.metadata?.configuration) { + throw new Error('Target folder has no configuration'); + } + // Only overlay the config folder's metadata; prevNext / hierarchy / configOwners + // stay those of the dataset being viewed + config = { + ...config, + diveConfig: { + ...config.diveConfig, + metadata: override.metadata as typeof config.diveConfig.metadata, + }, + }; + } catch (err) { + // eslint-disable-next-line no-console + console.warn('configFolderId override failed, using default configuration', err); + configFolderOverride.value = null; + prompt({ + title: 'Config folder unavailable', + text: 'The requested configuration folder could not be loaded (missing, no configuration, or no access). Using the default configuration.', + positiveButton: 'OK', + }); + } + } if (props.revision > 0) { latestRevisionId.value = props.revision; } else { diff --git a/client/dive-common/components/configurationEditors/LaunchConfigs.vue b/client/dive-common/components/configurationEditors/LaunchConfigs.vue new file mode 100644 index 00000000..dfb52986 --- /dev/null +++ b/client/dive-common/components/configurationEditors/LaunchConfigs.vue @@ -0,0 +1,209 @@ + + + diff --git a/client/platform/web-girder/views/AnnotationDataBrowser.vue b/client/platform/web-girder/views/AnnotationDataBrowser.vue index 43cf698e..6e3b01b7 100644 --- a/client/platform/web-girder/views/AnnotationDataBrowser.vue +++ b/client/platform/web-girder/views/AnnotationDataBrowser.vue @@ -11,11 +11,14 @@ import { getFolder } from 'platform/web-girder/api/girder.service'; import TooltipButton from 'vue-media-annotator/components/TooltipButton.vue'; import { useHandler } from 'vue-media-annotator/provides'; import { useStore } from '../store/types'; +import { useLaunchConfigs } from '../use/useLaunchConfigs'; +import LaunchButtons from './LaunchButtons.vue'; export default defineComponent({ components: { GirderFileManager, TooltipButton, + LaunchButtons, }, props: { datasetId: { @@ -63,6 +66,11 @@ export default defineComponent({ localDiveMetadataRootId.value = getDiveMetadataRootId(); }); + const { launchConfigs, load } = useLaunchConfigs(); + watch(location, (loc) => { + load(loc && loc._modelType === 'folder' ? loc._id : null); + }, { immediate: true }); + const queryParams = computed(() => { const params: Record = {}; if (localDiveMetadataRootId.value !== null && (location.value?._id === (locationStore.location as GirderModel)?._id)) { @@ -80,6 +88,7 @@ export default defineComponent({ itemsPerPageOptions, showDialog, location, + launchConfigs, /* methods */ isAnnotationFolder, setLocation, @@ -139,16 +148,12 @@ export default defineComponent({ > mdi-autorenew - - Launch Annotator - + :dataset-id="item._id" + :launch-configs="launchConfigs" + :extra-query="queryParams" + /> import { - defineComponent, onMounted, ref, Ref, PropType, + defineComponent, onMounted, ref, Ref, PropType, watch, } from 'vue'; import { DIVEMetadataResults, DIVEMetadataFilter, filterDiveMetadata, MetadataResultItem, FilterDisplayConfig, @@ -14,6 +14,8 @@ import { AccessType, getFolder, getFolderAccess } from 'platform/web-girder/api/ import { useGirderRest } from 'platform/web-girder/plugins/girder'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { useRouter } from 'vue-router/composables'; +import { useLaunchConfigs } from 'platform/web-girder/use/useLaunchConfigs'; +import LaunchButtons from 'platform/web-girder/views/LaunchButtons.vue'; import DIVEMetadataFilterVue from './DIVEMetadataFilter.vue'; import DIVEMetadataCloneVue from './DIVEMetadataClone.vue'; import DIVEMetadataEditKey from './DIVEMetadataEditKey.vue'; @@ -22,6 +24,7 @@ import MetadataKeyLabel from './MetadataKeyLabel.vue'; export default defineComponent({ name: 'DIVEMetadataSearch', components: { + LaunchButtons, DIVEMetadataFilterVue, DIVEMetadataCloneVue, DIVEMetadataEditKey, @@ -204,6 +207,9 @@ export default defineComponent({ }); } }; + // All rows use the metadata root's launch configs; resolved once, not per row + const { launchConfigs, load } = useLaunchConfigs(); + watch(() => props.id, (rootId) => load(rootId), { immediate: true }); const openInNewTab = (id: string) => { const route = router.resolve({ name: 'viewer', params: { id }, query: { diveMetadataRootId: props.id } }); window.open(route.href, '_blank'); @@ -221,6 +227,7 @@ export default defineComponent({ }; return { + launchConfigs, totalPages, count, filtered, @@ -292,7 +299,14 @@ export default defineComponent({
{{ item.filename }}
+ import { - computed, defineComponent, ref, onBeforeUnmount, + computed, defineComponent, ref, onBeforeUnmount, watch, } from 'vue'; import { GirderFileManager, getLocationType, GirderModel, @@ -8,13 +8,16 @@ import { import { itemsPerPageOptions } from 'dive-common/constants'; import { clientSettings } from 'dive-common/store/settings'; import { useStore, LocationType } from '../store/types'; +import { useLaunchConfigs } from '../use/useLaunchConfigs'; import Upload from './Upload.vue'; +import LaunchButtons from './LaunchButtons.vue'; import eventBus from '../eventBus'; export default defineComponent({ components: { GirderFileManager, Upload, + LaunchButtons, }, setup() { @@ -62,6 +65,13 @@ export default defineComponent({ && !locationStore.selected.length )); + // Resolve launch configs once per browse location, never per row + const { launchConfigs, load } = useLaunchConfigs(); + watch(() => locationStore.location, (loc) => { + const folder = loc && getLocationType(loc) === 'folder' ? (loc as GirderModel)._id : null; + load(folder); + }, { immediate: true }); + eventBus.$on('refresh-data-browser', handleNotification); onBeforeUnmount(() => { eventBus.$off('refresh-data-browser', handleNotification); @@ -70,6 +80,7 @@ export default defineComponent({ return { fileManager, locationStore, + launchConfigs, getters, shouldShowUpload, uploaderDialog, @@ -140,16 +151,11 @@ export default defineComponent({ > mdi-autorenew - - Launch Annotator - + :dataset-id="item._id" + :launch-configs="launchConfigs" + /> +import { computed, defineComponent, PropType } from 'vue'; +import { LaunchConfig } from 'vue-media-annotator/ConfigurationManager'; +import { splitLaunchConfigs } from 'vue-media-annotator/launchConfigs'; + +export default defineComponent({ + name: 'LaunchButtons', + props: { + datasetId: { type: String, required: true }, + launchConfigs: { type: Array as PropType, default: () => [] }, + /** Extra query params (e.g. diveMetadataRootId) added to every link */ + extraQuery: { type: Object as PropType>, default: () => ({}) }, + xSmall: { type: Boolean, default: true }, + }, + setup(props) { + const parts = computed(() => splitLaunchConfigs(props.launchConfigs)); + const linkFor = (config?: LaunchConfig) => ({ + name: 'viewer', + params: { id: props.datasetId }, + query: config + ? { ...props.extraQuery, configFolderId: config.configurationFolderId } + : { ...props.extraQuery }, + }); + return { parts, linkFor }; + }, +}); + + + diff --git a/docs/UI-Configuration.md b/docs/UI-Configuration.md index 05c57ba3..cad62571 100644 --- a/docs/UI-Configuration.md +++ b/docs/UI-Configuration.md @@ -37,6 +37,15 @@ The UI Configuration JSON includes: See [Data Formats — UI Configuration JSON](DataFormats.md#ui-configuration-json) for the file shape. +## Launch Configs + +Under the **Configuration** cog, **Launch Configs** lets the owner of the base configuration add extra launch buttons that open the viewer with the UI configuration of *another* folder. Each entry has a label, optional tooltip and color, and the id of a folder that already has a UI configuration. + +* Stored on the write-base configuration only (`configuration.launchConfigs`); it is not merged up or down the hierarchy and is included in UI configuration import/export (folder ids are not remapped). +* The Data Browser and Annotation Data Browser resolve the list once per browsed location; the DIVE Metadata browser uses the metadata root's list for every row. Up to three buttons are shown inline, the rest go in an overflow menu. With no entries the single **Launch Annotator** button is shown. +* Launching adds `?configFolderId=` to the viewer URL. This can also be typed or shared directly, e.g. `/viewer/?configFolderId=`. The override applies to that session only (it survives refresh, track/frame changes and Prev/Next); the dataset's stored base configuration is not changed. Config edits made while overridden are saved to the override folder. +* If the folder is missing, has no configuration, or is not accessible, a message is shown and the default configuration is used. + ## UI Settings ![UI Settings](images/Configuration/UISettings/UISettings.png) From 5d9e72375c01317a227167c5efc8fd413ff206b4 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 16 Oct 2026 10:42:57 -0400 Subject: [PATCH 04/13] fix parent folder loading issue --- server/dive_server/crud_dataset.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/server/dive_server/crud_dataset.py b/server/dive_server/crud_dataset.py index 902ba4f4..a588ef3b 100644 --- a/server/dive_server/crud_dataset.py +++ b/server/dive_server/crud_dataset.py @@ -645,10 +645,29 @@ def get_configuration(folder: Folder, user: User): folderParentType = folder.get('parentCollection', False) prev = None next = None - folderParent = Folder().load(str(folderParentId), level=AccessType.READ, user=user, force=True) - childFolders = list( - Folder().childFolders(folderParent, folderParentType, sort=[['lowerName', 1]], user=user) - ) + # Parent may be a user/collection; Folder().load() returns None for those ids. + if folderParentType == 'user': + folderParent = User().load(str(folderParentId), force=True) + elif folderParentType == 'folder': + folderParent = Folder().load( + str(folderParentId), level=AccessType.READ, user=user, force=True + ) + else: + folderParent = None + if folderParentId and folderParentType: + try: + from girder.models.collection import Collection + + folderParent = Collection().load(str(folderParentId), force=True) + except Exception: + folderParent = None + childFolders = [] + if folderParent is not None and folderParentType: + childFolders = list( + Folder().childFolders( + folderParent, folderParentType, sort=[['lowerName', 1]], user=user + ) + ) for index, item in enumerate(childFolders): if item.get('_id') == folder.get('_id'): if index > 0: From 526920d35216c5f7fad5d7927a6fe29f238dd3f4 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 16 Oct 2026 11:05:08 -0400 Subject: [PATCH 05/13] add launch configs for metadata folders --- .../web-girder/use/useLaunchConfigs.ts | 37 ++- .../views/DIVEMetadata/DIVEMetadataFilter.vue | 7 + .../views/DIVEMetadata/DIVEMetadataSearch.vue | 8 +- .../DIVEMetadata/MetadataLaunchConfigs.vue | 257 ++++++++++++++++++ .../web-girder/views/LaunchButtons.vue | 43 ++- docs/UI-Configuration.md | 2 +- server/dive_utils/models.py | 10 + 7 files changed, 352 insertions(+), 12 deletions(-) create mode 100644 client/platform/web-girder/views/DIVEMetadata/MetadataLaunchConfigs.vue diff --git a/client/platform/web-girder/use/useLaunchConfigs.ts b/client/platform/web-girder/use/useLaunchConfigs.ts index 4cb673fa..6ee892ec 100644 --- a/client/platform/web-girder/use/useLaunchConfigs.ts +++ b/client/platform/web-girder/use/useLaunchConfigs.ts @@ -2,17 +2,37 @@ import { ref, Ref } from 'vue'; import { LaunchConfig } from 'vue-media-annotator/ConfigurationManager'; import { normalizeLaunchConfigs } from 'vue-media-annotator/launchConfigs'; import { getDiveConfiguration } from '../api/dataset.service'; +import { getFolder } from '../api/girder.service'; // Cache per browse context (folder id) so list views never fetch per row const cache = new Map>(); -export function resolveLaunchConfigs(folderId: string): Promise { - let pending = cache.get(folderId); +function cacheKey(folderId: string, ownFolderOnly: boolean) { + return ownFolderOnly ? `${folderId}:own` : folderId; +} + +/** + * Resolve launch configs for a browse context. + * By default uses the write-base configuration (Data Browser / Annotation Browser). + * Pass ownFolderOnly to read only this folder's meta.configuration.launchConfigs + * (metadata folder view). + */ +export function resolveLaunchConfigs( + folderId: string, + opts?: { ownFolderOnly?: boolean }, +): Promise { + const ownFolderOnly = !!opts?.ownFolderOnly; + const key = cacheKey(folderId, ownFolderOnly); + let pending = cache.get(key); if (!pending) { - pending = getDiveConfiguration(folderId) - .then((resp) => normalizeLaunchConfigs(resp.data?.launchConfigs)) - .catch(() => [] as LaunchConfig[]); - cache.set(folderId, pending); + pending = ownFolderOnly + ? getFolder(folderId) + .then((resp) => normalizeLaunchConfigs(resp.data?.meta?.configuration?.launchConfigs)) + .catch(() => [] as LaunchConfig[]) + : getDiveConfiguration(folderId) + .then((resp) => normalizeLaunchConfigs(resp.data?.launchConfigs)) + .catch(() => [] as LaunchConfig[]); + cache.set(key, pending); } return pending; } @@ -20,6 +40,7 @@ export function resolveLaunchConfigs(folderId: string): Promise export function invalidateLaunchConfigs(folderId?: string) { if (folderId) { cache.delete(folderId); + cache.delete(cacheKey(folderId, true)); } else { cache.clear(); } @@ -27,8 +48,8 @@ export function invalidateLaunchConfigs(folderId?: string) { export function useLaunchConfigs() { const launchConfigs: Ref = ref([]); - const load = async (id?: string | null) => { - launchConfigs.value = id ? await resolveLaunchConfigs(id) : []; + const load = async (id?: string | null, opts?: { ownFolderOnly?: boolean }) => { + launchConfigs.value = id ? await resolveLaunchConfigs(id, opts) : []; }; return { launchConfigs, load }; } diff --git a/client/platform/web-girder/views/DIVEMetadata/DIVEMetadataFilter.vue b/client/platform/web-girder/views/DIVEMetadata/DIVEMetadataFilter.vue index b0f8f3e0..d0f4c02f 100644 --- a/client/platform/web-girder/views/DIVEMetadata/DIVEMetadataFilter.vue +++ b/client/platform/web-girder/views/DIVEMetadata/DIVEMetadataFilter.vue @@ -15,6 +15,7 @@ import DIVEMetadataSlicerVue from './DIVEMetadataSlicer.vue'; import DIVEMetadataExportVue from './DIVEMetadataExport.vue'; import DIVEMetadataImportVue from './DIVEMetadataImport.vue'; import DIVEMetadataIndexFolderVue from './DIVEMetadataIndexFolder.vue'; +import MetadataLaunchConfigs from './MetadataLaunchConfigs.vue'; export default defineComponent({ name: 'DIVEMetadataFilter', @@ -25,6 +26,7 @@ export default defineComponent({ DIVEMetadataExportVue, DIVEMetadataImportVue, DIVEMetadataIndexFolderVue, + MetadataLaunchConfigs, }, props: { currentPage: { @@ -365,6 +367,11 @@ export default defineComponent({ Edit Filters + diff --git a/client/platform/web-girder/views/DIVEMetadata/DIVEMetadataSearch.vue b/client/platform/web-girder/views/DIVEMetadata/DIVEMetadataSearch.vue index 2a777b9c..48048857 100644 --- a/client/platform/web-girder/views/DIVEMetadata/DIVEMetadataSearch.vue +++ b/client/platform/web-girder/views/DIVEMetadata/DIVEMetadataSearch.vue @@ -207,9 +207,10 @@ export default defineComponent({ }); } }; - // All rows use the metadata root's launch configs; resolved once, not per row + // All rows use this metadata folder's own launch configs (not parent write-base) const { launchConfigs, load } = useLaunchConfigs(); - watch(() => props.id, (rootId) => load(rootId), { immediate: true }); + const loadLaunchConfigs = (rootId?: string) => load(rootId ?? props.id, { ownFolderOnly: true }); + watch(() => props.id, (rootId) => loadLaunchConfigs(rootId), { immediate: true }); const openInNewTab = (id: string) => { const route = router.resolve({ name: 'viewer', params: { id }, query: { diveMetadataRootId: props.id } }); window.open(route.href, '_blank'); @@ -228,6 +229,7 @@ export default defineComponent({ return { launchConfigs, + loadLaunchConfigs, totalPages, count, filtered, @@ -273,6 +275,7 @@ export default defineComponent({ @update:currentPage="changePage($event)" @updateFilters="updateFilter($event)" @filter-data="setFilterData($event)" + @launch-configs-updated="loadLaunchConfigs()" >