+
+
+
+
+
+ Cancel
+
+
+ Save
+
+
+
+
+
+
diff --git a/client/platform/web-girder/views/DataBrowser.vue b/client/platform/web-girder/views/DataBrowser.vue
index 41b2f3f1..3fc97268 100644
--- a/client/platform/web-girder/views/DataBrowser.vue
+++ b/client/platform/web-girder/views/DataBrowser.vue
@@ -1,6 +1,6 @@
+
+
+
+
+ Launch Annotator
+
+ |
+
+ mdi-open-in-new
+
+
+
+
+
+
+
+ {{ config.label }}
+
+ |
+
+ mdi-open-in-new
+
+
+
+
+ {{ config.tooltip }}
+
+
+
+
+
+ mdi-dots-horizontal
+
+
+
+
+
+
+
+ mdi-circle
+
+ {{ config.label }}
+
+
+
+
+ mdi-open-in-new
+
+
+
+
+
+
+
+
+
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/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..c1fc0c48
--- /dev/null
+++ b/client/src/launchConfigs.ts
@@ -0,0 +1,41 @@
+import { LaunchConfig } from './ConfigurationManager';
+import { isHexColorCode } from './utils';
+
+export const MAX_INLINE_LAUNCH_BUTTONS = 3;
+
+/** Default Vuetify primary blue as hex when no color / theme name is set */
+export const DEFAULT_LAUNCH_COLOR = '#1976D2';
+
+export type LaunchConfigRow = LaunchConfig & { error?: string; folderName?: string };
+
+/** Normalize stored theme names (primary, etc.) to a hex for the color picker */
+export function normalizeLaunchColor(color?: string): string {
+ if (!color) {
+ return DEFAULT_LAUNCH_COLOR;
+ }
+ // Accept #RGB, #RRGGBB, or #RRGGBBAA from the picker; drop alpha for button colors
+ if (/^#([0-9A-Fa-f]{8})$/.test(color)) {
+ return color.slice(0, 7);
+ }
+ if (isHexColorCode(color)) {
+ return color;
+ }
+ return DEFAULT_LAUNCH_COLOR;
+}
+
+/** 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
diff --git a/docs/DIVE-Metadata.md b/docs/DIVE-Metadata.md
index e34b90eb..46692e82 100644
--- a/docs/DIVE-Metadata.md
+++ b/docs/DIVE-Metadata.md
@@ -59,6 +59,17 @@ This object controls key visibility, ordering, grouping, and some UI behavior.
- Empty groups are valid in saved metadata and can be shown in the edit UI.
- `display`/`hide` control list visibility, while `filterDisplay`/`filterHide` control filter panel visibility.
+## Launch Configs
+
+Independent of `DIVEMetadataFilter`, the DIVE Metadata browser can show custom **launch buttons** on every dataset row. They open the viewer with the UI configuration of another folder instead of the dataset's default configuration.
+
+- **Storage:** On the metadata folder being viewed only, at `folder.meta.configuration.launchConfigs`. Unlike Data Browser launch configs, this list does *not* come from the hierarchy write-base configuration, is not inherited from parent folders, and does not transfer to child/sibling metadata folders.
+- **Editing:** Folder owners and site admins use the **Launch Configs** (cog) button next to **Edit Filters** in the metadata toolbar. All users of the metadata view can use the buttons.
+- **Options per entry:** `label` (required button text), `configurationFolderId` (required Girder folder id that must already have a UI configuration), `color` (optional hex, default `#1976D2`), and `tooltip` (optional hover text). Entries appear in list order; up to three are shown inline and the rest go in an overflow menu. An empty list shows the default **Launch Annotator** button.
+- **Behavior:** Launching opens `/viewer/?configFolderId=&diveMetadataRootId=` (an open-in-new icon is available per button). The configuration override is session-only and does not change the dataset's stored base configuration; if the target folder is missing, has no configuration, or is inaccessible, the viewer falls back to the default configuration with a warning.
+
+See [Configuration — DIVEMetadata Launch Configs](UI-Configuration.md#divemetadata-launch-configs) for the full option reference, stored JSON shape, and troubleshooting.
+
## Process
A new collection of URL endpoints under `dive_metadata` allows for importing and querying DIVE Metadata.
diff --git a/docs/UI-Configuration.md b/docs/UI-Configuration.md
index 05c57ba3..8a487912 100644
--- a/docs/UI-Configuration.md
+++ b/docs/UI-Configuration.md
@@ -37,6 +37,64 @@ 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 (from the write-base configuration). 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.
+
+### DIVEMetadata Launch Configs
+
+The DIVE Metadata browser (**View Metadata** on a metadata folder) has its own launch config list, separate from the Data Browser / Annotation Data Browser list described above. It is the exception to the write-base rule:
+
+* **Where they are edited:** Folder owners and site admins see a **Launch Configs** (cog) button next to **Edit Filters** in the metadata toolbar. Other users can use the resulting launch buttons but cannot edit the list.
+* **Where they are stored:** On **that metadata folder only**, at `folder.meta.configuration.launchConfigs` — merged into the folder's existing `configuration` so other configuration fields are preserved. They are *not* read from the hierarchy write-base configuration, are *not* inherited from a parent folder, and do *not* carry over to a different (e.g. child or sibling) metadata folder. Each metadata folder that needs custom launch buttons needs its own list. Conversely, a list configured here does not appear in the Data Browser, and a Data Browser list does not appear here.
+* **Scope:** The same list is used for **every dataset row** in that metadata view. It is resolved once per metadata folder, not per dataset. If the list is empty, each row shows the default **Launch Annotator** button instead.
+* **Launching:** Clicking a button opens `/viewer/?configFolderId=&diveMetadataRootId=`. The `diveMetadataRootId` is included on every launch (including the default button) so the viewer stays linked to the metadata collection. The open-in-new icon (`| mdi-open-in-new`) next to a button opens the same link in a new tab. Otherwise the `configFolderId` session override behaves as described above.
+
+#### Options
+
+Each entry in the editor is one launch button:
+
+| Field | Required | Description |
+|---|---|---|
+| `label` | Yes | Text shown on the launch button. Saving is blocked while any label is empty. |
+| `configurationFolderId` (**Config folder id**) | Yes | Girder folder id whose stored UI configuration (`meta.configuration`) is applied for the launched session. On save the folder is validated: it must exist, be accessible to you, and already have a configuration. Its name is shown under the field when valid; otherwise an error (`Folder has no configuration` / `Folder not found or no access`) blocks saving. |
+| `color` (**Color**) | No | Button color, picked from the color swatch or entered as a hex value (e.g. `#1976D2`). New entries default to `#1976D2`; non-hex legacy theme names are normalized to that default when the editor is opened. |
+| `tooltip` (**Tooltip**) | No | Optional hover text for the button (shown as the title on overflow-menu entries). |
+
+Entries are displayed in editor row order (stored as `order`, sorted ascending). Up to three buttons are shown inline on each row; additional entries go into the **⋯** overflow menu. Use **Add** to append an entry and the trash icon to remove one; removing all entries and saving restores the default **Launch Annotator** button.
+
+The stored value on the metadata folder looks like:
+
+```json
+{
+ "configuration": {
+ "launchConfigs": [
+ {
+ "id": "17290000000000",
+ "label": "Review",
+ "tooltip": "Open with the review configuration",
+ "color": "#1976D2",
+ "configurationFolderId": "",
+ "order": 0
+ }
+ ]
+ }
+}
+```
+
+(`id` is generated by the editor and is not user-editable. Folder ids are Girder ids specific to this server, so entries generally need to be re-validated/re-created if the metadata folder is moved to another server.)
+
+#### Troubleshooting
+
+* **Buttons missing in the metadata view:** you are likely viewing a different metadata folder than the one the list was saved on, or the saved list is empty. Launch configs do not resolve through the parent hierarchy or down into child folders.
+* **Launch Configs button missing:** only folder owners (write access) and site admins can edit the list.
+* **Viewer opens with the default configuration:** the target folder was deleted, lost its configuration, or is not accessible to the launching user — a `Config folder unavailable` message is shown and the dataset's default configuration is used.
+
## UI Settings

diff --git a/server/dive_server/crud_dataset.py b/server/dive_server/crud_dataset.py
index ffe5bd18..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:
@@ -678,7 +697,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 6dec160d..e34226ec 100644
--- a/server/dive_utils/models.py
+++ b/server/dive_utils/models.py
@@ -613,6 +613,15 @@ class VisualMask(BaseModel):
style: Optional[CustomStyle]
+class LaunchConfig(BaseModel):
+ id: str
+ label: str
+ tooltip: Optional[str]
+ color: Optional[str]
+ configurationFolderId: str
+ order: Optional[int]
+
+
class DIVEConfiguration(BaseModel):
general: Optional[GeneralSettings]
UISettings: Optional[UISettings]
@@ -622,6 +631,7 @@ class DIVEConfiguration(BaseModel):
timelineConfigs: Optional[List[TimelineConfiguration]]
customUI: Optional[CustomUISettings]
visualMasks: Optional[Dict[str, List[VisualMask]]]
+ launchConfigs: Optional[List[LaunchConfig]]
@validator('timelineConfigs', pre=True)
@classmethod