diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6933eb7b..df0ef443f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,9 @@ jobs: with: node-version: 14.17.1 + - name: Install git + run: sudo apt-add-repository ppa:git-core/ppa && sudo apt-get update && sudo apt-get install git + # Install dependencies in CI mode - name: Install dependencies run: yarn install --frozen-lockfile diff --git a/client/src/components/controls/ReadOnlyRow.tsx b/client/src/components/controls/ReadOnlyRow.tsx index 1f9bed239..f74c4978e 100644 --- a/client/src/components/controls/ReadOnlyRow.tsx +++ b/client/src/components/controls/ReadOnlyRow.tsx @@ -15,19 +15,23 @@ interface ReadOnlyRowProps extends ViewableProps { value?: number | string | null; padding?: number; gridTemplate?: string; + width?: string; } function ReadOnlyRow(props: ReadOnlyRowProps): React.ReactElement { - const { label, value, padding, gridTemplate } = props; + const { label, value, padding, gridTemplate, width } = props; const rowFieldProps = { alignItems: 'center', justifyContent: 'space-between', style: { borderRadius: 0 } }; + if (width) { + rowFieldProps['style']['width'] = width; + } if (gridTemplate) { rowFieldProps['style']['display'] = 'grid'; rowFieldProps['style']['gridTemplateColumns'] = gridTemplate; } return ( - + {value} diff --git a/client/src/components/controls/RepositoryIcon.tsx b/client/src/components/controls/RepositoryIcon.tsx index 5cc918c95..ef9ea1549 100644 --- a/client/src/components/controls/RepositoryIcon.tsx +++ b/client/src/components/controls/RepositoryIcon.tsx @@ -4,47 +4,24 @@ * This component renders the icons for the repository tree view item. */ import React from 'react'; -import { Box, Typography } from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; import { eSystemObjectType } from '../../types/server'; import { getTermForSystemObjectType } from '../../utils/repository'; -const useStyles = makeStyles(({ typography, breakpoints }) => ({ - container: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - height: 18, - width: 18, - borderRadius: 2.5, - backgroundColor: ({ backgroundColor }: RepositoryIconProps) => backgroundColor, - [breakpoints.down('lg')]: { - height: 15, - width: 15, - }, - }, - initial: { - fontSize: 10, - fontWeight: typography.fontWeightMedium, - color: ({ textColor }: RepositoryIconProps) => textColor, - } -})); - export interface RepositoryIconProps { objectType: eSystemObjectType; backgroundColor: string; textColor: string; overrideText?: string | undefined; + makeStyles?: { [key: string]: string }; } export function RepositoryIcon(props: RepositoryIconProps): React.ReactElement { - const { objectType, overrideText } = props; - const classes = useStyles(props); + const { objectType, overrideText, makeStyles } = props; const initial = !overrideText ? getTermForSystemObjectType(objectType).toUpperCase().slice(0, 1) : overrideText; return ( - - {initial} - +
+

{initial}

+
); } diff --git a/client/src/components/shared/AssetIdentifiers.tsx b/client/src/components/shared/AssetIdentifiers.tsx index dda6aedfb..e07e0af72 100644 --- a/client/src/components/shared/AssetIdentifiers.tsx +++ b/client/src/components/shared/AssetIdentifiers.tsx @@ -34,10 +34,12 @@ interface AssetIdentifiersProps { onAddIdentifer: (identifiers: StateIdentifier[]) => void; onUpdateIdentifer: (identifiers: StateIdentifier[]) => void; onRemoveIdentifer: (identifiers: StateIdentifier[]) => void; + subjectView?: boolean; + onUpdateIdIdentifierPreferred?: (id: number) => void; } function AssetIdentifiers(props: AssetIdentifiersProps): React.ReactElement { - const { systemCreated, identifiers, onSystemCreatedChange, onAddIdentifer, onUpdateIdentifer, onRemoveIdentifer } = props; + const { systemCreated, identifiers, onSystemCreatedChange, onAddIdentifer, onUpdateIdentifer, onRemoveIdentifer, subjectView, onUpdateIdIdentifierPreferred } = props; const classes = useStyles(); const [getEntries, getInitialEntry] = useVocabularyStore(state => [state.getEntries, state.getInitialEntry]); @@ -46,8 +48,8 @@ function AssetIdentifiers(props: AssetIdentifiersProps): React.ReactElement { id: identifiers.length + 1, identifier: '', identifierType: getInitialEntry(eVocabularySetID.eIdentifierIdentifierType) || initialEntry, - selected: false, - idIdentifier: 0 + idIdentifier: 0, + preferred: undefined }; const updatedIdentifiers = lodash.concat(identifiers, [newIdentifier]); @@ -101,6 +103,8 @@ function AssetIdentifiers(props: AssetIdentifiersProps): React.ReactElement { onAdd={addIdentifer} onRemove={removeIdentifier} onUpdate={updateIdentifierFields} + subjectView={subjectView} + onUpdateIdIdentifierPreferred={onUpdateIdIdentifierPreferred} /> )}
diff --git a/client/src/components/shared/Header.tsx b/client/src/components/shared/Header.tsx index ad3ea4073..f3490c838 100644 --- a/client/src/components/shared/Header.tsx +++ b/client/src/components/shared/Header.tsx @@ -92,12 +92,11 @@ function Header(): React.ReactElement { const history = useHistory(); const { pathname } = useLocation(); const { user, logout } = useUserStore(); - const [search, keyword, updateSearch, getFilterState, initializeTree, resetRepositoryFilter, updateRepositoryFilter, resetKeywordSearch] = useRepositoryStore(state => [ + const [search, keyword, updateSearch, getFilterState, resetRepositoryFilter, updateRepositoryFilter, resetKeywordSearch] = useRepositoryStore(state => [ state.search, state.keyword, state.updateSearch, state.getFilterState, - state.initializeTree, state.resetRepositoryFilter, state.updateRepositoryFilter, state.resetKeywordSearch @@ -128,13 +127,12 @@ function Header(): React.ReactElement { const updateRepositorySearch = (): void => { const filterState = getFilterState(); filterState.search = filterState.keyword; + resetRepositoryFilter(); updateRepositoryFilter(filterState); - const updatedFilterState = getFilterState(); - const repositoryURL = generateRepositoryUrl(updatedFilterState); + const repositoryURL = generateRepositoryUrl(filterState); const route: string = resolveRoute(HOME_ROUTES.REPOSITORY); - console.log(`*** src/components/shared/Header.tsx Header updateRepositorySearch history.push(${route + repositoryURL}`); + // console.log(`*** src/components/shared/Header.tsx Header updateRepositorySearch history.push(${route + repositoryURL}`); history.push(route + repositoryURL); - initializeTree(); }; // General search function when in different views @@ -144,7 +142,7 @@ function Header(): React.ReactElement { const filterState = getFilterState(); filterState.search = filterState.keyword; updateRepositoryFilter(filterState); - console.log(`*** src/components/shared/Header.tsx Header onSearch history.push(${route}`); + // console.log(`*** src/components/shared/Header.tsx Header onSearch history.push(${route}`); history.push(route); }; diff --git a/client/src/components/shared/IdentifierList.tsx b/client/src/components/shared/IdentifierList.tsx index b210a4980..6d65baee1 100644 --- a/client/src/components/shared/IdentifierList.tsx +++ b/client/src/components/shared/IdentifierList.tsx @@ -5,15 +5,17 @@ * * This component renders identifier list used in photogrammetry metadata component. */ -import { Box, Button, Checkbox, MenuItem, Select, Typography } from '@material-ui/core'; +import { Box, Button, MenuItem, Select, Typography, Radio } from '@material-ui/core'; import { fade, makeStyles } from '@material-ui/core/styles'; import React from 'react'; import { DebounceInput } from 'react-debounce-input'; import { MdRemoveCircleOutline } from 'react-icons/md'; import { StateIdentifier, VocabularyOption } from '../../store'; import { ViewableProps } from '../../types/repository'; -import { sharedButtonProps, sharedLabelProps } from '../../utils/shared'; +import { sharedLabelProps } from '../../utils/shared'; import FieldType from './FieldType'; +import { Progress } from '..'; +import { Colors } from '../../theme'; const useStyles = makeStyles(({ palette, typography, breakpoints }) => ({ identifierInput: { @@ -59,7 +61,15 @@ const useStyles = makeStyles(({ palette, typography, breakpoints }) => ({ fontStyle: 'italic' }, header: sharedLabelProps, - addIdentifierButton: sharedButtonProps + addIdentifierButton: { + height: 35, + width: 80, + fontSize: typography.caption.fontSize, + color: Colors.defaults.white, + [breakpoints.down('lg')]: { + height: 30 + } + } })); interface IdentifierListProps extends ViewableProps { @@ -68,13 +78,26 @@ interface IdentifierListProps extends ViewableProps { onUpdate: (id: number, fieldName: string, fieldValue: number | string | boolean) => void; onRemove: (idIdentifier: number, id: number) => void; identifierTypes: VocabularyOption[]; + subjectView?: boolean; + onUpdateIdIdentifierPreferred?: (id: number) => void; + loading?: boolean; } function IdentifierList(props: IdentifierListProps): React.ReactElement { - const { identifiers, onAdd, onUpdate, identifierTypes, onRemove, viewMode = false, disabled = false } = props; + const { identifiers, onAdd, onUpdate, identifierTypes, onRemove, viewMode = false, disabled = false, subjectView, onUpdateIdIdentifierPreferred, loading } = props; const classes = useStyles(); - const hasIdentifiers: boolean = !!identifiers.length; + + if (loading && subjectView) { + return ( + + + + + + ); + } + return ( @@ -84,14 +107,15 @@ function IdentifierList(props: IdentifierListProps): React.ReactElement { No Identifiers )} - {identifiers.map(({ id, selected, identifier, identifierType, idIdentifier }, index) => { + {identifiers.map(({ id, identifier, identifierType, idIdentifier, preferred }, index) => { const remove = () => onRemove(idIdentifier, id); - const updateCheckbox = ({ target }) => onUpdate(id, target.name, target.checked); const update = ({ target }) => onUpdate(id, target.name, target.value); - + const check = () => { + if (onUpdateIdIdentifierPreferred) onUpdateIdIdentifierPreferred(id); + }; return ( - + {subjectView && } onAdd(identifierTypes[0].idVocabulary)} + style={{ width: 'fit-content' }} disabled={disabled} > - Add + New Identifier
diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index bb7f38875..0e3a0ef0f 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -42,7 +42,8 @@ export const INGESTION_ROUTE = { export enum REPOSITORY_ROUTES_TYPE { VIEW = '', - DETAILS = 'details/:idSystemObject' + DETAILS = 'details/:idSystemObject', + VOYAGER = 'voyager/:idSystemObject' } export const REPOSITORY_ROUTE = { diff --git a/client/src/global/quill.snow.css b/client/src/global/quill.snow.css new file mode 100644 index 000000000..ccf825dfe --- /dev/null +++ b/client/src/global/quill.snow.css @@ -0,0 +1,945 @@ +/*! + * Quill Editor v1.3.7 + * https://quilljs.com/ + * Copyright (c) 2014, Jason Chen + * Copyright (c) 2013, salesforce.com + */ +.ql-container { + box-sizing: border-box; + font-family: Helvetica, Arial, sans-serif; + font-size: 13px; + height: 100%; + margin: 0px; + position: relative; +} +.ql-container.ql-disabled .ql-tooltip { + visibility: hidden; +} +.ql-container.ql-disabled .ql-editor ul[data-checked] > li::before { + pointer-events: none; +} +.ql-clipboard { + left: -100000px; + height: 1px; + overflow-y: hidden; + position: absolute; + top: 50%; +} +.ql-clipboard p { + margin: 0; + padding: 0; +} +.ql-editor { + box-sizing: border-box; + line-height: 1.42; + height: 100%; + outline: none; + overflow-y: auto; + padding: 12px 15px; + tab-size: 4; + -moz-tab-size: 4; + text-align: left; + white-space: pre-wrap; + word-wrap: break-word; +} +.ql-editor > * { + cursor: text; +} +.ql-editor p, +.ql-editor ol, +.ql-editor ul, +.ql-editor pre, +.ql-editor blockquote, +.ql-editor h1, +.ql-editor h2, +.ql-editor h3, +.ql-editor h4, +.ql-editor h5, +.ql-editor h6 { + margin: 0; + padding: 0; + counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9; +} +.ql-editor ol, +.ql-editor ul { + padding-left: 1.5em; +} +.ql-editor ol > li, +.ql-editor ul > li { + list-style-type: none; +} +.ql-editor ul > li::before { + content: '\2022'; +} +.ql-editor ul[data-checked=true], +.ql-editor ul[data-checked=false] { + pointer-events: none; +} +.ql-editor ul[data-checked=true] > li *, +.ql-editor ul[data-checked=false] > li * { + pointer-events: all; +} +.ql-editor ul[data-checked=true] > li::before, +.ql-editor ul[data-checked=false] > li::before { + color: #777; + cursor: pointer; + pointer-events: all; +} +.ql-editor ul[data-checked=true] > li::before { + content: '\2611'; +} +.ql-editor ul[data-checked=false] > li::before { + content: '\2610'; +} +.ql-editor li::before { + display: inline-block; + white-space: nowrap; + width: 1.2em; +} +.ql-editor li:not(.ql-direction-rtl)::before { + margin-left: -1.5em; + margin-right: 0.3em; + text-align: right; +} +.ql-editor li.ql-direction-rtl::before { + margin-left: 0.3em; + margin-right: -1.5em; +} +.ql-editor ol li:not(.ql-direction-rtl), +.ql-editor ul li:not(.ql-direction-rtl) { + padding-left: 1.5em; +} +.ql-editor ol li.ql-direction-rtl, +.ql-editor ul li.ql-direction-rtl { + padding-right: 1.5em; +} +.ql-editor ol li { + counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9; + counter-increment: list-0; +} +.ql-editor ol li:before { + content: counter(list-0, decimal) '. '; +} +.ql-editor ol li.ql-indent-1 { + counter-increment: list-1; +} +.ql-editor ol li.ql-indent-1:before { + content: counter(list-1, lower-alpha) '. '; +} +.ql-editor ol li.ql-indent-1 { + counter-reset: list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9; +} +.ql-editor ol li.ql-indent-2 { + counter-increment: list-2; +} +.ql-editor ol li.ql-indent-2:before { + content: counter(list-2, lower-roman) '. '; +} +.ql-editor ol li.ql-indent-2 { + counter-reset: list-3 list-4 list-5 list-6 list-7 list-8 list-9; +} +.ql-editor ol li.ql-indent-3 { + counter-increment: list-3; +} +.ql-editor ol li.ql-indent-3:before { + content: counter(list-3, decimal) '. '; +} +.ql-editor ol li.ql-indent-3 { + counter-reset: list-4 list-5 list-6 list-7 list-8 list-9; +} +.ql-editor ol li.ql-indent-4 { + counter-increment: list-4; +} +.ql-editor ol li.ql-indent-4:before { + content: counter(list-4, lower-alpha) '. '; +} +.ql-editor ol li.ql-indent-4 { + counter-reset: list-5 list-6 list-7 list-8 list-9; +} +.ql-editor ol li.ql-indent-5 { + counter-increment: list-5; +} +.ql-editor ol li.ql-indent-5:before { + content: counter(list-5, lower-roman) '. '; +} +.ql-editor ol li.ql-indent-5 { + counter-reset: list-6 list-7 list-8 list-9; +} +.ql-editor ol li.ql-indent-6 { + counter-increment: list-6; +} +.ql-editor ol li.ql-indent-6:before { + content: counter(list-6, decimal) '. '; +} +.ql-editor ol li.ql-indent-6 { + counter-reset: list-7 list-8 list-9; +} +.ql-editor ol li.ql-indent-7 { + counter-increment: list-7; +} +.ql-editor ol li.ql-indent-7:before { + content: counter(list-7, lower-alpha) '. '; +} +.ql-editor ol li.ql-indent-7 { + counter-reset: list-8 list-9; +} +.ql-editor ol li.ql-indent-8 { + counter-increment: list-8; +} +.ql-editor ol li.ql-indent-8:before { + content: counter(list-8, lower-roman) '. '; +} +.ql-editor ol li.ql-indent-8 { + counter-reset: list-9; +} +.ql-editor ol li.ql-indent-9 { + counter-increment: list-9; +} +.ql-editor ol li.ql-indent-9:before { + content: counter(list-9, decimal) '. '; +} +.ql-editor .ql-indent-1:not(.ql-direction-rtl) { + padding-left: 3em; +} +.ql-editor li.ql-indent-1:not(.ql-direction-rtl) { + padding-left: 4.5em; +} +.ql-editor .ql-indent-1.ql-direction-rtl.ql-align-right { + padding-right: 3em; +} +.ql-editor li.ql-indent-1.ql-direction-rtl.ql-align-right { + padding-right: 4.5em; +} +.ql-editor .ql-indent-2:not(.ql-direction-rtl) { + padding-left: 6em; +} +.ql-editor li.ql-indent-2:not(.ql-direction-rtl) { + padding-left: 7.5em; +} +.ql-editor .ql-indent-2.ql-direction-rtl.ql-align-right { + padding-right: 6em; +} +.ql-editor li.ql-indent-2.ql-direction-rtl.ql-align-right { + padding-right: 7.5em; +} +.ql-editor .ql-indent-3:not(.ql-direction-rtl) { + padding-left: 9em; +} +.ql-editor li.ql-indent-3:not(.ql-direction-rtl) { + padding-left: 10.5em; +} +.ql-editor .ql-indent-3.ql-direction-rtl.ql-align-right { + padding-right: 9em; +} +.ql-editor li.ql-indent-3.ql-direction-rtl.ql-align-right { + padding-right: 10.5em; +} +.ql-editor .ql-indent-4:not(.ql-direction-rtl) { + padding-left: 12em; +} +.ql-editor li.ql-indent-4:not(.ql-direction-rtl) { + padding-left: 13.5em; +} +.ql-editor .ql-indent-4.ql-direction-rtl.ql-align-right { + padding-right: 12em; +} +.ql-editor li.ql-indent-4.ql-direction-rtl.ql-align-right { + padding-right: 13.5em; +} +.ql-editor .ql-indent-5:not(.ql-direction-rtl) { + padding-left: 15em; +} +.ql-editor li.ql-indent-5:not(.ql-direction-rtl) { + padding-left: 16.5em; +} +.ql-editor .ql-indent-5.ql-direction-rtl.ql-align-right { + padding-right: 15em; +} +.ql-editor li.ql-indent-5.ql-direction-rtl.ql-align-right { + padding-right: 16.5em; +} +.ql-editor .ql-indent-6:not(.ql-direction-rtl) { + padding-left: 18em; +} +.ql-editor li.ql-indent-6:not(.ql-direction-rtl) { + padding-left: 19.5em; +} +.ql-editor .ql-indent-6.ql-direction-rtl.ql-align-right { + padding-right: 18em; +} +.ql-editor li.ql-indent-6.ql-direction-rtl.ql-align-right { + padding-right: 19.5em; +} +.ql-editor .ql-indent-7:not(.ql-direction-rtl) { + padding-left: 21em; +} +.ql-editor li.ql-indent-7:not(.ql-direction-rtl) { + padding-left: 22.5em; +} +.ql-editor .ql-indent-7.ql-direction-rtl.ql-align-right { + padding-right: 21em; +} +.ql-editor li.ql-indent-7.ql-direction-rtl.ql-align-right { + padding-right: 22.5em; +} +.ql-editor .ql-indent-8:not(.ql-direction-rtl) { + padding-left: 24em; +} +.ql-editor li.ql-indent-8:not(.ql-direction-rtl) { + padding-left: 25.5em; +} +.ql-editor .ql-indent-8.ql-direction-rtl.ql-align-right { + padding-right: 24em; +} +.ql-editor li.ql-indent-8.ql-direction-rtl.ql-align-right { + padding-right: 25.5em; +} +.ql-editor .ql-indent-9:not(.ql-direction-rtl) { + padding-left: 27em; +} +.ql-editor li.ql-indent-9:not(.ql-direction-rtl) { + padding-left: 28.5em; +} +.ql-editor .ql-indent-9.ql-direction-rtl.ql-align-right { + padding-right: 27em; +} +.ql-editor li.ql-indent-9.ql-direction-rtl.ql-align-right { + padding-right: 28.5em; +} +.ql-editor .ql-video { + display: block; + max-width: 100%; +} +.ql-editor .ql-video.ql-align-center { + margin: 0 auto; +} +.ql-editor .ql-video.ql-align-right { + margin: 0 0 0 auto; +} +.ql-editor .ql-bg-black { + background-color: #000; +} +.ql-editor .ql-bg-red { + background-color: #e60000; +} +.ql-editor .ql-bg-orange { + background-color: #f90; +} +.ql-editor .ql-bg-yellow { + background-color: #ff0; +} +.ql-editor .ql-bg-green { + background-color: #008a00; +} +.ql-editor .ql-bg-blue { + background-color: #06c; +} +.ql-editor .ql-bg-purple { + background-color: #93f; +} +.ql-editor .ql-color-white { + color: #fff; +} +.ql-editor .ql-color-red { + color: #e60000; +} +.ql-editor .ql-color-orange { + color: #f90; +} +.ql-editor .ql-color-yellow { + color: #ff0; +} +.ql-editor .ql-color-green { + color: #008a00; +} +.ql-editor .ql-color-blue { + color: #06c; +} +.ql-editor .ql-color-purple { + color: #93f; +} +.ql-editor .ql-font-serif { + font-family: Georgia, Times New Roman, serif; +} +.ql-editor .ql-font-monospace { + font-family: Monaco, Courier New, monospace; +} +.ql-editor .ql-size-small { + font-size: 0.75em; +} +.ql-editor .ql-size-large { + font-size: 1.5em; +} +.ql-editor .ql-size-huge { + font-size: 2.5em; +} +.ql-editor .ql-direction-rtl { + direction: rtl; + text-align: inherit; +} +.ql-editor .ql-align-center { + text-align: center; +} +.ql-editor .ql-align-justify { + text-align: justify; +} +.ql-editor .ql-align-right { + text-align: right; +} +.ql-editor.ql-blank::before { + color: rgba(0,0,0,0.6); + content: attr(data-placeholder); + font-style: italic; + left: 15px; + pointer-events: none; + position: absolute; + right: 15px; +} +.ql-snow.ql-toolbar:after, +.ql-snow .ql-toolbar:after { + clear: both; + content: ''; + display: table; +} +.ql-snow.ql-toolbar button, +.ql-snow .ql-toolbar button { + background: none; + border: none; + cursor: pointer; + display: inline-block; + float: left; + height: 24px; + padding: 3px 5px; + width: 28px; +} +.ql-snow.ql-toolbar button svg, +.ql-snow .ql-toolbar button svg { + float: left; + height: 100%; +} +.ql-snow.ql-toolbar button:active:hover, +.ql-snow .ql-toolbar button:active:hover { + outline: none; +} +.ql-snow.ql-toolbar input.ql-image[type=file], +.ql-snow .ql-toolbar input.ql-image[type=file] { + display: none; +} +.ql-snow.ql-toolbar button:hover, +.ql-snow .ql-toolbar button:hover, +.ql-snow.ql-toolbar button:focus, +.ql-snow .ql-toolbar button:focus, +.ql-snow.ql-toolbar button.ql-active, +.ql-snow .ql-toolbar button.ql-active, +.ql-snow.ql-toolbar .ql-picker-label:hover, +.ql-snow .ql-toolbar .ql-picker-label:hover, +.ql-snow.ql-toolbar .ql-picker-label.ql-active, +.ql-snow .ql-toolbar .ql-picker-label.ql-active, +.ql-snow.ql-toolbar .ql-picker-item:hover, +.ql-snow .ql-toolbar .ql-picker-item:hover, +.ql-snow.ql-toolbar .ql-picker-item.ql-selected, +.ql-snow .ql-toolbar .ql-picker-item.ql-selected { + color: #06c; +} +.ql-snow.ql-toolbar button:hover .ql-fill, +.ql-snow .ql-toolbar button:hover .ql-fill, +.ql-snow.ql-toolbar button:focus .ql-fill, +.ql-snow .ql-toolbar button:focus .ql-fill, +.ql-snow.ql-toolbar button.ql-active .ql-fill, +.ql-snow .ql-toolbar button.ql-active .ql-fill, +.ql-snow.ql-toolbar .ql-picker-label:hover .ql-fill, +.ql-snow .ql-toolbar .ql-picker-label:hover .ql-fill, +.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-fill, +.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-fill, +.ql-snow.ql-toolbar .ql-picker-item:hover .ql-fill, +.ql-snow .ql-toolbar .ql-picker-item:hover .ql-fill, +.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-fill, +.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-fill, +.ql-snow.ql-toolbar button:hover .ql-stroke.ql-fill, +.ql-snow .ql-toolbar button:hover .ql-stroke.ql-fill, +.ql-snow.ql-toolbar button:focus .ql-stroke.ql-fill, +.ql-snow .ql-toolbar button:focus .ql-stroke.ql-fill, +.ql-snow.ql-toolbar button.ql-active .ql-stroke.ql-fill, +.ql-snow .ql-toolbar button.ql-active .ql-stroke.ql-fill, +.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill, +.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill, +.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill, +.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill, +.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill, +.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill, +.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill, +.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill { + fill: #06c; +} +.ql-snow.ql-toolbar button:hover .ql-stroke, +.ql-snow .ql-toolbar button:hover .ql-stroke, +.ql-snow.ql-toolbar button:focus .ql-stroke, +.ql-snow .ql-toolbar button:focus .ql-stroke, +.ql-snow.ql-toolbar button.ql-active .ql-stroke, +.ql-snow .ql-toolbar button.ql-active .ql-stroke, +.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke, +.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke, +.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke, +.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke, +.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke, +.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke, +.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke, +.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke, +.ql-snow.ql-toolbar button:hover .ql-stroke-miter, +.ql-snow .ql-toolbar button:hover .ql-stroke-miter, +.ql-snow.ql-toolbar button:focus .ql-stroke-miter, +.ql-snow .ql-toolbar button:focus .ql-stroke-miter, +.ql-snow.ql-toolbar button.ql-active .ql-stroke-miter, +.ql-snow .ql-toolbar button.ql-active .ql-stroke-miter, +.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke-miter, +.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke-miter, +.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter, +.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter, +.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke-miter, +.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke-miter, +.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter, +.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter { + stroke: #06c; +} +@media (pointer: coarse) { + .ql-snow.ql-toolbar button:hover:not(.ql-active), + .ql-snow .ql-toolbar button:hover:not(.ql-active) { + color: #444; + } + .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-fill, + .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-fill, + .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill, + .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill { + fill: #444; + } + .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke, + .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke, + .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter, + .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter { + stroke: #444; + } +} +.ql-snow { + box-sizing: border-box; +} +.ql-snow * { + box-sizing: border-box; +} +.ql-snow .ql-hidden { + display: none; +} +.ql-snow .ql-out-bottom, +.ql-snow .ql-out-top { + visibility: hidden; +} +.ql-snow .ql-tooltip { + position: absolute; + transform: translateY(10px); +} +.ql-snow .ql-tooltip a { + cursor: pointer; + text-decoration: none; +} +.ql-snow .ql-tooltip.ql-flip { + transform: translateY(-10px); +} +.ql-snow .ql-formats { + display: inline-block; + vertical-align: middle; +} +.ql-snow .ql-formats:after { + clear: both; + content: ''; + display: table; +} +.ql-snow .ql-stroke { + fill: none; + stroke: #444; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} +.ql-snow .ql-stroke-miter { + fill: none; + stroke: #444; + stroke-miterlimit: 10; + stroke-width: 2; +} +.ql-snow .ql-fill, +.ql-snow .ql-stroke.ql-fill { + fill: #444; +} +.ql-snow .ql-empty { + fill: none; +} +.ql-snow .ql-even { + fill-rule: evenodd; +} +.ql-snow .ql-thin, +.ql-snow .ql-stroke.ql-thin { + stroke-width: 1; +} +.ql-snow .ql-transparent { + opacity: 0.4; +} +.ql-snow .ql-direction svg:last-child { + display: none; +} +.ql-snow .ql-direction.ql-active svg:last-child { + display: inline; +} +.ql-snow .ql-direction.ql-active svg:first-child { + display: none; +} +.ql-snow .ql-editor h1 { + font-size: 2em; +} +.ql-snow .ql-editor h2 { + font-size: 1.5em; +} +.ql-snow .ql-editor h3 { + font-size: 1.17em; +} +.ql-snow .ql-editor h4 { + font-size: 1em; +} +.ql-snow .ql-editor h5 { + font-size: 0.83em; +} +.ql-snow .ql-editor h6 { + font-size: 0.67em; +} +.ql-snow .ql-editor a { + text-decoration: underline; +} +.ql-snow .ql-editor blockquote { + border-left: 4px solid #ccc; + margin-bottom: 5px; + margin-top: 5px; + padding-left: 16px; +} +.ql-snow .ql-editor code, +.ql-snow .ql-editor pre { + background-color: #f0f0f0; + border-radius: 3px; +} +.ql-snow .ql-editor pre { + white-space: pre-wrap; + margin-bottom: 5px; + margin-top: 5px; + padding: 5px 10px; +} +.ql-snow .ql-editor code { + font-size: 85%; + padding: 2px 4px; +} +.ql-snow .ql-editor pre.ql-syntax { + background-color: #23241f; + color: #f8f8f2; + overflow: visible; +} +.ql-snow .ql-editor img { + max-width: 100%; +} +.ql-snow .ql-picker { + color: #444; + display: inline-block; + float: left; + font-size: 14px; + font-weight: 500; + height: 24px; + position: relative; + vertical-align: middle; +} +.ql-snow .ql-picker-label { + cursor: pointer; + display: inline-block; + height: 100%; + padding-left: 8px; + padding-right: 2px; + position: relative; + width: 100%; +} +.ql-snow .ql-picker-label::before { + display: inline-block; + line-height: 22px; +} +.ql-snow .ql-picker-options { + background-color: #fff; + display: none; + min-width: 100%; + padding: 4px 8px; + position: absolute; + white-space: nowrap; +} +.ql-snow .ql-picker-options .ql-picker-item { + cursor: pointer; + display: block; + padding-bottom: 5px; + padding-top: 5px; +} +.ql-snow .ql-picker.ql-expanded .ql-picker-label { + color: #ccc; + z-index: 2; +} +.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-fill { + fill: #ccc; +} +.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-stroke { + stroke: #ccc; +} +.ql-snow .ql-picker.ql-expanded .ql-picker-options { + display: block; + margin-top: -1px; + top: 100%; + z-index: 1; +} +.ql-snow .ql-color-picker, +.ql-snow .ql-icon-picker { + width: 28px; +} +.ql-snow .ql-color-picker .ql-picker-label, +.ql-snow .ql-icon-picker .ql-picker-label { + padding: 2px 4px; +} +.ql-snow .ql-color-picker .ql-picker-label svg, +.ql-snow .ql-icon-picker .ql-picker-label svg { + right: 4px; +} +.ql-snow .ql-icon-picker .ql-picker-options { + padding: 4px 0px; +} +.ql-snow .ql-icon-picker .ql-picker-item { + height: 24px; + width: 24px; + padding: 2px 4px; +} +.ql-snow .ql-color-picker .ql-picker-options { + padding: 3px 5px; + width: 152px; +} +.ql-snow .ql-color-picker .ql-picker-item { + border: 1px solid transparent; + float: left; + height: 16px; + margin: 2px; + padding: 0px; + width: 16px; +} +.ql-snow .ql-picker:not(.ql-color-picker):not(.ql-icon-picker) svg { + position: absolute; + margin-top: -9px; + right: 0; + top: 50%; + width: 18px; +} +.ql-snow .ql-picker.ql-header .ql-picker-label[data-label]:not([data-label=''])::before, +.ql-snow .ql-picker.ql-font .ql-picker-label[data-label]:not([data-label=''])::before, +.ql-snow .ql-picker.ql-size .ql-picker-label[data-label]:not([data-label=''])::before, +.ql-snow .ql-picker.ql-header .ql-picker-item[data-label]:not([data-label=''])::before, +.ql-snow .ql-picker.ql-font .ql-picker-item[data-label]:not([data-label=''])::before, +.ql-snow .ql-picker.ql-size .ql-picker-item[data-label]:not([data-label=''])::before { + content: attr(data-label); +} +.ql-snow .ql-picker.ql-header { + width: 98px; +} +.ql-snow .ql-picker.ql-header .ql-picker-label::before, +.ql-snow .ql-picker.ql-header .ql-picker-item::before { + content: 'Normal'; +} +.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before, +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before { + content: 'Heading 1'; +} +.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before, +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before { + content: 'Heading 2'; +} +.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before, +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before { + content: 'Heading 3'; +} +.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before, +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before { + content: 'Heading 4'; +} +.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before, +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before { + content: 'Heading 5'; +} +.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before, +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before { + content: 'Heading 6'; +} +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before { + font-size: 2em; +} +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before { + font-size: 1.5em; +} +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before { + font-size: 1.17em; +} +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before { + font-size: 1em; +} +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before { + font-size: 0.83em; +} +.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before { + font-size: 0.67em; +} +.ql-snow .ql-picker.ql-font { + width: 108px; +} +.ql-snow .ql-picker.ql-font .ql-picker-label::before, +.ql-snow .ql-picker.ql-font .ql-picker-item::before { + content: 'Sans Serif'; +} +.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=serif]::before, +.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]::before { + content: 'Serif'; +} +.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=monospace]::before, +.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]::before { + content: 'Monospace'; +} +.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]::before { + font-family: Georgia, Times New Roman, serif; +} +.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]::before { + font-family: Monaco, Courier New, monospace; +} +.ql-snow .ql-picker.ql-size { + width: 98px; +} +.ql-snow .ql-picker.ql-size .ql-picker-label::before, +.ql-snow .ql-picker.ql-size .ql-picker-item::before { + content: 'Normal'; +} +.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=small]::before, +.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]::before { + content: 'Small'; +} +.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=large]::before, +.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]::before { + content: 'Large'; +} +.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=huge]::before, +.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]::before { + content: 'Huge'; +} +.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]::before { + font-size: 10px; +} +.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]::before { + font-size: 18px; +} +.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]::before { + font-size: 32px; +} +.ql-snow .ql-color-picker.ql-background .ql-picker-item { + background-color: #fff; +} +.ql-snow .ql-color-picker.ql-color .ql-picker-item { + background-color: #000; +} +.ql-toolbar.ql-snow { + border: 1px solid #ccc; + box-sizing: border-box; + font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; + padding: 8px; +} +.ql-toolbar.ql-snow .ql-formats { + margin-right: 15px; +} +.ql-toolbar.ql-snow .ql-picker-label { + border: 1px solid transparent; +} +.ql-toolbar.ql-snow .ql-picker-options { + border: 1px solid transparent; + box-shadow: rgba(0,0,0,0.2) 0 2px 8px; +} +.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-label { + border-color: #ccc; +} +.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-options { + border-color: #ccc; +} +.ql-toolbar.ql-snow .ql-color-picker .ql-picker-item.ql-selected, +.ql-toolbar.ql-snow .ql-color-picker .ql-picker-item:hover { + border-color: #000; +} +.ql-toolbar.ql-snow + .ql-container.ql-snow { + border-top: 0px; +} +.ql-snow .ql-tooltip { + background-color: #fff; + border: 1px solid #ccc; + box-shadow: 0px 0px 5px #ddd; + color: #444; + padding: 5px 12px; + white-space: nowrap; +} +.ql-snow .ql-tooltip::before { + content: "Visit URL:"; + line-height: 26px; + margin-right: 8px; +} +.ql-snow .ql-tooltip input[type=text] { + display: none; + border: 1px solid #ccc; + font-size: 13px; + height: 26px; + margin: 0px; + padding: 3px 5px; + width: 170px; +} +.ql-snow .ql-tooltip a.ql-preview { + display: inline-block; + max-width: 200px; + overflow-x: hidden; + text-overflow: ellipsis; + vertical-align: top; +} +.ql-snow .ql-tooltip a.ql-action::after { + border-right: 1px solid #ccc; + content: 'Edit'; + margin-left: 16px; + padding-right: 8px; +} +.ql-snow .ql-tooltip a.ql-remove::before { + content: 'Remove'; + margin-left: 8px; +} +.ql-snow .ql-tooltip a { + line-height: 26px; +} +.ql-snow .ql-tooltip.ql-editing a.ql-preview, +.ql-snow .ql-tooltip.ql-editing a.ql-remove { + display: none; +} +.ql-snow .ql-tooltip.ql-editing input[type=text] { + display: inline-block; +} +.ql-snow .ql-tooltip.ql-editing a.ql-action::after { + border-right: 0px; + content: 'Save'; + padding-right: 0px; +} +.ql-snow .ql-tooltip[data-mode=link]::before { + content: "Enter link:"; +} +.ql-snow .ql-tooltip[data-mode=formula]::before { + content: "Enter formula:"; +} +.ql-snow .ql-tooltip[data-mode=video]::before { + content: "Enter video:"; +} +.ql-snow a { + color: #06c; +} +.ql-container.ql-snow { + border: 1px solid #ccc; +} diff --git a/client/src/global/root.css b/client/src/global/root.css index 8d563be31..0a4850b66 100644 --- a/client/src/global/root.css +++ b/client/src/global/root.css @@ -1,5 +1,5 @@ html { - scroll-behavior: smooth; + scroll-behavior: smooth; } body { @@ -19,4 +19,16 @@ code { min-height: 100vh; width: 100vw; flex-direction: column; -} \ No newline at end of file +} + +/* Chrome, Safari, Edge, Opera */ +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Firefox */ +input[type='number'] { + -moz-appearance: textfield; +} diff --git a/client/src/global/voyager-story.min.css b/client/src/global/voyager-story.min.css new file mode 100644 index 000000000..ef1af77cb --- /dev/null +++ b/client/src/global/voyager-story.min.css @@ -0,0 +1 @@ +/*! normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css */html{-webkit-text-size-adjust:100%;line-height:normal}body{margin:0}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}[hidden],template{display:none}.ff-container{overflow:hidden}.ff-container,.ff-fullsize{bottom:0;box-sizing:border-box;left:0;position:absolute;right:0;top:0}.ff-caret-down{transform:rotate(180deg)}.ff-caret-down,.ff-caret-up{border-bottom:5px solid #a0a0a0;border-left:5px solid transparent;border-right:5px solid transparent;height:0;transition:transform .05s;width:0}.ff-caret-up{transform:rotate(0deg)}.ff-caret-right{transform:rotate(90deg)}.ff-caret-left,.ff-caret-right{border-bottom:5px solid #a0a0a0;border-left:5px solid transparent;border-right:5px solid transparent;height:0;transition:transform .05s;width:0}.ff-caret-left{transform:rotate(270deg)}html{color:#c8c8c8}button,input{background-color:inherit;border:none;color:inherit;cursor:inherit;display:block;font-size:inherit;margin:0;padding:0}:focus{outline:1px solid #0089bf;outline-offset:0}.ff-fullscreen{bottom:0;left:0;position:fixed;right:0;top:0}.ff-noselect,.ff-off{user-select:none;-moz-user-select:none;-webkit-user-select:none}.ff-off{pointer-events:none}.ff-on{pointer-events:auto;user-select:auto;-webkit-user-select:none}.ff-focusable{tab-index:0}.ff-flex-row{display:flex}.ff-flex-column{display:flex;flex-direction:column}.ff-flex-wrap{flex-wrap:wrap}.ff-flex-centered{align-items:center}.ff-flex-item-fixed{flex:0 0 auto;position:relative}.ff-flex-item-stretch,.ff-flex-spacer{flex:1 1 auto;position:relative}.ff-scroll-y{box-sizing:border-box;left:0;overflow-y:auto;right:0;top:0}.ff-position-above,.ff-scroll-y{bottom:0;position:absolute}.ff-position-below{position:absolute;top:100%}.ff-position-left{position:absolute;right:0}.ff-position-right{left:100%;position:absolute}.ff-position-above.ff-align-left,.ff-position-below.ff-align-left{left:0}.ff-position-above.ff-align-right,.ff-position-below.ff-align-right{right:0}.ff-position-left.ff-align-top,.ff-position-right.ff-align-top{top:0}.ff-position-left.ff-align-bottom,.ff-position-right.ff-align-bottom{bottom:0}.ff-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ff-placeholder{align-items:center;display:flex;flex:1 1 auto;height:100%;justify-content:center;width:100%}.ff-splitter-section{box-sizing:border-box;flex:1 1 auto;overflow:hidden;position:relative}.ff-splitter[direction=vertical]+.ff-splitter-section{border-top:2px solid #1b1b1b}.ff-splitter:not([direction=vertical])+.ff-splitter-section{border-left:2px solid #1b1b1b}.ff-frame{padding:4px}.ff-frame .ff-control{margin:4px}.ff-icon{fill:#a0a0a0;display:inline-block;height:1rem;position:relative}.ff-icon svg{height:inherit;width:inherit}.ff-button{cursor:pointer}.ff-button[vertical]{flex-direction:column}.ff-button.ff-inline .ff-icon+.ff-text{padding-left:.5em}.ff-button.ff-inline .ff-icon,.ff-button.ff-inline .ff-text{display:inline}.ff-button.ff-transparent{fill:#a0a0a0;align-items:center;background:transparent;display:flex;flex:0 1 auto;flex-wrap:wrap;justify-content:center;padding:5px;position:relative;transition:fill .2s}.ff-button.ff-transparent:focus,.ff-button.ff-transparent:hover,.ff-button.ff-transparent[selected]{fill:#c8c8c8}.ff-button.ff-transparent>.ff-text{margin:3px;white-space:nowrap}.ff-button.ff-transparent>.ff-icon{height:1.3em;margin:3px}.ff-button.ff-transparent>.ff-caret-down{margin:3px}.ff-button.ff-transparent.ff-disabled{fill:#606060;color:gray;pointer-events:none;user-select:none}.ff-button.ff-control{align-items:center;background:#4e4e4e;display:flex;flex:1 1 auto;flex-wrap:wrap;justify-content:center;padding:5px;position:relative;transition:background-color .2s}.ff-button.ff-control:focus,.ff-button.ff-control:hover{background-color:#676767}.ff-button.ff-control[selected]{background-color:#0089bf}.ff-button.ff-control[selected]:focus,.ff-button.ff-control[selected]:hover{background-color:#00a6e8}.ff-button.ff-control>.ff-text{margin:3px;white-space:nowrap}.ff-button.ff-control>.ff-icon{height:1.3em;margin:3px}.ff-button.ff-control>.ff-caret-down{margin:3px}.ff-button.ff-control.ff-disabled{fill:#606060;color:gray;pointer-events:none;user-select:none}.ff-button.ff-control.ff-disabled>.ff-icon{fill:#606060}.ff-menu{background-color:#4e4e4e;display:flex;flex-direction:column;z-index:100}.ff-menu .ff-divider{background:#a0a0a0;height:1px;padding:0}.ff-menu .ff-button{justify-content:flex-start;margin:0;padding:4px}.ff-menu .ff-button .ff-icon{height:1.2em;margin-right:4px;width:2.2em}.ff-dropdown .ff-menu{margin-top:3px;min-width:100%}.ff-modal-plane{background-color:rgba(0,0,0,.6);bottom:0;left:0;opacity:0;pointer-events:auto;position:fixed;right:0;top:0;transition:opacity .3s;z-index:990}.ff-modal-plane.ff-transition{opacity:1}ff-dialog{background:#343434;border:1px solid #4e4e4e;box-shadow:3px 3px 24px rgba(0,0,0,.4);box-sizing:border-box}ff-title-bar{background:#1b1b1b;padding:2px}ff-title-bar .ff-text{flex:1 1 auto;padding-right:8px}ff-title-bar .ff-icon{padding:2px}ff-popup-button .ff-content{transition:opacity .15s}.ff-line-edit{background-color:#1b1b1b;display:block;min-height:1.2em;overflow:hidden}.ff-line-edit input{padding:2px}.ff-text-edit{background-color:#1b1b1b;box-sizing:border-box;display:flex;flex:1 1 auto}.ff-text-edit textarea{background:transparent;border:none;box-sizing:border-box;color:inherit;display:block;padding:2px;resize:none;width:100%}.ff-color-edit{font-size:.8rem}.ff-color-edit .ff-control{margin:0}.ff-color-edit .ff-slider-controls{align-items:stretch;flex:1 1 auto}.ff-color-edit .ff-numeric-controls{align-items:center;flex:0 0 auto;margin-top:6px}.ff-color-edit .ff-text{color:#a0a0a0;margin:0 3px}.ff-color-edit .ff-line-edit{flex:4 5 auto}.ff-color-edit .ff-line-edit.ff-wide{flex:5 4 auto}.ff-color-edit .ff-knob{border:2px solid #fff;box-shadow:0 0 5px rgba(0,0,0,.5);margin:-2px 0 0 -2px}.ff-color-edit .ff-vector-slider{background-image:linear-gradient(180deg,transparent,#000),linear-gradient(90deg,#fff,red);flex:1 0 auto;padding:0 10px 10px 0}.ff-color-edit .ff-vector-slider .ff-knob{height:10px;width:10px}.ff-color-edit .ff-linear-slider{flex:0 0 24px;margin-left:6px;padding-bottom:10px}.ff-color-edit .ff-linear-slider .ff-knob{height:10px;width:100%}.ff-color-edit .ff-hue-slider{background:linear-gradient(180deg,red,#ff0,lime,cyan,blue,#f0f,red)}.ff-color-edit .ff-alpha-slider{color:#4e4e4e}.ff-quad-splitter .ff-left{border-right:1px solid #343434;box-sizing:border-box}.ff-quad-splitter .ff-top{border-bottom:1px solid #343434;box-sizing:border-box}#ff-notification-stack{bottom:0;max-width:500px;min-width:250px;position:fixed;right:0;width:30%;z-index:100}.ff-notification{background:#4e4e4e;box-shadow:0 0 20px rgba(0,0,0,.35);display:flex;left:0;margin:8px;padding:4px;position:relative}.ff-notification.ff-info>.ff-icon{fill:#73adff}.ff-notification.ff-success>.ff-icon{fill:#8ae65c}.ff-notification.ff-warning>.ff-icon{fill:#e6a345}.ff-notification.ff-error>.ff-icon{fill:#e64545}.ff-notification>.ff-icon{height:2em;padding:8px}.ff-notification.ff-out{left:100%;transition:left .5s ease-in}.ff-notification .ff-text{flex:1;overflow:hidden;padding:8px;text-overflow:ellipsis}.ff-notification .ff-button{flex:0;padding:8px}.ff-message-box{background:#343434;box-shadow:0 0 20px rgba(0,0,0,.35);max-width:450px;min-width:350px;opacity:0;padding:16px;position:relative;transition:opacity .15s;width:90%}.ff-message-box.ff-transition{opacity:1}.ff-message-box .ff-title{align-items:flex-start}.ff-message-box .ff-title .ff-type-icon{height:2.5em;margin:0 16px 0 0}.ff-message-box .ff-title .ff-type-icon[name=prompt]{fill:#e6cf5c}.ff-message-box .ff-title .ff-type-icon[name=info]{fill:#73adff}.ff-message-box .ff-title .ff-type-icon[name=warning]{fill:#e6a345}.ff-message-box .ff-title .ff-type-icon[name=error]{fill:#e64545}.ff-message-box .ff-title .ff-content{flex:1 1 auto;margin:0 0 2em}.ff-message-box .ff-title .ff-caption{font-size:1.5em}.ff-message-box .ff-title .ff-text{margin:2em 0 1em}.ff-message-box .ff-title .ff-line-edit{margin:0 0 1em}.ff-message-box .ff-title .ff-line-edit input{padding:4px}.ff-message-box .ff-title .ff-close-button{margin:0 0 0 16px;padding:0}.ff-message-box .ff-button+.ff-button{margin-left:8px}.ff-popup-options{background:#343434;border:1px solid #4e4e4e;box-shadow:3px 3px 24px rgba(0,0,0,.4);box-sizing:border-box;min-width:6rem}.ff-popup-options button{padding:4px 8px;text-align:start;user-select:none}.ff-popup-options button+button{margin-top:1px}.ff-popup-options button:hover{background-color:#4e4e4e}.ff-popup-options button:focus{background-color:#0089bf;outline:none}.ff-list{background-color:#272727;flex:1 1 auto}.ff-list .ff-list-item{cursor:pointer;padding:2px;user-select:none}.ff-list .ff-list-item:hover{background-color:#343434}.ff-list .ff-list-item[selected]{background-color:#0089bf}.ff-list .ff-list-item+.ff-list-item{border-top:1px solid #343434}.ff-table{position:relative}.ff-table,.ff-table table{box-sizing:border-box;width:100%}.ff-table table{border-collapse:collapse;border-spacing:0;table-layout:fixed}.ff-table tr[selected]{background-color:#0089bf}.ff-table td,.ff-table th{padding:4px}.ff-table th{background-color:#1b1b1b;color:#a0a0a0;text-align:start}.ff-table .ff-table-sort-icon{color:#a0a0a0;height:.8em;margin-left:4px}.ff-tree{overflow-y:auto;position:relative}.ff-tree .ff-tree-node-container{border-bottom:1px solid #3e3e3e;border-top:1px solid #3e3e3e;margin-bottom:-1px;margin-left:-501px}.ff-tree .ff-tree-node{margin-left:500px}.ff-tree .ff-tree-node.ff-inner{background-color:#2f2f2f}.ff-tree .ff-tree-node.ff-leaf{background-color:#3c3c3c}.ff-tree .ff-tree-node.ff-root{background-color:#272727}.ff-tree .ff-tree-node[selected]{background-color:#0089bf}.ff-tree .ff-tree-node .ff-header{cursor:pointer;padding-left:14px;position:relative}.ff-tree .ff-tree-node .ff-header .ff-text{user-select:none}.ff-tree .ff-tree-node.ff-drop-target>.ff-header{outline:1px dashed #0089bf}.ff-tree .ff-tree-node.ff-inner[expanded]>.ff-header:before{transform:rotate(180deg)}.ff-tree .ff-tree-node.ff-inner:not([expanded])>.ff-header:before,.ff-tree .ff-tree-node.ff-inner[expanded]>.ff-header:before{border-bottom:4px solid #a0a0a0;border-left:4px solid transparent;border-right:4px solid transparent;content:"";height:0;left:3px;position:absolute;top:.6em;transition:transform .05s;width:0}.ff-tree .ff-tree-node.ff-inner:not([expanded])>.ff-header:before{transform:rotate(90deg)}.ff-tree .ff-tree-node.ff-leaf .ff-header{cursor:default}.ff-tree .ff-tree-node .ff-content{margin-left:10px}.ff-tree .ff-tree-node:not([expanded])>.ff-content{display:none}ff-tab-container{background:#343434}ff-dock-stack,ff-tab-container{background:#272727;border:1px solid #1b1b1b}ff-dock-stack header,ff-tab-container header{background:#1b1b1b}ff-dock-panel-header,ff-tab-header{background:#1b1b1b;color:#c8c8c8;padding:1px 3px 2px 2px}ff-dock-panel-header[active],ff-tab-header[active]{background:linear-gradient(#626262,#343434);color:#c8c8c8}ff-dock-panel-header label,ff-tab-header label{pointer-events:none}ff-dock-panel-header .ff-text,ff-tab-header .ff-text{padding:0 1px}ff-dock-panel-header .ff-icon,ff-tab-header .ff-icon{height:.8rem;padding:0 1px;top:1px}.ff-dock-drop-marker{background:rgba(0,137,191,.3);border:1px solid #0089bf;box-sizing:border-box}.ff-viewport-overlay{font-size:.75rem;padding:6px}.ff-viewport-overlay .ff-row{display:flex;justify-content:space-between}.ff-viewport-overlay .ff-labels{display:flex;flex-direction:column}.ff-viewport-overlay .ff-bottom-left,.ff-viewport-overlay .ff-top-left{align-items:flex-start}.ff-viewport-overlay .ff-bottom-center,.ff-viewport-overlay .ff-top-center{align-items:center}.ff-viewport-overlay .ff-bottom-right,.ff-viewport-overlay .ff-top-right{align-items:flex-end}.ff-viewport-overlay .ff-label-box{background:rgba(0,0,0,.5);border-radius:2em;box-sizing:border-box;padding:1px 8px 2px}.sv-article h1{color:#e8e8e8;font-family:Amiri,serif;font-size:2.3em;font-weight:400;margin:.3em 0}.sv-article h1:before{background-color:#0089bf;content:"";height:5px;left:0;position:absolute;top:0;width:75px}.sv-article h2,.sv-article h3,.sv-article h4,.sv-article h5,.sv-article h6,.sv-article li,.sv-article ol,.sv-article p,.sv-article ul{color:#c8c8c8;font-family:Hind Siliguri,sans-serif}.sv-article h2,.sv-article h3,.sv-article h4,.sv-article h5,.sv-article h6{font-size:1.3em;margin:1.2em 0 .8em}.sv-article ol,.sv-article p,.sv-article ul{font-size:1.15em;margin:.8em 0}.sv-article p{line-height:1.55em}.sv-article ul{list-style:square inside;padding-left:.5em}.sv-article ol{list-style:decimal inside;padding-left:.5em}.sv-article a:active,.sv-article a:hover,.sv-article a:link,.sv-article a:visited{color:#0089bf}.sv-article img{max-width:100%}.sv-content-view{background-color:#343434;bottom:0;box-sizing:border-box;color:#c8c8c8;font-family:Segoe UI,HelveticaNeue,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:.85rem;font-weight:400;left:0;line-height:1.35;overflow:hidden;position:absolute;right:0;top:0;user-select:none;-moz-user-select:none;-webkit-user-select:none}.sv-logo{align-items:center;display:flex;flex:0 50 180px;flex-wrap:wrap;height:40px;justify-content:flex-end;min-width:40px;overflow:hidden;user-select:none}.sv-logo .sv-short{flex-grow:1;max-width:40px;width:0}.sv-logo .sv-full{display:flex;flex:1 0 180px}.sv-logo .sv-full .sv-sunburst{flex:0 0 14%}.sv-logo .sv-full .sv-smithsonian{flex:0 0 83%;margin-left:3%}@keyframes spin{to{transform:rotate(1turn)}}.sv-spinner{align-items:center;bottom:0;box-sizing:border-box;display:flex;justify-content:center;left:0;pointer-events:none;position:absolute;right:0;top:0}.sv-spinner-wheel{animation:spin 5s linear infinite;height:120px;width:120px}*{scrollbar-color:#676767 #1b1b1b;scrollbar-width:thin}* ::-webkit-scrollbar{width:8px}* ::-webkit-scrollbar-track{background-color:#1b1b1b}* ::-webkit-scrollbar-thumb{background-color:#676767}.sv-content-only .sv-scene-view,.sv-content-stack .sv-scene-view{bottom:0;box-sizing:border-box;left:0;position:absolute;right:0;top:0}.sv-reader-container{overflow-y:auto}.sv-content-stack .sv-reader-container{bottom:0;box-sizing:border-box;left:0;margin-top:52px;position:absolute;right:0;top:0}.sv-content-stack .sv-reader-view{display:flex;justify-content:center;pointer-events:auto}.sv-content-stack .sv-reader-view .sv-left{flex:0 0 52px}.sv-content-stack .sv-reader-view .sv-article{background-color:rgba(27,34,38,.5);flex:0 2 720px;padding-bottom:35px}.sv-content-stack .sv-reader-view .sv-right{background-color:rgba(27,34,38,.5);flex:0 3 16px}.sv-content-split{display:flex}.sv-content-split .sv-reader-container{bottom:0;box-sizing:border-box;left:0;margin-top:52px;position:absolute;right:0;top:0}.sv-content-split .sv-scene-view{flex:1 1 60%}.sv-content-split .sv-reader-view{flex:1 1 40%;padding:0 20px;pointer-events:auto}.sv-content-split .sv-reader-view .sv-left{flex:0 0 52px}.sv-content-split .sv-reader-view .sv-article{background-color:rgba(27,34,38,.5);flex:0 2 720px;padding-bottom:35px;padding-right:16px}.sv-content-split .sv-reader-view .sv-right{background-color:rgba(27,34,38,.5);flex:0 3 16px}.sv-article{padding:5px 8px 0 16px;position:relative}.sv-scene-view{bottom:0;box-sizing:border-box;left:0;overflow:hidden;position:absolute;right:0;top:0}.sv-scene-view.sv-blur{filter:brightness(60%) blur(5px);transition:filter .5s}.sv-annotation{box-sizing:border-box;position:absolute;user-select:none;-moz-user-select:none;-webkit-user-select:none;z-index:1}.sv-annotation-img{max-width:100%}.sv-circle-annotation{background-color:rgba(0,0,0,.7);border-radius:4px;max-width:260px;min-width:160px;padding:4px 8px;width:16%}.sv-circle-annotation.sv-align-right{transform:translateX(-100%)}.sv-circle-annotation.sv-align-bottom{transform:translateY(-100%)}.sv-circle-annotation .sv-title{font-size:.85rem;font-weight:700;padding:0 0 2px}.sv-circle-annotation p{color:#c8c8c8;font-family:Segoe UI,HelveticaNeue,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:.75rem;font-weight:400;line-height:1.35;margin:.5em 0}.sv-circle-annotation .ff-button{background:rgba(0,0,0,.01)}.sv-circle-annotation .ff-button .ff-icon{fill:#a0a0a0}.sv-circle-annotation .ff-button:hover{text-decoration:underline}.sv-circle-annotation .sv-content{overflow:hidden;overflow-wrap:break-word}.sv-pin-annotation{background-color:rgba(0,0,0,.5);color:#c8c8c8;padding:0 4px;transform:translate(-50%,-100%)}.sv-extended-annotation,.sv-standard-annotation{background-color:rgba(0,0,0,.5);color:#c8c8c8;max-width:20%;padding:0 4px;pointer-events:auto}.sv-extended-annotation.sv-expanded,.sv-standard-annotation.sv-expanded{background-color:rgba(0,0,0,.8);min-width:180px;width:20%}.sv-extended-annotation.sv-q0,.sv-standard-annotation.sv-q0{border-bottom-style:solid;border-bottom-width:1px;text-align:left;transform:translateY(-100%)}.sv-extended-annotation.sv-q1,.sv-standard-annotation.sv-q1{border-bottom-style:solid;border-bottom-width:1px;text-align:right;transform:translate(-100%,-100%)}.sv-extended-annotation.sv-q2,.sv-standard-annotation.sv-q2{border-top-style:solid;border-top-width:1px;text-align:right;transform:translate(-100%)}.sv-extended-annotation.sv-q3,.sv-standard-annotation.sv-q3{border-top-style:solid;border-top-width:1px;text-align:left;transform:translate(0)}.sv-extended-annotation.sv-static-width,.sv-standard-annotation.sv-static-width{width:fit-content}.sv-extended-annotation .sv-title,.sv-standard-annotation .sv-title{font-size:.85rem;font-weight:700;padding:1px 0 2px}.sv-extended-annotation .sv-content,.sv-standard-annotation .sv-content{font-size:.8rem;height:0;overflow:hidden;overflow-wrap:break-word;padding:2px 0;transition:height .2s}.sv-extended-annotation p,.sv-standard-annotation p{color:#c8c8c8;font-family:Segoe UI,HelveticaNeue,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:.75rem;font-weight:400;line-height:1.35;margin:.5em 0}.sv-extended-annotation .ff-button,.sv-standard-annotation .ff-button{background:rgba(0,0,0,.01)}.sv-extended-annotation .ff-button .ff-icon,.sv-standard-annotation .ff-button .ff-icon{fill:#a0a0a0}.sv-extended-annotation .ff-button:hover,.sv-standard-annotation .ff-button:hover{text-decoration:underline}.sv-extended-annotation .sv-title{cursor:pointer}.sv-standard-annotation{pointer-events:none}.sv-ar-prompt{align-items:center;background-color:rgba(54,61,64,.6);border-radius:20px;display:flex;flex:1 1 auto;flex-direction:column;margin:30% 0 0;overflow:hidden;text-align:center}.sv-ar-prompt .sv-content{flex:1;margin:10px;text-align:center;-webkit-user-select:none;-ms-user-select:none;user-select:none;width:100%}.sv-ar-prompt .sv-content .sv-ar-icon{height:10em}.sv-ar-menu{bottom:0;display:flex;flex-direction:row;margin:0 0 6px 6px;position:absolute}.sv-ar-menu>.ff-button{background-color:rgba(74,82,87,.5);border-radius:20px;box-sizing:border-box;font-size:17px;height:38px;margin:0 0 2px 2px;pointer-events:auto;transition:all .15s;width:38px}.sv-ar-menu>.ff-button:focus,.sv-ar-menu>.ff-button:hover{fill:#c8c8c8;background-color:rgba(74,82,87,.5);outline:none}.sv-ar-menu>.ff-button[selected]{background-color:#0089bf;color:#f0f0f0}.sv-ar-menu>.ff-button[selected]>.ff-icon{fill:#c8c8c8;filter:drop-shadow(1px 1px 6px #00648c)}.sv-ar-menu>.ff-button[selected]:focus,.sv-ar-menu>.ff-button[selected]:hover{background-color:#00a6e8}.sv-chrome-view{background:linear-gradient(180deg,rgba(0,0,0,.3),rgba(0,0,0,.15) 10%,transparent 25%);bottom:0;box-sizing:border-box;color:#c8c8c8;display:flex;flex-direction:column;font-family:Segoe UI,HelveticaNeue,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:.85rem;font-weight:400;left:0;line-height:1.35;overflow:hidden;position:absolute;right:0;top:0;z-index:1}.sv-chrome-view .ff-button{box-sizing:border-box;flex:0 1 auto}.sv-chrome-view .ff-button:focus,.sv-chrome-view .ff-button:hover{outline:none}.sv-chrome-view .ff-button:focus>.ff-icon,.sv-chrome-view .ff-button:hover>.ff-icon{fill:#c8c8c8}.sv-chrome-view .ff-button[selected]{background-color:#0089bf}.sv-chrome-view .ff-button[selected][disabled]{background-color:#4e4e4e}.sv-chrome-view .ff-button[selected]>.ff-icon{fill:#c8c8c8;filter:drop-shadow(1px 1px 6px #00648c)}.sv-chrome-header,.sv-chrome-header .sv-top-bar{align-items:flex-start;display:flex}.sv-chrome-header .sv-top-bar{flex:1 1 auto;height:40px;margin:9px 9px 0 0;overflow:hidden}.sv-chrome-header .sv-main-title{color:#e8e8e8;flex:1 1 auto;font-family:Hind Siliguri,sans-serif;font-size:1.2rem;font-weight:400;margin:6px 8px;user-select:none;-moz-user-select:none;-webkit-user-select:none;white-space:pre}.sv-chrome-header .sv-main-title span{color:transparent}.sv-chrome-footer{bottom:0;display:block;position:absolute;right:0;z-index:-1}.sv-chrome-footer .sv-bottom-bar{align-items:flex-end;display:flex;flex:1 1 auto;overflow:hidden;text-align:right}.sv-chrome-footer .sv-language-display{background:rgba(31,36,38,.8);border-radius:4px;color:#e8e8e8;flex:1 1 auto;font-family:Hind Siliguri,sans-serif;font-size:1rem;font-weight:400;margin:6px 8px;padding:4px 6px;pointer-events:auto;user-select:none;-moz-user-select:none;-webkit-user-select:none;white-space:pre}.sv-chrome-footer .sv-language-display:focus,.sv-chrome-footer .sv-language-display:hover{fill:#c8c8c8;background-color:rgba(74,82,87,.5);outline:none}.sv-chrome-footer .sv-language-display span{color:transparent}.sv-main-menu{display:flex;flex-direction:column;margin:6px 0 0 6px}.sv-main-menu>.ff-button{background-color:rgba(31,36,38,.8);border-radius:20px;box-sizing:border-box;font-size:17px;height:38px;margin:2px 0;pointer-events:auto;transition:all .15s;width:38px}.sv-main-menu>.ff-button:focus,.sv-main-menu>.ff-button:hover{fill:#c8c8c8;background-color:rgba(74,82,87,.5);outline:none}.sv-main-menu>.ff-button[selected]{background-color:#0089bf;color:#f0f0f0}.sv-main-menu>.ff-button[selected]>.ff-icon{fill:#c8c8c8;filter:drop-shadow(1px 1px 6px #00648c)}.sv-main-menu>.ff-button[selected]:focus,.sv-main-menu>.ff-button[selected]:hover{background-color:#00a6e8}.sv-share-menu{background:#343434;box-shadow:0 0 20px rgba(0,0,0,.35);max-width:85%;padding:8px;pointer-events:auto;position:relative;width:450px}.sv-share-menu .ff-title{margin:.5em 0 1em}.sv-share-menu .ff-button{flex:0 0 auto}.sv-share-menu .ff-text-edit{height:6.5em}.sv-share-menu .sv-share-buttons .ff-button{border-radius:48px;height:48px;margin:0 1em 1em 0;padding:0;width:48px}.sv-share-menu .sv-share-buttons .ff-button .ff-icon{fill:#f0f0f0;height:30px}.sv-share-menu .sv-share-button-twitter{background-color:#00aced}.sv-share-menu .sv-share-button-twitter:hover{background-color:#21c2ff}.sv-share-menu .sv-share-button-facebook{background-color:#3c5a99}.sv-share-menu .sv-share-button-facebook:hover{background-color:#4e71ba}.sv-share-menu .sv-share-button-linkedin{background-color:#0077b5}.sv-share-menu .sv-share-button-linkedin:hover{background-color:#0099e8}.sv-share-menu .sv-share-button-email{background-color:#d28842}.sv-share-menu .sv-share-button-email:hover{background-color:#dca26b}.sv-document-overlay{bottom:0;left:52px;pointer-events:auto;position:absolute;right:6px;top:52px}.sv-reader-view .sv-article,.sv-tour-menu .sv-article{position:relative}.sv-reader-view .sv-article .sv-nav-button,.sv-tour-menu .sv-article .sv-nav-button{float:right;margin:1em .5em;padding:2px}.sv-reader-view .sv-article .sv-nav-button .ff-icon,.sv-tour-menu .sv-article .sv-nav-button .ff-icon{height:1.5em}.sv-reader-view .sv-entry,.sv-tour-menu .sv-entry{background-color:rgba(27,34,38,.5);cursor:pointer;margin-bottom:16px;max-width:960px;padding:5px 0 5px 15px;position:relative}.sv-reader-view .sv-entry:hover,.sv-tour-menu .sv-entry:hover{background-color:rgba(51,59,64,.5)}.sv-language-menu{background:#343434;box-shadow:0 0 20px rgba(0,0,0,.35);height:200px;max-height:85%;max-width:85%;padding:8px;pointer-events:auto;position:relative;width:225px}.sv-language-menu .ff-title{margin:.3em 0}.sv-language-menu .ff-button{flex:0 0 auto}.sv-language-menu .ff-scroll-y{top:50px}.sv-language-menu .sv-entry{background-color:rgba(27,34,38,.5);cursor:pointer;margin-bottom:2px;max-width:960px;padding:5px 0 5px 15px;position:relative;user-select:none;-moz-user-select:none;-webkit-user-select:none}.sv-language-menu .sv-entry:hover{background-color:rgba(51,59,64,.5)}.sv-language-menu .sv-entry[selected]{background-color:#0089bf;color:#f0f0f0}.sv-language-menu .sv-entry[selected]>.ff-icon{fill:#c8c8c8;filter:drop-shadow(1px 1px 6px #00648c)}.sv-language-menu .sv-entry[selected]:focus,.sv-language-menu .sv-entry[selected]:hover{background-color:#00a6e8}.sv-bottom-bar-container{display:flex;justify-content:center;position:relative;transition:transform .3s ease-out,opacity .15s ease-out}.sv-bottom-bar-container.sv-transition{opacity:0;transform:translateY(40px)}.sv-bottom-bar-container .ff-button{transition:all .15s}.sv-blue-bar{background-color:rgba(5,7,8,.85);border-left:1px solid rgba(0,137,191,.5);border-right:1px solid rgba(0,137,191,.5);flex:0 1 960px;margin:0 -1px;min-width:0;pointer-events:auto;user-select:none;-moz-user-select:none;-webkit-user-select:none}.sv-blue-bar,.sv-blue-bar .sv-group{display:flex;flex-direction:column}.sv-blue-bar .sv-section{border-top:1px solid rgba(0,137,191,.5);display:flex;flex-direction:row;position:relative}.sv-blue-bar .sv-section-lead,.sv-blue-bar .sv-section-trail{flex:0 0 auto;padding:6px;width:38px}.sv-blue-bar .sv-section-lead:hover,.sv-blue-bar .sv-section-trail:hover{background-color:rgba(74,82,87,.5)}.sv-blue-bar .sv-section-lead{border-right:1px solid rgba(0,137,191,.5)}.sv-blue-bar .sv-section-trail{border-left:1px solid rgba(0,137,191,.5)}.sv-blue-bar .sv-transparent-button{border-radius:2px;flex:0 1 auto;padding:6px}.sv-blue-bar .sv-transparent-button .ff-text{margin:1px 2px}.sv-blue-bar .sv-transparent-button .ff-icon{height:22px;margin:1px 2px}.sv-tour-navigator{color:#c8c8c8}.sv-tour-navigator .sv-content{flex:1;margin:2px 8px;text-align:center}.sv-tour-navigator .sv-title{font-size:1.1em}.sv-tour-navigator .sv-text{font-size:.9em}.sv-target-navigator .sv-content{flex:1;margin:2px 8px;text-align:center}.sv-target-navigator .sv-title{font-size:1.1em}.sv-tag-cloud .sv-tag-buttons{display:flex;flex:1 1 auto;flex-wrap:wrap;justify-content:center;margin:4px}.sv-tag-cloud .sv-tag-button{background-color:rgba(31,36,38,.8);border-radius:3px;flex:0 1 auto;font-size:.75rem;margin:3px;padding:2px 4px}.sv-tag-cloud .sv-tag-button:hover{background-color:rgba(74,82,87,.5)}.sv-tag-cloud .sv-tag-button[selected]{background-color:#0089bf}.sv-tag-cloud .sv-tag-button .ff-text{margin:1px 2px}.sv-tag-cloud .sv-tag-button .ff-icon{height:22px;margin:1px 2px}.sv-tool-bar{color:#c8c8c8}.sv-tool-bar .sv-tool-buttons{display:flex;flex:1 1 auto;justify-content:center;margin:4px}.sv-tool-bar .sv-tool-button{border-radius:3px;flex:0 1 auto;font-size:.75rem;margin:2px;padding:1px 2px}.sv-tool-bar .sv-tool-button .ff-text{margin:1px 2px}.sv-tool-bar .sv-tool-button .ff-icon{height:22px;margin:1px 2px}.sv-tool-controls{align-items:center;display:flex;flex:1 1 auto;font-size:.75rem;justify-content:center}.sv-tool-controls .sv-property-view{align-self:start;display:flex;flex:0 1 auto;flex-direction:column;margin:4px}.sv-tool-controls .sv-property-view label{height:1.25em}.sv-tool-controls .sv-property-view.sv-nogap{margin:4px 0!important}.sv-tool-controls .sv-property-slider{flex:0 2 180px}.sv-tool-controls .sv-property-slider .ff-linear-slider{background-color:#4e4e4e;border-radius:2px;height:6px;margin:12px 2px;padding-right:16px}.sv-tool-controls .sv-property-slider .ff-linear-slider .ff-knob{background-color:#0089bf;border-radius:2px;box-shadow:0 0 6px #000;height:26px;margin:-10px 0 0;width:16px}.sv-tool-controls .sv-property-color>.ff-button{border:1px solid #343434;box-sizing:border-box;width:28px}.sv-tool-controls .sv-property-color .ff-color-edit{height:180px;position:absolute;right:8px;top:-188px;width:200px}.sv-tool-controls .sv-options{display:flex}.sv-tool-controls .ff-label{margin:2px}.sv-tool-controls .ff-string{flex:0 1 auto;font-size:1rem;height:26px;margin:2px}.sv-tool-controls .ff-button{background:#343434;border-radius:2px;height:26px;margin:2px;padding:0 4px}.sv-environment-tool-view .sv-tool-controls,.sv-light-tool-view .sv-options,.sv-render-tool-view .sv-options,.sv-slice-tool-view .sv-tool-controls,.sv-tape-tool-view .sv-tool-controls,.sv-view-tool-view .sv-options{flex-wrap:wrap}.ff-asset-tree .ff-tree-node.ff-folder{background-color:#1b1b1b}.ff-asset-tree .ff-tree-node.ff-file{background-color:#272727}.ff-asset-tree .ff-tree-node[selected]{background-color:#0089bf}.ff-asset-tree .ff-tree-node .ff-header{align-items:center;display:flex;line-height:1.6em}.ff-asset-tree .ff-tree-node .ff-header .ff-icon{height:1.1em;margin:0 4px 2px 0}.ff-hierarchy-tree-view{bottom:0;box-sizing:border-box;display:flex;flex-direction:column;left:0;position:absolute;right:0;top:0}.ff-hierarchy-tree-view>.ff-header{background-color:#343434;flex:0 0 auto;height:22px;padding:4px}.ff-hierarchy-tree-view>.ff-header .ff-text{align-self:center;color:#a0a0a0;flex:1 1 auto}.ff-hierarchy-tree-view>.ff-header .ff-button{color:#c8c8c8;flex:0 1 32px;padding:0}.ff-hierarchy-tree-view>.ff-header .ff-button+.ff-text{padding-left:8px}.ff-hierarchy-tree .ff-tree-node-container{border-bottom:1px solid #40464d;border-top:1px solid #40464d}.ff-hierarchy-tree .ff-tree-node .ff-header{padding-bottom:1px}.ff-hierarchy-tree .ff-tree-node.ff-node.ff-odd{background-color:#0e1421}.ff-hierarchy-tree .ff-tree-node.ff-node.ff-even{background-color:#131c2e}.ff-hierarchy-tree .ff-tree-node.ff-node>.ff-header .ff-text{color:#b8c8d9}.ff-hierarchy-tree .ff-tree-node.ff-node>.ff-content{margin-left:9px}.ff-hierarchy-tree .ff-tree-node.ff-component{background-color:#24272b}.ff-hierarchy-tree .ff-tree-node.ff-component>.ff-content{margin-left:11px}.ff-hierarchy-tree .ff-tree-node[selected]{background-color:#0089bf!important}.ff-property-tree .ff-tree-node.ff-leaf{background-color:#343434}.ff-property-tree .ff-tree-node.ff-node{background-color:#181818}.ff-property-tree .ff-tree-node.ff-component{background-color:#1d1d1d}.ff-property-tree .ff-tree-node.ff-inputs{background-color:#2e231e}.ff-property-tree .ff-tree-node.ff-inputs>.ff-header .ff-text{color:#d9ccc7}.ff-property-tree .ff-tree-node.ff-inputs .ff-inner{background-color:#382c24}.ff-property-tree .ff-tree-node.ff-inputs .ff-inner>.ff-header .ff-text{color:#d9ccc7}.ff-property-tree .ff-tree-node.ff-outputs{background-color:#1e292e}.ff-property-tree .ff-tree-node.ff-outputs>.ff-header .ff-text{color:#c7d4d9}.ff-property-tree .ff-tree-node.ff-outputs .ff-inner{background-color:#243338}.ff-property-tree .ff-tree-node.ff-outputs .ff-inner>.ff-header .ff-text{color:#c7d4d9}.ff-property-tree .ff-tree-node .ff-header{display:flex}.ff-property-tree .ff-tree-node .ff-header>.ff-label{padding-bottom:1px}.ff-property-tree .ff-tree-node .ff-header>.ff-property-label{flex:0 0 27%;padding-bottom:1px}.ff-property-tree .ff-tree-node .ff-header>.ff-property-view{flex:1 1 auto}.ff-property-view{display:flex}.ff-property-view .ff-label{color:#a0a0a0;padding:0 2px;user-select:none}.ff-property-view .ff-property-field{flex:1 1 100%;margin:2px}.ff-property-view .ff-edit-button{display:flex;flex:0 0 18px;padding:1px 2px}.ff-property-view .ff-edit-button .ff-popup{background-color:#343434;box-shadow:0 0 12px #000;margin-top:2px;padding:4px}.ff-property-view .ff-edit-button .ff-color-edit{height:200px;width:200px}@keyframes ff-event-flash{0%{background-color:#cf3}to{background-color:auto}}.ff-property-field{background-color:#272727;cursor:pointer;overflow:hidden;position:relative}.ff-property-field:focus{outline:none}.ff-property-field:hover .ff-event-button{background-color:#676767}.ff-property-field .ff-bar{background-color:#484848;margin:2px 0;z-index:0}.ff-property-field .ff-content{line-height:1.1;padding:0 2px;text-align:end}.ff-property-field .ff-event-button{background-color:#4e4e4e;bottom:1px;box-sizing:border-box;max-width:100px;min-width:30px;position:absolute;right:1px;top:1px;width:33%;z-index:1}.ff-property-field .ff-event-button.ff-event-flash{animation-duration:.7s;animation-name:ff-event-flash}.ff-property-field.ff-input .ff-event-button{border-color:#676767 #1b1b1b #1b1b1b #676767;border-style:solid;border-width:1px}.ff-property-field.ff-input.ff-linked{color:#e6b673;pointer-events:none}.ff-property-field.ff-output{pointer-events:none}.ff-property-field.ff-output.ff-linked{color:#73bfe6;pointer-events:none}.ff-property-field .ff-line-edit{z-index:2}.ff-list .ff-icon{height:1em;margin-right:4px;width:1.2em}.sv-annotation{pointer-events:auto!important}voyager-story{bottom:0;box-sizing:border-box;font-family:Segoe UI,HelveticaNeue,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:.85rem;font-weight:400;left:0;line-height:1.35;position:absolute;right:0;top:0}ff-dock-view{flex:1 1 auto}.sv-task-bar{background-color:#272727;border-bottom:3px solid #1b1b1b;display:flex}.sv-task-bar .ff-group{align-items:stretch}.sv-task-bar .sv-story-logo{align-self:center;height:28px;margin:8px}.sv-task-bar .sv-mode{align-self:center;background-color:#e6b900;color:#272727;font-size:16px;font-weight:700;margin:4px;padding:0 4px 2px}.sv-task-bar .sv-spacer{flex:1 1 auto}.sv-task-bar .sv-divider{background-color:#4e4e4e;width:1px}.sv-task-bar .ff-button{background-color:transparent;font-size:13px;padding:4px 10px}.sv-task-bar .ff-button .ff-icon{fill:#a0a0a0;height:20px}.sv-task-bar .ff-button[selected]{background-color:#0089bf;color:#e8e8e8}.sv-task-bar .ff-button[selected] .ff-icon{fill:#e8e8e8}.sv-node-tree .ff-tree-node-container{border-color:#484848}.sv-node-tree .ff-tree-node.ff-inner,.sv-node-tree .ff-tree-node.ff-leaf{background-color:#222;border-left:1px solid #4e4e4e}.sv-node-tree .ff-tree-node.sv-node-scene{background-color:#202329;border-left:1px solid #334b80}.sv-node-tree .ff-tree-node.sv-node-model{background-color:#1c2423;border-left:1px solid #26806c}.sv-node-tree .ff-tree-node.sv-node-camera{background-color:#2e2424;border-left:1px solid #802626}.sv-node-tree .ff-tree-node.sv-node-light{background-color:#2b2922;border-left:1px solid #806e33}.sv-node-tree .ff-tree-node[selected]{background-color:#0089bf}.sv-node-tree .ff-tree-node .ff-header{display:flex;line-height:1.6em}.sv-node-tree .ff-tree-node .ff-header .ff-icon{height:1.1em;margin:2px 4px 0 0}.sv-node-tree .ff-tree-node .ff-header .sv-icon-scene{fill:#5278cc}.sv-node-tree .ff-tree-node .ff-header .sv-icon-model{fill:#3dccab}.sv-node-tree .ff-tree-node .ff-header .sv-icon-light{fill:#ccad52}.sv-node-tree .ff-tree-node .ff-header .sv-icon-camera{fill:#cc3d3d}.sv-node-tree .ff-tree-node .ff-header .sv-icon-meta{fill:#d9d998}.sv-node-tree .ff-tree-node .ff-content{margin-left:14px}.sv-detail-view{padding:6px}.sv-detail-view .sv-indent{margin-left:15px}.sv-collection-panel{padding:6px}.sv-collection-panel .sv-indent{margin-left:15px}.sv-tour-feature-menu{align-content:flex-start;align-items:flex-start;display:flex;flex-wrap:wrap;justify-content:center;padding:12px}.sv-tour-feature-menu .ff-button{background-color:#343434;flex-grow:0;margin:4px;padding:6px 16px}.sv-notes-panel .ff-line-edit,.sv-notes-panel .ff-text-edit{margin:2px 0}.sv-panel{display:flex;flex-direction:column}.sv-panel-header{align-items:center;background-color:#343434;box-sizing:border-box;color:#a0a0a0;display:flex;flex:0 0 auto;flex-wrap:wrap;padding:3px 1px}.sv-panel-header .ff-button{background-color:transparent;flex:0 0 auto;margin:0 4px 0 0;padding:2px 6px}.sv-panel-header>.ff-icon{height:1.2em;padding:4px}.sv-panel-content{flex:1 1 auto;position:relative}.sv-panel-content .sv-commands{display:flex;flex:0 0 auto;flex-wrap:wrap;padding:2px}.sv-panel-content .sv-commands .ff-button{margin:2px}.sv-panel-section{display:flex;flex:1 1 50%;flex-direction:column}.sv-panel-section:first-child{border-bottom:2px solid #1b1b1b}.sv-task-view{bottom:0;box-sizing:border-box;display:flex;flex-direction:column;left:0;position:absolute;right:0;top:0}.sv-task-view .sv-placeholder{margin:12px;text-align:center}.sv-task-view .ff-list{box-sizing:border-box}.sv-task-view .sv-label{color:#a0a0a0;margin:8px 0 4px}.sv-task-view .sv-label-right{color:#a0a0a0;margin:4px 0;text-align:end}.sv-task-view .sv-image{margin:4px 0}.sv-task-view .sv-image img{height:auto;width:100%}.sv-task-item{flex-basis:50%;overflow:hidden;padding-left:3px;text-overflow:ellipsis;white-space:nowrap}.sv-item-border-l{border-left:solid;border-left-color:#343434;border-left-width:2px}.sv-property-view{display:flex;flex:0 0 auto;margin:2px 0}.sv-property-view .sv-property-name{color:#a0a0a0;flex:1 1 25%;padding-top:4px}.sv-property-view .sv-property-value{flex:1 1 75%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sv-property-view .sv-field-row{margin:4px 0}.sv-property-view .sv-field-label{color:#a0a0a0;flex:0 0 auto;margin:0 8px 0 0}.sv-property-view .ff-property-field{background-color:#1b1b1b;flex:1 1 auto;height:1em;padding:2px}.sv-property-view .ff-property-field input{background-color:#1b1b1b;padding:2px}.sv-article-editor{bottom:0;box-sizing:border-box;display:flex;flex-direction:column;left:0;position:absolute;right:0;top:0}.sv-article-editor .sv-container{overflow:hidden}.sv-article-editor .sv-custom-buttons{border-right:1px solid #a0a0a0;float:left;padding-right:1em}.sv-article-editor .sv-custom-buttons .ff-button.ff-transparent{float:left;padding:0 8px}.sv-article-editor .ql-container,.sv-article-editor .ql-editor,.sv-article-editor .ql-toolbar{border:none;font-family:Segoe UI,HelveticaNeue,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:1em;font-weight:400}.sv-article-editor .ql-container:focus,.sv-article-editor .ql-editor:focus,.sv-article-editor .ql-toolbar:focus{outline:none}.sv-article-editor .ql-editor:before{color:#a0a0a0;font-size:1.2em;text-align:center}.sv-article-editor .ql-toolbar{background:#343434}.sv-article-editor .ql-toolbar .ql-picker-label{color:#a0a0a0}.sv-article-editor .ql-toolbar .ql-stroke{stroke:#a0a0a0}.sv-article-editor .ql-toolbar .ql-fill{fill:#a0a0a0}.sv-article-editor .ql-toolbar .ql-active,.sv-article-editor .ql-toolbar :hover{color:#c8c8c8!important}.sv-article-editor .ql-toolbar .ql-active .ql-stroke,.sv-article-editor .ql-toolbar :hover .ql-stroke{stroke:#c8c8c8!important}.sv-article-editor .ql-toolbar .ql-active .ql-fill,.sv-article-editor .ql-toolbar :hover .ql-fill{fill:#c8c8c8!important}.sv-article-editor .sv-overlay{background-color:rgba(39,39,39,.5);bottom:0;box-sizing:border-box;left:0;position:absolute;right:0;top:0} \ No newline at end of file diff --git a/client/src/index.tsx b/client/src/index.tsx index ac274fcf4..16c57ed56 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -21,7 +21,6 @@ import { Home, Login } from './pages'; import * as serviceWorker from './serviceWorker'; import { useUserStore, useVocabularyStore, useLicenseStore, useUsersStore } from './store'; import theme from './theme'; -import { getHeaderTitle } from './utils/shared'; function AppRouter(): React.ReactElement { const [loading, setLoading] = useState(true); @@ -71,7 +70,7 @@ function App(): React.ReactElement { - {getHeaderTitle()} + Packrat ({ container: { @@ -156,6 +157,9 @@ function AddProjectForm(): React.ReactElement { return ( + + Create Project + {toTitleCase(singularSystemObjectType)} Name diff --git a/client/src/pages/Admin/components/AddUnitForm.tsx b/client/src/pages/Admin/components/AddUnitForm.tsx index ccffa9ac9..97bb1ae9d 100644 --- a/client/src/pages/Admin/components/AddUnitForm.tsx +++ b/client/src/pages/Admin/components/AddUnitForm.tsx @@ -11,6 +11,7 @@ import { CreateUnitDocument } from '../../../types/graphql'; import { apolloClient } from '../../../graphql/index'; import { toTitleCase } from '../../../constants/helperfunctions'; import * as yup from 'yup'; +import { Helmet } from 'react-helmet'; const useStyles = makeStyles(({ palette, breakpoints }) => ({ container: { @@ -158,6 +159,9 @@ function AddUnitForm(): React.ReactElement { return ( + + Create Unit + {toTitleCase(singularSystemObjectType)} Name diff --git a/client/src/pages/Admin/components/AdminProjectsView.tsx b/client/src/pages/Admin/components/AdminProjectsView.tsx index c52f66aa5..6ecb92f86 100644 --- a/client/src/pages/Admin/components/AdminProjectsView.tsx +++ b/client/src/pages/Admin/components/AdminProjectsView.tsx @@ -13,6 +13,7 @@ import { useLocation } from 'react-router'; import { GetProjectListDocument, GetProjectListResult } from '../../../types/graphql'; import { apolloClient } from '../../../graphql/index'; import GenericBreadcrumbsView from '../../../components/shared/GenericBreadcrumbsView'; +import { Helmet } from 'react-helmet'; const useStyles = makeStyles({ AdminListContainer: { @@ -216,6 +217,9 @@ function AdminProjectsView(): React.ReactElement { return ( + + Projects Admin + diff --git a/client/src/pages/Admin/components/AdminUnitsView.tsx b/client/src/pages/Admin/components/AdminUnitsView.tsx index ef7c6d1c0..d9f6ab5d6 100644 --- a/client/src/pages/Admin/components/AdminUnitsView.tsx +++ b/client/src/pages/Admin/components/AdminUnitsView.tsx @@ -13,6 +13,7 @@ import { useLocation } from 'react-router'; import { GetUnitsFromNameSearchDocument, GetUnitsFromNameSearchResult } from '../../../types/graphql'; import { apolloClient } from '../../../graphql/index'; import GenericBreadcrumbsView from '../../../components/shared/GenericBreadcrumbsView'; +import { Helmet } from 'react-helmet'; const useStyles = makeStyles({ AdminListContainer: { @@ -222,6 +223,9 @@ function AdminUnitsView(): React.ReactElement { return ( + + Units Admin + diff --git a/client/src/pages/Admin/components/AdminUserForm.tsx b/client/src/pages/Admin/components/AdminUserForm.tsx index bd1a96d48..e44db6b45 100644 --- a/client/src/pages/Admin/components/AdminUserForm.tsx +++ b/client/src/pages/Admin/components/AdminUserForm.tsx @@ -22,6 +22,7 @@ import GenericBreadcrumbsView from '../../../components/shared/GenericBreadcrumb import { toast } from 'react-toastify'; import * as yup from 'yup'; import { useUsersStore } from '../../../store'; +import { Helmet } from 'react-helmet'; const useStyles = makeStyles({ AdminUsersViewContainer: { @@ -216,6 +217,9 @@ function AdminUserForm(): React.ReactElement { return ( + + Create User + `} /> diff --git a/client/src/pages/Admin/components/AdminUsersView.tsx b/client/src/pages/Admin/components/AdminUsersView.tsx index 70fdbac72..5c20278b5 100644 --- a/client/src/pages/Admin/components/AdminUsersView.tsx +++ b/client/src/pages/Admin/components/AdminUsersView.tsx @@ -10,6 +10,7 @@ import { GetAllUsersDocument, User_Status } from '../../../types/graphql'; import { GetAllUsersResult } from '../../../types/graphql'; import { apolloClient } from '../../../graphql/index'; import GenericBreadcrumbsView from '../../../components/shared/GenericBreadcrumbsView'; +import { Helmet } from 'react-helmet'; const useStyles = makeStyles({ AdminUsersViewContainer: { @@ -82,6 +83,9 @@ function AdminUsersView(): React.ReactElement { return ( + + Users Admin + diff --git a/client/src/pages/Admin/components/License/LicenseForm.tsx b/client/src/pages/Admin/components/License/LicenseForm.tsx index 81745cf83..e92220c29 100644 --- a/client/src/pages/Admin/components/License/LicenseForm.tsx +++ b/client/src/pages/Admin/components/License/LicenseForm.tsx @@ -14,6 +14,7 @@ import { createLicense, updateLicense } from '../../hooks/useAdminview'; import { toTitleCase } from '../../../../constants/helperfunctions'; import * as yup from 'yup'; import { useLicenseStore } from '../../../../store'; +import { Helmet } from 'react-helmet'; const useStyles = makeStyles(({ breakpoints, typography }) => ({ container: { @@ -207,6 +208,9 @@ function LicenseForm(): React.ReactElement { return ( + + Create License + {toTitleCase(singularSystemObjectType)} Name diff --git a/client/src/pages/Admin/components/License/LicenseView.tsx b/client/src/pages/Admin/components/License/LicenseView.tsx index ffe76d781..ae6bd0f67 100644 --- a/client/src/pages/Admin/components/License/LicenseView.tsx +++ b/client/src/pages/Admin/components/License/LicenseView.tsx @@ -14,6 +14,7 @@ import { GetLicenseListDocument, License } from '../../../../types/graphql'; import { apolloClient } from '../../../../graphql/index'; import GenericBreadcrumbsView from '../../../../components/shared/GenericBreadcrumbsView'; import { useLicenseStore } from '../../../../store'; +import { Helmet } from 'react-helmet'; const useStyles = makeStyles({ AdminListContainer: { @@ -200,6 +201,9 @@ function LicenseView(): React.ReactElement { return ( + + Licenses Admin + diff --git a/client/src/pages/Admin/components/Subject/SubjectForm.tsx b/client/src/pages/Admin/components/Subject/SubjectForm.tsx index f83f852fd..7268e3287 100644 --- a/client/src/pages/Admin/components/Subject/SubjectForm.tsx +++ b/client/src/pages/Admin/components/Subject/SubjectForm.tsx @@ -14,6 +14,7 @@ import { eVocabularySetID } from '../../../../types/server'; import AssetIdentifiers from '../../../../components/shared/AssetIdentifiers'; import { useHistory } from 'react-router-dom'; import { CreateSubjectWithIdentifiersInput } from '../../../../types/graphql'; +import { Helmet } from 'react-helmet'; const useStyles = makeStyles(({ palette }) => ({ container: { @@ -48,35 +49,6 @@ const useStyles = makeStyles(({ palette }) => ({ })); /* - Create state for: - xName - xUnit - xIdentifiers (gotta fetch the correct ones) - xLatitude - xLongitude - xAltitude - xRotation Origin - xRotation Quaternion - - Component workflow - xWhen component is mounted, fetch unit list for the drop down - xWhen fields are filled out, update the UI to indicate change - xIf a subject is searched, - xHold on to the results - xIf the subject we want to use is in there, add it - xAdding will fill out the state for Name, Unit, and Identifiers - - Methods/Functions - xUpdating state - xSelecting the correct unit - xCreating the geolocation, identifiers, and the subject - xRevising the searchIngestionSubject - - Validation: - xName - xUnit - xIdentifier - Creating Identifiers: Pop the identifiers into the createIdentifiers mutation and get them back Find the one that's preferred and that'll be the idIdentifierPreferred for subject @@ -119,7 +91,7 @@ function SubjectForm(): React.ReactElement { const [validName, setValidName] = useState(true); const [validUnit, setValidUnit] = useState(true); - const subjects = useSubjectStore(state => state.subjects); + const [subjects, reset] = useSubjectStore(state => [state.subjects, state.reset]); const [getEntries] = useVocabularyStore(state => [state.getEntries]); const schema = yup.object().shape({ @@ -167,7 +139,6 @@ function SubjectForm(): React.ReactElement { id: newIdentifiers.length, identifier: arkId || '', identifierType: getEntries(eVocabularySetID.eIdentifierIdentifierType)[0].idVocabulary, - selected: true, idIdentifier: 0 }); } @@ -177,7 +148,6 @@ function SubjectForm(): React.ReactElement { id: newIdentifiers.length, identifier: collectionId || '', identifierType: getEntries(eVocabularySetID.eIdentifierIdentifierType)[2].idVocabulary, - selected: true, idIdentifier: 0 }); } @@ -193,6 +163,22 @@ function SubjectForm(): React.ReactElement { setSubjectIdentifiers(identifiers); }; + const onIdentifierPreferredChange = (id: number) => { + const subjectIdentifiersCopy = subjectIdentifiers.map(identifier => { + if (id === identifier.id) { + if (identifier.preferred) { + identifier.preferred = undefined; + } else { + identifier.preferred = true; + } + return identifier; + } + identifier.preferred = undefined; + return identifier; + }); + setSubjectIdentifiers(subjectIdentifiersCopy); + }; + const validateFields = async (): Promise => { toast.dismiss(); try { @@ -252,8 +238,8 @@ function SubjectForm(): React.ReactElement { } } - const identifierList = subjectIdentifiers.map(({ identifier, identifierType, selected }) => { - return { identifierValue: identifier, identifierType: identifierType || getEntries(eVocabularySetID.eIdentifierIdentifierType)[0].idVocabulary, selected }; + const identifierList = subjectIdentifiers.map(({ identifier, identifierType, preferred }) => { + return { identifierValue: identifier, identifierType: identifierType || getEntries(eVocabularySetID.eIdentifierIdentifierType)[0].idVocabulary, preferred }; }); const createSubjectWithIdentifiersInput: CreateSubjectWithIdentifiersInput = { @@ -261,6 +247,7 @@ function SubjectForm(): React.ReactElement { subject: { idUnit: subjectUnit, Name: subjectName, idGeoLocation }, systemCreated }; + console.log('createSubjectsWithIdentifiersInput', createSubjectWithIdentifiersInput); const { data: { @@ -269,6 +256,7 @@ function SubjectForm(): React.ReactElement { } = await createSubjectWithIdentifiers(createSubjectWithIdentifiersInput); if (success) { toast.success('Subject Successfully Created!'); + reset(); history.push('/admin/subjects'); } else { toast.error(`Error: Failure To Create Subject - ${message}`); @@ -280,6 +268,9 @@ function SubjectForm(): React.ReactElement { return ( + + Create Subject + @@ -316,6 +307,8 @@ function SubjectForm(): React.ReactElement { onAddIdentifer={onIdentifierChange} onUpdateIdentifer={onIdentifierChange} onRemoveIdentifer={onIdentifierChange} + onUpdateIdIdentifierPreferred={onIdentifierPreferredChange} + subjectView /> + + + )} + {objectType === eSystemObjectType.eScene && rootLink.length > 0 && documentLink.length > 0 && eMode !== eVoyagerStoryMode.eViewer && ( + )} ); diff --git a/client/src/pages/Repository/components/DetailsView/ObjectDetails.tsx b/client/src/pages/Repository/components/DetailsView/ObjectDetails.tsx index f66a33a22..6ad58598c 100644 --- a/client/src/pages/Repository/components/DetailsView/ObjectDetails.tsx +++ b/client/src/pages/Repository/components/DetailsView/ObjectDetails.tsx @@ -12,10 +12,11 @@ import { GetSystemObjectDetailsResult, RepositoryPath, License } from '../../../ import { getDetailsUrlForObject, getUpdatedCheckboxProps, isFieldUpdated } from '../../../../utils/repository'; import { withDefaultValueBoolean } from '../../../../utils/shared'; import { useLicenseStore } from '../../../../store'; -import { clearLicenseAssignment, assignLicense } from '../../hooks/useDetailsView'; +import { clearLicenseAssignment, assignLicense, publish } from '../../hooks/useDetailsView'; import { getTermForSystemObjectType } from '../../../../utils/repository'; import { LoadingButton } from '../../../../components'; import { toast } from 'react-toastify'; +import { ePublishedState } from '../../../../types/server'; const useStyles = makeStyles(({ palette, typography }) => ({ detail: { @@ -31,12 +32,6 @@ const useStyles = makeStyles(({ palette, typography }) => ({ value: { color: ({ clickable = true }: DetailProps) => (clickable ? palette.primary.main : palette.primary.dark), textDecoration: ({ clickable = true, value }: DetailProps) => (clickable && value ? 'underline' : undefined) - }, - btn: { - backgroundColor: '#687DDB', - color: 'white', - width: '90px', - height: '30px' } })); @@ -90,8 +85,11 @@ interface ObjectDetailsProps { item?: RepositoryPath | null; disabled: boolean; publishedState: string; + publishedEnum: number; + publishable: boolean; retired: boolean; hideRetired?: boolean; + hidePublishState?: boolean; originalFields?: GetSystemObjectDetailsResult; onRetiredUpdate?: (event: React.ChangeEvent, checked: boolean) => void; onLicenseUpdate?: (event) => void; @@ -108,8 +106,11 @@ function ObjectDetails(props: ObjectDetailsProps): React.ReactElement { subject, item, publishedState, + publishedEnum, + publishable, retired, hideRetired, + hidePublishState, disabled, originalFields, onRetiredUpdate, @@ -168,13 +169,41 @@ function ObjectDetails(props: ObjectDetailsProps): React.ReactElement { setLoading(false); }; + const onPublish = async () => { onPublishWorker(ePublishedState.ePublished, 'Publish'); }; + const onAPIOnly = async () => { onPublishWorker(ePublishedState.eAPIOnly, 'Publish for API Only'); }; + const onUnpublish = async () => { onPublishWorker(ePublishedState.eNotPublished, 'Unpublish'); }; + + const onPublishWorker = async (eState: number, action: string) => { + setLoading(true); + + const { data } = await publish(idSystemObject, eState); + if (data?.publish?.success) + toast.success(`${action} succeeded`); + else + toast.error(`${action} failed: ${data?.publish?.message}`); + + setLoading(false); + }; + return ( - + {!hidePublishState && ( + + {publishedState} +  Publish +  API Only +  {(publishedEnum !== ePublishedState.eNotPublished) && (Unpublish)} + + } + /> + )} {!hideRetired && ( ({ + container: { + display: 'flex', + flex: 1, + flexDirection: 'column', + // maxHeight: 'calc(100vh - 140px)', + padding: 20, + marginBottom: 20, + borderRadius: 10, + overflowY: 'scroll', + backgroundColor: palette.primary.light, + [breakpoints.down('lg')]: { + // maxHeight: 'calc(100vh - 120px)', + padding: 10 + } + } +})); + +type DetailsParams = { + idSystemObject: string; +}; + +type QueryParams = { + document: string; + root: string; + mode: string; +}; + + +function VoyagerStoryView(): React.ReactElement { + const classes = useStyles(); + const params = useParams(); // NOTE: params gives you access to the idSystemObject as defined in the route + const location = useLocation(); + const { document, root, mode } = qs.parse(location.search) as QueryParams; // NOTE: qs.parse gives you the object of query strings you use, such as document and root + const idSystemObject: number = Number.parseInt(params.idSystemObject, 10); + const { data, loading } = useObjectDetails(idSystemObject); + const [objectName, setObjectName] = useState(''); + const eMode = getVoyagerModeFromParam(mode); + + useEffect(() => { + if (data && !loading) { + const { name } = data.getSystemObjectDetails; + setObjectName(name); + } + }, [data, loading]); + + if (!data || !params.idSystemObject) { + return ; + } + + const { + objectType, + objectAncestors + } = data.getSystemObjectDetails; + + return ( + + {}} + /> + + + + {eMode === eVoyagerStoryMode.eViewer && ( + + )} + {eMode !== eVoyagerStoryMode.eViewer && ( + + )} + + + + + ); +} + +export default VoyagerStoryView; diff --git a/client/src/pages/Repository/components/DetailsView/index.tsx b/client/src/pages/Repository/components/DetailsView/index.tsx index 99b79425b..00abc718c 100644 --- a/client/src/pages/Repository/components/DetailsView/index.tsx +++ b/client/src/pages/Repository/components/DetailsView/index.tsx @@ -13,7 +13,7 @@ import { useParams } from 'react-router'; import { toast } from 'react-toastify'; import { LoadingButton } from '../../../../components'; import IdentifierList from '../../../../components/shared/IdentifierList'; -import { /*parseIdentifiersToState,*/ useVocabularyStore, useRepositoryStore, useIdentifierStore, useDetailTabStore, ModelDetailsType } from '../../../../store'; +import { /*parseIdentifiersToState,*/ useVocabularyStore, useRepositoryStore, useIdentifierStore, useDetailTabStore, ModelDetailsType, SceneDetailsType } from '../../../../store'; import { ActorDetailFieldsInput, AssetDetailFieldsInput, @@ -24,6 +24,7 @@ import { ModelDetailFieldsInput, ProjectDetailFieldsInput, ProjectDocumentationDetailFieldsInput, + RelatedObjectType, SceneDetailFieldsInput, StakeholderDetailFieldsInput, SubjectDetailFieldsInput, @@ -60,7 +61,6 @@ const useStyles = makeStyles(({ palette, breakpoints }) => ({ updateButton: { height: 35, width: 100, - marginTop: 10, color: palette.background.paper, [breakpoints.down('lg')]: { height: 30 @@ -86,20 +86,33 @@ function DetailsView(): React.ReactElement { // eslint-disable-next-line @typescript-eslint/no-explicit-any const [detailQuery, setDetailQuery] = useState({}); const [isUpdatingData, setIsUpdatingData] = useState(false); - const [objectRelationship, setObjectRelationship] = useState(''); - + const [objectRelationship, setObjectRelationship] = useState(RelatedObjectType.Source); + const [loadingIdentifiers, setLoadingIdentifiers] = useState(true); const idSystemObject: number = Number.parseInt(params.idSystemObject, 10); const { data, loading } = useObjectDetails(idSystemObject); let [updatedData, setUpdatedData] = useState({}); - + const [updatedIdentifiers, setUpdatedIdentifiers] = useState(false); const getEntries = useVocabularyStore(state => state.getEntries); - const [stateIdentifiers, addNewIdentifier, initializeIdentifierState, removeTargetIdentifier, updateIdentifier, checkIdentifiersBeforeUpdate] = useIdentifierStore(state => [ + const [ + stateIdentifiers, + areIdentifiersUpdated, + addNewIdentifier, + initializeIdentifierState, + removeTargetIdentifier, + updateIdentifier, + checkIdentifiersBeforeUpdate, + updateIdentifierPreferred, + initializePreferredIdentifier + ] = useIdentifierStore(state => [ state.stateIdentifiers, + state.areIdentifiersUpdated, state.addNewIdentifier, state.initializeIdentifierState, state.removeTargetIdentifier, state.updateIdentifier, - state.checkIdentifiersBeforeUpdate + state.checkIdentifiersBeforeUpdate, + state.updateIdentifierPreferred, + state.initializePreferredIdentifier ]); const [resetRepositoryFilter, resetKeywordSearch, initializeTree] = useRepositoryStore(state => [state.resetRepositoryFilter, state.resetKeywordSearch, state.initializeTree]); const [initializeDetailFields, getDetail, getDetailsViewFieldErrors] = useDetailTabStore(state => [ @@ -109,27 +122,35 @@ function DetailsView(): React.ReactElement { ]); const objectDetailsData = data; - useEffect(() => { - if (data && !loading) { - const { name, retired, license } = data.getSystemObjectDetails; - setDetails({ name, retired, idLicense: license?.idLicense || 0 }); - initializeIdentifierState(data.getSystemObjectDetails.identifiers); - } - }, [data, loading, initializeIdentifierState]); - - // new function for setting state useEffect(() => { if (data) { const fetchDetailTabDataAndInitializeStateStore = async () => { const detailsTabData = await getDetailsTabDataForObject(idSystemObject, objectType); setDetailQuery(detailsTabData); initializeDetailFields(detailsTabData, objectType); + if (objectType === eSystemObjectType.eSubject) { + initializePreferredIdentifier(detailsTabData?.data?.getDetailsTabDataForObject?.Subject?.idIdentifierPreferred); + setLoadingIdentifiers(false); + } }; fetchDetailTabDataAndInitializeStateStore(); } }, [idSystemObject, data]); + useEffect(() => { + if (data && !loading) { + const { name, retired, license } = data.getSystemObjectDetails; + setDetails({ name, retired, idLicense: license?.idLicense || 0 }); + initializeIdentifierState(data.getSystemObjectDetails.identifiers); + } + }, [data, loading, initializeIdentifierState]); + + // checks for updates to identifiers + useEffect(() => { + setUpdatedIdentifiers(areIdentifiersUpdated()); + }, [stateIdentifiers]); + if (!data || !params.idSystemObject) { return ; } @@ -139,6 +160,8 @@ function DetailsView(): React.ReactElement { objectType, allowed, publishedState, + publishedEnum, + publishable, thumbnail, unit, project, @@ -165,6 +188,7 @@ function DetailsView(): React.ReactElement { const deleteIdentifierSuccess = await deleteIdentifier(idIdentifier); if (deleteIdentifierSuccess) { removeTargetIdentifier(idIdentifier); + setUpdatedIdentifiers(false); toast.success('Identifier removed'); } else { toast.error('Error when removing identifier'); @@ -180,12 +204,12 @@ function DetailsView(): React.ReactElement { const onModalClose = () => { setModalOpen(false); - setObjectRelationship(''); + setObjectRelationship(RelatedObjectType.Source); resetRepositoryFilter(); }; const onAddSourceObject = () => { - setObjectRelationship('Source'); + setObjectRelationship(RelatedObjectType.Source); resetKeywordSearch(); resetRepositoryFilter(); initializeTree(); @@ -193,7 +217,7 @@ function DetailsView(): React.ReactElement { }; const onAddDerivedObject = () => { - setObjectRelationship('Derived'); + setObjectRelationship(RelatedObjectType.Derived); resetKeywordSearch(); resetRepositoryFilter(); initializeTree(); @@ -285,10 +309,25 @@ function DetailsView(): React.ReactElement { return; } + const stateIdentifiersWithIdSystemObject: UpdateIdentifier[] = stateIdentifiers.map(({ id, identifier, identifierType, idIdentifier, preferred }) => { + return { + id, + identifier, + identifierType, + idSystemObject, + idIdentifier, + preferred + }; + }); + + updatedData.Retired = updatedData?.Retired || details?.retired; + updatedData.Name = updatedData?.Name || objectDetailsData?.getSystemObjectDetails.name; + updatedData.Identifiers = stateIdentifiersWithIdSystemObject || []; + // Create another validation here to make sure that the appropriate SO types are being checked - const errors = getDetailsViewFieldErrors(updatedData, objectType); + const errors = await getDetailsViewFieldErrors(updatedData, objectType); if (errors.length) { - errors.forEach(error => toast.error(`Please input a valid ${error}`, { autoClose: false })); + errors.forEach(error => toast.error(`${error}`, { autoClose: false })); setIsUpdatingData(false); return; } @@ -312,11 +351,40 @@ function DetailsView(): React.ReactElement { } if (objectType === eSystemObjectType.eScene && updatedData.Scene) { - const { IsOriented, HasBeenQCd } = updatedData.Scene; - updatedData.Scene = { IsOriented, HasBeenQCd }; + const SceneDetails = getDetail(objectType) as SceneDetailsType; + const { ApprovedForPublication, PosedAndQCd } = SceneDetails; + updatedData.Scene = { PosedAndQCd, ApprovedForPublication }; + } + // convert subject and item inputs to numbers to handle scientific notation + if (objectType === eSystemObjectType.eSubject && updatedData.Subject) { + const { Latitude, Longitude, Altitude, TS0, TS1, TS2, R0, R1, R2, R3 } = updatedData.Subject; + updatedData.Subject.Latitude = Latitude ? Number(Latitude) : null; + updatedData.Subject.Longitude = Longitude ? Number(Longitude) : null; + updatedData.Subject.Altitude = Altitude ? Number(Altitude) : null; + updatedData.Subject.TS0 = TS0 ? Number(TS0) : null; + updatedData.Subject.TS1 = TS1 ? Number(TS1) : null; + updatedData.Subject.TS2 = TS2 ? Number(TS2) : null; + updatedData.Subject.R0 = R0 ? Number(R0) : null; + updatedData.Subject.R1 = R1 ? Number(R1) : null; + updatedData.Subject.R2 = R2 ? Number(R2) : null; + updatedData.Subject.R3 = R3 ? Number(R3) : null; } - if (objectType === eSystemObjectType.eCaptureData && !updatedData.CaptureData) { + if (objectType === eSystemObjectType.eItem && updatedData.Item) { + const { Latitude, Longitude, Altitude, TS0, TS1, TS2, R0, R1, R2, R3 } = updatedData.Item; + updatedData.Item.Latitude = Latitude ? Number(Latitude) : null; + updatedData.Item.Longitude = Longitude ? Number(Longitude) : null; + updatedData.Item.Altitude = Altitude ? Number(Altitude) : null; + updatedData.Item.TS0 = TS0 ? Number(TS0) : null; + updatedData.Item.TS1 = TS1 ? Number(TS1) : null; + updatedData.Item.TS2 = TS2 ? Number(TS2) : null; + updatedData.Item.R0 = R0 ? Number(R0) : null; + updatedData.Item.R1 = R1 ? Number(R1) : null; + updatedData.Item.R2 = R2 ? Number(R2) : null; + updatedData.Item.R3 = R3 ? Number(R3) : null; + } + + if (objectType === eSystemObjectType.eCaptureData) { const CaptureDataDetails = getDetail(objectType) as CaptureDataDetailFields; const { captureMethod, @@ -357,20 +425,6 @@ function DetailsView(): React.ReactElement { }; } - const stateIdentifiersWithIdSystemObject: UpdateIdentifier[] = stateIdentifiers.map(({ id, identifier, identifierType, selected, idIdentifier }) => { - return { - id, - identifier, - identifierType, - selected, - idSystemObject, - idIdentifier - }; - }); - - updatedData.Retired = updatedData?.Retired || details?.retired; - updatedData.Name = updatedData?.Name || objectDetailsData?.getSystemObjectDetails.name; - updatedData.Identifiers = stateIdentifiersWithIdSystemObject || []; const { data } = await updateDetailsTabData(idSystemObject, idObject, objectType, updatedData); if (data?.updateObjectDetails?.success) { toast.success('Data saved successfully'); @@ -378,7 +432,8 @@ function DetailsView(): React.ReactElement { throw new Error(data?.updateObjectDetails?.message); } } catch (error) { - toast.error(error || 'Failed to save updated data'); + if (error instanceof Error) + toast.error(error.toString() || 'Failed to save updated data'); } finally { setIsUpdatingData(false); } @@ -401,13 +456,16 @@ function DetailsView(): React.ReactElement { project={project} subject={subject} item={item} + disabled={disabled} + publishedState={publishedState} + publishedEnum={publishedEnum} + publishable={publishable} + retired={withDefaultValueBoolean(details.retired, false)} + hidePublishState={objectType !== eSystemObjectType.eScene} onRetiredUpdate={onRetiredUpdate} onLicenseUpdate={onLicenseUpdate} - publishedState={publishedState} originalFields={data.getSystemObjectDetails} - retired={withDefaultValueBoolean(details.retired, false)} license={withDefaultValueNumber(details.idLicense, 0)} - disabled={disabled} idSystemObject={idSystemObject} licenseInherited={licenseInherited} path={objectAncestors} @@ -421,10 +479,20 @@ function DetailsView(): React.ReactElement { onAdd={addIdentifer} onRemove={removeIdentifier} onUpdate={updateIdentifierFields} + subjectView={objectType === eSystemObjectType.eSubject} + onUpdateIdIdentifierPreferred={updateIdentifierPreferred} + loading={loadingIdentifiers} /> + + + Update + + {updatedIdentifiers &&
Update needed to save your identifier data entry
} +
+ - - Update - -
); diff --git a/client/src/pages/Repository/components/RepositoryFilterView/RepositoryFilterOptions.ts b/client/src/pages/Repository/components/RepositoryFilterView/RepositoryFilterOptions.ts index 552105e72..5d156adfc 100644 --- a/client/src/pages/Repository/components/RepositoryFilterView/RepositoryFilterOptions.ts +++ b/client/src/pages/Repository/components/RepositoryFilterView/RepositoryFilterOptions.ts @@ -89,8 +89,6 @@ export const metadataToDisplayOptions: FilterOption[] = [ { label: 'Model Channel Position', value: eMetadata.eModelChannelPosition }, { label: 'Model Channel Width', value: eMetadata.eModelChannelWidth }, { label: 'Model UV Map Type', value: eMetadata.eModelUVMapType }, - { label: 'Scene Is Oriented', value: eMetadata.eSceneIsOriented }, - { label: 'Scene Has Been QCd', value: eMetadata.eSceneHasBeenQCd }, { label: 'Scene Count', value: eMetadata.eSceneCountScene }, { label: 'Scene Node Count', value: eMetadata.eSceneCountNode }, { label: 'Scene Camera Count', value: eMetadata.eSceneCountCamera }, @@ -99,6 +97,9 @@ export const metadataToDisplayOptions: FilterOption[] = [ { label: 'Scene Meta Count', value: eMetadata.eSceneCountMeta }, { label: 'Scene Setup Count', value: eMetadata.eSceneCountSetup }, { label: 'Scene Tour Count', value: eMetadata.eSceneCountTour }, + { label: 'Scene Edan UUID', value: eMetadata.eSceneEdanUUID }, + { label: 'Scene Posed And QCd', value: eMetadata.eScenePosedAndQCd }, + { label: 'Scene Approved For Publication', value: eMetadata.eSceneApprovedForPublication }, { label: 'Asset File Name', value: eMetadata.eAssetFileName }, { label: 'Asset File Path', value: eMetadata.eAssetFilePath }, { label: 'Asset Type', value: eMetadata.eAssetType }, diff --git a/client/src/pages/Repository/components/RepositoryFilterView/index.tsx b/client/src/pages/Repository/components/RepositoryFilterView/index.tsx index e0f34205a..2ba3d76f6 100644 --- a/client/src/pages/Repository/components/RepositoryFilterView/index.tsx +++ b/client/src/pages/Repository/components/RepositoryFilterView/index.tsx @@ -105,7 +105,7 @@ const StyledChip = withStyles(({ palette }) => ({ function RepositoryFilterView(): React.ReactElement { const { data, loading } = useGetFilterViewDataQuery(); - const [units, projects, isExpanded] = useRepositoryStore(state => [state.units, state.projects, state.isExpanded]); + const [units, projects, isExpanded, repositoryBrowserRootObjectType, repositoryBrowserRootName, repositoryRootType] = useRepositoryStore(state => [state.units, state.projects, state.isExpanded, state.repositoryBrowserRootObjectType, state.repositoryBrowserRootName, state.repositoryRootType]); const [toggleFilter, removeUnitsOrProjects] = useRepositoryStore(state => [state.toggleFilter, state.removeUnitsOrProjects]); const getEntries = useVocabularyStore(state => state.getEntries); const classes = useStyles(isExpanded); @@ -132,10 +132,20 @@ function RepositoryFilterView(): React.ReactElement { } }; + let unrootedRepositoryType: string; + if (!repositoryRootType.length) { + unrootedRepositoryType = 'Unit'; + } else if (repositoryRootType.length === 1) { + unrootedRepositoryType = getTermForSystemObjectType(repositoryRootType[0]); + } else { + unrootedRepositoryType = 'Multiple'; + } + const typeName = repositoryBrowserRootObjectType ? `${repositoryBrowserRootObjectType}: ${repositoryBrowserRootName}` : `${unrootedRepositoryType}: All`; + let content: React.ReactNode = ( - Unit: All + {typeName} diff --git a/client/src/pages/Repository/components/RepositoryTreeView/MetadataView.tsx b/client/src/pages/Repository/components/RepositoryTreeView/MetadataView.tsx index c91429c95..8ceba45ae 100644 --- a/client/src/pages/Repository/components/RepositoryTreeView/MetadataView.tsx +++ b/client/src/pages/Repository/components/RepositoryTreeView/MetadataView.tsx @@ -3,9 +3,9 @@ * * This component renders metadata view used in RepositoryTreeView and RepositoryTreeHeader. */ -import { makeStyles } from '@material-ui/core/styles'; import lodash from 'lodash'; import React from 'react'; +import { palette } from '../../../../theme'; import { eMetadata } from '../../../../types/server'; import { computeMetadataViewWidth, trimmedMetadataField } from '../../../../utils/repository'; @@ -15,40 +15,15 @@ export type TreeViewColumn = { size: number; }; -const useStyles = makeStyles(({ palette, typography, breakpoints }) => ({ - metadata: { - display: 'flex' - }, - column: { - display: 'flex', - alignItems: 'center', - padding: '0px 10px', - fontSize: ({ header }: MetadataViewProps) => header ? typography.pxToRem(18) : undefined, - color: ({ header }: MetadataViewProps) => header ? palette.primary.dark : palette.grey[900], - fontWeight: ({ header }: MetadataViewProps) => header ? typography.fontWeightRegular : typography.fontWeightLight, - overflow: 'hidden', - textOverflow: 'ellipsis', - [breakpoints.down('lg')]: { - fontSize: ({ header }: MetadataViewProps) => header ? typography.pxToRem(14) : undefined, - } - }, - text: { - fontSize: '0.8em', - [breakpoints.down('lg')]: { - fontSize: '0.9em', - } - } -})); - interface MetadataViewProps { header: boolean; treeColumns: TreeViewColumn[]; options?: React.ReactNode; + makeStyles?: { [key: string]: string }; } function MetadataView(props: MetadataViewProps): React.ReactElement { - const { header, treeColumns, options = null } = props; - const classes = useStyles(props); + const { header, treeColumns, options = null, makeStyles } = props; const width = computeMetadataViewWidth(treeColumns); @@ -58,8 +33,8 @@ function MetadataView(props: MetadataViewProps): React.ReactElement { const width = `${size}vw`; return ( -
- +
+ {trimmedMetadataField(label, 20, 10)}
@@ -67,7 +42,7 @@ function MetadataView(props: MetadataViewProps): React.ReactElement { }); return ( -
+
{options} {renderTreeColumns(treeColumns)}
diff --git a/client/src/pages/Repository/components/RepositoryTreeView/RepositoryTreeHeader.tsx b/client/src/pages/Repository/components/RepositoryTreeView/RepositoryTreeHeader.tsx index ab90514d7..8635d76c8 100644 --- a/client/src/pages/Repository/components/RepositoryTreeView/RepositoryTreeHeader.tsx +++ b/client/src/pages/Repository/components/RepositoryTreeView/RepositoryTreeHeader.tsx @@ -48,6 +48,28 @@ const useStyles = makeStyles(({ palette, typography, breakpoints }) => ({ paddingLeft: 10, left: 10, } + }, + metadata: { + display: 'flex' + }, + column: { + display: 'flex', + alignItems: 'center', + padding: '0px 10px', + fontSize: typography.pxToRem(18), + color: palette.primary.dark, + fontWeight: typography.fontWeightRegular, + overflow: 'hidden', + textOverflow: 'ellipsis', + [breakpoints.down('lg')]: { + fontSize: typography.pxToRem(14), + } + }, + text: { + fontSize: '0.8em', + [breakpoints.down('lg')]: { + fontSize: '0.9em', + } } })); @@ -69,7 +91,7 @@ function RepositoryTreeHeader(props: RepositoryTreeHeaderProps): React.ReactElem - + ); } diff --git a/client/src/pages/Repository/components/RepositoryTreeView/TreeLabel.tsx b/client/src/pages/Repository/components/RepositoryTreeView/TreeLabel.tsx index de277577b..740151e00 100644 --- a/client/src/pages/Repository/components/RepositoryTreeView/TreeLabel.tsx +++ b/client/src/pages/Repository/components/RepositoryTreeView/TreeLabel.tsx @@ -11,50 +11,12 @@ import clsx from 'clsx'; import lodash from 'lodash'; import React, { useMemo } from 'react'; import { FaCheckCircle, FaRegCircle } from 'react-icons/fa'; -import { NewTabLink, Progress } from '../../../../components'; +import { Progress } from '../../../../components'; import { palette } from '../../../../theme'; import { getDetailsUrlForObject, getTermForSystemObjectType } from '../../../../utils/repository'; import MetadataView, { TreeViewColumn } from './MetadataView'; import { RiExternalLinkFill } from 'react-icons/ri'; - -const useStyles = makeStyles(({ breakpoints }) => ({ - container: { - display: 'flex', - }, - label: { - display: 'flex', - flex: 0.9, - alignItems: 'center', - position: 'sticky', - left: 45, - [breakpoints.down('lg')]: { - left: 30 - } - }, - labelText: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - width: '60%', - fontSize: '0.8em', - backgroundColor: ({ color }: TreeLabelProps) => color, - zIndex: 10, - [breakpoints.down('lg')]: { - fontSize: '0.9em', - } - }, - options: { - display: 'flex', - alignItems: 'center', - position: 'sticky', - left: 0, - width: 120 - }, - option: { - display: 'flex', - alignItems: 'center' - } -})); +import { Link } from 'react-router-dom'; interface TreeLabelProps { idSystemObject: number; @@ -64,33 +26,39 @@ interface TreeLabelProps { treeColumns: TreeViewColumn[]; renderSelected?: boolean; selected?: boolean; + makeStyles?: { [key: string]: string }; onSelect?: (event: React.MouseEvent) => void; onUnSelect?: (event: React.MouseEvent) => void; } function TreeLabel(props: TreeLabelProps): React.ReactElement { - const { idSystemObject, label, treeColumns, renderSelected = false, selected = false, onSelect, onUnSelect, objectType } = props; - const classes = useStyles(props); + const { idSystemObject, label, treeColumns, renderSelected = false, selected = false, onSelect, onUnSelect, objectType, makeStyles, color } = props; const objectTitle = useMemo(() => `${getTermForSystemObjectType(objectType)} ${label}`, [objectType, label]); return ( -
-
+
+
{renderSelected && ( {!selected && } {selected && } )} -
+
{label}
- - + + event.stopPropagation()} + target='_blank' + rel='noopener noreferrer' + className={makeStyles?.link} + > - +
} /> diff --git a/client/src/pages/Repository/components/RepositoryTreeView/index.tsx b/client/src/pages/Repository/components/RepositoryTreeView/index.tsx index b3b0a22dd..3e111510a 100644 --- a/client/src/pages/Repository/components/RepositoryTreeView/index.tsx +++ b/client/src/pages/Repository/components/RepositoryTreeView/index.tsx @@ -30,7 +30,7 @@ import TreeLabel, { TreeLabelEmpty, TreeLabelLoading } from './TreeLabel'; import InViewTreeItem from './InViewTreeItem'; import { repositoryRowCount } from '../../../../types/server'; -const useStyles = makeStyles(({ breakpoints }) => ({ +const useStyles = makeStyles(({ breakpoints, typography, palette }) => ({ container: { display: 'flex', flex: 5, @@ -51,6 +51,82 @@ const useStyles = makeStyles(({ breakpoints }) => ({ }, fullWidth: { maxWidth: '95.5vw' + }, + iconContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: 18, + width: 18, + borderRadius: 2.5, + [breakpoints.down('lg')]: { + height: 15, + width: 15, + }, + }, + initial: { + fontSize: 10, + fontWeight: typography.fontWeightMedium, + }, + // TreeLabel + treeLabelContainer: { + display: 'flex', + }, + label: { + display: 'flex', + flex: 0.9, + alignItems: 'center', + position: 'sticky', + left: 45, + [breakpoints.down('lg')]: { + left: 30 + } + }, + labelText: { + color: 'rgb(44,64,90)', + fontWeight: 400, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '60%', + fontSize: '0.8em', + zIndex: 10, + [breakpoints.down('lg')]: { + fontSize: '0.9em', + } + }, + column: { + display: 'flex', + alignItems: 'center', + padding: '0px 10px', + fontSize: undefined, + color: palette.grey[900], + fontWeight: typography.fontWeightLight, + overflow: 'hidden', + textOverflow: 'ellipsis' + }, + text: { + color: 'rgb(44,64,90)', + fontWeight: 400, + fontSize: '0.8em', + [breakpoints.down('lg')]: { + fontSize: '0.9em', + } + }, + options: { + display: 'flex', + alignItems: 'center', + position: 'sticky', + left: 0, + width: 120 + }, + option: { + display: 'flex', + alignItems: 'center' + }, + link: { + color: palette.primary.dark, + textDecoration: 'none' } })); @@ -98,6 +174,8 @@ function RepositoryTreeView(props: RepositoryTreeViewProps): React.ReactElement // recursive const renderTree = (children: NavigationResultEntry[] | undefined, isChild?: boolean, parentNodeId?: string) => { + // console.log(`renderTree: ${children?.length} total`, children); + if (!children) return null; return children.map((child: NavigationResultEntry, index: number) => { const { idSystemObject, objectType, idObject, name, metadata } = child; @@ -116,10 +194,11 @@ function RepositoryTreeView(props: RepositoryTreeViewProps): React.ReactElement } const variant = getTreeColorVariant(index); - const { icon, color } = getObjectInterfaceDetails(objectType, variant); + const { icon, color } = getObjectInterfaceDetails(objectType, variant, { container: classes.iconContainer, initial: classes.initial }); + const treeColumns = getTreeViewColumns(metadataColumns, false, metadata); - const isSelected = isRepositoryItemSelected(nodeId, selectedItems); + const isSelected = isRepositoryItemSelected(nodeId, selectedItems); const select = (event: React.MouseEvent) => { if (onSelect) { event.stopPropagation(); @@ -151,6 +230,7 @@ function RepositoryTreeView(props: RepositoryTreeViewProps): React.ReactElement objectType={objectType} color={color} treeColumns={treeColumns} + makeStyles={{ container: classes.treeLabelContainer, label: classes.label, labelText: classes.labelText, column: classes.column, text: classes.text, options: classes.options, option: classes.option, link: classes.link }} /> ); @@ -208,7 +288,6 @@ function RepositoryTreeView(props: RepositoryTreeViewProps): React.ReactElement const treeColumns = getTreeViewColumns(metadataColumns, false); const width = getTreeWidth(treeColumns.length, sideBarExpanded, isModal); const children = tree.get(treeRootKey); - content = ( } defaultExpandIcon={} onNodeToggle={onNodeToggle} style={{ width }}> diff --git a/client/src/pages/Repository/hooks/useDetailsView.ts b/client/src/pages/Repository/hooks/useDetailsView.ts index 2512c2f85..a1dd63280 100644 --- a/client/src/pages/Repository/hooks/useDetailsView.ts +++ b/client/src/pages/Repository/hooks/useDetailsView.ts @@ -21,10 +21,13 @@ import { GetLicenseListDocument, ClearLicenseAssignmentDocument, AssignLicenseDocument, + PublishDocument, ClearLicenseAssignmentMutation, - AssignLicenseMutation + AssignLicenseMutation, + PublishMutation, + ExistingRelationship } from '../../../types/graphql'; -import { eSystemObjectType } from '../../../types/server'; +import { eSystemObjectType, ePublishedState } from '../../../types/server'; export function useObjectDetails(idSystemObject: number): GetSystemObjectDetailsQueryResult { return useQuery(GetSystemObjectDetailsDocument, { @@ -32,7 +35,8 @@ export function useObjectDetails(idSystemObject: number): GetSystemObjectDetails input: { idSystemObject } - } + }, + fetchPolicy: 'no-cache' }); } @@ -75,7 +79,8 @@ export async function getDetailsTabDataForObject(idSystemObject: number, objectT idSystemObject, objectType } - } + }, + fetchPolicy: 'no-cache' }); } @@ -99,62 +104,50 @@ export function updateDetailsTabData( }); } -export function updateSourceObjects(idSystemObject: number, sources: number[], PreviouslySelected: number[]) { +export function updateSourceObjects(idSystemObject: number, objectType: number, sources: ExistingRelationship[], PreviouslySelected: ExistingRelationship[]) { return apolloClient.mutate({ mutation: UpdateSourceObjectsDocument, variables: { input: { idSystemObject, + ChildObjectType: objectType, Sources: sources, PreviouslySelected } }, - refetchQueries: ['getSystemObjectDetails', 'getDetailsTabDataForObject'] + refetchQueries: ['getSystemObjectDetails', 'getDetailsTabDataForObject'], + awaitRefetchQueries: true }); } -export function updateDerivedObjects(idSystemObject: number, derivatives: number[], PreviouslySelected: number[]) { +export function updateDerivedObjects(idSystemObject: number, objectType: number, derivatives: ExistingRelationship[], PreviouslySelected: ExistingRelationship[]) { return apolloClient.mutate({ mutation: UpdateDerivedObjectsDocument, variables: { input: { idSystemObject, + ParentObjectType: objectType, Derivatives: derivatives, PreviouslySelected } }, - refetchQueries: ['getSystemObjectDetails', 'getDetailsTabDataForObject'] + refetchQueries: ['getSystemObjectDetails', 'getDetailsTabDataForObject'], + awaitRefetchQueries: true }); } -export async function deleteObjectConnection(idSystemObjectMaster: number, idSystemObjectDerived: number, type: string, systemObjectType: number) { +export async function deleteObjectConnection(idSystemObjectMaster: number, objectTypeMaster: eSystemObjectType, idSystemObjectDerived: number, objectTypeDerived: eSystemObjectType) { return await apolloClient.mutate({ mutation: DeleteObjectConnectionDocument, variables: { input: { idSystemObjectMaster, - idSystemObjectDerived + objectTypeMaster, + idSystemObjectDerived, + objectTypeDerived } }, - refetchQueries: [ - { - query: GetSystemObjectDetailsDocument, - variables: { - input: { - idSystemObject: type === 'Source' ? idSystemObjectDerived : idSystemObjectMaster - } - } - }, - { - query: GetDetailsTabDataForObjectDocument, - variables: { - input: { - idSystemObject: type === 'Source' ? idSystemObjectDerived : idSystemObjectMaster, - objectType: systemObjectType - } - } - } - ], + refetchQueries: ['getSystemObjectDetails', 'getDetailsTabDataForObject'], awaitRefetchQueries: true }); } @@ -166,7 +159,8 @@ export async function deleteIdentifier(idIdentifier: number) { input: { idIdentifier } - } + }, + refetchQueries: ['getSystemObjectDetails', 'getDetailsTabDataForObject'] }); } @@ -219,3 +213,17 @@ export async function assignLicense(idSystemObject: number, idLicense: number): refetchQueries: ['getSystemObjectDetails', 'getDetailsTabDataForObject'] }); } + +export async function publish(idSystemObject: number, eState: ePublishedState): Promise> { + return await apolloClient.mutate({ + mutation: PublishDocument, + variables: { + input: { + idSystemObject, + eState + } + }, + refetchQueries: ['getSystemObjectDetails', 'getDetailsTabDataForObject'] + }); +} + diff --git a/client/src/pages/Repository/hooks/useRepository.ts b/client/src/pages/Repository/hooks/useRepository.ts index d77c2a2d3..4f8a96c8c 100644 --- a/client/src/pages/Repository/hooks/useRepository.ts +++ b/client/src/pages/Repository/hooks/useRepository.ts @@ -15,11 +15,11 @@ function getObjectChildrenForRoot(filter: RepositoryFilter, idSystemObject = 0): fetchPolicy: 'network-only', variables: { input: { - idRoot: idSystemObject, + idRoot: filter?.idRoot ?? idSystemObject, objectTypes: filter.repositoryRootType, metadataColumns: filter.metadataToDisplay, objectsToDisplay: filter.objectsToDisplay, - search: filter.search, + search: String(filter.search), units: filter.units, projects: filter.projects, has: filter.has, @@ -47,7 +47,7 @@ function getObjectChildren(idRoot: number, filter: RepositoryFilter): Promise ({ container: { @@ -53,6 +55,7 @@ export type RepositoryFilter = { dateCreatedFrom?: Date | string | null; dateCreatedTo?: Date | string | null; cursorMark?: string | null; + idRoot?: number | null; }; function Repository(): React.ReactElement { @@ -64,6 +67,7 @@ function Repository(): React.ReactElement { + @@ -89,6 +93,7 @@ function TreeViewPage(): React.ReactElement { modelFileType, dateCreatedFrom, dateCreatedTo, + idRoot, updateRepositoryFilter } = useRepositoryStore(); const queries: RepositoryFilter = parseRepositoryUrl(location.search); @@ -108,7 +113,8 @@ function TreeViewPage(): React.ReactElement { modelPurpose: [], modelFileType: [], dateCreatedFrom: null, - dateCreatedTo: null + dateCreatedTo: null, + idRoot: null })};path=/`; }; @@ -150,16 +156,19 @@ function TreeViewPage(): React.ReactElement { modelPurpose, modelFileType, dateCreatedFrom, - dateCreatedTo + dateCreatedTo, + idRoot }; const route = generateRepositoryUrl(newRepositoryFilterState) || generateRepositoryUrl(cookieFilterSelections); if (route !== location.search) { - console.log(`*** src/pages/Repository/index.tsx TreeViewPage window.history.pushState(path: ${route}, '', ${route})`); window.history.pushState({ path: route }, '', route); } return ( + + Repository + diff --git a/client/src/pages/Workflow/components/WorkflowView/WorkflowList.tsx b/client/src/pages/Workflow/components/WorkflowView/WorkflowList.tsx index 82d8d0599..2912413b1 100644 --- a/client/src/pages/Workflow/components/WorkflowView/WorkflowList.tsx +++ b/client/src/pages/Workflow/components/WorkflowView/WorkflowList.tsx @@ -319,7 +319,12 @@ function WorkflowIcon(props: WorkflowIconProps): React.ReactElement { if (reportType === eWorkflowLinkType.eSet) source = SetIcon; return ( - + This icon indicates a clickable hyperlink. ); diff --git a/client/src/pages/Workflow/components/WorkflowView/index.tsx b/client/src/pages/Workflow/components/WorkflowView/index.tsx index 72b14194e..016fb16ab 100644 --- a/client/src/pages/Workflow/components/WorkflowView/index.tsx +++ b/client/src/pages/Workflow/components/WorkflowView/index.tsx @@ -3,6 +3,7 @@ import React, { useEffect } from 'react'; import WorkflowFilter from './WorkflowFilter'; import WorkflowList from './WorkflowList'; import { useWorkflowStore } from '../../../../store/index'; +import { Helmet } from 'react-helmet'; function WorkflowView(): React.ReactElement { const fetchWorkflowList = useWorkflowStore(state => state.fetchWorkflowList); @@ -13,6 +14,9 @@ function WorkflowView(): React.ReactElement { return ( + + Workflow + diff --git a/client/src/store/detailTab.tsx b/client/src/store/detailTab.tsx index 23c588f64..ae86b8366 100644 --- a/client/src/store/detailTab.tsx +++ b/client/src/store/detailTab.tsx @@ -11,9 +11,10 @@ import { AssetDetailFields, AssetVersionDetailFields, ActorDetailFields, - UpdateObjectDetailsDataInput + UpdateObjectDetailsDataInput, + IngestFolder } from '../types/graphql'; -import lodash from 'lodash'; +import * as yup from 'yup'; export interface ModelDetailsType { DateCreated: string | null; @@ -33,11 +34,16 @@ export type DetailsViewFieldErrors = { name: boolean; dateCaptured: boolean; }; + captureData: { + name: boolean; + datasetFieldId: boolean; + itemPositionFieldId: boolean; + itemArrangementFieldId: boolean; + clusterGeometryFieldId: boolean; + }; }; -interface SceneDetailsType { - HasBeenQCd: boolean; - IsOriented: boolean; +export interface SceneDetailsType { CountScene: number; CountNode: number; CountCamera: number; @@ -47,6 +53,9 @@ interface SceneDetailsType { CountSetup: number; CountTour: number; EdanUUID: string | null; + ApprovedForPublication: boolean; + PublicationApprover: string | null; + PosedAndQCd: boolean; ModelSceneXref: any[]; } @@ -77,7 +86,7 @@ type DetailTabStore = { AssetDetails: AssetDetailFields; ActorDetails: ActorDetailFields; StakeholderDetails: StakeholderDetailFields; - updateDetailField: (metadataType: eSystemObjectType, fieldName: string, value: number | string | boolean | Date | null) => void; + updateDetailField: (metadataType: eSystemObjectType, fieldName: string, value: number | string | boolean | Date | null | IngestFolder[]) => void; getDetail: (type: eSystemObjectType) => DetailsTabType | void; initializeDetailFields: (data: any, type: eSystemObjectType) => void; getDetailsViewFieldErrors: (metadata: UpdateObjectDetailsDataInput, objectType: eSystemObjectType) => string[]; @@ -144,8 +153,6 @@ export const useDetailTabStore = create((set: SetState((set: SetState((set: SetState { + return { + name: folder.name, + variantType: folder.variantType + }; + }); + updateDetailField(eSystemObjectType.eCaptureData, 'folders', sanitizedFolders); updateDetailField(eSystemObjectType.eCaptureData, 'isValidData', isValidData); updateDetailField(eSystemObjectType.eCaptureData, 'itemArrangementFieldId', itemArrangementFieldId); updateDetailField(eSystemObjectType.eCaptureData, 'itemPositionFieldId', itemPositionFieldId); @@ -461,10 +477,11 @@ export const useDetailTabStore = create((set: SetState((set: SetState { - // UPDATE these error fields as we include more validation for ingestion - const errors: DetailsViewFieldErrors = { - model: { - name: false, - dateCaptured: false - } + // UPDATE these error fields as we include more validation for details tab + // errors should be responsible for rendering error version of the input + // const errors: DetailsViewFieldErrors = { + // model: { + // name: false, + // dateCaptured: false + // }, + // captureData: { + // name: false, + // datasetFieldId: false, + // itemPositionFieldId: false, + // itemArrangementFieldId: false, + // clusterGeometryFieldId: false + // } + // }; + + const option = { + abortEarly: false }; - const errorMessages: string[] = []; - + if (!metadata.Name?.trim().length) errorMessages.push('Please input a valid Name'); if (objectType === eSystemObjectType.eModel) { - if (!lodash.isNil(metadata.Name)) { - errors.model.name = !metadata.Name.trim().length; + const { Model } = metadata; + + try { + schemaModel.validateSync( + { + dateCaptured: Model?.DateCaptured + }, + option + ); + } catch (error) { + if (error instanceof Error) + errorMessages.push(error.message); } - if (!lodash.isNil(metadata.Model?.DateCaptured)) { - errors.model.dateCaptured = metadata?.Model?.DateCaptured.toString() === 'Invalid Date' || new Date(metadata?.Model?.DateCaptured).getTime() > new Date().getTime(); + } + + if (objectType === eSystemObjectType.eCaptureData) { + const { CaptureData } = metadata; + + try { + schemaCD.validateSync( + { + datasetFieldId: CaptureData?.datasetFieldId, + itemArrangementFieldId: CaptureData?.itemArrangementFieldId, + itemPositionFieldId: CaptureData?.itemPositionFieldId, + clusterGeometryFieldId: CaptureData?.clusterGeometryFieldId + }, + option + ); + } catch (error) { + if (error instanceof Error) + errorMessages.push(error.message); } - for (const field in errors.model) { - if (errors.model[field]) errorMessages.push(field); + } + + if (objectType === eSystemObjectType.eItem || objectType === eSystemObjectType.eSubject) { + const { Item, Subject } = metadata; + const itemOrSubject = objectType === eSystemObjectType.eItem ? Item : Subject; + try { + schemaItemAndSubject.validateSync( + { + Latitude: Number(itemOrSubject?.Latitude), + Longitude: Number(itemOrSubject?.Longitude), + Altitude: Number(itemOrSubject?.Altitude), + TS0: Number(itemOrSubject?.TS0), + TS1: Number(itemOrSubject?.TS1), + TS2: Number(itemOrSubject?.TS2), + R0: Number(itemOrSubject?.R0), + R1: Number(itemOrSubject?.R1), + R2: Number(itemOrSubject?.R2), + R3: Number(itemOrSubject?.R3) + }, + option + ); + } catch (error) { + if (error instanceof Error) + errorMessages.push(error.message); } } return errorMessages; } })); + +const schemaCD = yup.object().shape({ + datasetFieldId: yup.number().positive('Dataset Field ID must be positive').max(2147483647, 'Dataset Field ID is too large').nullable(), + itemPositionFieldId: yup.number().positive('Item Position Field ID must be positive').max(2147483647, 'Item Position Field ID is too large').nullable(), + itemArrangementFieldId: yup.number().positive('Item Arrangement Field ID must be positive').max(2147483647, 'Item Arrangement Field ID is too large').nullable(), + clusterGeometryFieldId: yup.number().positive('Cluster Geometry Field ID must be positive').max(2147483647, 'Cluster Geometry Field ID is too large').nullable() +}); + +const schemaModel = yup.object().shape({ + dateCaptured: yup.date().max(Date(), 'Date Created cannot be set in the future') +}); + +const schemaItemAndSubject = yup.object().shape({ + Latitude: yup.number().typeError('Number must be in standard or scientific notation'), + Longitude: yup.number().typeError('Number must be in standard or scientific notation'), + Altitude: yup.number().typeError('Number must be in standard or scientific notation'), + TS0: yup.number().typeError('Number must be in standard or scientific notation'), + TS1: yup.number().typeError('Number must be in standard or scientific notation'), + TS2: yup.number().typeError('Number must be in standard or scientific notation'), + R0: yup.number().typeError('Number must be in standard or scientific notation'), + R1: yup.number().typeError('Number must be in standard or scientific notation'), + R2: yup.number().typeError('Number must be in standard or scientific notation'), + R3: yup.number().typeError('Number must be in standard or scientific notation') +}); \ No newline at end of file diff --git a/client/src/store/identifier.ts b/client/src/store/identifier.ts index fdae07a7d..0b15242cb 100644 --- a/client/src/store/identifier.ts +++ b/client/src/store/identifier.ts @@ -15,22 +15,32 @@ type StateIdentifier = { id: number; identifier: string; identifierType: number; - selected: boolean; idIdentifier: number; + preferred?: boolean; }; type IdentifierStore = { stateIdentifiers: StateIdentifier[]; + originalIdentifiers: StateIdentifier[]; + areIdentifiersUpdated: () => boolean; getIdentifierState: () => StateIdentifier[]; addNewIdentifier: () => void; initializeIdentifierState: (identifiers: Identifier[]) => void; + initializeIdentifierPreferred: () => void; removeTargetIdentifier: (id: number, idInd?: boolean | number) => void; updateIdentifier: (id: number, name: string, value: string | number | boolean) => void; checkIdentifiersBeforeUpdate: () => string[]; + initializePreferredIdentifier: (idIdentifier: number) => void; + updateIdentifierPreferred: (id: number) => void; }; export const useIdentifierStore = create((set: SetState, get: GetState) => ({ stateIdentifiers: [], + originalIdentifiers: [], + areIdentifiersUpdated: () => { + const { stateIdentifiers, originalIdentifiers } = get(); + return !lodash.isEqual(stateIdentifiers, originalIdentifiers); + }, getIdentifierState: () => { const { stateIdentifiers } = get(); return stateIdentifiers; @@ -41,24 +51,24 @@ export const useIdentifierStore = create((set: SetState { + initializeIdentifierState: identifiers => { const initialIdentifiers: StateIdentifier[] = identifiers.map((identifier, ind) => { return { id: ind, identifier: identifier.identifier, identifierType: identifier.identifierType, - selected: true, idIdentifier: identifier.idIdentifier }; }); - set({ stateIdentifiers: initialIdentifiers }); + set({ stateIdentifiers: initialIdentifiers, originalIdentifiers: initialIdentifiers }); }, + initializeIdentifierPreferred: () => {}, removeTargetIdentifier: (id: number, idInd = false) => { const { stateIdentifiers } = get(); let updatedIdentifiers; @@ -76,7 +86,7 @@ export const useIdentifierStore = create((set: SetState((set: SetState { + stateIdentifiers.forEach(identifier => { if (!identifier.identifier) { errors['Missing identifier field'] = 'Missing identifier field'; } if (!identifier.identifierType) { errors['Missing identifier type'] = 'Missing identifier type'; } - if (!identifier.selected) { - errors['Identifiers should not be unchecked'] = ('Identifiers should not be unchecked'); - } }); for (const error in errors) { result.push(error); } return result; + }, + // this method will be responsible for setting the preferred identifier + initializePreferredIdentifier: (idIdentifier: number): void => { + const { stateIdentifiers } = get(); + const stateIdentifiersCopy = stateIdentifiers.map(identifier => { + if (identifier.idIdentifier === idIdentifier) identifier.preferred = true; + return identifier; + }); + set({ stateIdentifiers: stateIdentifiersCopy }); + }, + updateIdentifierPreferred: (id: number): void => { + const { stateIdentifiers } = get(); + const stateIdentifiersCopy = stateIdentifiers.map(identifier => { + if (id === identifier.id) { + if (identifier.preferred) { + identifier.preferred = undefined; + } else { + identifier.preferred = true; + } + return identifier; + } + identifier.preferred = undefined; + return identifier; + }); + set({ stateIdentifiers: stateIdentifiersCopy }); } -})); \ No newline at end of file +})); diff --git a/client/src/store/metadata/index.ts b/client/src/store/metadata/index.ts index ffd080aba..b82dde9da 100644 --- a/client/src/store/metadata/index.ts +++ b/client/src/store/metadata/index.ts @@ -12,7 +12,6 @@ import { apolloClient } from '../../graphql'; import { AreCameraSettingsUniformDocument, AssetVersionContent, - GetAssetVersionsDetailsDocument, GetAssetVersionsDetailsQuery, GetContentsForAssetVersionsDocument, @@ -60,7 +59,7 @@ type MetadataStore = { export const useMetadataStore = create((set: SetState, get: GetState) => ({ metadatas: [], - getSelectedIdentifiers: (identifiers: StateIdentifier[]): StateIdentifier[] | undefined => lodash.filter(identifiers, { selected: true }), + getSelectedIdentifiers: (identifiers: StateIdentifier[]): StateIdentifier[] | undefined => identifiers, getFieldErrors: (metadata: StateMetadata): FieldErrors => { const { getAssetType } = useVocabularyStore.getState(); // UPDATE these error fields as we include more validation for ingestion @@ -352,12 +351,21 @@ export const useMetadataStore = create((set: SetState { - const { getInitialEntry } = useVocabularyStore.getState(); - const stateFolders: StateFolder[] = folders.map((folder, index: number) => ({ - id: index, - name: folder, - variantType: getInitialEntry(eVocabularySetID.eCaptureDataFileVariantType) - })); + const { getInitialEntry, getEntries } = useVocabularyStore.getState(); + const stateFolders: StateFolder[] = folders.map((folder, index: number) => { + let variantType = getInitialEntry(eVocabularySetID.eCaptureDataFileVariantType); + const variantTypes = getEntries(eVocabularySetID.eCaptureDataFileVariantType); + + if (folder.search('raw') !== -1) variantType = variantTypes[0].idVocabulary; + if (folder.search('processed') !== -1) variantType = variantTypes[1].idVocabulary; + if (folder.search('camera') !== -1) variantType = variantTypes[2].idVocabulary; + + return { + id: index, + name: folder, + variantType + }; + }); return stateFolders; }, diff --git a/client/src/store/metadata/metadata.defaults.ts b/client/src/store/metadata/metadata.defaults.ts index b70bf978b..3510b7601 100644 --- a/client/src/store/metadata/metadata.defaults.ts +++ b/client/src/store/metadata/metadata.defaults.ts @@ -3,9 +3,8 @@ * * Default field definitions for the metadata store. */ -import lodash from 'lodash'; import * as yup from 'yup'; -import { ModelFields, OtherFields, PhotogrammetryFields, SceneFields, StateIdentifier } from './metadata.types'; +import { ModelFields, OtherFields, PhotogrammetryFields, SceneFields } from './metadata.types'; const identifierWhenSelectedValidation = { is: true, @@ -16,8 +15,7 @@ const identifierWhenSelectedValidation = { const identifierSchema = yup.object().shape({ id: yup.number().required(), identifier: yup.string().trim().when('selected', identifierWhenSelectedValidation), - identifierType: yup.number().nullable(true), - selected: yup.boolean().required() + identifierType: yup.number().nullable(true) }); const folderSchema = yup.object().shape({ @@ -27,8 +25,8 @@ const folderSchema = yup.object().shape({ }); const identifierValidation = { - test: array => !!lodash.filter(array as StateIdentifier[], { selected: true }).length, - message: 'Should select/provide at least 1 identifier' + test: array => array.length && array.every(identifier => identifier.identifier.length), + message: 'Should provide at least 1 identifier with valid identifier ID' }; const identifiersWhenValidation = { @@ -65,19 +63,39 @@ export const photogrammetryFieldsSchema = yup.object().shape({ systemCreated: yup.boolean().required(), identifiers: yup.array().of(identifierSchema).when('systemCreated', identifiersWhenValidation), folders: yup.array().of(folderSchema), - name: yup.string(), - description: yup.string().required('Description cannot be empty'), + name: yup.string().required('Name cannot be empty'), + // description: yup.string().required('Description cannot be empty'), dateCaptured: yup.date().required(), datasetType: yup.number().typeError('Please select a valid dataset type'), - datasetFieldId: yup.number().nullable(true), + datasetFieldId: yup + .number() + .nullable(true) + .typeError('Dataset Field ID must be a positive integer') + .positive('Dataset Field ID must be a positive integer') + .max(2147483647, 'Dataset Field ID is too large'), itemPositionType: yup.number().nullable(true), - itemPositionFieldId: yup.number().nullable(true), - itemArrangementFieldId: yup.number().nullable(true), + itemPositionFieldId: yup + .number() + .nullable(true) + .typeError('Item Position Field ID must be a positive integer') + .positive('Item Position Field ID must be a positive integer') + .max(2147483647, 'Item Position Field ID is too large'), + itemArrangementFieldId: yup + .number() + .nullable(true) + .typeError('Item Arrangement Field ID must be a positive integer') + .positive('Item Arrangement Field ID must be a positive integer') + .max(2147483647, 'Item Arrangement Field ID is too large'), focusType: yup.number().nullable(true), lightsourceType: yup.number().nullable(true), backgroundRemovalMethod: yup.number().nullable(true), clusterType: yup.number().nullable(true), - clusterGeometryFieldId: yup.number().nullable(true), + clusterGeometryFieldId: yup + .number() + .nullable(true) + .typeError('Cluster Geometry Field ID must be a positive integer') + .positive('Cluster Geometry Field ID must be a positive integer') + .max(2147483647, 'Cluster Geometry Field ID is too large'), cameraSettingUniform: yup.boolean().required(), directory: yup.string() }); @@ -150,11 +168,11 @@ export const defaultSceneFields: SceneFields = { sourceObjects: [], derivedObjects: [], referenceModels: [], - hasBeenQCd: false, - isOriented: false, name: '', directory: '', EdanUUID: '', + approvedForPublication: false, + posedAndQCd: false, }; export type SceneSchemaType = typeof sceneFieldsSchema; diff --git a/client/src/store/metadata/metadata.types.ts b/client/src/store/metadata/metadata.types.ts index 10ba4e855..3d7a4ac80 100644 --- a/client/src/store/metadata/metadata.types.ts +++ b/client/src/store/metadata/metadata.types.ts @@ -55,8 +55,8 @@ export type StateIdentifier = { id: number; identifier: string; identifierType: number | null; - selected: boolean; idIdentifier: number; + preferred?: boolean; }; export type StateFolder = { @@ -111,11 +111,11 @@ export type SceneFields = { sourceObjects: StateRelatedObject[]; derivedObjects: StateRelatedObject[]; referenceModels: StateReferenceModel[]; - hasBeenQCd: boolean; - isOriented: boolean; name: string; directory: string; EdanUUID: string; + approvedForPublication: boolean; + posedAndQCd: boolean; idAsset?: number; }; diff --git a/client/src/store/repository.ts b/client/src/store/repository.ts index 6dc7c94e1..1bcf71d2c 100644 --- a/client/src/store/repository.ts +++ b/client/src/store/repository.ts @@ -8,7 +8,10 @@ import { RepositoryFilter } from '../pages/Repository'; import { getObjectChildren, getObjectChildrenForRoot } from '../pages/Repository/hooks/useRepository'; import { NavigationResultEntry } from '../types/graphql'; import { eMetadata, eSystemObjectType } from '../types/server'; -import { parseRepositoryTreeNodeId, validateArray } from '../utils/repository'; +import { parseRepositoryTreeNodeId, validateArray, getTermForSystemObjectType } from '../utils/repository'; +import { apolloClient } from '../graphql'; +import { GetSystemObjectDetailsDocument } from '../types/graphql'; +import { toast } from 'react-toastify'; type RepositoryStore = { isExpanded: boolean; @@ -32,7 +35,9 @@ type RepositoryStore = { modelFileType: number[]; dateCreatedFrom: Date | string | null; dateCreatedTo: Date | string | null; - repositoryBrowserRoot: number | null; + idRoot: number | null; + repositoryBrowserRootObjectType: string | null; + repositoryBrowserRootName: string | null; getFilterState: () => RepositoryFilter; removeUnitsOrProjects: (id: number, type: eSystemObjectType) => void; updateFilterValue: (name: string, value: number | number[] | Date | null) => void; @@ -44,9 +49,10 @@ type RepositoryStore = { getMoreChildren: (nodeId: string, cursorMark: string) => Promise; updateRepositoryFilter: (filter: RepositoryFilter) => void; setCookieToState: () => void; - setDefaultIngestionFilters: (systemObjectType: eSystemObjectType, idRoot: number | undefined) => void; + setDefaultIngestionFilters: (systemObjectType: eSystemObjectType, idRoot: number | undefined) => Promise; getChildrenForIngestion: (idRoot: number) => void; closeRepositoryBrowser: () => void; + resetRepositoryBrowserRoot: () => void; }; export const treeRootKey: string = 'root'; @@ -72,7 +78,9 @@ export const useRepositoryStore = create((set: SetState { const { initializeTree, setCookieToState, keyword } = get(); set({ [name]: value, loading: true, search: keyword }); @@ -88,10 +96,10 @@ export const useRepositoryStore = create((set: SetState => { - const { getFilterState, getChildrenForIngestion, repositoryBrowserRoot } = get(); + const { getFilterState, getChildrenForIngestion, idRoot } = get(); const filter = getFilterState(); - if (repositoryBrowserRoot) { - getChildrenForIngestion(repositoryBrowserRoot); + if (idRoot) { + getChildrenForIngestion(idRoot); } else { const { data, error } = await getObjectChildrenForRoot(filter); if (data && !error) { @@ -165,6 +173,7 @@ export const useRepositoryStore = create((set: SetState = new Map(tree); const previousEntries = updatedTree.get(nodeId) || []; updatedTree.set(nodeId, [...previousEntries, ...entries]); + console.log(`getMoreChildren: ${updatedTree.size}`); set({ tree: updatedTree }); if (cursorMark) { const newCursors = cursors; @@ -199,7 +208,7 @@ export const useRepositoryStore = create((set: SetState { + updateRepositoryFilter: async (filter: RepositoryFilter): Promise => { const { repositoryRootType, objectsToDisplay, @@ -217,7 +226,6 @@ export const useRepositoryStore = create((set: SetState((set: SetState(filter.captureMethod, captureMethod), variantType: validateArray(filter.variantType, variantType), modelPurpose: validateArray(filter.modelPurpose, modelPurpose), - modelFileType: validateArray(filter.modelFileType, modelFileType) + modelFileType: validateArray(filter.modelFileType, modelFileType), + idRoot: filter.idRoot // dateCreatedFrom: filter.dateCreatedFrom, // dateCreatedTo: filter.dateCreatedTo, }; - set(stateValues); + + if (filter.idRoot) { + const { data: { getSystemObjectDetails: { name, objectType } } } = await apolloClient.query({ + query: GetSystemObjectDetailsDocument, + variables: { + input: { + idSystemObject: filter.idRoot + } + } + }); + set({ idRoot: filter.idRoot, repositoryBrowserRootName: name, repositoryBrowserRootObjectType: getTermForSystemObjectType(objectType) }); + } } setCookieToState(); initializeTree(); @@ -256,7 +276,10 @@ export const useRepositoryStore = create((set: SetState((set: SetState((set: SetState((set: SetState((set: SetState { + setDefaultIngestionFilters: async (systemObjectType: eSystemObjectType, idRoot: number | undefined): Promise => { const { resetKeywordSearch, resetRepositoryFilter, getChildrenForIngestion } = get(); - set({ isExpanded: false, repositoryBrowserRoot: idRoot }); + if (idRoot !== undefined) { + const { data: { getSystemObjectDetails: { name, objectType } } } = await apolloClient.query({ + query: GetSystemObjectDetailsDocument, + variables: { + input: { + idSystemObject: idRoot + } + } + }); + resetRepositoryFilter(false); + set({ isExpanded: false, idRoot, repositoryBrowserRootName: name, repositoryBrowserRootObjectType: getTermForSystemObjectType(objectType) }); + } else { + toast.warn('Subject was not found in database.'); + } resetKeywordSearch(); - resetRepositoryFilter(false); if (systemObjectType === eSystemObjectType.eModel) { set({ repositoryRootType: [eSystemObjectType.eModel, eSystemObjectType.eScene], objectsToDisplay: [eSystemObjectType.eCaptureData, eSystemObjectType.eModel] }); @@ -375,6 +414,9 @@ export const useRepositoryStore = create((set: SetState { - set({ isExpanded: true, repositoryBrowserRoot: null }); + set({ isExpanded: true, idRoot: null }); + }, + resetRepositoryBrowserRoot: (): void => { + set({ idRoot: null, repositoryBrowserRootObjectType: null, repositoryBrowserRootName: null }); } })); \ No newline at end of file diff --git a/client/src/store/utils.ts b/client/src/store/utils.ts index 7c20f6c73..a0a7bc097 100644 --- a/client/src/store/utils.ts +++ b/client/src/store/utils.ts @@ -78,8 +78,8 @@ export function parseIdentifiersToState(identifiers: IngestIdentifier[], default id: index, identifier, identifierType, - selected: true, - idIdentifier + idIdentifier, + preferred: false }) ); diff --git a/client/src/store/workflow.tsx b/client/src/store/workflow.tsx index 79aa3d449..8b97e116d 100644 --- a/client/src/store/workflow.tsx +++ b/client/src/store/workflow.tsx @@ -44,8 +44,8 @@ export const useWorkflowStore = create((set: SetState { @@ -89,11 +89,9 @@ export const useWorkflowStore = create((set: SetState => { const { fetchWorkflowList } = get(); - console.log('changeType', changeType, 'value', value, column, direction); if (changeType === ePaginationChange.ePage && value !== null) set({ pageNumber: value }); if (changeType === ePaginationChange.eRowCount && value !== null) set({ rowCount: value }); diff --git a/client/src/types/graphql.tsx b/client/src/types/graphql.tsx index fb05a5bf6..1ea77628f 100644 --- a/client/src/types/graphql.tsx +++ b/client/src/types/graphql.tsx @@ -357,6 +357,7 @@ export type Mutation = { deleteObjectConnection: DeleteObjectConnectionResult; discardUploadedAssetVersions: DiscardUploadedAssetVersionsResult; ingestData: IngestDataResult; + publish: PublishResult; rollbackSystemObjectVersion: RollbackSystemObjectVersionResult; updateDerivedObjects: UpdateDerivedObjectsResult; updateLicense: CreateLicenseResult; @@ -462,6 +463,11 @@ export type MutationIngestDataArgs = { }; +export type MutationPublishArgs = { + input: PublishInput; +}; + + export type MutationRollbackSystemObjectVersionArgs = { input: RollbackSystemObjectVersionInput; }; @@ -625,8 +631,8 @@ export type IngestScene = { idAssetVersion: Scalars['Int']; systemCreated: Scalars['Boolean']; name: Scalars['String']; - hasBeenQCd: Scalars['Boolean']; - isOriented: Scalars['Boolean']; + approvedForPublication: Scalars['Boolean']; + posedAndQCd: Scalars['Boolean']; directory: Scalars['String']; identifiers: Array; referenceModels: Array; @@ -934,8 +940,8 @@ export type IngestSceneInput = { idAsset?: Maybe; systemCreated: Scalars['Boolean']; name: Scalars['String']; - hasBeenQCd: Scalars['Boolean']; - isOriented: Scalars['Boolean']; + approvedForPublication: Scalars['Boolean']; + posedAndQCd: Scalars['Boolean']; directory: Scalars['String']; identifiers: Array; sourceObjects: Array; @@ -1305,8 +1311,6 @@ export type GetFilterViewDataResult = { export type CreateSceneInput = { Name: Scalars['String']; - HasBeenQCd: Scalars['Boolean']; - IsOriented: Scalars['Boolean']; idAssetThumbnail?: Maybe; CountScene?: Maybe; CountNode?: Maybe; @@ -1317,6 +1321,8 @@ export type CreateSceneInput = { CountSetup?: Maybe; CountTour?: Maybe; EdanUUID?: Maybe; + ApprovedForPublication: Scalars['Boolean']; + PosedAndQCd: Scalars['Boolean']; }; export type CreateSceneResult = { @@ -1345,9 +1351,7 @@ export type GetIntermediaryFileResult = { export type Scene = { __typename?: 'Scene'; idScene: Scalars['Int']; - HasBeenQCd: Scalars['Boolean']; idAssetThumbnail?: Maybe; - IsOriented: Scalars['Boolean']; Name: Scalars['String']; CountScene?: Maybe; CountNode?: Maybe; @@ -1358,6 +1362,8 @@ export type Scene = { CountSetup?: Maybe; CountTour?: Maybe; EdanUUID?: Maybe; + ApprovedForPublication: Scalars['Boolean']; + PosedAndQCd: Scalars['Boolean']; AssetThumbnail?: Maybe; ModelSceneXref?: Maybe>>; SystemObject?: Maybe; @@ -1415,6 +1421,7 @@ export type SubjectDetailFieldsInput = { TS0?: Maybe; TS1?: Maybe; TS2?: Maybe; + idIdentifierPreferred?: Maybe; }; export type ItemDetailFieldsInput = { @@ -1448,6 +1455,7 @@ export type CaptureDataDetailFieldsInput = { clusterType?: Maybe; clusterGeometryFieldId?: Maybe; folders: Array; + isValidData?: Maybe; }; export type ModelDetailFieldsInput = { @@ -1464,8 +1472,8 @@ export type SceneDetailFieldsInput = { AssetType?: Maybe; Tours?: Maybe; Annotation?: Maybe; - HasBeenQCd?: Maybe; - IsOriented?: Maybe; + ApprovedForPublication?: Maybe; + PosedAndQCd?: Maybe; }; export type ProjectDocumentationDetailFieldsInput = { @@ -1522,35 +1530,46 @@ export type UpdateObjectDetailsResult = { message: Scalars['String']; }; +export type ExistingRelationship = { + idSystemObject: Scalars['Int']; + objectType: Scalars['Int']; +}; + export type UpdateDerivedObjectsInput = { idSystemObject: Scalars['Int']; - Derivatives: Array; - PreviouslySelected: Array; + ParentObjectType: Scalars['Int']; + Derivatives: Array; + PreviouslySelected: Array; }; export type UpdateDerivedObjectsResult = { __typename?: 'UpdateDerivedObjectsResult'; success: Scalars['Boolean']; + message: Scalars['String']; + status: Scalars['String']; }; export type UpdateSourceObjectsInput = { idSystemObject: Scalars['Int']; - Sources: Array; - PreviouslySelected: Array; + ChildObjectType: Scalars['Int']; + Sources: Array; + PreviouslySelected: Array; }; export type UpdateSourceObjectsResult = { __typename?: 'UpdateSourceObjectsResult'; success: Scalars['Boolean']; + message: Scalars['String']; + status: Scalars['String']; }; export type UpdateIdentifier = { id: Scalars['Int']; identifier: Scalars['String']; identifierType: Scalars['Int']; - selected: Scalars['Boolean']; idSystemObject: Scalars['Int']; idIdentifier: Scalars['Int']; + preferred?: Maybe; }; export type DeleteObjectConnectionResult = { @@ -1561,7 +1580,9 @@ export type DeleteObjectConnectionResult = { export type DeleteObjectConnectionInput = { idSystemObjectMaster: Scalars['Int']; + objectTypeMaster: Scalars['Int']; idSystemObjectDerived: Scalars['Int']; + objectTypeDerived: Scalars['Int']; }; export type DeleteIdentifierResult = { @@ -1599,7 +1620,18 @@ export type CreateIdentifierInput = { identifierValue: Scalars['String']; identifierType: Scalars['Int']; idSystemObject?: Maybe; - selected: Scalars['Boolean']; + preferred?: Maybe; +}; + +export type PublishInput = { + idSystemObject: Scalars['Int']; + eState: Scalars['Int']; +}; + +export type PublishResult = { + __typename?: 'PublishResult'; + success: Scalars['Boolean']; + message: Scalars['String']; }; @@ -1631,6 +1663,7 @@ export type SubjectDetailFields = { TS0?: Maybe; TS1?: Maybe; TS2?: Maybe; + idIdentifierPreferred?: Maybe; }; export type ItemDetailFields = { @@ -1675,8 +1708,6 @@ export type SceneDetailFields = { AssetType?: Maybe; Tours?: Maybe; Annotation?: Maybe; - HasBeenQCd?: Maybe; - IsOriented?: Maybe; CountScene?: Maybe; CountNode?: Maybe; CountCamera?: Maybe; @@ -1686,6 +1717,9 @@ export type SceneDetailFields = { CountSetup?: Maybe; CountTour?: Maybe; EdanUUID?: Maybe; + ApprovedForPublication?: Maybe; + PublicationApprover?: Maybe; + PosedAndQCd?: Maybe; idScene?: Maybe; }; @@ -1770,6 +1804,8 @@ export type GetSystemObjectDetailsResult = { objectType: Scalars['Int']; allowed: Scalars['Boolean']; publishedState: Scalars['String']; + publishedEnum: Scalars['Int']; + publishable: Scalars['Boolean']; thumbnail?: Maybe; identifiers: Array; objectAncestors: Array>; @@ -2701,6 +2737,19 @@ export type DeleteObjectConnectionMutation = ( ) } ); +export type PublishMutationVariables = Exact<{ + input: PublishInput; +}>; + + +export type PublishMutation = ( + { __typename?: 'Mutation' } + & { publish: ( + { __typename?: 'PublishResult' } + & Pick + ) } +); + export type RollbackSystemObjectVersionMutationVariables = Exact<{ input: RollbackSystemObjectVersionInput; }>; @@ -2723,7 +2772,7 @@ export type UpdateDerivedObjectsMutation = ( { __typename?: 'Mutation' } & { updateDerivedObjects: ( { __typename?: 'UpdateDerivedObjectsResult' } - & Pick + & Pick ) } ); @@ -2749,7 +2798,7 @@ export type UpdateSourceObjectsMutation = ( { __typename?: 'Mutation' } & { updateSourceObjects: ( { __typename?: 'UpdateSourceObjectsResult' } - & Pick + & Pick ) } ); @@ -2978,7 +3027,7 @@ export type GetAssetVersionsDetailsQuery = ( )> } )>, Scene?: Maybe<( { __typename?: 'IngestScene' } - & Pick + & Pick & { identifiers: Array<( { __typename?: 'IngestIdentifier' } & Pick @@ -3289,7 +3338,7 @@ export type GetSceneQuery = ( { __typename?: 'GetSceneResult' } & { Scene?: Maybe<( { __typename?: 'Scene' } - & Pick + & Pick & { ModelSceneXref?: Maybe @@ -3312,7 +3361,7 @@ export type GetSceneForAssetVersionQuery = ( { __typename?: 'SceneConstellation' } & { Scene?: Maybe<( { __typename?: 'Scene' } - & Pick + & Pick )>, ModelSceneXref?: Maybe @@ -3362,7 +3411,7 @@ export type GetDetailsTabDataForObjectQuery = ( & Pick )>, Subject?: Maybe<( { __typename?: 'SubjectDetailFields' } - & Pick + & Pick )>, Item?: Maybe<( { __typename?: 'ItemDetailFields' } & Pick @@ -3396,7 +3445,7 @@ export type GetDetailsTabDataForObjectQuery = ( )>> } )>, Scene?: Maybe<( { __typename?: 'SceneDetailFields' } - & Pick + & Pick )>, IntermediaryFile?: Maybe<( { __typename?: 'IntermediaryFileDetailFields' } & Pick @@ -3480,7 +3529,7 @@ export type GetSystemObjectDetailsQuery = ( { __typename?: 'Query' } & { getSystemObjectDetails: ( { __typename?: 'GetSystemObjectDetailsResult' } - & Pick + & Pick & { identifiers: Array<( { __typename?: 'IngestIdentifier' } & Pick @@ -3612,7 +3661,7 @@ export type GetObjectsForItemQuery = ( & Pick )>, Scene: Array<( { __typename?: 'Scene' } - & Pick + & Pick )>, IntermediaryFile: Array<( { __typename?: 'IntermediaryFile' } & Pick @@ -4336,6 +4385,40 @@ export function useDeleteObjectConnectionMutation(baseOptions?: Apollo.MutationH export type DeleteObjectConnectionMutationHookResult = ReturnType; export type DeleteObjectConnectionMutationResult = Apollo.MutationResult; export type DeleteObjectConnectionMutationOptions = Apollo.BaseMutationOptions; +export const PublishDocument = gql` + mutation publish($input: PublishInput!) { + publish(input: $input) { + success + message + } +} + `; +export type PublishMutationFn = Apollo.MutationFunction; + +/** + * __usePublishMutation__ + * + * To run a mutation, you first call `usePublishMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `usePublishMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [publishMutation, { data, loading, error }] = usePublishMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function usePublishMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(PublishDocument, options); + } +export type PublishMutationHookResult = ReturnType; +export type PublishMutationResult = Apollo.MutationResult; +export type PublishMutationOptions = Apollo.BaseMutationOptions; export const RollbackSystemObjectVersionDocument = gql` mutation rollbackSystemObjectVersion($input: RollbackSystemObjectVersionInput!) { rollbackSystemObjectVersion(input: $input) { @@ -4374,6 +4457,8 @@ export const UpdateDerivedObjectsDocument = gql` mutation updateDerivedObjects($input: UpdateDerivedObjectsInput!) { updateDerivedObjects(input: $input) { success + message + status } } `; @@ -4441,6 +4526,8 @@ export const UpdateSourceObjectsDocument = gql` mutation updateSourceObjects($input: UpdateSourceObjectsInput!) { updateSourceObjects(input: $input) { success + status + message } } `; @@ -4949,9 +5036,9 @@ export const GetAssetVersionsDetailsDocument = gql` idAssetVersion systemCreated name - hasBeenQCd - isOriented directory + approvedForPublication + posedAndQCd identifiers { identifier identifierType @@ -5674,8 +5761,6 @@ export const GetSceneDocument = gql` getScene(input: $input) { Scene { idScene - HasBeenQCd - IsOriented Name CountCamera CountScene @@ -5686,6 +5771,8 @@ export const GetSceneDocument = gql` CountSetup CountTour EdanUUID + ApprovedForPublication + PosedAndQCd ModelSceneXref { idModelSceneXref idModel @@ -5741,9 +5828,7 @@ export const GetSceneForAssetVersionDocument = gql` SceneConstellation { Scene { idScene - HasBeenQCd idAssetThumbnail - IsOriented Name CountScene CountNode @@ -5753,6 +5838,8 @@ export const GetSceneForAssetVersionDocument = gql` CountMeta CountSetup CountTour + ApprovedForPublication + PosedAndQCd } ModelSceneXref { idModelSceneXref @@ -5871,6 +5958,7 @@ export const GetDetailsTabDataForObjectDocument = gql` TS0 TS1 TS2 + idIdentifierPreferred } Item { EntireSubject @@ -5979,9 +6067,10 @@ export const GetDetailsTabDataForObjectDocument = gql` AssetType Tours Annotation - HasBeenQCd - IsOriented EdanUUID + ApprovedForPublication + PublicationApprover + PosedAndQCd idScene } IntermediaryFile { @@ -6175,6 +6264,8 @@ export const GetSystemObjectDetailsDocument = gql` objectType allowed publishedState + publishedEnum + publishable thumbnail identifiers { identifier @@ -6472,9 +6563,9 @@ export const GetObjectsForItemDocument = gql` } Scene { idScene - HasBeenQCd - IsOriented Name + ApprovedForPublication + PosedAndQCd } IntermediaryFile { idIntermediaryFile diff --git a/client/src/types/server.ts b/client/src/types/server.ts index bebe6f66e..8dddeb354 100644 --- a/client/src/types/server.ts +++ b/client/src/types/server.ts @@ -153,8 +153,6 @@ export enum eMetadata { eModelChannelPosition, eModelChannelWidth, eModelUVMapType, - eSceneIsOriented, - eSceneHasBeenQCd, eSceneCountScene, eSceneCountNode, eSceneCountCamera, @@ -163,6 +161,9 @@ export enum eMetadata { eSceneCountMeta, eSceneCountSetup, eSceneCountTour, + eSceneEdanUUID, + eScenePosedAndQCd, + eSceneApprovedForPublication, eAssetFileName, eAssetFilePath, eAssetType, @@ -194,12 +195,17 @@ export enum eSystemObjectType { eStakeholder = 13, } +export enum eLicense { + eViewDownloadCC0 = 1, // 'View and Download CC0' + eViewDownloadRestriction = 2, // 'View and Download with usage restrictions', + eViewOnly = 3, // 'View Only', + eRestricted = 4, // 'Restricted', default +} + export enum ePublishedState { eNotPublished = 0, // 'Not Published', default - eRestricted = 1, // 'Restricted', - eViewOnly = 2, // 'View Only', - eViewDownloadRestriction = 3, // 'View and Download with usage restrictions', - eViewDownloadCC0 = 4, // 'View and Download CC0' + eAPIOnly = 1, // 'API Only', + ePublished = 2, // 'Published' } export enum eIdentifierIdentifierType { @@ -208,16 +214,24 @@ export enum eIdentifierIdentifierType { eUnitCMSID = 81 } -export const PublishedStateEnumToString = (eState: ePublishedState): string => { +export function LicenseEnumToString(eState: eLicense): string { switch (eState) { - case ePublishedState.eRestricted: return 'Restricted'; - case ePublishedState.eViewOnly: return 'View Only'; - case ePublishedState.eViewDownloadRestriction: return 'View and Download with usage restrictions'; - case ePublishedState.eViewDownloadCC0: return 'View and Download CC0'; + case eLicense.eViewDownloadCC0: return 'View and Download CC0'; + case eLicense.eViewDownloadRestriction: return 'View and Download with usage restrictions'; + case eLicense.eViewOnly: return 'View Only'; default: - case ePublishedState.eNotPublished: return 'Not Published'; + case eLicense.eRestricted: return 'Restricted'; } -}; +} + +export function PublishedStateEnumToString(eState: ePublishedState): string { + switch (eState) { + case ePublishedState.eAPIOnly: return 'API Only'; + case ePublishedState.ePublished: return 'Published'; + default: + case ePublishedState.eNotPublished: return 'Not Published'; + } +} export enum eSubjectUnitIdentifierSortColumns { eUnitAbbreviation = 1, diff --git a/client/src/utils/repository.tsx b/client/src/utils/repository.tsx index 70c28780c..51ad31f60 100644 --- a/client/src/utils/repository.tsx +++ b/client/src/utils/repository.tsx @@ -22,6 +22,7 @@ import Colors, { RepositoryColorVariant } from '../theme/colors'; import { NavigationResultEntry } from '../types/graphql'; import { eMetadata, eSystemObjectType } from '../types/server'; import { safeDate, convertLocalDateToUTC } from './shared'; +import { ExistingRelationship } from '../types/graphql'; export function getSystemObjectTypesForFilter(filter: RepositoryFilter): eSystemObjectType[] { const objectTypes: eSystemObjectType[] = []; @@ -210,7 +211,7 @@ type ObjectInterfaceDetails = { }; // prettier-ignore -export function getObjectInterfaceDetails(objectType: eSystemObjectType, variant: RepositoryColorVariant): ObjectInterfaceDetails { +export function getObjectInterfaceDetails(objectType: eSystemObjectType, variant: RepositoryColorVariant, makeStyles: any): ObjectInterfaceDetails { const color: string = Colors.repository[objectType][variant]; const textColor: string = Colors.defaults.white; const backgroundColor: string = Colors.repository[objectType][RepositoryColorVariant.dark] || Colors.repository.default[RepositoryColorVariant.dark]; @@ -228,7 +229,8 @@ export function getObjectInterfaceDetails(objectType: eSystemObjectType, variant case eSystemObjectType.eAssetVersion: return { icon: , color }; } - return { icon: , color }; + + return { icon: , color }; } export function sortEntriesAlphabetically(entries: NavigationResultEntry[]): NavigationResultEntry[] { @@ -262,10 +264,59 @@ export function getDownloadObjectVersionUrlForObject(serverEndPoint: string | un return `${serverEndPoint}/download?idSystemObjectVersion=${idSystemObjectVersion}`; } -export function getRootSceneDownloadUrlForVoyager(serverEndPoint: string | undefined, idSystemObject: number, path: string): string { - return `${serverEndPoint}/download/idSystemObject-${idSystemObject}/${path ? path + '/' : ''}`; +export enum eVoyagerStoryMode { + eViewer, + eEdit, + eQC, + eAuthor, + eExpert, +} + +export function getModeForVoyager(eMode?: eVoyagerStoryMode): string { + switch (eMode) { + default: + case eVoyagerStoryMode.eViewer: return ''; + case eVoyagerStoryMode.eEdit: return 'edit'; + case eVoyagerStoryMode.eQC: return 'qc'; + case eVoyagerStoryMode.eAuthor: return 'author'; + case eVoyagerStoryMode.eExpert: return 'expert'; + } +} + +export function getVoyagerModeFromParam(sMode: string): eVoyagerStoryMode { + switch (sMode) { + default: + case '': return eVoyagerStoryMode.eViewer; + case 'edit': return eVoyagerStoryMode.eEdit; + case 'qc': return eVoyagerStoryMode.eQC; + case 'author': return eVoyagerStoryMode.eAuthor; + case 'expert': return eVoyagerStoryMode.eExpert; + } +} + +export function getRootSceneDownloadUrlForVoyager(serverEndPoint: string | undefined, idSystemObject: number, + path: string, eMode?: eVoyagerStoryMode | undefined): string { + let dlPath: string = 'download'; + switch (eMode) { + default: + case eVoyagerStoryMode.eViewer: dlPath='download'; break; + case eVoyagerStoryMode.eEdit: dlPath='download-wd'; break; + case eVoyagerStoryMode.eQC: dlPath='download-wd'; break; + case eVoyagerStoryMode.eAuthor: dlPath='download-wd'; break; + case eVoyagerStoryMode.eExpert: dlPath='download-wd'; break; + } + return `${serverEndPoint}/${dlPath}/idSystemObject-${idSystemObject}/${path ? path + '/' : ''}`; +} + +export function getVoyagerStoryUrl(serverEndPoint: string | undefined, idSystemObject: number, + document: string, path: string, eMode?: eVoyagerStoryMode | undefined): string { + + const mode: string = getModeForVoyager(eMode); + const root: string = getRootSceneDownloadUrlForVoyager(serverEndPoint, idSystemObject, path, eMode); + return `/repository/voyager/${idSystemObject}?mode=${mode}&root=${root}&document=${document}`; } + // prettier-ignore export function getTreeViewStyleHeight(isExpanded: boolean, isModal: boolean, breakpoint: Breakpoint): string { const isSmallScreen: boolean = breakpoint === 'lg'; @@ -310,3 +361,84 @@ export function getUpdatedCheckboxProps(updated: boolean): CheckboxProps { color: updated ? 'secondary' : 'primary' }; } + +export function isValidParentChildRelationship( + parent: number, + child: number, + selected: ExistingRelationship[], + existingParentRelationships: ExistingRelationship[], + isAddingSource: boolean +): boolean { + let result = false; + /* + *NOTE: when updating this relationship validation function, + make sure to also apply changes to the server-side version located at + ingestData.ts to maintain consistency + **NOTE: this client-side validation function will be validating a selected item BEFORE adding it, + which means the maximum connection count will be different from those seen in ingestData.ts + + xproject child to 1 - many unit parent + -skip on stakeholders for now + -skip on stakeholders for now + xitem child to only 1 parent project parent + xitem child to multiple subject parent + xCD child to 1 - many item parent + xmodel child to 1 - many parent Item + xscene child to 1 or more item parent + xmodel child to 0 - many CD parent + xCD child to 0 - many CD parent + -skip on actor for now + xmodel child to 0 to many model parent + xscene child to 1 to many model parent + -skip on actor for now + xmodel child to only 1 scene parent + -skip on IF for now + -skip on PD for now + */ + + const existingAndNewRelationships = [...existingParentRelationships, ...selected]; + switch (child) { + case eSystemObjectType.eProject: + result = parent === eSystemObjectType.eUnit; + break; + case eSystemObjectType.eItem: { + if (parent === eSystemObjectType.eSubject) result = true; + + if (parent === eSystemObjectType.eProject) { + if (isAddingSource) { + result = maximumConnections(existingAndNewRelationships, eSystemObjectType.eProject, 1); + } else { + result = maximumConnections(existingAndNewRelationships, eSystemObjectType.eProject, 1); + } + } + break; + } + case eSystemObjectType.eCaptureData: { + + if (parent === eSystemObjectType.eCaptureData || parent === eSystemObjectType.eItem) result = true; + break; + } + case eSystemObjectType.eModel: { + + if (parent === eSystemObjectType.eScene) { + if (isAddingSource) { + result = maximumConnections(existingAndNewRelationships, eSystemObjectType.eScene, 1); + } else { + result = maximumConnections(existingAndNewRelationships, eSystemObjectType.eScene, 1); + } + } + + if (parent === eSystemObjectType.eCaptureData || parent === eSystemObjectType.eModel || parent === eSystemObjectType.eItem) result = true; + break; + } + case eSystemObjectType.eScene: { + if (parent === eSystemObjectType.eItem || parent === eSystemObjectType.eModel) result = true; + break; + } + } + + return result; +} + +const maximumConnections = (relationships: ExistingRelationship[], objectType: number, limit: number) => + relationships.filter(relationship => relationship.objectType === objectType).length < limit; diff --git a/client/src/utils/shared.ts b/client/src/utils/shared.ts index a2e546c5a..0ae82721a 100644 --- a/client/src/utils/shared.ts +++ b/client/src/utils/shared.ts @@ -63,11 +63,6 @@ export const sharedLabelProps: CSSProperties = { color: palette.primary.dark }; -export function getHeaderTitle(title?: string): string { - if (title) return `${title} - Packrat`; - return 'Packrat'; -} - export function safeDate(value: any): Date | null { if (value == null) return null; diff --git a/conf/docker/daemon.json b/conf/docker/daemon.json new file mode 100644 index 000000000..ab97a9002 --- /dev/null +++ b/conf/docker/daemon.json @@ -0,0 +1,8 @@ +{ + "default-address-pools": [ + { + "base": "172.19.19.0/24", + "size": 24 + } + ] +} \ No newline at end of file diff --git a/conf/docker/docker-compose.deploy.yml b/conf/docker/docker-compose.deploy.yml index 443d8c122..79c7cebb4 100644 --- a/conf/docker/docker-compose.deploy.yml +++ b/conf/docker/docker-compose.deploy.yml @@ -41,19 +41,19 @@ services: - $PACKRAT_SERVER_PORT:4000 environment: - NODE_ENV=$NODE_ENV - - PACKRAT_CLIENT_ENDPOINT=$PACKRAT_CLIENT_ENDPOINT + - REACT_APP_PACKRAT_SERVER_ENDPOINT=$REACT_APP_PACKRAT_SERVER_ENDPOINT - PACKRAT_DATABASE_URL=$PACKRAT_DATABASE_URL + - PACKRAT_CLIENT_ENDPOINT=$PACKRAT_CLIENT_ENDPOINT - PACKRAT_SESSION_SECRET=$PACKRAT_SESSION_SECRET - PACKRAT_EDAN_AUTH_KEY=$PACKRAT_EDAN_AUTH_KEY - PACKRAT_EDAN_SERVER=$PACKRAT_EDAN_SERVER - PACKRAT_EDAN_3D_API=$PACKRAT_EDAN_3D_API - PACKRAT_EDAN_APPID=$PACKRAT_EDAN_APPID - - PACKRAT_EDAN_UPSERT_RESOURCE_ROOT:$PACKRAT_EDAN_UPSERT_RESOURCE_ROOT + - PACKRAT_EDAN_UPSERT_RESOURCE_ROOT=$PACKRAT_EDAN_UPSERT_RESOURCE_ROOT - PACKRAT_EDAN_STAGING_ROOT=$PACKRAT_EDAN_STAGING_ROOT + - PACKRAT_EDAN_RESOURCES_HOTFOLDER=$PACKRAT_EDAN_RESOURCES_HOTFOLDER - PACKRAT_OCFL_STORAGE_ROOT=$PACKRAT_OCFL_STORAGE_ROOT - PACKRAT_OCFL_STAGING_ROOT=$PACKRAT_OCFL_STAGING_ROOT - - PACKRAT_EDAN_RESOURCES_HOTFOLDER=$PACKRAT_EDAN_RESOURCES_HOTFOLDER - - PACKRAT_SOLR_HOST=$PACKRAT_SOLR_HOST - PACKRAT_COOK_SERVER_URL=$PACKRAT_COOK_SERVER_URL - PACKRAT_AUTH_TYPE=$PACKRAT_AUTH_TYPE - PACKRAT_LDAP_SERVER=$PACKRAT_LDAP_SERVER @@ -61,8 +61,13 @@ services: - PACKRAT_LDAP_CN=$PACKRAT_LDAP_CN - PACKRAT_LDAP_OU=$PACKRAT_LDAP_OU - PACKRAT_LDAP_DC=$PACKRAT_LDAP_DC + - PACKRAT_SOLR_HOST=$PACKRAT_SOLR_HOST + - PACKRAT_SOLR_PORT=$PACKRAT_SOLR_PORT + volumes: - $PACKRAT_OCFL_STORAGE_ROOT:$PACKRAT_OCFL_STORAGE_ROOT + - $PACKRAT_EDAN_STAGING_ROOT:$PACKRAT_EDAN_STAGING_ROOT + - $PACKRAT_EDAN_RESOURCES_HOTFOLDER:$PACKRAT_EDAN_RESOURCES_HOTFOLDER packrat-server-prod: container_name: packrat-server-prod @@ -76,19 +81,19 @@ services: - $PACKRAT_SERVER_PORT:4000 environment: - NODE_ENV=$NODE_ENV - - PACKRAT_CLIENT_ENDPOINT=$PACKRAT_CLIENT_ENDPOINT + - REACT_APP_PACKRAT_SERVER_ENDPOINT=$REACT_APP_PACKRAT_SERVER_ENDPOINT - PACKRAT_DATABASE_URL=$PACKRAT_DATABASE_URL + - PACKRAT_CLIENT_ENDPOINT=$PACKRAT_CLIENT_ENDPOINT - PACKRAT_SESSION_SECRET=$PACKRAT_SESSION_SECRET - PACKRAT_EDAN_AUTH_KEY=$PACKRAT_EDAN_AUTH_KEY - PACKRAT_EDAN_SERVER=$PACKRAT_EDAN_SERVER - PACKRAT_EDAN_3D_API=$PACKRAT_EDAN_3D_API - PACKRAT_EDAN_APPID=$PACKRAT_EDAN_APPID - - PACKRAT_EDAN_UPSERT_RESOURCE_ROOT:$PACKRAT_EDAN_UPSERT_RESOURCE_ROOT + - PACKRAT_EDAN_UPSERT_RESOURCE_ROOT=$PACKRAT_EDAN_UPSERT_RESOURCE_ROOT - PACKRAT_EDAN_STAGING_ROOT=$PACKRAT_EDAN_STAGING_ROOT - PACKRAT_EDAN_RESOURCES_HOTFOLDER=$PACKRAT_EDAN_RESOURCES_HOTFOLDER - PACKRAT_OCFL_STORAGE_ROOT=$PACKRAT_OCFL_STORAGE_ROOT - PACKRAT_OCFL_STAGING_ROOT=$PACKRAT_OCFL_STAGING_ROOT - - PACKRAT_SOLR_HOST=$PACKRAT_SOLR_HOST - PACKRAT_COOK_SERVER_URL=$PACKRAT_COOK_SERVER_URL - PACKRAT_AUTH_TYPE=$PACKRAT_AUTH_TYPE - PACKRAT_LDAP_SERVER=$PACKRAT_LDAP_SERVER @@ -96,6 +101,13 @@ services: - PACKRAT_LDAP_CN=$PACKRAT_LDAP_CN - PACKRAT_LDAP_OU=$PACKRAT_LDAP_OU - PACKRAT_LDAP_DC=$PACKRAT_LDAP_DC + - PACKRAT_SOLR_HOST=$PACKRAT_SOLR_HOST + - PACKRAT_SOLR_PORT=$PACKRAT_SOLR_PORT + + volumes: + - $PACKRAT_OCFL_STORAGE_ROOT:$PACKRAT_OCFL_STORAGE_ROOT + - $PACKRAT_EDAN_STAGING_ROOT:$PACKRAT_EDAN_STAGING_ROOT + - $PACKRAT_EDAN_RESOURCES_HOTFOLDER:$PACKRAT_EDAN_RESOURCES_HOTFOLDER packrat-db-dev: container_name: packrat-db-dev diff --git a/conf/docker/docker-compose.dev.yml b/conf/docker/docker-compose.dev.yml index 89c487e3e..1f40a8159 100644 --- a/conf/docker/docker-compose.dev.yml +++ b/conf/docker/docker-compose.dev.yml @@ -9,9 +9,6 @@ services: context: ../.. dockerfile: ./conf/docker/proxy-dev.Dockerfile target: proxy - depends_on: - - packrat-client - - packrat-server volumes: - ../../conf/nginx/nginx-dev.conf:/etc/nginx/nginx.conf ports: @@ -47,19 +44,19 @@ services: - $PACKRAT_SERVER_PORT:4000 environment: - NODE_ENV=$NODE_ENV - - PACKRAT_CLIENT_ENDPOINT=$PACKRAT_CLIENT_ENDPOINT + - REACT_APP_PACKRAT_SERVER_ENDPOINT=$REACT_APP_PACKRAT_SERVER_ENDPOINT - PACKRAT_DATABASE_URL=$PACKRAT_DATABASE_URL + - PACKRAT_CLIENT_ENDPOINT=$PACKRAT_CLIENT_ENDPOINT - PACKRAT_SESSION_SECRET=$PACKRAT_SESSION_SECRET - PACKRAT_EDAN_AUTH_KEY=$PACKRAT_EDAN_AUTH_KEY - PACKRAT_EDAN_SERVER=$PACKRAT_EDAN_SERVER - PACKRAT_EDAN_3D_API=$PACKRAT_EDAN_3D_API - PACKRAT_EDAN_APPID=$PACKRAT_EDAN_APPID - - PACKRAT_EDAN_UPSERT_RESOURCE_ROOT:$PACKRAT_EDAN_UPSERT_RESOURCE_ROOT + - PACKRAT_EDAN_UPSERT_RESOURCE_ROOT=$PACKRAT_EDAN_UPSERT_RESOURCE_ROOT - PACKRAT_EDAN_STAGING_ROOT=$PACKRAT_EDAN_STAGING_ROOT - PACKRAT_EDAN_RESOURCES_HOTFOLDER=$PACKRAT_EDAN_RESOURCES_HOTFOLDER - PACKRAT_OCFL_STORAGE_ROOT=$PACKRAT_OCFL_STORAGE_ROOT - PACKRAT_OCFL_STAGING_ROOT=$PACKRAT_OCFL_STAGING_ROOT - - PACKRAT_SOLR_HOST=$PACKRAT_SOLR_HOST - PACKRAT_COOK_SERVER_URL=$PACKRAT_COOK_SERVER_URL - PACKRAT_AUTH_TYPE=$PACKRAT_AUTH_TYPE - PACKRAT_LDAP_SERVER=$PACKRAT_LDAP_SERVER @@ -67,6 +64,8 @@ services: - PACKRAT_LDAP_CN=$PACKRAT_LDAP_CN - PACKRAT_LDAP_OU=$PACKRAT_LDAP_OU - PACKRAT_LDAP_DC=$PACKRAT_LDAP_DC + - PACKRAT_SOLR_HOST=$PACKRAT_SOLR_HOST + - PACKRAT_SOLR_PORT=$PACKRAT_SOLR_PORT volumes: - ../../node_modules:/app/node_modules diff --git a/conf/docker/docker-compose.prod.yml b/conf/docker/docker-compose.prod.yml index ff1ba15e5..b73f59a1c 100644 --- a/conf/docker/docker-compose.prod.yml +++ b/conf/docker/docker-compose.prod.yml @@ -41,19 +41,19 @@ services: - $PACKRAT_SERVER_PORT:4000 environment: - NODE_ENV=$NODE_ENV - - PACKRAT_CLIENT_ENDPOINT=$PACKRAT_CLIENT_ENDPOINT + - REACT_APP_PACKRAT_SERVER_ENDPOINT=$REACT_APP_PACKRAT_SERVER_ENDPOINT - PACKRAT_DATABASE_URL=$PACKRAT_DATABASE_URL + - PACKRAT_CLIENT_ENDPOINT=$PACKRAT_CLIENT_ENDPOINT - PACKRAT_SESSION_SECRET=$PACKRAT_SESSION_SECRET - PACKRAT_EDAN_AUTH_KEY=$PACKRAT_EDAN_AUTH_KEY - PACKRAT_EDAN_SERVER=$PACKRAT_EDAN_SERVER - PACKRAT_EDAN_3D_API=$PACKRAT_EDAN_3D_API - PACKRAT_EDAN_APPID=$PACKRAT_EDAN_APPID - - PACKRAT_EDAN_UPSERT_RESOURCE_ROOT:$PACKRAT_EDAN_UPSERT_RESOURCE_ROOT + - PACKRAT_EDAN_UPSERT_RESOURCE_ROOT=$PACKRAT_EDAN_UPSERT_RESOURCE_ROOT - PACKRAT_EDAN_STAGING_ROOT=$PACKRAT_EDAN_STAGING_ROOT - PACKRAT_EDAN_RESOURCES_HOTFOLDER=$PACKRAT_EDAN_RESOURCES_HOTFOLDER - PACKRAT_OCFL_STORAGE_ROOT=$PACKRAT_OCFL_STORAGE_ROOT - PACKRAT_OCFL_STAGING_ROOT=$PACKRAT_OCFL_STAGING_ROOT - - PACKRAT_SOLR_HOST=$PACKRAT_SOLR_HOST - PACKRAT_COOK_SERVER_URL=$PACKRAT_COOK_SERVER_URL - PACKRAT_AUTH_TYPE=$PACKRAT_AUTH_TYPE - PACKRAT_LDAP_SERVER=$PACKRAT_LDAP_SERVER @@ -61,6 +61,8 @@ services: - PACKRAT_LDAP_CN=$PACKRAT_LDAP_CN - PACKRAT_LDAP_OU=$PACKRAT_LDAP_OU - PACKRAT_LDAP_DC=$PACKRAT_LDAP_DC + - PACKRAT_SOLR_HOST=$PACKRAT_SOLR_HOST + - PACKRAT_SOLR_PORT=$PACKRAT_SOLR_PORT depends_on: - db @@ -86,6 +88,7 @@ services: - $PACKRAT_SOLR_PORT:8983 volumes: - ../../server/config/solr/data/packrat:/var/solr/data/packrat + - ../../server/config/solr/data/packratMeta:/var/solr/data/packratMeta networks: default: diff --git a/conf/docker/server-dev.Dockerfile b/conf/docker/server-dev.Dockerfile index 916effac4..4dbdb9fa3 100644 --- a/conf/docker/server-dev.Dockerfile +++ b/conf/docker/server-dev.Dockerfile @@ -10,8 +10,11 @@ COPY . . FROM base AS server # Remove client to prevent duplication RUN rm -rf client +RUN apk update # Install perl, needed by exiftool RUN apk add perl +# Install git, needed to fetch npm-server-webdav +RUN apk add git # Expose port(s) EXPOSE 4000 # Install dependencies diff --git a/conf/docker/server-prod.Dockerfile b/conf/docker/server-prod.Dockerfile index ff9c3cbf3..5f58d126d 100644 --- a/conf/docker/server-prod.Dockerfile +++ b/conf/docker/server-prod.Dockerfile @@ -9,6 +9,9 @@ COPY . . FROM base AS server-builder # Remove client from server build RUN rm -rf client +# Install git, needed to fetch npm-server-webdav +RUN apk update +RUN apk add git # Install dependencies (production mode) and build RUN yarn install --frozen-lockfile && yarn build:prod diff --git a/conf/docker/solr.Dockerfile b/conf/docker/solr.Dockerfile index 604434cf4..1785af0cb 100644 --- a/conf/docker/solr.Dockerfile +++ b/conf/docker/solr.Dockerfile @@ -1,2 +1,3 @@ FROM solr:8 as solr COPY --chown=solr:solr ./server/config/solr/data/packrat/ /var/solr/data/packrat/ +COPY --chown=solr:solr ./server/config/solr/data/packratMeta/ /var/solr/data/packratMeta/ diff --git a/conf/nginx/conf.d/common-locations b/conf/nginx/conf.d/common-locations index 6783bdad4..6c718ac87 100644 --- a/conf/nginx/conf.d/common-locations +++ b/conf/nginx/conf.d/common-locations @@ -1,6 +1,7 @@ location /server { rewrite /server/(.*) /$1 break; proxy_pass http://server-dev; + proxy_set_header Host $host//server; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } diff --git a/conf/nginx/nginx-dev.conf b/conf/nginx/nginx-dev.conf index ad878bc8b..43c7b98a1 100644 --- a/conf/nginx/nginx-dev.conf +++ b/conf/nginx/nginx-dev.conf @@ -24,6 +24,7 @@ http { location /server { rewrite /server/(.*) /$1 break; + proxy_set_header Host localhost:6656//server/; proxy_pass http://server; } diff --git a/conf/nginx/tls.howto.txt b/conf/nginx/tls.howto.txt new file mode 100644 index 000000000..803e02b40 --- /dev/null +++ b/conf/nginx/tls.howto.txt @@ -0,0 +1,42 @@ +**************** +Initial Requests +**************** +Generated CSRs following the instructions here: https://www.thesslstore.com/knowledgebase/ssl-generate/csr-generation-guide-for-nginx-openssl/ +* openssl req -new -newkey rsa:2048 -nodes -keyout packrat.si.edu.key -out packrat.si.edu.csr +* openssl req -new -newkey rsa:2048 -nodes -keyout packrat-test.si.edu.key -out packrat-test.si.edu.csr + +Provide these CSR to OCIO staff (Mandy Hargis-Martin), by way of a ServiceNow ticket. + +Specified the following information: +Country Name (2 letter code) [AU]:US +State or Province Name (full name) [Some-State]:DC +Locality Name (eg, city) []:Washington +Organization Name (eg, company) [Internet Widgits Pty Ltd]:Smithsonian Institution +Organizational Unit Name (eg, section) []:DPO 3D +Common Name (e.g. server FQDN or YOUR name) []:packrat.si.edu +Email Address []: + +Please enter the following 'extra' attributes +to be sent with your certificate request +A challenge password []: +An optional company name []: + +******** +Renewals +******** +Generate CSRs: +* openssl req -new -nodes -key packrat.si.edu.key -out packrat.si.edu.csr +* openssl req -new -nodes -key packrat-test.si.edu.key -out packrat-test.si.edu.csr + +Specified the same info as the initial request. Note that this generated the same actual CSR file ... so it seems like we can simply reuse the original CSR if we're not changing any of the details. + +Note that OCIO staff requested these CSRs without our prompting -- they appear to have their own reminders about upcoming TLS cert expirations. + +************ +Installation +************ +1. Download the Full Chain PEM bundle from the certificate authority +2. Edit this bundle in a text editor, reversing the order of the certificates +3. Install the cert (as root) in /etc/pki/tls/certs, using the filename specified in nginx.conf (packrat.si.edu.cert and packrat-test.si.edu.cert) +4. Restart nginx: sudo systemctl restart nginx +5. Verify that the new cert is active (visit https://packrat-test.si.edu:8443/ and inspect the certificate) diff --git a/package.json b/package.json index a71bfdda3..d81a9ba1d 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "devbox:down": "docker container rm packrat-devbox --force", "dev": "docker-compose --env-file .env.dev -p dpo-packrat -f ./conf/docker/docker-compose.dev.yml up -d", "dev:solr": "docker-compose --env-file .env.dev -p dpo-packrat -f ./conf/docker/docker-compose.dev.yml up -d packrat-solr", + "dev:proxy": "docker-compose --env-file .env.dev -p dpo-packrat -f ./conf/docker/docker-compose.dev.yml up -d packrat-proxy", "prod": "docker-compose --env-file .env.prod -p dpo-packrat -f ./conf/docker/docker-compose.prod.yml up -d", "deploy:dev": "sh ./scripts/deploy.sh dev", "deploy:prod": "sh ./scripts/deploy.sh prod", diff --git a/server/auth/index.ts b/server/auth/index.ts index ae71f0dfc..21445a14c 100644 --- a/server/auth/index.ts +++ b/server/auth/index.ts @@ -27,12 +27,9 @@ if (!PACKRAT_SESSION_SECRET) { const Store = MemoryStore(session); const { maxAge, checkPeriod } = Config.auth.session; -// const maxAge: number = Date.now() + age; const sessionConfig = { - cookie: { - maxAge - }, + cookie: { maxAge }, secret: PACKRAT_SESSION_SECRET, resave: true, saveUninitialized: true, diff --git a/server/cache/LicenseCache.ts b/server/cache/LicenseCache.ts index 645f8bb7e..1a66fe87b 100644 --- a/server/cache/LicenseCache.ts +++ b/server/cache/LicenseCache.ts @@ -6,7 +6,7 @@ import { CacheControl } from './CacheControl'; export class LicenseCache { private static singleton: LicenseCache | null = null; private licenseMap: Map = new Map(); // map of idLicense -> License - private publishedStateMap: Map = new Map(); // map of ePublishedState -> License + private licenseEnumMap: Map = new Map(); // map of eLicense -> License private licenseResolverMap: Map = new Map(); // map of idSystemObject -> LicenseResolver, representing cache of resolved license information // ************************** @@ -45,10 +45,10 @@ export class LicenseCache { for (const license of LicenseFetch) { this.licenseMap.set(license.idLicense, license); switch (license.Name.toLowerCase()) { - case 'view and download cc0': this.publishedStateMap.set(DBAPI.ePublishedState.eViewDownloadCC0, license); break; - case 'view with download restrictions': this.publishedStateMap.set(DBAPI.ePublishedState.eViewDownloadRestriction, license); break; - case 'view only': this.publishedStateMap.set(DBAPI.ePublishedState.eViewOnly, license); break; - case 'restricted': this.publishedStateMap.set(DBAPI.ePublishedState.eRestricted, license); break; + case 'view and download cc0': this.licenseEnumMap.set(DBAPI.eLicense.eViewDownloadCC0, license); break; + case 'view with download restrictions': this.licenseEnumMap.set(DBAPI.eLicense.eViewDownloadRestriction, license); break; + case 'view only': this.licenseEnumMap.set(DBAPI.eLicense.eViewOnly, license); break; + case 'restricted': this.licenseEnumMap.set(DBAPI.eLicense.eRestricted, license); break; } } // LOG.info(`LicenseCache publishedStateMap=\n${JSON.stringify(this.publishedStateMap, H.Helpers.saferStringify)}`, LOG.LS.eCACHE); @@ -69,14 +69,14 @@ export class LicenseCache { return license ?? undefined; } - private async getLicenseByPublishedStateInternal(eState: DBAPI.ePublishedState): Promise { - return this.publishedStateMap.get(eState); + private async getLicenseByEnumInternal(eState: DBAPI.eLicense): Promise { + return this.licenseEnumMap.get(eState); } - private async getLicenseResolverInternal(idSystemObject: number): Promise { + private async getLicenseResolverInternal(idSystemObject: number, OGD?: DBAPI.ObjectGraphDatabase | undefined): Promise { let licenseResolver: DBAPI.LicenseResolver | undefined | null = this.licenseResolverMap.get(idSystemObject); if (!licenseResolver) { // cache miss, look it up - licenseResolver = await DBAPI.LicenseResolver.fetch(idSystemObject); + licenseResolver = await DBAPI.LicenseResolver.fetch(idSystemObject, OGD); if (licenseResolver) this.licenseResolverMap.set(idSystemObject, licenseResolver); // LOG.info(`LicenseCache.getLicenseResolverInternal(${idSystemObject}) computed ${JSON.stringify(licenseResolver)}`, LOG.LS.eCACHE); @@ -127,12 +127,13 @@ export class LicenseCache { return await (await this.getInstance()).getLicenseInternal(idLicense); } - static async getLicenseByPublishedState(eState: DBAPI.ePublishedState): Promise { - return await (await this.getInstance()).getLicenseByPublishedStateInternal(eState); + static async getLicenseByEnum(eState: DBAPI.eLicense): Promise { + return await (await this.getInstance()).getLicenseByEnumInternal(eState); } - static async getLicenseResolver(idSystemObject: number): Promise { - return await (await this.getInstance()).getLicenseResolverInternal(idSystemObject); + /** If passing in OGD, make sure to compute this navigating through the ancestors of idSystemObject */ + static async getLicenseResolver(idSystemObject: number, OGD?: DBAPI.ObjectGraphDatabase | undefined): Promise { + return await (await this.getInstance()).getLicenseResolverInternal(idSystemObject, OGD); } static async clearAssignment(idSystemObject: number): Promise { diff --git a/server/cache/SystemObjectCache.ts b/server/cache/SystemObjectCache.ts index c4462f7d0..e5d3df937 100644 --- a/server/cache/SystemObjectCache.ts +++ b/server/cache/SystemObjectCache.ts @@ -8,6 +8,7 @@ export class SystemObjectCache { private objectIDToSystemMap: Map = new Map(); // map of { idObject, eDBObjectType } -> { idSystemObject, Retired } private systemIDToObjectMap: Map = new Map(); // map of idSystemObject -> { idObject, eDBObjectType } + private systemIDToNameMap: Map = new Map(); // map of idSystemObject -> name // ************************** // Boilerplate Implementation @@ -110,6 +111,16 @@ export class SystemObjectCache { } private async getObjectNameInternal(SO: DBAPI.SystemObject): Promise { + let name: string | undefined = this.systemIDToNameMap.get(SO.idSystemObject); + if (name) + return name; + name = await this.getObjectNameInternalWorker(SO); + if (name) + this.systemIDToNameMap.set(SO.idSystemObject, name); + return name; + } + + private async getObjectNameInternalWorker(SO: DBAPI.SystemObject): Promise { const oID: DBAPI.ObjectIDAndType | undefined = await this.getObjectFromSystemInternal(SO.idSystemObject); if (!oID) /* istanbul ignore next */ return undefined; @@ -175,6 +186,19 @@ export class SystemObjectCache { } } + private async getObjectNameByIDInternal(idSystemObject: number): Promise { + const name: string | undefined = this.systemIDToNameMap.get(idSystemObject); + if (name) + return name; + + const SO: DBAPI.SystemObject | null = await DBAPI.SystemObject.fetch(idSystemObject); + if (!SO) { + LOG.error(`SystemObjectCache.getObjectNameByIDInternal unable to lookup SystemObject for id ${idSystemObject}`, LOG.LS.eCACHE); + return undefined; + } + return this.getObjectNameInternal(SO); + } + private async flushObjectWorker(idSystemObject: number): Promise { const SO: SystemObject | null = await SystemObject.fetch(idSystemObject); if (!SO) { @@ -187,6 +211,8 @@ export class SystemObjectCache { this.objectIDToSystemMap.set(oID, { idSystemObject, Retired: SO.Retired }); this.systemIDToObjectMap.set(idSystemObject, oID); } + + this.systemIDToNameMap.delete(idSystemObject); return oID; } // #endregion @@ -211,12 +237,7 @@ export class SystemObjectCache { } static async getObjectNameByID(idSystemObject: number): Promise { - const SO: DBAPI.SystemObject | null = await DBAPI.SystemObject.fetch(idSystemObject); - if (!SO) { - LOG.error(`SystemObjectCache.getObjectNameByID unable to lookup SystemObject for id ${idSystemObject}`, LOG.LS.eCACHE); - return undefined; - } - return SystemObjectCache.getObjectName(SO); + return await (await this.getInstance()).getObjectNameByIDInternal(idSystemObject); } /** diff --git a/server/collections/impl/EdanCollection.ts b/server/collections/impl/EdanCollection.ts index 6ee9d2906..30b961437 100644 --- a/server/collections/impl/EdanCollection.ts +++ b/server/collections/impl/EdanCollection.ts @@ -3,7 +3,9 @@ import fetch, { RequestInit } from 'node-fetch'; import { v4 as uuidv4 } from 'uuid'; import * as COL from '../interface'; +import { PublishScene } from './PublishScene'; import { Config } from '../../config'; +import * as DBAPI from '../../db'; import * as LOG from '../../utils/logger'; import * as H from '../../utils/helpers'; @@ -109,6 +111,20 @@ export class EdanCollection implements COL.ICollection { return result; } + async publish(idSystemObject: number, ePublishState: number): Promise { + switch (ePublishState) { + case DBAPI.ePublishedState.eNotPublished: + case DBAPI.ePublishedState.eAPIOnly: + case DBAPI.ePublishedState.ePublished: + break; + default: + LOG.error(`EdanCollection.publish called with invalid ePublishState ${ePublishState} for idSystemObject ${idSystemObject}`, LOG.LS.eCOLL); + return false; + } + const PS: PublishScene = new PublishScene(this, idSystemObject, ePublishState); + return PS.publish(); + } + async createEdanMDM(edanmdm: COL.EdanMDMContent, status: number, publicSearch: boolean): Promise { const body: any = { url: `edanmdm:${edanmdm.descriptiveNonRepeating.record_ID}`, @@ -139,7 +155,8 @@ export class EdanCollection implements COL.ICollection { /** c.f. http://dev.3d.api.si.edu/apidocs/#api-admin-upsertContent */ private async upsertContent(body: any, caller: string): Promise { - LOG.info(`EdanCollection.upsertContent: ${JSON.stringify(body)}`, LOG.LS.eCOLL); + // LOG.info(`EdanCollection.upsertContent: ${JSON.stringify(body)}`, LOG.LS.eCOLL); + LOG.info('EdanCollection.upsertContent', LOG.LS.eCOLL); const reqResult: HttpRequestResult = await this.sendRequest(eAPIType.eEDAN3dApi, eHTTPMethod.ePost, 'api/v1.0/admin/upsertContent', '', JSON.stringify(body), 'application/json'); // LOG.info(`EdanCollection.upsertContent: ${JSON.stringify(body)}: ${reqResult.output}`, LOG.LS.eCOLL); if (!reqResult.success) { diff --git a/server/event/impl/InProcess/PublishScene.ts b/server/collections/impl/PublishScene.ts similarity index 58% rename from server/event/impl/InProcess/PublishScene.ts rename to server/collections/impl/PublishScene.ts index beb5dd1ab..2d9f1194c 100644 --- a/server/event/impl/InProcess/PublishScene.ts +++ b/server/collections/impl/PublishScene.ts @@ -1,18 +1,19 @@ -import { Config } from '../../../config'; -import * as DBAPI from '../../../db'; -import * as CACHE from '../../../cache'; -import * as COL from '../../../collections/interface/'; -import * as LOG from '../../../utils/logger'; -import * as H from '../../../utils/helpers'; -import * as ZIP from '../../../utils/zipStream'; -import * as STORE from '../../../storage/interface'; -import { SvxReader } from '../../../utils/parser'; -import { IDocument } from '../../../types/voyager'; +import { Config } from '../../config'; +import * as DBAPI from '../../db'; +import * as CACHE from '../../cache'; +import * as COL from '../../collections/interface/'; +import * as LOG from '../../utils/logger'; +import * as H from '../../utils/helpers'; +import * as ZIP from '../../utils/zipStream'; +import * as STORE from '../../storage/interface'; +import { SvxReader } from '../../utils/parser'; +import { IDocument } from '../../types/voyager'; import { v4 as uuidv4 } from 'uuid'; import path from 'path'; type SceneAssetCollector = { + idSystemObject: number; asset: DBAPI.Asset; assetVersion: DBAPI.AssetVersion; model?: DBAPI.Model; @@ -20,13 +21,15 @@ type SceneAssetCollector = { }; export class PublishScene { - private audit: DBAPI.Audit; + private ICol: COL.ICollection; + private idSystemObject: number; + private eState: DBAPI.ePublishedState; - private idSystemObject?: number | null; private scene?: DBAPI.Scene | null; + private systemObjectVersion?: DBAPI.SystemObjectVersion | null; private subject?: DBAPI.Subject; private DownloadMSXMap?: Map; // map of model's idSystemObject -> ModelSceneXref - private SacMap?: Map; // map of idSystemObject for object owning asset -> SceneAssetCollector + private SacList: SceneAssetCollector[] = []; // array of SceneAssetCollector private assetVersions?: DBAPI.AssetVersion[] | null = null; @@ -38,21 +41,23 @@ export class PublishScene { private sharedName?: string | undefined = undefined; - constructor(audit: DBAPI.Audit) { - this.audit = audit; + constructor(ICol: COL.ICollection, idSystemObject: number, eState: DBAPI.ePublishedState) { + this.ICol = ICol; + this.idSystemObject = idSystemObject; + this.eState = eState; } async publish(): Promise { - if (!await this.handleAuditFetchScene() || !this.scene || !this.idSystemObject || !this.subject) + if (!await this.fetchScene() || !this.scene || !this.subject) return false; - LOG.info(`PublishScene.publish Publishing Scene with UUID ${this.scene.EdanUUID}`, LOG.LS.eEVENT); + LOG.info(`PublishScene.publish UUID ${this.scene.EdanUUID}`, LOG.LS.eCOLL); // Process models, building a mapping from the model's idSystemObject -> ModelSceneXref, for those models that are for Downloads if (!await this.computeMSXMap() || !this.DownloadMSXMap) return false; // collect and analyze assets - if (!await this.collectAssets() || !this.SacMap || this.SacMap.size <= 0) + if (!await this.collectAssets() || this.SacList.length <= 0) return false; // stage scene @@ -60,61 +65,75 @@ export class PublishScene { return false; // create EDAN 3D Package - const ICol: COL.ICollection = COL.CollectionFactory.getInstance(); - let edanRecord: COL.EdanRecord | null = await ICol.createEdan3DPackage(this.sharedName, this.sceneFile); + let edanRecord: COL.EdanRecord | null = await this.ICol.createEdan3DPackage(this.sharedName, this.sceneFile); if (!edanRecord) { - LOG.error('PublishScene.publish publish to EDAN failed', LOG.LS.eEVENT); + LOG.error('PublishScene.publish EDAN failed', LOG.LS.eCOLL); return false; } + LOG.info(`PublishScene.publish ${edanRecord.url} succeeded with Edan status ${edanRecord.status}, publicSearch ${edanRecord.publicSearch}`, LOG.LS.eCOLL); // stage downloads if (!await this.stageDownloads() || !this.edan3DResourceList) return false; - // update EDAN 3D Package if we have downloads - if (this.svxDocument && this.edan3DResourceList.length > 0) { + // update SystemObjectVersion.PublishedState + if (!await this.updatePublishedState()) + return false; + + const { status, publicSearch, downloads } = this.computeEdanSearchFlags(edanRecord, this.eState); + const haveDownloads: boolean = (this.edan3DResourceList.length > 0); + const updatePackage: boolean = haveDownloads // we have downloads, or + || (status !== edanRecord.status) // publication status changed + || (publicSearch !== edanRecord.publicSearch); // public search changed + + // update EDAN 3D Package if we have downloads and/or if our published state has changed + if (this.svxDocument && updatePackage) { const E3DPackage: COL.Edan3DPackageContent = { document: this.svxDocument, - resources: this.edan3DResourceList + resources: (downloads && haveDownloads) ? this.edan3DResourceList : undefined }; - edanRecord = await ICol.updateEdan3DPackage(edanRecord.url, E3DPackage, edanRecord.status, edanRecord.publicSearch); + + LOG.info(`PublishScene.publish updating ${edanRecord.url}`, LOG.LS.eCOLL); + edanRecord = await this.ICol.updateEdan3DPackage(edanRecord.url, E3DPackage, status, publicSearch); if (!edanRecord) { - LOG.error('PublishScene.publish publish of resources to EDAN failed', LOG.LS.eEVENT); + LOG.error('PublishScene.publish Edan3DPackage update failed', LOG.LS.eCOLL); return false; } } + + LOG.info(`PublishScene.publish UUID ${this.scene.EdanUUID}, status ${status}, publicSearch ${publicSearch}, downloads ${downloads}, has downloads ${haveDownloads}`, LOG.LS.eCOLL); return true; } - private async handleAuditFetchScene(): Promise { - this.idSystemObject = this.audit.idSystemObject; - if (this.idSystemObject === null && this.audit.idDBObject && this.audit.DBObjectType) { - const oID: DBAPI.ObjectIDAndType = { idObject: this.audit.idDBObject , eObjectType: this.audit.DBObjectType }; - const SOInfo: DBAPI.SystemObjectInfo | undefined = await CACHE.SystemObjectCache.getSystemFromObjectID(oID); - if (SOInfo) { - this.idSystemObject = SOInfo.idSystemObject; - this.audit.idSystemObject = this.idSystemObject; - } + private async fetchScene(): Promise { + const oID: DBAPI.ObjectIDAndType | undefined = await CACHE.SystemObjectCache.getObjectFromSystem(this.idSystemObject); + if (!oID) { + LOG.error(`PublishScene.fetchScene unable to retrieve object details from ${this.idSystemObject}`, LOG.LS.eCOLL); + return false; } - LOG.info(`PublishScene.handleAuditFetchScene Scene QCd ${this.audit.idDBObject}`, LOG.LS.eEVENT); - if (this.audit.idAudit === 0) - this.audit.create(); // don't use await so this happens asynchronously - - if (!this.idSystemObject) { - LOG.error(`PublishScene.handleAuditFetchScene received eSceneQCd event for scene without idSystemObject ${JSON.stringify(this.audit, H.Helpers.saferStringify)}`, LOG.LS.eEVENT); + if (oID.eObjectType !== DBAPI.eSystemObjectType.eScene) { + LOG.error(`PublishScene.fetchScene received eSceneQCd event for non scene object ${JSON.stringify(oID, H.Helpers.saferStringify)}`, LOG.LS.eCOLL); return false; } - if (this.audit.getDBObjectType() !== DBAPI.eSystemObjectType.eScene) { - LOG.error(`PublishScene.handleAuditFetchScene received eSceneQCd event for non scene object ${JSON.stringify(this.audit, H.Helpers.saferStringify)}`, LOG.LS.eEVENT); + // fetch SystemObjectVersion + this.systemObjectVersion = await DBAPI.SystemObjectVersion.fetchLatestFromSystemObject(this.idSystemObject); + if (!this.systemObjectVersion) { + LOG.error(`PublishScene.fetchScene could not compute SystemObjectVersion for idSystemObject ${this.idSystemObject}`, LOG.LS.eCOLL); return false; } // fetch scene - this.scene = this.audit.idDBObject ? await DBAPI.Scene.fetch(this.audit.idDBObject) : null; + this.scene = oID.idObject ? await DBAPI.Scene.fetch(oID.idObject) : null; if (!this.scene) { - LOG.error(`PublishScene.handleAuditFetchScene received eSceneQCd event for non scene object ${JSON.stringify(this.audit, H.Helpers.saferStringify)}`, LOG.LS.eEVENT); + LOG.error(`PublishScene.fetchScene could not compute scene from ${JSON.stringify(oID, H.Helpers.saferStringify)}`, LOG.LS.eCOLL); + return false; + } + + if (this.eState !== DBAPI.ePublishedState.eNotPublished && + (!this.scene.ApprovedForPublication || !this.scene.PosedAndQCd)) { + LOG.error(`PublishScene.fetchScene attempting to publish non-Approved and/or non-QC'd scene ${JSON.stringify(this.scene, H.Helpers.saferStringify)}`, LOG.LS.eCOLL); return false; } @@ -122,7 +141,7 @@ export class PublishScene { if (!this.scene.EdanUUID) { this.scene.EdanUUID = uuidv4(); if (!await this.scene.update()) { - LOG.error(`PublishScene.handleAuditFetchScene unable to persist UUID for scene object ${JSON.stringify(this.audit, H.Helpers.saferStringify)}`, LOG.LS.eEVENT); + LOG.error(`PublishScene.fetchScene unable to persist UUID for scene object ${JSON.stringify(this.scene, H.Helpers.saferStringify)}`, LOG.LS.eCOLL); return false; } } @@ -130,7 +149,7 @@ export class PublishScene { // compute subject(s) owning this scene const OG: DBAPI.ObjectGraph = new DBAPI.ObjectGraph(this.idSystemObject, DBAPI.eObjectGraphMode.eAncestors); if (!await OG.fetch()) { - LOG.error(`PublishScene.handleAuditFetchScene unable to compute object graph for scene ${JSON.stringify(this.scene, H.Helpers.saferStringify)}`, LOG.LS.eEVENT); + LOG.error(`PublishScene.fetchScene unable to compute object graph for scene ${JSON.stringify(this.scene, H.Helpers.saferStringify)}`, LOG.LS.eCOLL); return false; } if (OG.subject && OG.subject.length > 0) @@ -143,7 +162,7 @@ export class PublishScene { return false; const MSXs: DBAPI.ModelSceneXref[] | null = await DBAPI.ModelSceneXref.fetchFromScene(this.scene.idScene); if (!MSXs) { - LOG.error(`PublishScene.computeMSXMap unable to fetch ModelSceneXrefs for scene ${this.scene.idScene}`, LOG.LS.eEVENT); + LOG.error(`PublishScene.computeMSXMap unable to fetch ModelSceneXrefs for scene ${this.scene.idScene}`, LOG.LS.eCOLL); return false; } @@ -160,12 +179,11 @@ export class PublishScene { } private async collectAssets(): Promise { - if (!this.idSystemObject || !this.DownloadMSXMap) + if (!this.DownloadMSXMap) return false; - this.SacMap = new Map(); this.assetVersions = await DBAPI.AssetVersion.fetchLatestFromSystemObject(this.idSystemObject); if (!this.assetVersions || this.assetVersions.length === 0) { - LOG.error(`PublishScene.collectAssets unable to load asset versions for scene ${JSON.stringify(this.scene, H.Helpers.saferStringify)}`, LOG.LS.eEVENT); + LOG.error(`PublishScene.collectAssets unable to load asset versions for scene ${JSON.stringify(this.scene, H.Helpers.saferStringify)}`, LOG.LS.eCOLL); return false; } @@ -174,63 +192,63 @@ export class PublishScene { let stageRes: H.IOResults = { success: true, error: '' }; for (const assetVersion of this.assetVersions) { const asset: DBAPI.Asset | null = await DBAPI.Asset.fetch(assetVersion.idAsset); + LOG.info(`PublishScene.collectAssets considering assetVersion=${JSON.stringify(assetVersion, H.Helpers.saferStringify)} asset=${JSON.stringify(asset, H.Helpers.saferStringify)}`, LOG.LS.eCOLL); if (!asset) { - LOG.error(`PublishScene.collectAssets unable to load asset by id ${assetVersion.idAsset}`, LOG.LS.eEVENT); + LOG.error(`PublishScene.collectAssets unable to load asset by id ${assetVersion.idAsset}`, LOG.LS.eCOLL); return false; } if (asset.idSystemObject) { const modelSceneXref: DBAPI.ModelSceneXref | undefined = this.DownloadMSXMap.get(asset.idSystemObject ?? 0); if (!modelSceneXref) - this.SacMap.set(asset.idSystemObject, { asset, assetVersion }); + this.SacList.push({ idSystemObject: asset.idSystemObject, asset, assetVersion }); else { const model: DBAPI.Model | null = await DBAPI.Model.fetch(modelSceneXref.idModel); if (!model) { - LOG.error(`PublishScene.collectAssets unable to load model from xref ${JSON.stringify(modelSceneXref, H.Helpers.saferStringify)}`, LOG.LS.eEVENT); + LOG.error(`PublishScene.collectAssets unable to load model from xref ${JSON.stringify(modelSceneXref, H.Helpers.saferStringify)}`, LOG.LS.eCOLL); return false; } - this.SacMap.set(asset.idSystemObject, { asset, assetVersion, model, modelSceneXref }); + this.SacList.push({ idSystemObject: asset.idSystemObject, asset, assetVersion, model, modelSceneXref }); } } if (!this.sceneFile && assetVersion.FileName.toLowerCase().endsWith('.svx.json')) { this.sceneFile = assetVersion.FileName; this.extractedPath = asset.FilePath; - // extract scene's SVX.JSON for use in creating downloads - if (this.DownloadMSXMap.size > 0) { - const RSR: STORE.ReadStreamResult = await STORE.AssetStorageAdapter.readAsset(asset, assetVersion); - if (!RSR.success || !RSR.readStream) { - LOG.error(`PublishScene.collectAssets failed to extract stream for scene's asset version ${assetVersion.idAssetVersion}`, LOG.LS.eEVENT); - return false; - } - const svx: SvxReader = new SvxReader(); - stageRes = await svx.loadFromStream(RSR.readStream); - if (!stageRes.success) { - LOG.error(`PublishScene.collectAssets failed to extract scene's svx.json contents: ${stageRes.error}`, LOG.LS.eEVENT); - return false; - } - this.svxDocument = svx.SvxDocument; + // extract scene's SVX.JSON for use in updating EDAN search status and creating downloads + const RSR: STORE.ReadStreamResult = await STORE.AssetStorageAdapter.readAsset(asset, assetVersion); + if (!RSR.success || !RSR.readStream) { + LOG.error(`PublishScene.collectAssets failed to extract stream for scene's asset version ${assetVersion.idAssetVersion}`, LOG.LS.eCOLL); + return false; } + + const svx: SvxReader = new SvxReader(); + stageRes = await svx.loadFromStream(RSR.readStream); + if (!stageRes.success) { + LOG.error(`PublishScene.collectAssets failed to extract scene's svx.json contents: ${stageRes.error}`, LOG.LS.eCOLL); + return false; + } + this.svxDocument = svx.SvxDocument; } } return true; } private async stageSceneFiles(): Promise { - if (!this.SacMap || !this.scene) + if (this.SacList.length <= 0 || !this.scene) return false; let stageRes: H.IOResults = { success: true, error: '' }; // second pass: zip up appropriate assets; prepare to copy downloads const zip: ZIP.ZipStream = new ZIP.ZipStream(); - for (const SAC of this.SacMap.values()) { + for (const SAC of this.SacList.values()) { if (SAC.model) // skip downloads continue; const RSR: STORE.ReadStreamResult = await STORE.AssetStorageAdapter.readAsset(SAC.asset, SAC.assetVersion); if (!RSR.success || !RSR.readStream) { - LOG.error(`PublishScene.stageFiles failed to extract stream for asset version ${SAC.assetVersion.idAssetVersion}`, LOG.LS.eEVENT); + LOG.error(`PublishScene.stageSceneFiles failed to extract stream for asset version ${SAC.assetVersion.idAssetVersion}`, LOG.LS.eCOLL); return false; } @@ -239,17 +257,17 @@ export class PublishScene { rebasedPath = rebasedPath.substring(1); const fileNameAndPath: string = path.posix.join(rebasedPath, SAC.assetVersion.FileName); - LOG.info(`PublishScene.stageFiles adding ${fileNameAndPath} to zip`, LOG.LS.eEVENT); + LOG.info(`PublishScene.stageSceneFiles adding ${fileNameAndPath} to zip`, LOG.LS.eCOLL); const res: H.IOResults = await zip.add(fileNameAndPath, RSR.readStream); if (!res.success) { - LOG.error(`PublishScene.stageFiles failed to add asset version ${SAC.assetVersion.idAssetVersion} to zip: ${res.error}`, LOG.LS.eEVENT); + LOG.error(`PublishScene.stageSceneFiles failed to add asset version ${SAC.assetVersion.idAssetVersion} to zip: ${res.error}`, LOG.LS.eCOLL); return false; } } const zipStream: NodeJS.ReadableStream | null = await zip.streamContent(null); if (!zipStream) { - LOG.error('PublishScene.stageFiles failed to extract stream from zip', LOG.LS.eEVENT); + LOG.error('PublishScene.stageSceneFiles failed to extract stream from zip', LOG.LS.eCOLL); return false; } @@ -257,32 +275,33 @@ export class PublishScene { if (!stageRes.success) stageRes = await H.Helpers.createDirectory(Config.collection.edan.stagingRoot); if (!stageRes.success) { - LOG.error(`PublishScene.stageFiles unable to ensure existence of staging directory ${Config.collection.edan.stagingRoot}: ${stageRes.error}`, LOG.LS.eEVENT); + LOG.error(`PublishScene.stageSceneFiles unable to ensure existence of staging directory ${Config.collection.edan.stagingRoot}: ${stageRes.error}`, LOG.LS.eCOLL); return false; } const noFinalSlash: boolean = !Config.collection.edan.upsertContentRoot.endsWith('/'); this.sharedName = Config.collection.edan.upsertContentRoot + (noFinalSlash ? '/' : '') + this.scene.EdanUUID! + '.zip'; // eslint-disable-line @typescript-eslint/no-non-null-assertion const stagedName: string = path.join(Config.collection.edan.stagingRoot, this.scene.EdanUUID!) + '.zip'; // eslint-disable-line @typescript-eslint/no-non-null-assertion - LOG.info(`*** PublishScene.stageFiles staging file ${stagedName}, referenced in publish as ${this.sharedName}`, LOG.LS.eEVENT); + LOG.info(`PublishScene.stageSceneFiles staging file ${stagedName}, referenced in publish as ${this.sharedName}`, LOG.LS.eCOLL); stageRes = await H.Helpers.writeStreamToFile(zipStream, stagedName); if (!stageRes.success) { - LOG.error(`PublishScene.stageFiles unable to stage file ${stagedName}: ${stageRes.error}`, LOG.LS.eEVENT); + LOG.error(`PublishScene.stageSceneFiles unable to stage file ${stagedName}: ${stageRes.error}`, LOG.LS.eCOLL); return false; } + LOG.info(`PublishScene.stageSceneFiles staged file ${stagedName}`, LOG.LS.eCOLL); return true; } private async stageDownloads(): Promise { - if (!this.SacMap || !this.scene) + if (this.SacList.length <= 0 || !this.scene) return false; // third pass: stage downloads let stageRes: H.IOResults = { success: true, error: '' }; this.edan3DResourceList = []; this.resourcesHotFolder = path.join(Config.collection.edan.resourcesHotFolder, this.scene.EdanUUID!); // eslint-disable-line @typescript-eslint/no-non-null-assertion - for (const SAC of this.SacMap.values()) { + for (const SAC of this.SacList.values()) { if (!SAC.model) // SAC is not a download, skip it continue; @@ -291,18 +310,19 @@ export class PublishScene { const RSR: STORE.ReadStreamResult = await STORE.AssetStorageAdapter.readAsset(SAC.asset, SAC.assetVersion); if (!RSR.success || !RSR.readStream) { - LOG.error(`PublishScene.stageFiles failed to extract stream for asset version ${SAC.assetVersion.idAssetVersion}`, LOG.LS.eEVENT); + LOG.error(`PublishScene.stageDownloads failed to extract stream for asset version ${SAC.assetVersion.idAssetVersion}`, LOG.LS.eCOLL); return false; } // copy stream to resourcesHotFolder const stagedName: string = path.join(this.resourcesHotFolder, SAC.assetVersion.FileName); - LOG.info(`*** PublishScene.stageFiles staging file ${stagedName}`, LOG.LS.eEVENT); + LOG.info(`PublishScene.stageDownloads staging file ${stagedName}`, LOG.LS.eCOLL); stageRes = await H.Helpers.writeStreamToFile(RSR.readStream, stagedName); if (!stageRes.success) { - LOG.error(`PublishScene.stageFiles unable to stage file ${stagedName}: ${stageRes.error}`, LOG.LS.eEVENT); + LOG.error(`PublishScene.stageDownloads unable to stage file ${stagedName}: ${stageRes.error}`, LOG.LS.eCOLL); return false; } + LOG.info(`PublishScene.stageDownloads staged file ${stagedName}`, LOG.LS.eCOLL); // prepare download entry const resource: COL.Edan3DResource | null = await this.extractResource(SAC, this.scene.EdanUUID!); // eslint-disable-line @typescript-eslint/no-non-null-assertion @@ -322,7 +342,7 @@ export class PublishScene { if (!stageRes.success) stageRes = await H.Helpers.createDirectory(this.resourcesHotFolder); if (!stageRes.success) { - LOG.error(`PublishScene.ensureResourceHotFolderExists failed to create resources hot folder ${this.resourcesHotFolder}: ${stageRes.error}`, LOG.LS.eEVENT); + LOG.error(`PublishScene.ensureResourceHotFolderExists failed to create resources hot folder ${this.resourcesHotFolder}: ${stageRes.error}`, LOG.LS.eCOLL); return false; } return true; @@ -335,7 +355,7 @@ export class PublishScene { let type: COL.Edan3DResourceType | undefined = undefined; const typeV: DBAPI.Vocabulary | undefined = SAC.model.idVCreationMethod ? await CACHE.VocabularyCache.vocabulary(SAC.model.idVCreationMethod) : undefined; switch (typeV?.Term) { - default: LOG.error(`PublishScene.extractResource found no type mapping for ${typeV?.Term}`, LOG.LS.eEVENT); break; + default: LOG.error(`PublishScene.extractResource found no type mapping for ${typeV?.Term}`, LOG.LS.eCOLL); break; case undefined: break; case 'Scan To Mesh': type = '3D mesh'; break; case 'CAD': type = 'CAD model'; break; @@ -344,7 +364,7 @@ export class PublishScene { let UNITS: COL.Edan3DResourceAttributeUnits | undefined = undefined; const unitsV: DBAPI.Vocabulary | undefined = SAC.model.idVUnits ? await CACHE.VocabularyCache.vocabulary(SAC.model.idVUnits) : undefined; switch (unitsV?.Term) { - default: LOG.error(`PublishScene.extractResource found no units mapping for ${unitsV?.Term}`, LOG.LS.eEVENT); break; + default: LOG.error(`PublishScene.extractResource found no units mapping for ${unitsV?.Term}`, LOG.LS.eCOLL); break; case undefined: break; case 'Millimeter': UNITS = 'mm'; break; case 'Centimeter': UNITS = 'cm'; break; @@ -359,7 +379,7 @@ export class PublishScene { let MODEL_FILE_TYPE: COL.Edan3DResourceAttributeModelFileType | undefined = undefined; const modelTypeV: DBAPI.Vocabulary | undefined = SAC.model.idVFileType ? await CACHE.VocabularyCache.vocabulary(SAC.model.idVFileType) : undefined; switch (modelTypeV?.Term) { - default: LOG.error(`PublishScene.extractResource found no model file type mapping for ${modelTypeV?.Term}`, LOG.LS.eEVENT); break; + default: LOG.error(`PublishScene.extractResource found no model file type mapping for ${modelTypeV?.Term}`, LOG.LS.eCOLL); break; case undefined: break; case 'obj - Alias Wavefront Object': MODEL_FILE_TYPE = 'obj'; break; case 'ply - Stanford Polygon File Format': MODEL_FILE_TYPE = 'ply'; break; @@ -380,7 +400,7 @@ export class PublishScene { let FILE_TYPE: COL.Edan3DResourceAttributeFileType | undefined = undefined; switch (path.extname(SAC.assetVersion.FileName).toLowerCase()) { - default: LOG.error(`PublishScene.extractResource found no file type mapping for ${SAC.assetVersion.FileName}`, LOG.LS.eEVENT); break; + default: LOG.error(`PublishScene.extractResource found no file type mapping for ${SAC.assetVersion.FileName}`, LOG.LS.eCOLL); break; case '.zip': FILE_TYPE = 'zip'; break; case '.glb': FILE_TYPE = 'glb'; break; case '.usdz': FILE_TYPE = 'usdz'; break; @@ -411,4 +431,42 @@ export class PublishScene { const attributes: COL.Edan3DResourceAttribute[] = [{ UNITS, MODEL_FILE_TYPE, FILE_TYPE, GLTF_STANDARDIZED, DRACO_COMPRESSED }]; return { filename, url, type, title, name, attributes, category }; } + + private async updatePublishedState(): Promise { + if (!this.systemObjectVersion) + return false; + + // Determine if licensing prevents publishing + const LR: DBAPI.LicenseResolver | undefined = await CACHE.LicenseCache.getLicenseResolver(this.idSystemObject); + if (LR && LR.License && + DBAPI.LicenseRestrictLevelToPublishedStateEnum(LR.License.RestrictLevel) === DBAPI.ePublishedState.eNotPublished) + this.eState = DBAPI.ePublishedState.eNotPublished; + LOG.info(`PublishScene.updatePublishedState computed license ${LR ? JSON.stringify(LR.License, H.Helpers.saferStringify) : 'none'}, resulting in published state of ${this.eState}`, LOG.LS.eCOLL); + + if (this.systemObjectVersion.publishedStateEnum() !== this.eState) { + this.systemObjectVersion.setPublishedState(this.eState); + if (!await this.systemObjectVersion.update()) { + LOG.error(`PublishScene.updatePublishedState unable to update published state for ${JSON.stringify(this.systemObjectVersion, H.Helpers.saferStringify)}`, LOG.LS.eCOLL); + return false; + } + } + + return true; + } + + private computeEdanSearchFlags(edanRecord: COL.EdanRecord, eState: DBAPI.ePublishedState): { status: number, publicSearch: boolean, downloads: boolean } { + let status: number = edanRecord.status; + let publicSearch: boolean = edanRecord.publicSearch; + let downloads: boolean = publicSearch; + + switch (eState) { + default: + case DBAPI.ePublishedState.eNotPublished: status = 1; publicSearch = false; downloads = false; break; + case DBAPI.ePublishedState.eAPIOnly: status = 0; publicSearch = false; downloads = true; break; + case DBAPI.ePublishedState.ePublished: status = 0; publicSearch = true; downloads = true; break; + // case DBAPI.ePublishedState.eViewOnly: status = 0; publicSearch = true; downloads = false; break; + } + LOG.info(`PublishScene.computeEdanSearchFlags(${DBAPI.ePublishedState[eState]}) = { status ${status}, publicSearch ${publicSearch}, downloads ${downloads} }`, LOG.LS.eCOLL); + return { status, publicSearch, downloads }; + } } \ No newline at end of file diff --git a/server/collections/interface/ICollection.ts b/server/collections/interface/ICollection.ts index 3945d469e..d243306c4 100644 --- a/server/collections/interface/ICollection.ts +++ b/server/collections/interface/ICollection.ts @@ -45,6 +45,7 @@ export type CollectionQueryOptions = { */ export interface ICollection { queryCollection(query: string, rows: number, start: number, options: CollectionQueryOptions | null): Promise; + publish(idSystemObject: number, ePublishState: number): Promise; createEdanMDM(edanmdm: EdanMDMContent, status: number, publicSearch: boolean): Promise; createEdan3DPackage(path: string, sceneFile?: string | undefined): Promise; updateEdan3DPackage(url: string, sceneContent: Edan3DPackageContent, status: number, publicSearch: boolean): Promise; diff --git a/server/config/index.ts b/server/config/index.ts index e6b336622..7dafdc240 100644 --- a/server/config/index.ts +++ b/server/config/index.ts @@ -126,7 +126,7 @@ export const Config: ConfigType = { api3d: process.env.PACKRAT_EDAN_3D_API ? process.env.PACKRAT_EDAN_3D_API : /* istanbul ignore next */ 'https://3d-api.si.edu/', appId: process.env.PACKRAT_EDAN_APPID ? process.env.PACKRAT_EDAN_APPID : /* istanbul ignore next */ 'OCIO3D', authKey: process.env.PACKRAT_EDAN_AUTH_KEY ? process.env.PACKRAT_EDAN_AUTH_KEY : /* istanbul ignore next */ '', - upsertContentRoot: process.env.PACKRAT_EDAN_UPSERT_RESOURCE_ROOT ? process.env.PACKRAT_EDAN_UPSERT_RESOURCE_ROOT : 'nfs:///ifs/smb/ocio/ocio-3ddigip01/upload/', + upsertContentRoot: process.env.PACKRAT_EDAN_UPSERT_RESOURCE_ROOT ? process.env.PACKRAT_EDAN_UPSERT_RESOURCE_ROOT : 'nfs:///si-3ddigi-staging/upload/', stagingRoot: process.env.PACKRAT_EDAN_STAGING_ROOT ? process.env.PACKRAT_EDAN_STAGING_ROOT : '/3ddigip01/upload', resourcesHotFolder: process.env.PACKRAT_EDAN_RESOURCES_HOTFOLDER ? process.env.PACKRAT_EDAN_RESOURCES_HOTFOLDER : '/3ddigip01/3d_api_hot_folder/dev/3d_api_hot_folder_downloads', } diff --git a/server/config/solr/data/packrat/conf/schema.xml b/server/config/solr/data/packrat/conf/schema.xml index 99d0becf2..5d963502c 100644 --- a/server/config/solr/data/packrat/conf/schema.xml +++ b/server/config/solr/data/packrat/conf/schema.xml @@ -100,8 +100,6 @@ - - @@ -110,6 +108,9 @@ + + + diff --git a/server/db/api/Audit.ts b/server/db/api/Audit.ts index 5e63bf226..30fbb7ecf 100644 --- a/server/db/api/Audit.ts +++ b/server/db/api/Audit.ts @@ -1,8 +1,10 @@ /* eslint-disable camelcase */ -import { Audit as AuditBase } from '@prisma/client'; +import { Audit as AuditBase, User as UserBase } from '@prisma/client'; import * as DBC from '../connection'; import * as LOG from '../../utils/logger'; +// import * as H from '../../utils/helpers'; import { eDBObjectType, eAuditType /*, eSystemObjectType */ } from './ObjectType'; // importing eSystemObjectType causes as circular dependency +import { User } from './User'; export class Audit extends DBC.DBObject implements AuditBase { idAudit!: number; @@ -93,4 +95,25 @@ export class Audit extends DBC.DBObject implements AuditBase { return null; } } + + static async fetchLastUser(idSystemObject: number, eAudit: eAuditType): Promise { + if (!idSystemObject || !eAudit) + return null; + try { + const userBaseList: UserBase[] | null = + await DBC.DBConnection.prisma.$queryRaw` + SELECT U.* + FROM Audit AS AU + JOIN User AS U ON (AU.idUser = U.idUser) + WHERE AU.AuditType = ${eAudit} + AND AU.idSystemObject = ${idSystemObject} + ORDER BY AU.AuditDate DESC + LIMIT 1`; + // LOG.info(`DBAPI.Audit.fetchLastUser(${idSystemObject}, ${eAudit}) raw ${JSON.stringify(userBaseList, H.Helpers.saferStringify)}`, LOG.LS.eDB); + return (userBaseList && userBaseList.length > 0) ? User.constructFromPrisma(userBaseList[0]) : /* istanbul ignore next */ null; + } catch (error) /* istanbul ignore next */ { + LOG.error('DBAPI.Audit.fetchLastUser', LOG.LS.eDB, error); + return null; + } + } } diff --git a/server/db/api/Model.ts b/server/db/api/Model.ts index c18fb495c..a0f4ef954 100644 --- a/server/db/api/Model.ts +++ b/server/db/api/Model.ts @@ -33,6 +33,28 @@ export class Model extends DBC.DBObject implements ModelBase, SystemO public fetchTableName(): string { return 'Model'; } public fetchID(): number { return this.idModel; } + public cloneData(model: Model): void { + this.Name = model.Name; + this.DateCreated = model.DateCreated; + this.idVCreationMethod = model.idVCreationMethod; + this.idVModality = model.idVModality; + this.idVPurpose = model.idVPurpose; + this.idVUnits = model.idVUnits; + this.idVFileType = model.idVFileType; + this.idAssetThumbnail = model.idAssetThumbnail; + this.CountAnimations = model.CountAnimations; + this.CountCameras = model.CountCameras; + this.CountFaces = model.CountFaces; + this.CountLights = model.CountLights; + this.CountMaterials = model.CountMaterials; + this.CountMeshes = model.CountMeshes; + this.CountVertices = model.CountVertices; + this.CountEmbeddedTextures = model.CountEmbeddedTextures; + this.CountLinkedTextures = model.CountLinkedTextures; + this.FileEncoding = model.FileEncoding; + this.IsDracoCompressed = model.IsDracoCompressed; + this.AutomationTag = model.AutomationTag; + } protected async createWorker(): Promise { try { diff --git a/server/db/api/ObjectType.ts b/server/db/api/ObjectType.ts index ab0390ec0..cf2fb7518 100644 --- a/server/db/api/ObjectType.ts +++ b/server/db/api/ObjectType.ts @@ -44,6 +44,7 @@ export enum eNonSystemObjectType { eModelProcessingAction = 45, eModelProcessingActionStep = 46, eModelSceneXref = 47, + eSentinel = 63, eSystemObject = 48, eSystemObjectVersion = 49, eSystemObjectVersionAssetVersionXref = 60, @@ -168,6 +169,7 @@ export function DBObjectTypeToName(dbType: eDBObjectType | null): string { case eNonSystemObjectType.eModelProcessingAction: return 'ModelProcessingAction'; case eNonSystemObjectType.eModelProcessingActionStep: return 'ModelProessingActionStep'; case eNonSystemObjectType.eModelSceneXref: return 'ModelSceneXref'; + case eNonSystemObjectType.eSentinel: return 'Sentinel'; case eNonSystemObjectType.eSystemObject: return 'SystemObject'; case eNonSystemObjectType.eSystemObjectVersion: return 'SystemObjectVersion'; case eNonSystemObjectType.eSystemObjectVersionAssetVersionXref: return 'SystemObjectVersionAssetVersionXref'; @@ -242,6 +244,7 @@ export function DBObjectNameToType(objectTypeName: string | null): eDBObjectType case 'Model Proessing Action Step': return eNonSystemObjectType.eModelProcessingActionStep; case 'ModelSceneXref': return eNonSystemObjectType.eModelSceneXref; case 'Model Scene Xref': return eNonSystemObjectType.eModelSceneXref; + case 'Sentinel': return eNonSystemObjectType.eSentinel; case 'SystemObject': return eNonSystemObjectType.eSystemObject; case 'System Object': return eNonSystemObjectType.eSystemObject; case 'SystemObjectVersion': return eNonSystemObjectType.eSystemObjectVersion; @@ -284,25 +287,48 @@ export enum eAuditType { eSceneQCd = 5 } +export enum eLicense { + eViewDownloadCC0 = 1, // 'View and Download CC0' + eViewDownloadRestriction = 2, // 'View and Download with usage restrictions', + eViewOnly = 3, // 'View Only', + eRestricted = 4, // 'Restricted', default +} + export enum ePublishedState { eNotPublished = 0, // 'Not Published', default - eRestricted = 1, // 'Restricted', - eViewOnly = 2, // 'View Only', - eViewDownloadRestriction = 3, // 'View and Download with usage restrictions', - eViewDownloadCC0 = 4, // 'View and Download CC0' + eAPIOnly = 1, // 'API Only', + ePublished = 2, // 'Published' +} + +export function LicenseEnumToString(eState: eLicense): string { + switch (eState) { + case eLicense.eViewDownloadCC0: return 'View and Download CC0'; + case eLicense.eViewDownloadRestriction: return 'View and Download with usage restrictions'; + case eLicense.eViewOnly: return 'View Only'; + default: + case eLicense.eRestricted: return 'Restricted'; + } } export function PublishedStateEnumToString(eState: ePublishedState): string { switch (eState) { - case ePublishedState.eRestricted: return 'Restricted'; - case ePublishedState.eViewOnly: return 'View Only'; - case ePublishedState.eViewDownloadRestriction: return 'View and Download with usage restrictions'; - case ePublishedState.eViewDownloadCC0: return 'View and Download CC0'; + case ePublishedState.eAPIOnly: return 'API Only'; + case ePublishedState.ePublished: return 'Published'; default: case ePublishedState.eNotPublished: return 'Not Published'; } } +export function LicenseRestrictLevelToPublishedStateEnum(restrictLevel: number): ePublishedState { + if (restrictLevel <= 10) + return ePublishedState.ePublished; + if (restrictLevel <= 20) + return ePublishedState.ePublished; + if (restrictLevel <= 30) + return ePublishedState.ePublished; + return ePublishedState.eNotPublished; +} + // Keep this in sync with SQL in WorkflowListResult.search() export enum eWorkflowJobRunStatus { eUnitialized = 0, diff --git a/server/db/api/Scene.ts b/server/db/api/Scene.ts index 02904350d..59b161f01 100644 --- a/server/db/api/Scene.ts +++ b/server/db/api/Scene.ts @@ -9,8 +9,6 @@ export class Scene extends DBC.DBObject implements SceneBase, SystemO idScene!: number; Name!: string; idAssetThumbnail!: number | null; - IsOriented!: boolean; - HasBeenQCd!: boolean; CountScene!: number | null; CountNode!: number | null; CountCamera!: number | null; @@ -20,15 +18,17 @@ export class Scene extends DBC.DBObject implements SceneBase, SystemO CountSetup!: number | null; CountTour!: number | null; EdanUUID!: string | null; + PosedAndQCd!: boolean; + ApprovedForPublication!: boolean; - HasBeenQCdOrig!: boolean; + ApprovedForPublicationOrig!: boolean; constructor(input: SceneBase) { super(input); } protected updateCachedValues(): void { - this.HasBeenQCdOrig = this.HasBeenQCd; + this.ApprovedForPublicationOrig = this.ApprovedForPublication; } public fetchTableName(): string { return 'Scene'; } @@ -36,10 +36,10 @@ export class Scene extends DBC.DBObject implements SceneBase, SystemO protected async createWorker(): Promise { try { - const { Name, idAssetThumbnail, IsOriented, HasBeenQCd, CountScene, CountNode, CountCamera, + const { Name, idAssetThumbnail, PosedAndQCd, ApprovedForPublication, CountScene, CountNode, CountCamera, CountLight, CountModel, CountMeta, CountSetup, CountTour, EdanUUID } = this; ({ idScene: this.idScene, Name: this.Name, idAssetThumbnail: this.idAssetThumbnail, - IsOriented: this.IsOriented, HasBeenQCd: this.HasBeenQCd, CountScene: this.CountScene, + PosedAndQCd: this.PosedAndQCd, ApprovedForPublication: this.ApprovedForPublication, CountScene: this.CountScene, CountNode: this.CountNode, CountCamera: this.CountCamera, CountLight: this.CountLight, CountModel: this.CountModel, CountMeta: this.CountMeta, CountSetup: this.CountSetup, CountTour: this.CountTour, EdanUUID: this.EdanUUID } = @@ -47,15 +47,15 @@ export class Scene extends DBC.DBObject implements SceneBase, SystemO data: { Name, Asset: idAssetThumbnail ? { connect: { idAsset: idAssetThumbnail }, } : undefined, - IsOriented, - HasBeenQCd, + PosedAndQCd, + ApprovedForPublication, SystemObject: { create: { Retired: false }, }, CountScene, CountNode, CountCamera, CountLight, CountModel, CountMeta, CountSetup, CountTour, EdanUUID }, })); // Audit if someone marks this scene as QC'd - if (HasBeenQCd) + if (ApprovedForPublication) this.audit(eEventKey.eSceneQCd); // don't await, allow this to continue asynchronously return true; } catch (error) /* istanbul ignore next */ { @@ -66,21 +66,21 @@ export class Scene extends DBC.DBObject implements SceneBase, SystemO protected async updateWorker(): Promise { try { - const { idScene, Name, idAssetThumbnail, IsOriented, HasBeenQCd, - CountScene, CountNode, CountCamera, CountLight, CountModel, CountMeta, CountSetup, CountTour, EdanUUID, HasBeenQCdOrig } = this; + const { idScene, Name, idAssetThumbnail, PosedAndQCd, ApprovedForPublication, + CountScene, CountNode, CountCamera, CountLight, CountModel, CountMeta, CountSetup, CountTour, EdanUUID, ApprovedForPublicationOrig } = this; const retValue: boolean = await DBC.DBConnection.prisma.scene.update({ where: { idScene, }, data: { Name, Asset: idAssetThumbnail ? { connect: { idAsset: idAssetThumbnail }, } : { disconnect: true, }, - IsOriented, - HasBeenQCd, + PosedAndQCd, + ApprovedForPublication, CountScene, CountNode, CountCamera, CountLight, CountModel, CountMeta, CountSetup, CountTour, EdanUUID }, }) ? true : /* istanbul ignore next */ false; // Audit if someone marks this scene as QC'd - if (HasBeenQCd && !HasBeenQCdOrig) + if (ApprovedForPublication && !ApprovedForPublicationOrig) this.audit(eEventKey.eSceneQCd); // don't await, allow this to continue asynchronously return retValue; } catch (error) /* istanbul ignore next */ { diff --git a/server/db/api/Sentinel.ts b/server/db/api/Sentinel.ts new file mode 100644 index 000000000..0058b97d9 --- /dev/null +++ b/server/db/api/Sentinel.ts @@ -0,0 +1,75 @@ +/* eslint-disable camelcase */ +import { Sentinel as SentinelBase } from '@prisma/client'; +import * as DBC from '../connection'; +import * as LOG from '../../utils/logger'; + +export class Sentinel extends DBC.DBObject implements SentinelBase { + idSentinel!: number; + URLBase!: string; + ExpirationDate!: Date; + idUser!: number; + + constructor(input: SentinelBase) { + super(input); + } + + public fetchTableName(): string { return 'Sentinel'; } + public fetchID(): number { return this.idSentinel; } + + protected async createWorker(): Promise { + try { + const { URLBase, ExpirationDate, idUser } = this; + ({ idSentinel: this.idSentinel, URLBase: this.URLBase, ExpirationDate: this.ExpirationDate, idUser: this.idUser } = + await DBC.DBConnection.prisma.sentinel.create({ + data: { + URLBase, + ExpirationDate, + User: { connect: { idUser }, }, + }, + })); + return true; + } catch (error) /* istanbul ignore next */ { + LOG.error('DBAPI.Sentinel.create', LOG.LS.eDB, error); + return false; + } + } + + protected async updateWorker(): Promise { + try { + const { idSentinel, URLBase, ExpirationDate, idUser } = this; + return await DBC.DBConnection.prisma.sentinel.update({ + where: { idSentinel, }, + data: { + URLBase, + ExpirationDate, + User: { connect: { idUser }, }, + }, + }) ? true : /* istanbul ignore next */ false; + } catch (error) /* istanbul ignore next */ { + LOG.error('DBAPI.Sentinel.update', LOG.LS.eDB, error); + return false; + } + } + + static async fetch(idSentinel: number): Promise { + if (!idSentinel) + return null; + try { + return DBC.CopyObject( + await DBC.DBConnection.prisma.sentinel.findUnique({ where: { idSentinel, }, }), Sentinel); + } catch (error) /* istanbul ignore next */ { + LOG.error('DBAPI.Sentinel.fetch', LOG.LS.eDB, error); + return null; + } + } + + static async fetchByURLBase(URLBase: string): Promise { + try { + return DBC.CopyArray( + await DBC.DBConnection.prisma.sentinel.findMany({ where: { URLBase, }, }), Sentinel); + } catch (error) /* istanbul ignore next */ { + LOG.error('DBAPI.Sentinel.fetchAll', LOG.LS.eDB, error); + return null; + } + } +} diff --git a/server/db/api/SystemObjectVersion.ts b/server/db/api/SystemObjectVersion.ts index 62eb371f8..c3585417f 100644 --- a/server/db/api/SystemObjectVersion.ts +++ b/server/db/api/SystemObjectVersion.ts @@ -1,8 +1,9 @@ /* eslint-disable camelcase */ -import { SystemObjectVersion as SystemObjectVersionBase } from '@prisma/client'; +import { PrismaClient, SystemObjectVersion as SystemObjectVersionBase } from '@prisma/client'; import { ePublishedState, SystemObjectVersionAssetVersionXref } from '..'; import * as DBC from '../connection'; import * as LOG from '../../utils/logger'; +// import * as H from '../../utils/helpers'; export class SystemObjectVersion extends DBC.DBObject implements SystemObjectVersionBase { idSystemObjectVersion!: number; @@ -19,10 +20,8 @@ export class SystemObjectVersion extends DBC.DBObject i public publishedStateEnum(): ePublishedState { switch (this.PublishedState) { - case 1: return ePublishedState.eRestricted; - case 2: return ePublishedState.eViewOnly; - case 3: return ePublishedState.eViewDownloadRestriction; - case 4: return ePublishedState.eViewDownloadCC0; + case 1: return ePublishedState.eAPIOnly; + case 2: return ePublishedState.ePublished; default: return ePublishedState.eNotPublished; } } @@ -134,12 +133,29 @@ export class SystemObjectVersion extends DBC.DBObject i if (!idSystemObject) return null; try { + const prisma: PrismaClient = new PrismaClient(); + return await prisma.$transaction(async (prisma) => { + const transactionNumber: number = await DBC.DBConnection.setPrismaTransaction(prisma); + const retValue: SystemObjectVersion | null = await SystemObjectVersion.cloneObjectAndXrefsTrans(idSystemObject, idSystemObjectVersion, assetVersionOverrideMap); + DBC.DBConnection.clearPrismaTransaction(transactionNumber); + return retValue; + }); + } catch (error) /* istanbul ignore next */ { + LOG.error('DBAPI.SystemObjectVersion.cloneObjectAndXrefs', LOG.LS.eDB, error); + return null; + } + } + + private static async cloneObjectAndXrefsTrans(idSystemObject: number, idSystemObjectVersion: number | null, + assetVersionOverrideMap?: Map | undefined): Promise { + try { + // LOG.info(`DBAPI.SystemObjectVersion.cloneObjectAndXrefsTrans(${idSystemObject}, ${idSystemObjectVersion}, ${JSON.stringify(assetVersionOverrideMap, H.Helpers.saferStringify)})`, LOG.LS.eDB); // fetch latest SystemObjectVerion's mapping of idAsset -> idAssetVersion const assetVersionMap: Map | null = idSystemObjectVersion ? await SystemObjectVersionAssetVersionXref.fetchAssetVersionMap(idSystemObjectVersion) : await SystemObjectVersionAssetVersionXref.fetchLatestAssetVersionMap(idSystemObject); /* istanbul ignore next */ if (!assetVersionMap) { - LOG.error(`DBAPI.SystemObjectVersion.cloneObjectAndXrefs unable to fetch assetVersionMap from idSystemObject ${idSystemObject}, idSystemObjectVersion ${idSystemObjectVersion}`, LOG.LS.eDB); + LOG.error(`DBAPI.SystemObjectVersion.cloneObjectAndXrefsTrans unable to fetch assetVersionMap from idSystemObject ${idSystemObject}, idSystemObjectVersion ${idSystemObjectVersion}`, LOG.LS.eDB); return null; } @@ -152,7 +168,7 @@ export class SystemObjectVersion extends DBC.DBObject i }); /* istanbul ignore next */ if (!await SOV.create()) { - LOG.error(`DBAPI.SystemObjectVersion.cloneObjectAndXrefs failed to create new SystemObjectVersion for ${idSystemObject}`, LOG.LS.eDB); + LOG.error(`DBAPI.SystemObjectVersion.cloneObjectAndXrefsTrans failed to create new SystemObjectVersion for ${idSystemObject}`, LOG.LS.eDB); return null; } @@ -171,15 +187,16 @@ export class SystemObjectVersion extends DBC.DBObject i idAssetVersion }); success = await SOVAVX.create() && success; + // LOG.info(`DBAPI.SystemObjectVersion.cloneObjectAndXrefsTrans(${idSystemObject}, ${idSystemObjectVersion}) created ${JSON.stringify(SOVAVX, H.Helpers.saferStringify)})`, LOG.LS.eDB); } /* istanbul ignore next */ if (!success) { - LOG.error(`DBAPI.SystemObjectVersion.cloneObjectAndXrefs failed to create all SystemObjectVersionAssetVersionXref's for ${JSON.stringify(SOV)}`, LOG.LS.eDB); + LOG.error(`DBAPI.SystemObjectVersion.cloneObjectAndXrefsTrans failed to create all SystemObjectVersionAssetVersionXref's for ${JSON.stringify(SOV)}`, LOG.LS.eDB); return null; } return SOV; } catch (error) /* istanbul ignore next */ { - LOG.error('DBAPI.SystemObjectVersion.cloneObjectAndXrefs', LOG.LS.eDB, error); + LOG.error('DBAPI.SystemObjectVersion.cloneObjectAndXrefsTrans', LOG.LS.eDB, error); return null; } } diff --git a/server/db/api/User.ts b/server/db/api/User.ts index 27f9b7b6c..aa4b66338 100644 --- a/server/db/api/User.ts +++ b/server/db/api/User.ts @@ -2,6 +2,7 @@ import { User as UserBase } from '@prisma/client'; import * as DBC from '../connection'; import * as LOG from '../../utils/logger'; +import * as H from '../../utils/helpers'; export enum eUserStatus { eAll, @@ -29,6 +30,20 @@ export class User extends DBC.DBObject implements UserBase { public fetchTableName(): string { return 'User'; } public fetchID(): number { return this.idUser; } + static constructFromPrisma(userBase: UserBase): User { + return new User({ + idUser: userBase.idUser, + Name: userBase.Name, + EmailAddress: userBase.EmailAddress, + SecurityID: userBase.SecurityID, + Active: /* istanbul ignore next */ H.Helpers.safeBoolean(userBase.Active) ?? false, + DateActivated: userBase.DateActivated, + DateDisabled: userBase.DateDisabled, + WorkflowNotificationTime: userBase.WorkflowNotificationTime, + EmailSettings: userBase.EmailSettings + }); + } + protected updateCachedValues(): void { this.ActiveOrig = this.Active; } diff --git a/server/db/api/composite/LicenseResolver.ts b/server/db/api/composite/LicenseResolver.ts index 4b48d4cec..3dc4baf58 100644 --- a/server/db/api/composite/LicenseResolver.ts +++ b/server/db/api/composite/LicenseResolver.ts @@ -15,19 +15,21 @@ export class LicenseResolver { this.inherited = inherited; } - public static async fetch(idSystemObject: number): Promise { + public static async fetch(idSystemObject: number, OGD?: ObjectGraphDatabase | undefined): Promise { const LR: LicenseResolver | null = await LicenseResolver.fetchSpecificLicense(idSystemObject, false); if (LR) return LR; - const OGD: ObjectGraphDatabase = new ObjectGraphDatabase(); - const OG: ObjectGraph = new ObjectGraph(idSystemObject, eObjectGraphMode.eAncestors, 32, OGD); /* istanbul ignore if */ - if (!await OG.fetch()) { - LOG.error(`LicenseResolver unable to fetch object graph for ${idSystemObject}`, LOG.LS.eDB); - return null; + if (!OGD) { + OGD = new ObjectGraphDatabase(); + const OG: ObjectGraph = new ObjectGraph(idSystemObject, eObjectGraphMode.eAncestors, 32, OGD); /* istanbul ignore if */ + if (!await OG.fetch()) { + LOG.error(`LicenseResolver unable to fetch object graph for ${idSystemObject}`, LOG.LS.eDB); + return null; + } } - return await LicenseResolver.fetchParentsLicense(OGD, idSystemObject, 32); + return await LicenseResolver.fetchParentsLicense(OGD, idSystemObject, 32, new Map()); } private static async pickMostRestrictiveLicense(assignments: LicenseAssignment[], inherited: boolean): Promise { @@ -59,11 +61,12 @@ export class LicenseResolver { let LR: LicenseResolver | null = null; if (assignments && assignments.length > 0) LR = await LicenseResolver.pickMostRestrictiveLicense(assignments, inherited); - // LOG.info(`LR.fetchSpecificLicense found ${JSON.stringify(LR)}`, LOG.LS.eDB); + // LOG.info(`LR.fetchSpecificLicense(${idSystemObject}) found ${JSON.stringify(LR)}`, LOG.LS.eDB); return LR; } - private static async fetchParentsLicense(OGD: ObjectGraphDatabase, idSystemObject: number, depth: number): Promise { + private static async fetchParentsLicense(OGD: ObjectGraphDatabase, idSystemObject: number, depth: number, + LRMap: Map): Promise { let LR: LicenseResolver | null = null; const OGDE: ObjectGraphDataEntry | undefined = OGD.objectMap.get(idSystemObject); @@ -74,14 +77,18 @@ export class LicenseResolver { for (const idSystemObjectParent of OGDE.parentMap.keys()) { // for each parent, get its specific LicenseResolver - let LRP: LicenseResolver | null = await LicenseResolver.fetchSpecificLicense(idSystemObjectParent, true); - if (!LRP || !LRP.License) { /* istanbul ignore if */ - // if none, step "up" to the parent, and fetch it's aggregate parents' notion of license, via recursion - if (depth <= 0) - continue; + let LRP: LicenseResolver | null | undefined = LRMap.get(idSystemObjectParent); + if (LRP === undefined) { + LRP = await LicenseResolver.fetchSpecificLicense(idSystemObjectParent, true); + if (!LRP || !LRP.License) { /* istanbul ignore if */ + // if none, step "up" to the parent, and fetch it's aggregate parents' notion of license, via recursion + if (depth <= 0) + continue; - LRP = await LicenseResolver.fetchParentsLicense(OGD, idSystemObjectParent, depth - 1); - } /* istanbul ignore else */ + LRP = await LicenseResolver.fetchParentsLicense(OGD, idSystemObjectParent, depth - 1, LRMap); + } /* istanbul ignore else */ + LRMap.set(idSystemObjectParent, LRP); + } if (LRP && LRP.License) { if (!LR || !LR.License) // if we don't yet have a license, use this one @@ -91,7 +98,7 @@ export class LicenseResolver { continue; } } - // LOG.info(`LR.fetchParentsLicense found ${JSON.stringify(LR)}`, LOG.LS.eDB); + // LOG.info(`LR.fetchParentsLicense(${idSystemObject}) found ${JSON.stringify(LR)}, LRMap size = ${LRMap.size}`, LOG.LS.eDB); return LR; } } diff --git a/server/db/api/composite/ObjectGraphDataEntry.ts b/server/db/api/composite/ObjectGraphDataEntry.ts index b719fd2ff..4e6591a05 100644 --- a/server/db/api/composite/ObjectGraphDataEntry.ts +++ b/server/db/api/composite/ObjectGraphDataEntry.ts @@ -105,17 +105,15 @@ export class ObjectGraphDataEntry { } } - if (eDirection == eApplyGraphStateDirection.eParent) { + if (eDirection == eApplyGraphStateDirection.eSelf || + eDirection == eApplyGraphStateDirection.eParent) { if (objectGraphState.eType) { if (!this.childrenObjectTypes.has(objectGraphState.eType)) { this.childrenObjectTypes.add(objectGraphState.eType); retValue = true; } } - } - if (eDirection == eApplyGraphStateDirection.eSelf || - eDirection == eApplyGraphStateDirection.eParent) { if (objectGraphState.captureMethod) { if (!this.childrenCaptureMethods.has(objectGraphState.captureMethod)) { this.childrenCaptureMethods.add(objectGraphState.captureMethod); diff --git a/server/db/api/composite/SubjectUnitIdentifier.ts b/server/db/api/composite/SubjectUnitIdentifier.ts index e447fb8ca..54c14fb88 100644 --- a/server/db/api/composite/SubjectUnitIdentifier.ts +++ b/server/db/api/composite/SubjectUnitIdentifier.ts @@ -54,10 +54,11 @@ export class SubjectUnitIdentifier { SELECT SO.idSystemObject FROM Subject AS S - JOIN Unit AS U ON (S.idUnit = U.idUnit) JOIN SystemObject AS SO ON (S.idSubject = SO.idSubject) - WHERE (S.Name LIKE ${query} - OR U.Abbreviation LIKE ${query}) + LEFT JOIN SystemObjectXref AS SOX ON (SO.idSystemObject = SOX.idSystemObjectMaster) + LEFT JOIN SystemObject AS SOD ON (SOX.idSystemObjectDerived = SOD.idSystemObject) + LEFT JOIN Item AS I ON (SOD.idItem = I.idItem) + WHERE (S.Name LIKE ${query} OR I.Name LIKE ${query}) ), _ARKIDs (idSystemObject, IdentifierValue) AS ( SELECT ID.idSystemObject, ID.IdentifierValue @@ -143,7 +144,7 @@ export class SubjectUnitIdentifier { ${orderBy} LIMIT ?, ?`; // LOG.info(`DBAPI.SubjectUnitIdentifier.search, sql=${sql}; params=${JSON.stringify(queryRawParams)}`, LOG.LS.eDB); - return await DBC.DBConnection.prisma.$queryRaw(sql, ...queryRawParams); + return await DBC.DBConnection.prisma.$queryRawUnsafe(sql, ...queryRawParams); } catch (error) /* istanbul ignore next */ { LOG.error('DBAPI.SubjectUnitIdentifier.search', LOG.LS.eDB, error); return null; diff --git a/server/db/api/composite/WorkflowListResult.ts b/server/db/api/composite/WorkflowListResult.ts index daf878d10..a1428a578 100644 --- a/server/db/api/composite/WorkflowListResult.ts +++ b/server/db/api/composite/WorkflowListResult.ts @@ -147,7 +147,7 @@ export class WorkflowListResult { ${orderBy} LIMIT ?, ?`; // LOG.info(`DBAPI.WorkflowListResult.search, sql=${sql}; params=${JSON.stringify(queryRawParams)}`, LOG.LS.eDB); - return await DBC.DBConnection.prisma.$queryRaw(sql, ...queryRawParams); + return await DBC.DBConnection.prisma.$queryRawUnsafe(sql, ...queryRawParams); } catch (error) /* istanbul ignore next */ { LOG.error('DBAPI.WorkflowListResult.search', LOG.LS.eDB, error); return null; diff --git a/server/db/connection/DBConnection.ts b/server/db/connection/DBConnection.ts index d73a1661b..c4f46e7b0 100644 --- a/server/db/connection/DBConnection.ts +++ b/server/db/connection/DBConnection.ts @@ -1,8 +1,14 @@ import { PrismaClient } from '@prisma/client'; +import { ASL, LocalStore } from '../../utils/localStore'; + +type PrismaClientTrans = Omit; export class DBConnection { private static dbConnection: DBConnection; private _prisma: PrismaClient | null = null; + private _prismaTransMap: Map = new Map(); + private _prismaTransNumber: number = 0; + private constructor() { } private static getInstance(): DBConnection { @@ -11,12 +17,39 @@ export class DBConnection { return DBConnection.dbConnection; } - private get prisma(): PrismaClient { - if (!this._prisma) { + private get prisma(): PrismaClientTrans { + if (!this._prisma) this._prisma = new PrismaClient(); - return this._prisma; - } else - return this._prisma; + if (this._prismaTransMap.size > 0) { + const LS: LocalStore | undefined = ASL.getStore(); + if (LS && LS.transactionNumber) { + const PCT: PrismaClientTrans | undefined = this._prismaTransMap.get(LS.transactionNumber); + if (PCT) + return PCT; + } + } + return this._prisma; + } + + private async setPrismaTransaction(prisma: PrismaClientTrans): Promise { + const transactionNumber: number = ++this._prismaTransNumber; + this._prismaTransMap.set(transactionNumber, prisma); + + const LS: LocalStore = await ASL.getOrCreateStore(); + LS.transactionNumber = transactionNumber; + + return transactionNumber; + } + + private clearPrismaTransaction(transactionNumber?: number | undefined): void { + if (!transactionNumber) { + const LS: LocalStore | undefined = ASL.getStore(); + if (!LS || !LS.transactionNumber) + return; + transactionNumber = LS.transactionNumber; + LS.transactionNumber = undefined; + } + this._prismaTransMap.delete(transactionNumber); } private async disconnect(): Promise { @@ -26,10 +59,18 @@ export class DBConnection { } } - static get prisma(): PrismaClient { + static get prisma(): PrismaClientTrans { return DBConnection.getInstance().prisma; } + static async setPrismaTransaction(prisma: PrismaClientTrans): Promise { + return DBConnection.getInstance().setPrismaTransaction(prisma); + } + + static clearPrismaTransaction(transactionNumber?: number | undefined): void { + return DBConnection.getInstance().clearPrismaTransaction(transactionNumber); + } + static async disconnect(): Promise { await DBConnection.getInstance().disconnect(); } diff --git a/server/db/index.ts b/server/db/index.ts index b0bb43d97..3ace1650b 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -41,6 +41,7 @@ export * from './api/ObjectType'; export * from './api/Project'; export * from './api/ProjectDocumentation'; export * from './api/Scene'; +export * from './api/Sentinel'; export * from './api/Stakeholder'; export * from './api/Subject'; export * from './api/SystemObject'; diff --git a/server/db/prisma/schema.prisma b/server/db/prisma/schema.prisma index 6cd53a25b..327809149 100644 --- a/server/db/prisma/schema.prisma +++ b/server/db/prisma/schema.prisma @@ -2,6 +2,7 @@ generator client { provider = "prisma-client-js" output = "../../../node_modules/@prisma/client" binaryTargets = ["native", "linux-musl"] + previewFeatures = ["interactiveTransactions"] } datasource mariasql { @@ -558,23 +559,23 @@ model ProjectDocumentation { } model Scene { - idScene Int @id @default(autoincrement()) - Name String @mariasql.VarChar(255) - idAssetThumbnail Int? - IsOriented Boolean - HasBeenQCd Boolean - CountScene Int? - CountNode Int? - CountCamera Int? - CountLight Int? - CountModel Int? - CountMeta Int? - CountSetup Int? - CountTour Int? - EdanUUID String? @mariasql.VarChar(64) - Asset Asset? @relation(fields: [idAssetThumbnail], references: [idAsset]) - ModelSceneXref ModelSceneXref[] - SystemObject SystemObject? + idScene Int @id @default(autoincrement()) + Name String @mariasql.VarChar(255) + idAssetThumbnail Int? + CountScene Int? + CountNode Int? + CountCamera Int? + CountLight Int? + CountModel Int? + CountMeta Int? + CountSetup Int? + CountTour Int? + EdanUUID String? @mariasql.VarChar(64) + PosedAndQCd Boolean + ApprovedForPublication Boolean + Asset Asset? @relation(fields: [idAssetThumbnail], references: [idAsset]) + ModelSceneXref ModelSceneXref[] + SystemObject SystemObject? @@index([idAssetThumbnail], name: "fk_scene_asset1") } @@ -724,6 +725,7 @@ model User { Audit Audit[] LicenseAssignment LicenseAssignment[] Metadata Metadata[] + Sentinel Sentinel[] UserPersonalizationSystemObject UserPersonalizationSystemObject[] UserPersonalizationUrl UserPersonalizationUrl[] Workflow Workflow[] @@ -892,3 +894,14 @@ model WorkflowSet { idWorkflowSet Int @id @default(autoincrement()) Workflow Workflow[] } + +model Sentinel { + idSentinel Int @id @default(autoincrement()) + URLBase String @mariasql.VarChar(512) + ExpirationDate DateTime @mariasql.DateTime(0) + idUser Int + User User @relation(fields: [idUser], references: [idUser]) + + @@index([URLBase], name: "Sentinel_URLBase") + @@index([idUser], name: "fk_sentinel_user1") +} diff --git a/server/db/sql/models/Packrat.mwb b/server/db/sql/models/Packrat.mwb index e12b0c434..b89cff5f9 100644 Binary files a/server/db/sql/models/Packrat.mwb and b/server/db/sql/models/Packrat.mwb differ diff --git a/server/db/sql/scripts/Packrat.ALTER.sql b/server/db/sql/scripts/Packrat.ALTER.sql index df17cd92a..f26d987fc 100644 --- a/server/db/sql/scripts/Packrat.ALTER.sql +++ b/server/db/sql/scripts/Packrat.ALTER.sql @@ -148,3 +148,35 @@ ADD CONSTRAINT `fk_metadata_systemobject2` -- 2021-08-12 Jon ALTER TABLE Scene ADD COLUMN EdanUUID varchar(64) NULL; + +-- 2021-08-20 Deployed to Staging + +-- 2021-09-30 Jon +ALTER TABLE Scene ADD COLUMN `PosedAndQCd` boolean NULL; +ALTER TABLE Scene ADD COLUMN `ApprovedForPublication` boolean NULL; +UPDATE Scene SET PosedAndQCd = isOriented, ApprovedForPublication = hasBeenQCd; +ALTER TABLE Scene MODIFY COLUMN `PosedAndQCd` boolean NOT NULL; +ALTER TABLE Scene MODIFY COLUMN `ApprovedForPublication` boolean NOT NULL; +ALTER TABLE Scene DROP COLUMN `isOriented`; +ALTER TABLE Scene DROP COLUMN `hasBeenQCd`; + +-- 2021-10-26 Deployed to Staging + +-- 2021-10-27 Jon +CREATE TABLE IF NOT EXISTS `Sentinel` ( + `idSentinel` int(11) NOT NULL AUTO_INCREMENT, + `URLBase` varchar(512) NOT NULL, + `ExpirationDate` datetime NOT NULL, + `idUser` int(11) NOT NULL, + PRIMARY KEY (`idSentinel`), + KEY `Sentinel_URLBase` (`URLBase`) +) ENGINE=InnoDB DEFAULT CHARSET=UTF8MB4; + +ALTER TABLE `Sentinel` +ADD CONSTRAINT `fk_sentinel_user1` + FOREIGN KEY (`idUser`) + REFERENCES `User` (`idUser`) + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- 2021-11-04 Deployed to Staging diff --git a/server/db/sql/scripts/Packrat.DATA.sql b/server/db/sql/scripts/Packrat.DATA.sql index aa37d7d80..e4eb7ed52 100644 --- a/server/db/sql/scripts/Packrat.DATA.sql +++ b/server/db/sql/scripts/Packrat.DATA.sql @@ -1284,7 +1284,7 @@ INSERT INTO Job (idVJobType, Name, Status, Frequency) SELECT idVocabulary, Term, INSERT INTO Job (idVJobType, Name, Status, Frequency) SELECT idVocabulary, Term, 1, NULL FROM Vocabulary WHERE Term = 'Cook: unwrap'; INSERT INTO License (Name, Description, RestrictLevel) VALUES ('View And Download CC0', 'View And Download CC0', 10); -INSERT INTO License (Name, Description, RestrictLevel) VALUES ('View with Download Restrictions', 'View with Download Restrictions', 20); +INSERT INTO License (Name, Description, RestrictLevel) VALUES ('View With Download Restrictions', 'View With Download Restrictions', 20); INSERT INTO License (Name, Description, RestrictLevel) VALUES ('View Only', 'View Only', 30); INSERT INTO License (Name, Description, RestrictLevel) VALUES ('Restricted', 'Restricted', 1000); diff --git a/server/db/sql/scripts/Packrat.DROP.sql b/server/db/sql/scripts/Packrat.DROP.sql index b9d6bf2ae..2e602e873 100644 --- a/server/db/sql/scripts/Packrat.DROP.sql +++ b/server/db/sql/scripts/Packrat.DROP.sql @@ -9,6 +9,7 @@ DROP TABLE IF EXISTS `Actor`; DROP TABLE IF EXISTS `Asset`; DROP TABLE IF EXISTS `AssetGroup`; DROP TABLE IF EXISTS `AssetVersion`; +DROP TABLE IF EXISTS `Audit`; DROP TABLE IF EXISTS `CaptureData`; DROP TABLE IF EXISTS `CaptureDataFile`; DROP TABLE IF EXISTS `CaptureDataGroup`; @@ -35,6 +36,7 @@ DROP TABLE IF EXISTS `ModelSceneXref`; DROP TABLE IF EXISTS `Project`; DROP TABLE IF EXISTS `ProjectDocumentation`; DROP TABLE IF EXISTS `Scene`; +DROP TABLE IF EXISTS `Sentinel`; DROP TABLE IF EXISTS `Stakeholder`; DROP TABLE IF EXISTS `Subject`; DROP TABLE IF EXISTS `SystemObject`; @@ -49,8 +51,11 @@ DROP TABLE IF EXISTS `UserPersonalizationUrl`; DROP TABLE IF EXISTS `Vocabulary`; DROP TABLE IF EXISTS `VocabularySet`; DROP TABLE IF EXISTS `Workflow`; +DROP TABLE IF EXISTS `WorkflowReport`; +DROP TABLE IF EXISTS `WorkflowSet`; DROP TABLE IF EXISTS `WorkflowStep`; DROP TABLE IF EXISTS `WorkflowStepSystemObjectXref`; SET foreign_key_checks = 1; -DROP PROCEDURE IF EXISTS AssetVersionCreate; \ No newline at end of file +DROP PROCEDURE IF EXISTS AssetVersionCreate; +DROP PROCEDURE IF EXISTS SubjectUnitIdentifierQuery; \ No newline at end of file diff --git a/server/db/sql/scripts/Packrat.SCHEMA.sql b/server/db/sql/scripts/Packrat.SCHEMA.sql index 1a9b13c16..4c84bd3ea 100644 --- a/server/db/sql/scripts/Packrat.SCHEMA.sql +++ b/server/db/sql/scripts/Packrat.SCHEMA.sql @@ -429,8 +429,6 @@ CREATE TABLE IF NOT EXISTS `Scene` ( `idScene` int(11) NOT NULL AUTO_INCREMENT, `Name` varchar(255) NOT NULL, `idAssetThumbnail` int(11) DEFAULT NULL, - `IsOriented` boolean NOT NULL, - `HasBeenQCd` boolean NOT NULL, `CountScene` int(11) DEFAULT NULL, `CountNode` int(11) DEFAULT NULL, `CountCamera` int(11) DEFAULT NULL, @@ -440,9 +438,20 @@ CREATE TABLE IF NOT EXISTS `Scene` ( `CountSetup` int(11) DEFAULT NULL, `CountTour` int(11) DEFAULT NULL, `EdanUUID` varchar(64) NULL, + `PosedAndQCd` boolean NOT NULL, + `ApprovedForPublication` boolean NOT NULL, PRIMARY KEY (`idScene`) ) ENGINE=InnoDB DEFAULT CHARSET=UTF8MB4; +CREATE TABLE IF NOT EXISTS `Sentinel` ( + `idSentinel` int(11) NOT NULL AUTO_INCREMENT, + `URLBase` varchar(512) NOT NULL, + `ExpirationDate` datetime NOT NULL, + `idUser` int(11) NOT NULL, + PRIMARY KEY (`idSentinel`), + KEY `Sentinel_URLBase` (`URLBase`) +) ENGINE=InnoDB DEFAULT CHARSET=UTF8MB4; + CREATE TABLE IF NOT EXISTS `Stakeholder` ( `idStakeholder` int(11) NOT NULL AUTO_INCREMENT, `IndividualName` varchar(255) NOT NULL, @@ -1027,6 +1036,13 @@ ADD CONSTRAINT `fk_scene_asset1` ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE `Sentinel` +ADD CONSTRAINT `fk_sentinel_user1` + FOREIGN KEY (`idUser`) + REFERENCES `User` (`idUser`) + ON DELETE NO ACTION + ON UPDATE NO ACTION; + ALTER TABLE `Subject` ADD CONSTRAINT `fk_subject_unit1` FOREIGN KEY (`idUnit`) diff --git a/server/event/impl/InProcess/EventConsumerPublish.ts b/server/event/impl/InProcess/EventConsumerPublish.ts index 34553479f..c7fd7caa1 100644 --- a/server/event/impl/InProcess/EventConsumerPublish.ts +++ b/server/event/impl/InProcess/EventConsumerPublish.ts @@ -3,8 +3,9 @@ import { EventConsumer } from './EventConsumer'; import { EventConsumerDB } from './EventConsumerDB'; import { EventEngine } from './EventEngine'; import * as DBAPI from '../../../db'; +import * as CACHE from '../../../cache'; import * as LOG from '../../../utils/logger'; -import { PublishScene } from './PublishScene'; +import * as H from '../../../utils/helpers'; export class EventConsumerPublish extends EventConsumer { constructor(engine: EventEngine) { @@ -34,7 +35,31 @@ export class EventConsumerPublish extends EventConsumer { protected async publishScene(dataItemValue: Value): Promise { const audit: DBAPI.Audit = EventConsumerDB.convertDataToAudit(dataItemValue); - const PS: PublishScene = new PublishScene(audit); - return await PS.publish(); + + let idSystemObject: number | null = audit.idSystemObject; + if (idSystemObject === null && audit.idDBObject && audit.DBObjectType) { + const oID: DBAPI.ObjectIDAndType = { idObject: audit.idDBObject, eObjectType: audit.DBObjectType }; + const SOInfo: DBAPI.SystemObjectInfo | undefined = await CACHE.SystemObjectCache.getSystemFromObjectID(oID); + if (SOInfo) { + idSystemObject = SOInfo.idSystemObject; + audit.idSystemObject = idSystemObject; + } + } + + LOG.info(`EventConsumerPublish.publishScene Scene QCd ${audit.idDBObject}`, LOG.LS.eEVENT); + if (audit.idAudit === 0) + audit.create(); // don't use await so this happens asynchronously + + if (!idSystemObject) { + LOG.error(`EventConsumerPublish.publishScene received eSceneQCd event for scene without idSystemObject ${JSON.stringify(audit, H.Helpers.saferStringify)}`, LOG.LS.eEVENT); + return false; + } + + if (audit.getDBObjectType() !== DBAPI.eSystemObjectType.eScene) { + LOG.error(`EventConsumerPublish.publishScene received eSceneQCd event for non scene object ${JSON.stringify(audit, H.Helpers.saferStringify)}`, LOG.LS.eEVENT); + return false; + } + + return true; } } diff --git a/server/graphql/api/mutations/systemobject/publish.ts b/server/graphql/api/mutations/systemobject/publish.ts new file mode 100644 index 000000000..d0d0f37ba --- /dev/null +++ b/server/graphql/api/mutations/systemobject/publish.ts @@ -0,0 +1,12 @@ +import { gql } from 'apollo-server-express'; + +const publish = gql` + mutation publish($input: PublishInput!) { + publish(input: $input) { + success + message + } + } +`; + +export default publish; diff --git a/server/graphql/api/mutations/systemobject/updateDerivedObjects.ts b/server/graphql/api/mutations/systemobject/updateDerivedObjects.ts index 416f3eba0..a097e59a2 100644 --- a/server/graphql/api/mutations/systemobject/updateDerivedObjects.ts +++ b/server/graphql/api/mutations/systemobject/updateDerivedObjects.ts @@ -4,8 +4,10 @@ const updateDerivedObjects = gql` mutation updateDerivedObjects($input: UpdateDerivedObjectsInput!) { updateDerivedObjects(input: $input) { success + message + status } } `; -export default updateDerivedObjects; \ No newline at end of file +export default updateDerivedObjects; diff --git a/server/graphql/api/mutations/systemobject/updateSourceObjects.ts b/server/graphql/api/mutations/systemobject/updateSourceObjects.ts index 93811137a..503f575d3 100644 --- a/server/graphql/api/mutations/systemobject/updateSourceObjects.ts +++ b/server/graphql/api/mutations/systemobject/updateSourceObjects.ts @@ -4,8 +4,10 @@ const updateSourceObjects = gql` mutation updateSourceObjects($input: UpdateSourceObjectsInput!) { updateSourceObjects(input: $input) { success + status + message } } `; -export default updateSourceObjects; \ No newline at end of file +export default updateSourceObjects; diff --git a/server/graphql/api/queries/asset/getAssetVersionsDetails.ts b/server/graphql/api/queries/asset/getAssetVersionsDetails.ts index fca52f044..d1a7b1b6e 100644 --- a/server/graphql/api/queries/asset/getAssetVersionsDetails.ts +++ b/server/graphql/api/queries/asset/getAssetVersionsDetails.ts @@ -71,9 +71,9 @@ const getAssetVersionsDetails = gql` idAssetVersion systemCreated name - hasBeenQCd - isOriented directory + approvedForPublication + posedAndQCd identifiers { identifier identifierType diff --git a/server/graphql/api/queries/scene/getScene.ts b/server/graphql/api/queries/scene/getScene.ts index 76f037ea5..75fa13779 100644 --- a/server/graphql/api/queries/scene/getScene.ts +++ b/server/graphql/api/queries/scene/getScene.ts @@ -5,8 +5,6 @@ const getScene = gql` getScene(input: $input) { Scene { idScene - HasBeenQCd - IsOriented Name CountCamera CountScene @@ -17,6 +15,8 @@ const getScene = gql` CountSetup CountTour EdanUUID + ApprovedForPublication + PosedAndQCd ModelSceneXref { idModelSceneXref idModel diff --git a/server/graphql/api/queries/scene/getSceneForAssetVersion.ts b/server/graphql/api/queries/scene/getSceneForAssetVersion.ts index 790afc42a..93fa1c8b3 100644 --- a/server/graphql/api/queries/scene/getSceneForAssetVersion.ts +++ b/server/graphql/api/queries/scene/getSceneForAssetVersion.ts @@ -7,9 +7,7 @@ const getSceneForAssetVersion = gql` SceneConstellation { Scene { idScene - HasBeenQCd idAssetThumbnail - IsOriented Name CountScene CountNode @@ -19,6 +17,8 @@ const getSceneForAssetVersion = gql` CountMeta CountSetup CountTour + ApprovedForPublication + PosedAndQCd } ModelSceneXref { idModelSceneXref diff --git a/server/graphql/api/queries/systemobject/getDetailsTabDataForObject.ts b/server/graphql/api/queries/systemobject/getDetailsTabDataForObject.ts index d2144547b..311dbd9a5 100644 --- a/server/graphql/api/queries/systemobject/getDetailsTabDataForObject.ts +++ b/server/graphql/api/queries/systemobject/getDetailsTabDataForObject.ts @@ -21,6 +21,7 @@ const getDetailsTabDataForObject = gql` TS0 TS1 TS2 + idIdentifierPreferred } Item { EntireSubject @@ -129,9 +130,10 @@ const getDetailsTabDataForObject = gql` AssetType Tours Annotation - HasBeenQCd - IsOriented EdanUUID + ApprovedForPublication + PublicationApprover + PosedAndQCd idScene } IntermediaryFile { diff --git a/server/graphql/api/queries/systemobject/getSystemObjectDetails.ts b/server/graphql/api/queries/systemobject/getSystemObjectDetails.ts index a5f547587..133caa293 100644 --- a/server/graphql/api/queries/systemobject/getSystemObjectDetails.ts +++ b/server/graphql/api/queries/systemobject/getSystemObjectDetails.ts @@ -10,6 +10,8 @@ const getSystemObjectDetails = gql` objectType allowed publishedState + publishedEnum + publishable thumbnail identifiers { identifier diff --git a/server/graphql/api/queries/unit/getObjectsForItem.ts b/server/graphql/api/queries/unit/getObjectsForItem.ts index 935141283..1eeb77f7b 100644 --- a/server/graphql/api/queries/unit/getObjectsForItem.ts +++ b/server/graphql/api/queries/unit/getObjectsForItem.ts @@ -16,9 +16,9 @@ const getObjectsForItem = gql` Scene { idScene - HasBeenQCd - IsOriented Name + ApprovedForPublication + PosedAndQCd } IntermediaryFile { diff --git a/server/graphql/schema.graphql b/server/graphql/schema.graphql index a95668a70..448621deb 100644 --- a/server/graphql/schema.graphql +++ b/server/graphql/schema.graphql @@ -121,6 +121,7 @@ type Mutation { deleteObjectConnection(input: DeleteObjectConnectionInput!): DeleteObjectConnectionResult! discardUploadedAssetVersions(input: DiscardUploadedAssetVersionsInput!): DiscardUploadedAssetVersionsResult! ingestData(input: IngestDataInput!): IngestDataResult! + publish(input: PublishInput!): PublishResult! rollbackSystemObjectVersion(input: RollbackSystemObjectVersionInput!): RollbackSystemObjectVersionResult! updateDerivedObjects(input: UpdateDerivedObjectsInput!): UpdateDerivedObjectsResult! updateLicense(input: UpdateLicenseInput!): CreateLicenseResult! @@ -247,8 +248,8 @@ type IngestScene { idAssetVersion: Int! systemCreated: Boolean! name: String! - hasBeenQCd: Boolean! - isOriented: Boolean! + approvedForPublication: Boolean! + posedAndQCd: Boolean! directory: String! identifiers: [IngestIdentifier!]! referenceModels: [ReferenceModel!]! @@ -538,8 +539,8 @@ input IngestSceneInput { idAsset: Int systemCreated: Boolean! name: String! - hasBeenQCd: Boolean! - isOriented: Boolean! + approvedForPublication: Boolean! + posedAndQCd: Boolean! directory: String! identifiers: [IngestIdentifierInput!]! sourceObjects: [RelatedObjectInput!]! @@ -884,8 +885,6 @@ type GetFilterViewDataResult { input CreateSceneInput { Name: String! - HasBeenQCd: Boolean! - IsOriented: Boolean! idAssetThumbnail: Int CountScene: Int CountNode: Int @@ -896,6 +895,8 @@ input CreateSceneInput { CountSetup: Int CountTour: Int EdanUUID: String + ApprovedForPublication: Boolean! + PosedAndQCd: Boolean! } type CreateSceneResult { @@ -920,9 +921,7 @@ type GetIntermediaryFileResult { type Scene { idScene: Int! - HasBeenQCd: Boolean! idAssetThumbnail: Int - IsOriented: Boolean! Name: String! CountScene: Int CountNode: Int @@ -933,6 +932,8 @@ type Scene { CountSetup: Int CountTour: Int EdanUUID: String + ApprovedForPublication: Boolean! + PosedAndQCd: Boolean! AssetThumbnail: Asset ModelSceneXref: [ModelSceneXref] SystemObject: SystemObject @@ -987,6 +988,7 @@ input SubjectDetailFieldsInput { TS0: Float TS1: Float TS2: Float + idIdentifierPreferred: Int } input ItemDetailFieldsInput { @@ -1020,6 +1022,7 @@ input CaptureDataDetailFieldsInput { clusterType: Int clusterGeometryFieldId: Int folders: [IngestFolderInput!]! + isValidData: Boolean } input ModelDetailFieldsInput { @@ -1036,8 +1039,8 @@ input SceneDetailFieldsInput { AssetType: Int Tours: Int Annotation: Int - HasBeenQCd: Boolean - IsOriented: Boolean + ApprovedForPublication: Boolean + PosedAndQCd: Boolean } input ProjectDocumentationDetailFieldsInput { @@ -1093,33 +1096,44 @@ type UpdateObjectDetailsResult { message: String! } +input ExistingRelationship { + idSystemObject: Int! + objectType: Int! +} + input UpdateDerivedObjectsInput { idSystemObject: Int! - Derivatives: [Int!]! - PreviouslySelected: [Int!]! + ParentObjectType: Int! + Derivatives: [ExistingRelationship!]! + PreviouslySelected: [ExistingRelationship!]! } type UpdateDerivedObjectsResult { success: Boolean! + message: String! + status: String! } input UpdateSourceObjectsInput { idSystemObject: Int! - Sources: [Int!]! - PreviouslySelected: [Int!]! + ChildObjectType: Int! + Sources: [ExistingRelationship!]! + PreviouslySelected: [ExistingRelationship!]! } type UpdateSourceObjectsResult { success: Boolean! + message: String! + status: String! } input UpdateIdentifier { id: Int! identifier: String! identifierType: Int! - selected: Boolean! idSystemObject: Int! idIdentifier: Int! + preferred: Boolean } type DeleteObjectConnectionResult { @@ -1129,7 +1143,9 @@ type DeleteObjectConnectionResult { input DeleteObjectConnectionInput { idSystemObjectMaster: Int! + objectTypeMaster: Int! idSystemObjectDerived: Int! + objectTypeDerived: Int! } type DeleteIdentifierResult { @@ -1164,7 +1180,17 @@ input CreateIdentifierInput { identifierValue: String! identifierType: Int! idSystemObject: Int - selected: Boolean! + preferred: Boolean +} + +input PublishInput { + idSystemObject: Int! + eState: Int! +} + +type PublishResult { + success: Boolean! + message: String! } scalar JSON @@ -1194,6 +1220,7 @@ type SubjectDetailFields { TS0: Float TS1: Float TS2: Float + idIdentifierPreferred: Int } type ItemDetailFields { @@ -1235,8 +1262,6 @@ type SceneDetailFields { AssetType: Int Tours: Int Annotation: Int - HasBeenQCd: Boolean - IsOriented: Boolean CountScene: Int CountNode: Int CountCamera: Int @@ -1246,6 +1271,9 @@ type SceneDetailFields { CountSetup: Int CountTour: Int EdanUUID: String + ApprovedForPublication: Boolean + PublicationApprover: String + PosedAndQCd: Boolean idScene: Int } @@ -1321,6 +1349,8 @@ type GetSystemObjectDetailsResult { objectType: Int! allowed: Boolean! publishedState: String! + publishedEnum: Int! + publishable: Boolean! thumbnail: String identifiers: [IngestIdentifier!]! objectAncestors: [[RepositoryPath!]!]! diff --git a/server/graphql/schema/asset/queries.graphql b/server/graphql/schema/asset/queries.graphql index 318c3c815..a86ffbf46 100644 --- a/server/graphql/schema/asset/queries.graphql +++ b/server/graphql/schema/asset/queries.graphql @@ -99,8 +99,8 @@ type IngestScene { idAssetVersion: Int! systemCreated: Boolean! name: String! - hasBeenQCd: Boolean! - isOriented: Boolean! + approvedForPublication: Boolean! + posedAndQCd: Boolean! directory: String! identifiers: [IngestIdentifier!]! referenceModels: [ReferenceModel!]! diff --git a/server/graphql/schema/asset/resolvers/mutations/uploadAsset.ts b/server/graphql/schema/asset/resolvers/mutations/uploadAsset.ts index 88dd0f652..5392a70f9 100644 --- a/server/graphql/schema/asset/resolvers/mutations/uploadAsset.ts +++ b/server/graphql/schema/asset/resolvers/mutations/uploadAsset.ts @@ -170,7 +170,9 @@ class UploadAssetWorker extends ResolverBase { if (!assetVersions) return { status: UploadStatus.Failed, error: 'No Asset Versions created' }; - this.workflowHelper = await this.createWorkflow(assetVersions); + this.workflowHelper = await this.createUploadWorkflow(assetVersions); + if (!this.workflowHelper.success) + return { status: UploadStatus.Failed, error: this.workflowHelper.error }; let success: boolean = true; let error: string = ''; @@ -208,12 +210,7 @@ class UploadAssetWorker extends ResolverBase { } } else { await this.appendToWFReport(`uploadAsset post-upload workflow error: ${results.error}`, true, true); - const SO: DBAPI.SystemObject | null = await assetVersion.fetchSystemObject(); - if (SO) { - if (!await SO.retireObject()) - LOG.error('uploadAsset post-upload workflow error handler failed to retire uploaded asset', LOG.LS.eGQL); - } else - LOG.error('uploadAsset post-upload workflow error handler failed to fetch system object for uploaded asset', LOG.LS.eGQL); + await this.retireFailedUpload(assetVersion); success = false; error = 'Post-upload Workflow Failed'; } @@ -226,7 +223,7 @@ class UploadAssetWorker extends ResolverBase { return { status: UploadStatus.Failed, error, idAssetVersions }; } - async createWorkflow(assetVersions: DBAPI.AssetVersion[]): Promise { + async createUploadWorkflow(assetVersions: DBAPI.AssetVersion[]): Promise { const workflowEngine: WF.IWorkflowEngine | null = await WF.WorkflowFactory.getInstance(); if (!workflowEngine) { const error: string = 'uploadAsset createWorkflow could not load WorkflowEngine'; @@ -268,6 +265,29 @@ class UploadAssetWorker extends ResolverBase { } const workflowReport: REP.IReport | null = await REP.ReportFactory.getReport(); + const results: H.IOResults = workflow ? await workflow.waitForCompletion(3600000) : { success: true, error: '' }; + if (!results.success) { + for (const assetVersion of assetVersions) + await this.retireFailedUpload(assetVersion); + LOG.error(`uploadAsset createWorkflow Upload workflow failed: ${results.error}`, LOG.LS.eGQL); + return results; + } + return { success: true, error: '', workflowEngine, workflow, workflowReport }; } + + private async retireFailedUpload(assetVersion: DBAPI.AssetVersion): Promise { + const SO: DBAPI.SystemObject | null = await assetVersion.fetchSystemObject(); + if (SO) { + if (await SO.retireObject()) + return { success: true, error: '' }; + const error: string = 'uploadAsset post-upload workflow error handler failed to retire uploaded asset'; + LOG.error(error, LOG.LS.eGQL); + return { success: false, error }; + } else { + const error: string = 'uploadAsset post-upload workflow error handler failed to fetch system object for uploaded asset'; + LOG.error(error, LOG.LS.eGQL); + return { success: false, error }; + } + } } diff --git a/server/graphql/schema/ingestion/mutations.graphql b/server/graphql/schema/ingestion/mutations.graphql index 614236d86..a651f942c 100644 --- a/server/graphql/schema/ingestion/mutations.graphql +++ b/server/graphql/schema/ingestion/mutations.graphql @@ -85,8 +85,8 @@ input IngestSceneInput { idAsset: Int systemCreated: Boolean! name: String! - hasBeenQCd: Boolean! - isOriented: Boolean! + approvedForPublication: Boolean! + posedAndQCd: Boolean! directory: String! identifiers: [IngestIdentifierInput!]! sourceObjects: [RelatedObjectInput!]! diff --git a/server/graphql/schema/ingestion/resolvers/mutations/ingestData.ts b/server/graphql/schema/ingestion/resolvers/mutations/ingestData.ts index bb4dfd282..7d8e1427c 100644 --- a/server/graphql/schema/ingestion/resolvers/mutations/ingestData.ts +++ b/server/graphql/schema/ingestion/resolvers/mutations/ingestData.ts @@ -1,7 +1,7 @@ import { IngestDataInput, IngestDataResult, MutationIngestDataArgs, IngestSubjectInput, IngestItemInput, IngestIdentifierInput, User, - IngestPhotogrammetryInput, IngestModelInput, IngestSceneInput, IngestOtherInput + IngestPhotogrammetryInput, IngestModelInput, IngestSceneInput, IngestOtherInput, ExistingRelationship, RelatedObjectType } from '../../../../../types/graphql'; import { ResolverBase, IWorkflowHelper } from '../../../ResolverBase'; import { Parent, Context } from '../../../../../types/resolvers'; @@ -18,12 +18,19 @@ import { AssetStorageAdapter, IngestAssetInput, IngestAssetResult, OperationInfo import { VocabularyCache, eVocabularyID } from '../../../../../cache'; import { JobCookSIPackratInspectOutput } from '../../../../../job/impl/Cook'; import { RouteBuilder, eHrefMode } from '../../../../../http/routes/routeBuilder'; +import { getRelatedObjects } from '../../../systemobject/resolvers/queries/getSystemObjectDetails'; type AssetPair = { asset: DBAPI.Asset; assetVersion: DBAPI.AssetVersion | undefined; }; +type ModelInfo = { + model: IngestModelInput; + idModel: number; + JCOutput: JobCookSIPackratInspectOutput; +}; + export default async function ingestData(_: Parent, args: MutationIngestDataArgs, context: Context): Promise { const { input } = args; const { user } = context; @@ -44,8 +51,9 @@ class IngestDataWorker extends ResolverBase { private ingestUpdate: boolean = false; private assetVersionSet: Set = new Set(); // set of idAssetVersions - private assetVersionMap: Map = new Map(); // map from idAssetVersion -> object that "owns" the asset -- populated during creation of asset-owning objects below - private ingestPhotoMap: Map = new Map(); // map from idAssetVersion -> photogrammetry input + private assetVersionMap: Map = new Map(); // map from idAssetVersion -> object that "owns" the asset -- populated during creation of asset-owning objects below + private ingestPhotoMap: Map = new Map(); // map from idAssetVersion -> photogrammetry input + private ingestModelMap: Map = new Map(); // map from idAssetVersion -> model input, JCOutput, idModel constructor(input: IngestDataInput, user: User | undefined) { super(); @@ -69,15 +77,18 @@ class IngestDataWorker extends ResolverBase { private async ingestWorker(): Promise { LOG.info(`ingestData: input=${JSON.stringify(this.input, H.Helpers.saferStringify)}`, LOG.LS.eGQL); - const results: H.IOResults = this.validateInput(); + const results: H.IOResults = await this.validateInput(); this.workflowHelper = await this.createWorkflow(); // do this *after* this.validateInput, and *before* returning from validation failure - if (!results.success) - return results; + if (!results.success) return { success: results.success, message: results.error }; let itemDB: DBAPI.Item | null = null; if (this.ingestNew) { await this.appendToWFReport('Ingesting content for new object'); + let SOI: DBAPI.SystemObjectInfo | undefined = undefined; + let path: string = ''; + let href: string = ''; + // retrieve/create subjects; if creating subjects, create related objects (Identifiers, possibly UnitEdan records, though unlikely) const subjectsDB: DBAPI.Subject[] = []; for (const subject of this.input.subjects) { @@ -93,18 +104,34 @@ class IngestDataWorker extends ResolverBase { if (!subjectDB) return { success: false, message: 'failure to retrieve or create subject' }; + SOI = await CACHE.SystemObjectCache.getSystemFromSubject(subjectDB); + path = SOI ? RouteBuilder.RepositoryDetails(SOI.idSystemObject, eHrefMode.ePrependClientURL) : ''; + href = H.Helpers.computeHref(path, subject.name); + await this.appendToWFReport(`Subject ${href} (ARK ID ${subject.arkId}) ${subject.id ? 'validated' : 'created'}`); + subjectsDB.push(subjectDB); } // wire projects to subjects - if (this.input.project.id && !(await this.wireProjectToSubjects(this.input.project.id, subjectsDB))) - return { success: false, message: 'failure to wire project to subjects' }; - await this.appendToWFReport(`Packrat Project: ${this.input.project.name}`); + if (this.input.project.id) { + const projectDB: DBAPI.Project | null = await this.wireProjectToSubjects(this.input.project.id, subjectsDB); + if (!projectDB) + return { success: false, message: 'failure to wire project to subjects' }; + + SOI = await CACHE.SystemObjectCache.getSystemFromProject(projectDB); + path = SOI ? RouteBuilder.RepositoryDetails(SOI.idSystemObject, eHrefMode.ePrependClientURL) : ''; + href = H.Helpers.computeHref(path, this.input.project.name); + await this.appendToWFReport(`Packrat Project: ${href}`); + } itemDB = await this.fetchOrCreateItem(this.input.item); if (!itemDB) return { success: false, message: 'failure to retrieve or create item' }; - await this.appendToWFReport(`Packrat Item: ${itemDB.Name}`); + + SOI = await CACHE.SystemObjectCache.getSystemFromItem(itemDB); + path = SOI ? RouteBuilder.RepositoryDetails(SOI.idSystemObject, eHrefMode.ePrependClientURL) : ''; + href = H.Helpers.computeHref(path, itemDB.Name); + await this.appendToWFReport(`Packrat Item${!this.input.item.id ? ' (created)' : ''}: ${href}`); // wire subjects to item if (!await this.wireSubjectsToItem(subjectsDB, itemDB)) @@ -144,12 +171,6 @@ class IngestDataWorker extends ResolverBase { } } - // wire item to asset-owning objects - if (itemDB) { - if (!await this.wireItemToAssetOwners(itemDB)) - return { success: false, message: 'failure to wire item to asset owner' }; - } - // next, promote asset into repository storage const { ingestResMap, transformUpdated } = await this.promoteAssetsIntoRepository(); if (transformUpdated) @@ -159,6 +180,15 @@ class IngestDataWorker extends ResolverBase { if (this.ingestPhotogrammetry) await this.createPhotogrammetryDerivedObjects(ingestResMap); + if (this.ingestModel) + await this.createModelDerivedObjects(ingestResMap); + + // wire item to asset-owning objects; do this *after* createModelDerivedObjects + if (itemDB) { + if (!await this.wireItemToAssetOwners(itemDB)) + return { success: false, message: 'failure to wire item to asset owner' }; + } + // notify workflow engine about this ingestion: if (!await this.sendWorkflowIngestionEvent(ingestResMap, modelTransformUpdated)) return { success: false, message: 'failure to notify workflow engine about ingestion event' }; @@ -335,7 +365,6 @@ class IngestDataWorker extends ResolverBase { return null; } - await this.appendToWFReport(`Subject ${subject.name} (ARK ID ${subject.arkId}) validated`); return subjectDB; } @@ -357,7 +386,6 @@ class IngestDataWorker extends ResolverBase { const subjectDB: DBAPI.Subject | null = await this.createSubject(unit.idUnit, subject.name, identifier); if (!subjectDB) return null; - await this.appendToWFReport(`Subject ${subject.name} (ARK ID ${subject.arkId}) created`); // update identifier, if it exists with systemobject ID of our subject if (!await this.updateSubjectIdentifier(identifier, subjectDB)) @@ -366,21 +394,21 @@ class IngestDataWorker extends ResolverBase { return subjectDB; } - private async wireProjectToSubjects(idProject: number, subjectsDB: DBAPI.Subject[]): Promise { + private async wireProjectToSubjects(idProject: number, subjectsDB: DBAPI.Subject[]): Promise { const projectDB: DBAPI.Project | null = await DBAPI.Project.fetch(idProject); if (!projectDB) { LOG.error(`ingestData unable to fetch project ${idProject}`, LOG.LS.eGQL); - return false; + return null; } for (const subjectDB of subjectsDB) { const xref: DBAPI.SystemObjectXref | null = await DBAPI.SystemObjectXref.wireObjectsIfNeeded(projectDB, subjectDB); if (!xref) { LOG.error(`ingestData unable to wire project ${JSON.stringify(projectDB)} to subject ${JSON.stringify(subjectDB)}`, LOG.LS.eGQL); - return false; + return null; } } - return true; + return projectDB; } private async fetchOrCreateItem(item: IngestItemInput): Promise { @@ -590,40 +618,27 @@ class IngestDataWorker extends ResolverBase { return false; } - let modelDB: DBAPI.Model = JCOutput.modelConstellation.Model; - modelDB.Name = model.name; - modelDB.DateCreated = H.Helpers.convertStringToDate(model.dateCaptured) || new Date(); - modelDB.idVCreationMethod = model.creationMethod; - modelDB.idVModality = model.modality; - modelDB.idVPurpose = model.purpose; - modelDB.idVUnits = model.units; - modelDB.idVFileType = model.modelFileType; - - const assetVersion: DBAPI.AssetVersion | null = await DBAPI.AssetVersion.fetch(model.idAssetVersion); - if (!assetVersion) { - LOG.error(`ingestData unable to fetch asset version from ${JSON.stringify(model)}`, LOG.LS.eGQL); - return false; - } - const asset: DBAPI.Asset | null = await DBAPI.Asset.fetch(assetVersion.idAsset); - if (!asset) { - LOG.error(`ingestData unable to fetch asset from ${JSON.stringify(model)}, idAsset ${assetVersion.idAsset}`, LOG.LS.eGQL); - return false; - } - const assetMap: Map = new Map(); - assetMap.set(asset.FileName, asset.idAsset); - - // write entries to database - // LOG.info(`ingestData createModelObjects model=${JSON.stringify(model, H.Helpers.saferStringify)} vs asset=${JSON.stringify(asset, H.Helpers.saferStringify)}vs assetVersion=${JSON.stringify(assetVersion, H.Helpers.saferStringify)}`, LOG.LS.eGQL); // Examine model.idAsset; if Asset.idVAssetType -> model or model geometry file, then // Lookup SystemObject from Asset.idSystemObject; if idModel is not null, then use that idModel let idModel: number = 0; if (model.idAsset) { + const assetVersion: DBAPI.AssetVersion | null = await DBAPI.AssetVersion.fetch(model.idAssetVersion); + if (!assetVersion) { + LOG.error(`ingestData createModelObjects unable to fetch asset version from ${JSON.stringify(model, H.Helpers.saferStringify)}`, LOG.LS.eGQL); + return false; + } + const asset: DBAPI.Asset | null = await DBAPI.Asset.fetch(assetVersion.idAsset); + if (!asset) { + LOG.error(`ingestData createModelObjects unable to fetch asset from ${JSON.stringify(model, H.Helpers.saferStringify)}, idAsset ${assetVersion.idAsset}`, LOG.LS.eGQL); + return false; + } + const assetType: CACHE.eVocabularyID | undefined = await asset.assetType(); if (assetType === CACHE.eVocabularyID.eAssetAssetTypeModel || assetType === CACHE.eVocabularyID.eAssetAssetTypeModelGeometryFile) { const SO: DBAPI.SystemObject | null = asset.idSystemObject ? await DBAPI.SystemObject.fetch(asset.idSystemObject) : null; if (!SO) { - LOG.error(`ingestData unable to fetch model's asset's system object ${JSON.stringify(asset, H.Helpers.saferStringify)}`, LOG.LS.eGQL); + LOG.error(`ingestData createModelObjects unable to fetch model's asset's system object ${JSON.stringify(asset, H.Helpers.saferStringify)}`, LOG.LS.eGQL); return false; } @@ -632,44 +647,117 @@ class IngestDataWorker extends ResolverBase { } } - const res: H.IOResults = await JCOutput.persist(idModel, assetMap); - if (!res.success) { - LOG.error(`ingestData unable to create model constellation ${JSON.stringify(model)}: ${res.success}`, LOG.LS.eGQL); + let modelDB: DBAPI.Model | null = idModel ? await DBAPI.Model.fetch(idModel) : null; + if (!modelDB) + modelDB = JCOutput.modelConstellation.Model; + else + modelDB.cloneData(JCOutput.modelConstellation.Model); + + modelDB.Name = model.name; + modelDB.DateCreated = H.Helpers.convertStringToDate(model.dateCaptured) || new Date(); + modelDB.idVCreationMethod = model.creationMethod; + modelDB.idVModality = model.modality; + modelDB.idVPurpose = model.purpose; + modelDB.idVUnits = model.units; + modelDB.idVFileType = model.modelFileType; + + const updateRes: boolean = idModel ? await modelDB.update() : await modelDB.create(); + if (!updateRes) { + LOG.error(`ingestData createModelObjects unable to ${idModel ? 'update' : 'create'} model ${JSON.stringify(modelDB, H.Helpers.saferStringify)}`, LOG.LS.eGQL); return false; } - modelDB = JCOutput.modelConstellation.Model; // retrieve again, as we may have swapped the object above, in JCOutput.persist - const SOI: DBAPI.SystemObjectInfo | undefined = await CACHE.SystemObjectCache.getSystemFromModel(modelDB); - const path: string = SOI ? RouteBuilder.RepositoryDetails(SOI.idSystemObject, eHrefMode.ePrependClientURL) : ''; - const href: string = H.Helpers.computeHref(path, modelDB.Name); - await this.appendToWFReport(`Model: ${href}`); + // LOG.info(`ingestData createModelObjects model=${JSON.stringify(model, H.Helpers.saferStringify)} vs asset=${JSON.stringify(asset, H.Helpers.saferStringify)}vs assetVersion=${JSON.stringify(assetVersion, H.Helpers.saferStringify)}`, LOG.LS.eGQL); + if (model.idAssetVersion) { + this.assetVersionMap.set(model.idAssetVersion, modelDB); + const MI: ModelInfo = { model, idModel: modelDB.idModel, JCOutput }; + this.ingestModelMap.set(model.idAssetVersion, MI); + LOG.info(`ingestData createModelObjects computed ${JSON.stringify(MI, H.Helpers.saferStringify)}`, LOG.LS.eGQL); + } - if (!await this.handleIdentifiers(modelDB, model.systemCreated, model.identifiers)) - return false; + return true; + } - // wire model to sourceObjects - if (model.sourceObjects && model.sourceObjects.length > 0) { - for (const sourceObject of model.sourceObjects) { - if (!await DBAPI.SystemObjectXref.wireObjectsIfNeeded(sourceObject.idSystemObject, modelDB)) { - LOG.error('ingestData failed to create SystemObjectXref', LOG.LS.eGQL); - continue; + // ingestResMap: map from idAssetVersion -> object that "owns" the asset + private async createModelDerivedObjects(ingestResMap: Map): Promise { + // populate assetMap + const assetMap: Map = new Map(); // Map of asset filename -> idAsset + let ret: boolean = true; + + for (const [idAssetVersion, SOBased] of this.assetVersionMap) { + LOG.info(`ingestData createModelDerivedObjects considering idAssetVersion ${idAssetVersion}: ${JSON.stringify(SOBased, H.Helpers.saferStringify)}`, LOG.LS.eGQL); + if (!(SOBased instanceof DBAPI.Model)) + continue; + const ingestAssetRes: IngestAssetResult | null | undefined = ingestResMap.get(idAssetVersion); + if (!ingestAssetRes) { + LOG.error(`ingestData createModelDerivedObjects unable to locate ingest results for idAssetVersion ${idAssetVersion}`, LOG.LS.eGQL); + ret = false; + continue; + } + if (!ingestAssetRes.success) { + LOG.error(`ingestData createModelDerivedObjects failed for idAssetVersion ${idAssetVersion}: ${ingestAssetRes.error}`, LOG.LS.eGQL); + ret = false; + continue; + } + + for (const asset of ingestAssetRes.assets || []) + assetMap.set(asset.FileName, asset.idAsset); + + const modelInfo: ModelInfo | undefined = this.ingestModelMap.get(idAssetVersion); + if (!modelInfo) { + LOG.error(`ingestData createModelDerivedObjects unable to find model info for idAssetVersion ${idAssetVersion}`, LOG.LS.eGQL); + ret = false; + continue; + } + + const model: IngestModelInput = modelInfo.model; + const JCOutput: JobCookSIPackratInspectOutput = modelInfo.JCOutput; + const idModel: number = modelInfo.idModel; + + if (!JCOutput.success || !JCOutput.modelConstellation || !JCOutput.modelConstellation.Model) { + LOG.error(`ingestData createModelDerivedObjects unable to find valid model object results for idAssetVersion ${idAssetVersion}: ${JSON.stringify(JCOutput, H.Helpers.saferStringify)}`, LOG.LS.eGQL); + ret = false; + continue; + } + + const res: H.IOResults = await JCOutput.persist(idModel, assetMap); + if (!res.success) { + LOG.error(`ingestData unable to create model constellation ${JSON.stringify(model)}: ${res.success}`, LOG.LS.eGQL); + ret = false; + continue; + } + const modelDB: DBAPI.Model = JCOutput.modelConstellation.Model; // retrieve again, as we may have swapped the object above, in JCOutput.persist + this.assetVersionMap.set(idAssetVersion, modelDB); + + const SOI: DBAPI.SystemObjectInfo | undefined = await CACHE.SystemObjectCache.getSystemFromModel(modelDB); + const path: string = SOI ? RouteBuilder.RepositoryDetails(SOI.idSystemObject, eHrefMode.ePrependClientURL) : ''; + const href: string = H.Helpers.computeHref(path, modelDB.Name); + await this.appendToWFReport(`Model: ${href}`); + + if (!await this.handleIdentifiers(modelDB, model.systemCreated, model.identifiers)) + return false; + + // wire model to sourceObjects + if (model.sourceObjects && model.sourceObjects.length > 0) { + for (const sourceObject of model.sourceObjects) { + if (!await DBAPI.SystemObjectXref.wireObjectsIfNeeded(sourceObject.idSystemObject, modelDB)) { + LOG.error('ingestData failed to create SystemObjectXref', LOG.LS.eGQL); + continue; + } } } - } - // wire model to derivedObjects - if (model.derivedObjects && model.derivedObjects.length > 0) { - for (const derivedObject of model.derivedObjects) { - if (!await DBAPI.SystemObjectXref.wireObjectsIfNeeded(modelDB, derivedObject.idSystemObject)) { - LOG.error('ingestData failed to create SystemObjectXref', LOG.LS.eGQL); - continue; + // wire model to derivedObjects + if (model.derivedObjects && model.derivedObjects.length > 0) { + for (const derivedObject of model.derivedObjects) { + if (!await DBAPI.SystemObjectXref.wireObjectsIfNeeded(modelDB, derivedObject.idSystemObject)) { + LOG.error('ingestData failed to create SystemObjectXref', LOG.LS.eGQL); + continue; + } } } } - - if (model.idAssetVersion) - this.assetVersionMap.set(model.idAssetVersion, modelDB); - return true; + return ret; } private async createSceneObjects(scene: IngestSceneInput): Promise<{ success: boolean, transformUpdated?: boolean | undefined }> { @@ -706,8 +794,8 @@ class IngestDataWorker extends ResolverBase { } sceneDB.Name = scene.name; - sceneDB.HasBeenQCd = scene.hasBeenQCd; - sceneDB.IsOriented = scene.isOriented; + sceneDB.ApprovedForPublication = scene.approvedForPublication; + sceneDB.PosedAndQCd = scene.posedAndQCd; LOG.info(`ingestData createSceneObjects, updateMode=${updateMode}, sceneDB=${JSON.stringify(sceneDB, H.Helpers.saferStringify)}, sceneConstellation=${JSON.stringify(sceneConstellation, H.Helpers.saferStringify)}`, LOG.LS.eGQL); let success: boolean = sceneDB.idScene ? await sceneDB.update() : await sceneDB.create(); @@ -979,7 +1067,7 @@ class IngestDataWorker extends ResolverBase { return ret; } - validateInput(): H.IOResults { + async validateInput(): Promise { this.ingestPhotogrammetry = this.input.photogrammetry && this.input.photogrammetry.length > 0; this.ingestModel = this.input.model && this.input.model.length > 0; this.ingestScene = this.input.scene && this.input.scene.length > 0; @@ -989,6 +1077,26 @@ class IngestDataWorker extends ResolverBase { if (this.ingestPhotogrammetry) { for (const photogrammetry of this.input.photogrammetry) { + // add validation in this area while we iterate through the objects + if (photogrammetry.sourceObjects && photogrammetry.sourceObjects.length) { + for (const sourceObject of photogrammetry.sourceObjects) { + if (!isValidParentChildRelationship(sourceObject.objectType, DBAPI.eSystemObjectType.eCaptureData, photogrammetry.sourceObjects, [], true)) { + LOG.error(`ingestData will not create the inappropriate parent-child relationship between ${sourceObject.objectType} and photogrammetry`, LOG.LS.eGQL); + return { success: false, error: `ingestData will not create the inappropriate parent-child relationship between ${sourceObject.objectType} and photogrammetry` }; + } + } + } + + if (photogrammetry.derivedObjects && photogrammetry.derivedObjects.length) { + for (const derivedObject of photogrammetry.derivedObjects) { + const sourceObjectsOfChild = await getRelatedObjects(derivedObject.idSystemObject, RelatedObjectType.Source); + if (!isValidParentChildRelationship(DBAPI.eSystemObjectType.eCaptureData, derivedObject.objectType, [], sourceObjectsOfChild, false)) { + LOG.error(`ingestData will not create the inappropriate parent-child relationship between photogrammetry and ${derivedObject.objectType}`, LOG.LS.eGQL); + return { success: false, error: `ingestData will not create the inappropriate parent-child relationship between photogrammetry and ${derivedObject.objectType}` }; + } + } + } + if (photogrammetry.idAssetVersion) this.assetVersionSet.add(photogrammetry.idAssetVersion); if (photogrammetry.idAsset) @@ -1000,6 +1108,26 @@ class IngestDataWorker extends ResolverBase { if (this.ingestModel) { for (const model of this.input.model) { + // add validation in this area while we iterate through the objects + if (model.sourceObjects && model.sourceObjects.length) { + for (const sourceObject of model.sourceObjects) { + if (!isValidParentChildRelationship(sourceObject.objectType, DBAPI.eSystemObjectType.eCaptureData, model.sourceObjects, [], true)) { + LOG.error(`ingestData will not create the inappropriate parent-child relationship between ${sourceObject.objectType} and model`, LOG.LS.eGQL); + return { success: false, error: `ingestData will not create the inappropriate parent-child relationship between ${sourceObject.objectType} and model` }; + } + } + } + + if (model.derivedObjects && model.derivedObjects.length) { + for (const derivedObject of model.derivedObjects) { + const sourceObjectsOfChild = await getRelatedObjects(derivedObject.idSystemObject, RelatedObjectType.Source); + if (!isValidParentChildRelationship(DBAPI.eSystemObjectType.eCaptureData, derivedObject.objectType, [], sourceObjectsOfChild, false)) { + LOG.error(`ingestData will not create the inappropriate parent-child relationship between model and ${derivedObject.objectType}`, LOG.LS.eGQL); + return { success: false, error: `ingestData will not create the inappropriate parent-child relationship between model and ${derivedObject.objectType}` }; + } + } + } + if (model.idAssetVersion) this.assetVersionSet.add(model.idAssetVersion); if (model.idAsset) @@ -1011,6 +1139,26 @@ class IngestDataWorker extends ResolverBase { if (this.ingestScene) { for (const scene of this.input.scene) { + // add validation in this area while we iterate through the objects + if (scene.sourceObjects && scene.sourceObjects.length) { + for (const sourceObject of scene.sourceObjects) { + if (!isValidParentChildRelationship(sourceObject.objectType, DBAPI.eSystemObjectType.eCaptureData, scene.sourceObjects, [], true)) { + LOG.error(`ingestData will not create the inappropriate parent-child relationship between ${sourceObject.objectType} and scene`, LOG.LS.eGQL); + return { success: false, error: `ingestData will not create the inappropriate parent-child relationship between ${sourceObject.objectType} and scene` }; + } + } + } + + if (scene.derivedObjects && scene.derivedObjects.length) { + for (const derivedObject of scene.derivedObjects) { + const sourceObjectsOfChild = await getRelatedObjects(derivedObject.idSystemObject, RelatedObjectType.Source); + if (!isValidParentChildRelationship(DBAPI.eSystemObjectType.eCaptureData, derivedObject.objectType, [], sourceObjectsOfChild, false)) { + LOG.error(`ingestData will not create the inappropriate parent-child relationship between scene and ${derivedObject.objectType}`, LOG.LS.eGQL); + return { success: false, error: `ingestData will not create the inappropriate parent-child relationship between scene and ${derivedObject.objectType}` }; + } + } + } + if (scene.idAssetVersion) this.assetVersionSet.add(scene.idAssetVersion); if (scene.idAsset) @@ -1305,4 +1453,76 @@ class IngestDataWorker extends ResolverBase { const workflowReport: REP.IReport | null = await REP.ReportFactory.getReport(); return { success: true, error: '', workflowEngine, workflow, workflowReport }; } -} \ No newline at end of file +} + +export function isValidParentChildRelationship(parent: DBAPI.eSystemObjectType, child: DBAPI.eSystemObjectType, selected: ExistingRelationship[], existingParentRelationships: ExistingRelationship[], isAddingSource: boolean): boolean { + let result = false; + /* + *NOTE: when updating this relationship validation function, + make sure to also apply changes to the client-side version located at + client/src/util/repository.tsx to maintain consistency + **NOTE: this server-side validation function will be validating a selected item AFTER adding it, + which means the maximum connection count will be different from those seen in repository.tsx + + xproject child to 1 - many unit parent + -skip on stakeholders for now + -skip on stakeholders for now + xitem child to only 1 parent project parent + xitem child to multiple subject parent + xCD child to 1 - many item parent + xmodel child to 1 - many parent Item + xscene child to 1 or more item parent + xmodel child to 0 - many CD parent + xCD child to 0 - many CD parent + -skip on actor for now + xmodel child to 0 to many model parent + xscene child to 1 to many model parent + -skip on actor for now + xmodel child to only 1 scene parent + -skip on IF for now + -skip on PD for now + */ + + const existingAndNewRelationships = [...existingParentRelationships, ...selected]; + switch (child) { + case DBAPI.eSystemObjectType.eProject: + result = parent === DBAPI.eSystemObjectType.eUnit; + break; + case DBAPI.eSystemObjectType.eItem: { + if (parent === DBAPI.eSystemObjectType.eSubject) result = true; + + if (parent === DBAPI.eSystemObjectType.eProject) { + if (isAddingSource) { + result = maximumConnections(existingAndNewRelationships, DBAPI.eSystemObjectType.eProject, 2); + } else { + result = maximumConnections(existingAndNewRelationships, DBAPI.eSystemObjectType.eProject, 1); + } + } + break; + } + case DBAPI.eSystemObjectType.eCaptureData: { + if (parent === DBAPI.eSystemObjectType.eCaptureData || parent === DBAPI.eSystemObjectType.eItem) result = true; + break; + } + case DBAPI.eSystemObjectType.eModel: { + if (parent === DBAPI.eSystemObjectType.eScene) { + if (isAddingSource) { + result = maximumConnections(existingAndNewRelationships, DBAPI.eSystemObjectType.eScene, 2); + } else { + result = maximumConnections(existingAndNewRelationships, DBAPI.eSystemObjectType.eScene, 1); + } + } + + if (parent === DBAPI.eSystemObjectType.eCaptureData || parent === DBAPI.eSystemObjectType.eModel || parent === DBAPI.eSystemObjectType.eItem) result = true; + break; + } + case DBAPI.eSystemObjectType.eScene: { + if (parent === DBAPI.eSystemObjectType.eItem || parent === DBAPI.eSystemObjectType.eModel) result = true; + break; + } + } + + return result; +} + +const maximumConnections = (relationships: ExistingRelationship[], objectType: DBAPI.eSystemObjectType, limit: number) => relationships.filter(relationship => relationship.objectType === objectType).length < limit; diff --git a/server/graphql/schema/scene/mutations.graphql b/server/graphql/schema/scene/mutations.graphql index 1084b903f..b792000c5 100644 --- a/server/graphql/schema/scene/mutations.graphql +++ b/server/graphql/schema/scene/mutations.graphql @@ -4,8 +4,6 @@ type Mutation { input CreateSceneInput { Name: String! - HasBeenQCd: Boolean! - IsOriented: Boolean! idAssetThumbnail: Int CountScene: Int CountNode: Int @@ -16,6 +14,8 @@ input CreateSceneInput { CountSetup: Int CountTour: Int EdanUUID: String + ApprovedForPublication: Boolean! + PosedAndQCd: Boolean! } type CreateSceneResult { diff --git a/server/graphql/schema/scene/resolvers/mutations/createScene.ts b/server/graphql/schema/scene/resolvers/mutations/createScene.ts index e78d51c3d..70c1b8317 100644 --- a/server/graphql/schema/scene/resolvers/mutations/createScene.ts +++ b/server/graphql/schema/scene/resolvers/mutations/createScene.ts @@ -4,15 +4,14 @@ import * as DBAPI from '../../../../../db'; export default async function createScene(_: Parent, args: MutationCreateSceneArgs): Promise { const { input } = args; - const { Name, HasBeenQCd, IsOriented, idAssetThumbnail, CountScene, CountNode, CountCamera, - CountLight, CountModel, CountMeta, CountSetup, CountTour, EdanUUID } = input; + const { Name, idAssetThumbnail, CountScene, CountNode, CountCamera, + CountLight, CountModel, CountMeta, CountSetup, CountTour, EdanUUID, + ApprovedForPublication, PosedAndQCd } = input; const sceneArgs = { idScene: 0, Name, idAssetThumbnail: idAssetThumbnail || null, - HasBeenQCd, - IsOriented, CountScene: CountScene || null, CountNode: CountNode || null, CountCamera: CountCamera || null, @@ -21,7 +20,9 @@ export default async function createScene(_: Parent, args: MutationCreateSceneAr CountMeta: CountMeta || null, CountSetup: CountSetup || null, CountTour: CountTour || null, - EdanUUID: EdanUUID || null + EdanUUID: EdanUUID || null, + ApprovedForPublication, + PosedAndQCd, }; const Scene = new DBAPI.Scene(sceneArgs); diff --git a/server/graphql/schema/scene/types.graphql b/server/graphql/schema/scene/types.graphql index 08a8dc998..0fe3b2d44 100644 --- a/server/graphql/schema/scene/types.graphql +++ b/server/graphql/schema/scene/types.graphql @@ -2,9 +2,7 @@ scalar DateTime type Scene { idScene: Int! - HasBeenQCd: Boolean! idAssetThumbnail: Int - IsOriented: Boolean! Name: String! CountScene: Int CountNode: Int @@ -15,6 +13,8 @@ type Scene { CountSetup: Int CountTour: Int EdanUUID: String + ApprovedForPublication: Boolean! + PosedAndQCd: Boolean! AssetThumbnail: Asset ModelSceneXref: [ModelSceneXref] SystemObject: SystemObject diff --git a/server/graphql/schema/systemobject/mutations.graphql b/server/graphql/schema/systemobject/mutations.graphql index 328c61566..9d7696992 100644 --- a/server/graphql/schema/systemobject/mutations.graphql +++ b/server/graphql/schema/systemobject/mutations.graphql @@ -8,6 +8,7 @@ type Mutation { deleteIdentifier(input: DeleteIdentifierInput!): DeleteIdentifierResult! rollbackSystemObjectVersion(input: RollbackSystemObjectVersionInput!): RollbackSystemObjectVersionResult! createSubjectWithIdentifiers(input: CreateSubjectWithIdentifiersInput!): CreateSubjectWithIdentifiersResult! + publish(input: PublishInput!): PublishResult! } input UpdateObjectDetailsInput { @@ -37,6 +38,7 @@ input SubjectDetailFieldsInput { TS0: Float TS1: Float TS2: Float + idIdentifierPreferred: Int } input ItemDetailFieldsInput { @@ -70,6 +72,7 @@ input CaptureDataDetailFieldsInput { clusterType: Int clusterGeometryFieldId: Int folders: [IngestFolderInput!]! + isValidData: Boolean } input ModelDetailFieldsInput { @@ -86,8 +89,8 @@ input SceneDetailFieldsInput { AssetType: Int Tours: Int Annotation: Int - HasBeenQCd: Boolean - IsOriented: Boolean + ApprovedForPublication: Boolean + PosedAndQCd: Boolean } input ProjectDocumentationDetailFieldsInput { @@ -142,33 +145,44 @@ type UpdateObjectDetailsResult { message: String! } +input ExistingRelationship { + idSystemObject: Int! + objectType: Int! +} + input UpdateDerivedObjectsInput { idSystemObject: Int! - Derivatives: [Int!]! - PreviouslySelected: [Int!]! + ParentObjectType: Int! + Derivatives: [ExistingRelationship!]! + PreviouslySelected: [ExistingRelationship!]! } type UpdateDerivedObjectsResult { success: Boolean! + message: String! + status: String! } input UpdateSourceObjectsInput { idSystemObject: Int! - Sources: [Int!]! - PreviouslySelected: [Int!]! + ChildObjectType: Int! + Sources: [ExistingRelationship!]! + PreviouslySelected: [ExistingRelationship!]! } type UpdateSourceObjectsResult { success: Boolean! + message: String! + status: String! } input UpdateIdentifier { id: Int! identifier: String! identifierType: Int! - selected: Boolean! idSystemObject: Int! idIdentifier: Int! + preferred: Boolean } type DeleteObjectConnectionResult { @@ -178,7 +192,9 @@ type DeleteObjectConnectionResult { input DeleteObjectConnectionInput { idSystemObjectMaster: Int! + objectTypeMaster: Int! idSystemObjectDerived: Int! + objectTypeDerived: Int! } type DeleteIdentifierResult { @@ -213,5 +229,15 @@ input CreateIdentifierInput { identifierValue: String! identifierType: Int! idSystemObject: Int - selected: Boolean! + preferred: Boolean +} + +input PublishInput { + idSystemObject: Int! + eState: Int! +} + +type PublishResult { + success: Boolean! + message: String! } diff --git a/server/graphql/schema/systemobject/queries.graphql b/server/graphql/schema/systemobject/queries.graphql index 81f9f83cc..e5daf85f1 100644 --- a/server/graphql/schema/systemobject/queries.graphql +++ b/server/graphql/schema/systemobject/queries.graphql @@ -36,6 +36,7 @@ type SubjectDetailFields { TS0: Float TS1: Float TS2: Float + idIdentifierPreferred: Int } type ItemDetailFields { @@ -77,8 +78,6 @@ type SceneDetailFields { AssetType: Int Tours: Int Annotation: Int - HasBeenQCd: Boolean - IsOriented: Boolean CountScene: Int CountNode: Int CountCamera: Int @@ -88,6 +87,9 @@ type SceneDetailFields { CountSetup: Int CountTour: Int EdanUUID: String + ApprovedForPublication: Boolean + PublicationApprover: String + PosedAndQCd: Boolean idScene: Int } @@ -162,6 +164,8 @@ type GetSystemObjectDetailsResult { objectType: Int! allowed: Boolean! publishedState: String! + publishedEnum: Int! + publishable: Boolean! thumbnail: String identifiers: [IngestIdentifier!]! objectAncestors: [[RepositoryPath!]!]! diff --git a/server/graphql/schema/systemobject/resolvers/index.ts b/server/graphql/schema/systemobject/resolvers/index.ts index 3920a7c34..e589359d3 100644 --- a/server/graphql/schema/systemobject/resolvers/index.ts +++ b/server/graphql/schema/systemobject/resolvers/index.ts @@ -16,6 +16,7 @@ import deleteObjectConnection from './mutations/deleteObjectConnection'; import deleteIdentifier from './mutations/deleteIdentifier'; import rollbackSystemObjectVersion from './mutations/rollbackSystemObjectVersion'; import createSubjectWithIdentifiers from './mutations/createSubjectWithIdentifiers'; +import publish from './mutations/publish'; const resolvers = { Query: { @@ -34,7 +35,8 @@ const resolvers = { deleteObjectConnection, deleteIdentifier, rollbackSystemObjectVersion, - createSubjectWithIdentifiers + createSubjectWithIdentifiers, + publish }, SystemObject, SystemObjectVersion, diff --git a/server/graphql/schema/systemobject/resolvers/mutations/createSubjectWithIdentifiers.ts b/server/graphql/schema/systemobject/resolvers/mutations/createSubjectWithIdentifiers.ts index a3b18c3ae..ec404b323 100644 --- a/server/graphql/schema/systemobject/resolvers/mutations/createSubjectWithIdentifiers.ts +++ b/server/graphql/schema/systemobject/resolvers/mutations/createSubjectWithIdentifiers.ts @@ -5,33 +5,33 @@ import * as COL from '../../../../../collections/interface'; import { VocabularyCache, eVocabularySetID } from '../../../../../cache'; export default async function createSubjectWithIdentifiers(_: Parent, args: MutationCreateSubjectWithIdentifiersArgs): Promise { - const { input: { systemCreated, identifiers, subject } } = args; + const { + input: { systemCreated, identifiers, subject } + } = args; const { idUnit, Name, idGeoLocation } = subject; const ICOL: COL.ICollection = COL.CollectionFactory.getInstance(); - // Use identifiersList to keep track of all the created identifiers that need to update idSystemObject once subject is created const identifiersList: DBAPI.Identifier[] = []; const ARKId: string = ICOL.generateArk(null, false); const identifierTypeARK = await VocabularyCache.vocabularyBySetAndTerm(eVocabularySetID.eIdentifierIdentifierType, 'ARK'); const idIentifierType = identifierTypeARK?.idVocabulary || 79; + let idIdentifierPreferred: null | number = null; + if (systemCreated) { const Identifier = new DBAPI.Identifier({ idIdentifier: 0, idVIdentifierType: idIentifierType, IdentifierValue: ARKId, - idSystemObject: null, + idSystemObject: null }); const successfulIdentifierCreation = await Identifier.create(); - if (successfulIdentifierCreation) identifiersList.push(Identifier); + if (successfulIdentifierCreation) idIdentifierPreferred = Identifier.idIdentifier; } - let selectedIdentifierCount = 0; for await (const identifier of identifiers) { - if (identifier.selected) selectedIdentifierCount++; - const Identifier = new DBAPI.Identifier({ idIdentifier: 0, idVIdentifierType: identifier.identifierType, @@ -40,23 +40,18 @@ export default async function createSubjectWithIdentifiers(_: Parent, args: Muta }); const successfulIdentifierCreation = await Identifier.create(); - if (successfulIdentifierCreation) identifiersList.push(Identifier); - } + if (successfulIdentifierCreation) { + identifiersList.push(Identifier); - let idIdentifierPreferred: number | null = null; - - // idIdentifierPreferred is set by a systemCreated ARKID > a selected identifier > an ARKID - if (systemCreated) { - const systemCreatedIdentifier = identifiersList.find((identifier) => identifier.IdentifierValue === ARKId); - if (systemCreatedIdentifier?.idIdentifier) { - idIdentifierPreferred = systemCreatedIdentifier?.idIdentifier; + // TODO: Do we want system created to always be preferred? + // If so, uncomment the next line and comment the line after + // if (identifier.preferred && !idIdentifierPreferred) { + if (identifier.preferred) idIdentifierPreferred = Identifier.idIdentifier; } - } else if (selectedIdentifierCount === 1) { - const selectedIdentifier = identifiers.find((identifier) => identifier.selected === true); - const preferredIdentifier = identifiersList.find((identifier) => identifier.IdentifierValue === selectedIdentifier?.identifierValue); - if (preferredIdentifier?.idIdentifier) idIdentifierPreferred = preferredIdentifier.idIdentifier; - } else { - const preferredIdentifier = identifiersList.find((identifier) => identifier.idVIdentifierType === idIentifierType); + } + + if (idIdentifierPreferred === null) { + const preferredIdentifier = identifiersList.find(identifier => identifier.idVIdentifierType === idIentifierType); if (preferredIdentifier?.idIdentifier) idIdentifierPreferred = preferredIdentifier.idIdentifier; } @@ -76,10 +71,10 @@ export default async function createSubjectWithIdentifiers(_: Parent, args: Muta if (successfulSubjectCreation) { const SO = await Subject.fetchSystemObject(); - identifiersList.forEach(async (identifier) => { + for await (const identifier of identifiersList) { if (SO?.idSystemObject) identifier.idSystemObject = SO.idSystemObject; await identifier.update(); - }); + } } else { return { success: false, message: 'Error when creating subject' }; } diff --git a/server/graphql/schema/systemobject/resolvers/mutations/deleteObjectConnection.ts b/server/graphql/schema/systemobject/resolvers/mutations/deleteObjectConnection.ts index 84d057966..f10c19981 100644 --- a/server/graphql/schema/systemobject/resolvers/mutations/deleteObjectConnection.ts +++ b/server/graphql/schema/systemobject/resolvers/mutations/deleteObjectConnection.ts @@ -2,12 +2,25 @@ import { DeleteObjectConnectionResult, MutationDeleteObjectConnectionArgs } from import { Parent } from '../../../../../types/resolvers'; import * as DBAPI from '../../../../../db'; import * as LOG from '../../../../../utils/logger'; +import { getRelatedObjects } from '../queries/getSystemObjectDetails'; +import { RelatedObjectType } from '../../../../../types/graphql'; +import { eSystemObjectType } from '../../../../../db'; + export default async function deleteObjectConnection(_: Parent, args: MutationDeleteObjectConnectionArgs): Promise { - const { input: { idSystemObjectMaster, idSystemObjectDerived } } = args; + const { input: { idSystemObjectMaster, objectTypeMaster, idSystemObjectDerived, objectTypeDerived } } = args; let result = { success: true, details: 'Relationship Removed!' }; + const idSystemObjectXrefs = await DBAPI.SystemObjectXref.fetchXref(idSystemObjectMaster, idSystemObjectDerived); + if ((objectTypeDerived === eSystemObjectType.eModel && objectTypeMaster === eSystemObjectType.eItem) || (objectTypeDerived === eSystemObjectType.eCaptureData && objectTypeMaster === eSystemObjectType.eItem)) { + const sourceObjectsOfChild = await getRelatedObjects(idSystemObjectDerived, RelatedObjectType.Source); + const sourceItemCount = sourceObjectsOfChild.filter(source => source.objectType === eSystemObjectType.eItem).length; + if (sourceItemCount <= 1) { + return { success: false, details: 'Cannot delete last item parent' }; + } + } + if (idSystemObjectXrefs) { for (let i = 0; i < idSystemObjectXrefs.length; i++) { const xref = idSystemObjectXrefs[i]; diff --git a/server/graphql/schema/systemobject/resolvers/mutations/publish.ts b/server/graphql/schema/systemobject/resolvers/mutations/publish.ts new file mode 100644 index 000000000..2b0ee67ce --- /dev/null +++ b/server/graphql/schema/systemobject/resolvers/mutations/publish.ts @@ -0,0 +1,13 @@ +import { PublishResult, MutationPublishArgs } from '../../../../../types/graphql'; +import { Parent } from '../../../../../types/resolvers'; +import * as COL from '../../../../../collections/interface'; + +export default async function publish(_: Parent, args: MutationPublishArgs): Promise { + const { + input: { idSystemObject, eState } + } = args; + + const ICol: COL.ICollection = COL.CollectionFactory.getInstance(); + const success: boolean = await ICol.publish(idSystemObject, eState); + return { success, message: success ? '' : 'Error encountered during publishing' }; +} diff --git a/server/graphql/schema/systemobject/resolvers/mutations/updateDerivedObjects.ts b/server/graphql/schema/systemobject/resolvers/mutations/updateDerivedObjects.ts index 25eda2a1f..b54c36a62 100644 --- a/server/graphql/schema/systemobject/resolvers/mutations/updateDerivedObjects.ts +++ b/server/graphql/schema/systemobject/resolvers/mutations/updateDerivedObjects.ts @@ -1,37 +1,47 @@ -import { UpdateDerivedObjectsResult, MutationUpdateDerivedObjectsArgs } from '../../../../../types/graphql'; +import { UpdateDerivedObjectsResult, MutationUpdateDerivedObjectsArgs, ExistingRelationship, RelatedObjectType } from '../../../../../types/graphql'; import { Parent } from '../../../../../types/resolvers'; import * as DBAPI from '../../../../../db'; import * as LOG from '../../../../../utils/logger'; +import { getRelatedObjects } from '../queries/getSystemObjectDetails'; +import { isValidParentChildRelationship } from '../../../ingestion/resolvers/mutations/ingestData'; export default async function updateDerivedObjects(_: Parent, args: MutationUpdateDerivedObjectsArgs): Promise { const { input } = args; - const { idSystemObject, Derivatives, PreviouslySelected } = input; + const { idSystemObject, Derivatives, PreviouslySelected, ParentObjectType } = input; const uniqueHash = {}; - PreviouslySelected.forEach((previous) => uniqueHash[previous] = previous); - const newlySelectedArr: number[] = []; - Derivatives.forEach((derivative) => { - if (!uniqueHash[derivative]) { + PreviouslySelected.forEach(previous => (uniqueHash[previous.idSystemObject] = previous)); + const newlySelectedArr: ExistingRelationship[] = []; + Derivatives.forEach(derivative => { + if (!uniqueHash[derivative.idSystemObject]) { newlySelectedArr.push(derivative); } }); + + const result = { success: true, message: '', status: 'success' }; + if (Derivatives && Derivatives.length > 0 && newlySelectedArr.length > 0) { const SO: DBAPI.SystemObject | null = await DBAPI.SystemObject.fetch(idSystemObject); if (SO) { for (const newlySelected of newlySelectedArr) { - const xref: DBAPI.SystemObjectXref = new DBAPI.SystemObjectXref({ - idSystemObjectMaster: SO.idSystemObject, - idSystemObjectDerived: newlySelected, - idSystemObjectXref: 0 - }); - if (!await xref.create()) { - LOG.error(`updateDerivedObjects failed to create SystemObjectXref ${JSON.stringify(xref)}`, LOG.LS.eGQL); + const parentsOfNewlySelected = await getRelatedObjects(newlySelected.idSystemObject, RelatedObjectType.Source); + const isValidRelationship = isValidParentChildRelationship(ParentObjectType, newlySelected.objectType, Derivatives, parentsOfNewlySelected, false); + if (!isValidRelationship) { + LOG.error(`updateDerivedObjects failed to create connection between ${idSystemObject} and ${newlySelected.idSystemObject}`, LOG.LS.eGQL); + result.status = 'warn'; + result.message += ' ' + newlySelected.idSystemObject; + continue; + } + + const wireSourceToDerived = await DBAPI.SystemObjectXref.wireObjectsIfNeeded(SO.idSystemObject, newlySelected.idSystemObject); + if (!wireSourceToDerived) { + LOG.error(`updateSourceObjects failed to wire SystemObjectXref ${JSON.stringify(wireSourceToDerived)}`, LOG.LS.eGQL); continue; } } } else { LOG.error(`updateDerivedObjects failed to fetch system object ${idSystemObject}`, LOG.LS.eGQL); - return { success: false }; + return { success: false, message: `Failed to fetch system object ${idSystemObject}`, status: 'error' }; } } - return { success: true }; -} \ No newline at end of file + return result; +} diff --git a/server/graphql/schema/systemobject/resolvers/mutations/updateObjectDetails.ts b/server/graphql/schema/systemobject/resolvers/mutations/updateObjectDetails.ts index 452d3cceb..df6debf29 100644 --- a/server/graphql/schema/systemobject/resolvers/mutations/updateObjectDetails.ts +++ b/server/graphql/schema/systemobject/resolvers/mutations/updateObjectDetails.ts @@ -6,75 +6,95 @@ import * as DBAPI from '../../../../../db'; import { maybe } from '../../../../../utils/types'; import { isNull, isUndefined } from 'lodash'; import { SystemObjectTypeToName } from '../../../../../db/api/ObjectType'; +import * as H from '../../../../../utils/helpers'; export default async function updateObjectDetails(_: Parent, args: MutationUpdateObjectDetailsArgs): Promise { const { input } = args; const { idSystemObject, idObject, objectType, data } = input; - // LOG.info(JSON.stringify(data, null, 2), LOG.LS.eGQL); - if (!data.Name || isUndefined(data.Retired) || isNull(data.Retired)) { - return { success: false, message: 'Error with Name and/or Retired field(s)' }; + const message = 'Error with Name and/or Retired field(s); update failed'; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } const SO = await DBAPI.SystemObject.fetch(idSystemObject); - /** - * TODO: KARAN: add an error property and handle errors - */ - if (SO) { - if (data.Retired) { - await SO.retireObject(); - } else { - await SO.reinstateObject(); + + if (!SO) { + const message = 'Error with fetching the object; update failed'; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } + if (data.Retired) { + const retireSuccess = await SO.retireObject(); + if (!retireSuccess) { + const message = 'Error with retiring object; update failed'; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } + } else { + const reinstateScuccess = await SO.reinstateObject(); + if (!reinstateScuccess) { + const message = 'Error with reinstating object; update failed'; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } } + let identifierPreferred: null | number = null; if (data?.Identifiers && data?.Identifiers.length) { - - data.Identifiers.forEach(async ({ idIdentifier, selected, identifier, identifierType }) => { - // update exisiting identifier - if (idIdentifier && selected && identifier && identifierType) { + for await (const Identifier of data.Identifiers) { + const { idIdentifier, identifier, identifierType, preferred } = Identifier; + if (idIdentifier && identifier && identifierType) { const existingIdentifier = await DBAPI.Identifier.fetch(idIdentifier); if (existingIdentifier) { + if (preferred) + identifierPreferred = idIdentifier; existingIdentifier.IdentifierValue = identifier; existingIdentifier.idVIdentifierType = Number(identifierType); const updateSuccess = await existingIdentifier.update(); - if (updateSuccess) { - return LOG.info(`updated identifier ${idIdentifier}`, LOG.LS.eDB); + if (!updateSuccess) { + const message = `Unable to update identifier with id ${idIdentifier}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } else { - return LOG.error(`failed to updated identifier ${idIdentifier}`, LOG.LS.eDB); + const message = `Unable to fetch identifier with id ${idIdentifier}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } - } else { - return LOG.error(`failed to find identifier ${idIdentifier}`, LOG.LS.eDB); } } // create new identifier - if (idIdentifier === 0 && selected && identifier && identifierType) { + if (idIdentifier === 0 && identifier && identifierType) { const newIdentifier = new DBAPI.Identifier({ idIdentifier: 0, IdentifierValue: identifier, idVIdentifierType: identifierType, idSystemObject }); const createNewIdentifier = await newIdentifier.create(); + if (!createNewIdentifier) { - return LOG.error(`updateObjectDetails failed to create newIdentifier ${JSON.stringify(newIdentifier)}`, LOG.LS.eDB); + const message = `Unable to create identifier when updating ${SystemObjectTypeToName(objectType)}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } + if (preferred === true) { + identifierPreferred = newIdentifier.idIdentifier; } } - }); + } } if (data.License) { const reassignedLicense = await DBAPI.License.fetch(data.License); - if (reassignedLicense) { - const reassignmentSuccess = await DBAPI.LicenseManager.setAssignment(idSystemObject, reassignedLicense); - if (!reassignmentSuccess) { - return { - success: false, - message: 'There was an error assigning the license. Please try again.' - }; - } - } else { - return { - success: false, - message: 'There was an error fetching the license for assignment. Please try again.' - }; + if (!reassignedLicense) { + const message = `Unable to fetch license with id ${data.License}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } + + const reassignmentSuccess = await DBAPI.LicenseManager.setAssignment(idSystemObject, reassignedLicense); + if (!reassignmentSuccess) { + const message = `Unable to reassign license with id ${reassignedLicense.idLicense}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } } @@ -90,12 +110,16 @@ export default async function updateObjectDetails(_: Parent, args: MutationUpdat Unit.ARKPrefix = maybe(ARKPrefix); } - await Unit.update(); + const updateSuccess = await Unit.update(); + if (!updateSuccess) { + const message = `Unable to update ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } } else { - return { - success: false, - message: `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed` - }; + const message = `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } break; } @@ -109,12 +133,16 @@ export default async function updateObjectDetails(_: Parent, args: MutationUpdat Project.Description = maybe(Description); } - await Project.update(); + const updateSuccess = await Project.update(); + if (!updateSuccess) { + const message = `Unable to update ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } } else { - return { - success: false, - message: `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed` - }; + const message = `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } break; } @@ -125,8 +153,33 @@ export default async function updateObjectDetails(_: Parent, args: MutationUpdat if (Subject) { Subject.Name = data.Name; - - if (!Subject.idGeoLocation) { + Subject.idIdentifierPreferred = identifierPreferred; + + // update exisiting geolocation OR create a new one and then connect with subject + if (Subject.idGeoLocation) { + const GeoLocation = await DBAPI.GeoLocation.fetch(Subject.idGeoLocation); + if (!GeoLocation) { + const message = `Unable to fetch GeoLocation with id ${Subject.idGeoLocation}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } + GeoLocation.Altitude = maybe(Altitude); + GeoLocation.Latitude = maybe(Latitude); + GeoLocation.Longitude = maybe(Longitude); + GeoLocation.R0 = maybe(R0); + GeoLocation.R1 = maybe(R1); + GeoLocation.R2 = maybe(R2); + GeoLocation.R3 = maybe(R3); + GeoLocation.TS0 = maybe(TS0); + GeoLocation.TS1 = maybe(TS1); + GeoLocation.TS2 = maybe(TS2); + const updateSuccess = await GeoLocation.update(); + if (!updateSuccess) { + const message = `Unable to update GeoLocation with id ${Subject.idGeoLocation}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } + } else { const GeoLocationInput = { idGeoLocation: 0, Altitude: maybe(Altitude), @@ -141,35 +194,25 @@ export default async function updateObjectDetails(_: Parent, args: MutationUpdat TS2: maybe(TS2) }; const GeoLocation = new DBAPI.GeoLocation(GeoLocationInput); - await GeoLocation.create(); + const creationSuccess = await GeoLocation.create(); + if (!creationSuccess) { + const message = `Unable to create GeoLocation when updating ${SystemObjectTypeToName(objectType)}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } Subject.idGeoLocation = GeoLocation.idGeoLocation; - - await Subject.update(); - break; } - await Subject.update(); - - const GeoLocation = await DBAPI.GeoLocation.fetch(Subject.idGeoLocation); - - if (GeoLocation) { - GeoLocation.Altitude = maybe(Altitude); - GeoLocation.Latitude = maybe(Latitude); - GeoLocation.Longitude = maybe(Longitude); - GeoLocation.R0 = maybe(R0); - GeoLocation.R1 = maybe(R1); - GeoLocation.R2 = maybe(R2); - GeoLocation.R3 = maybe(R3); - GeoLocation.TS0 = maybe(TS0); - GeoLocation.TS1 = maybe(TS1); - GeoLocation.TS2 = maybe(TS2); - await GeoLocation.update(); + const subjectUpdateSuccess = await Subject.update(); + if (!subjectUpdateSuccess) { + const message = `Unable to update ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } } else { - return { - success: false, - message: `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed` - }; + const message = `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } } break; @@ -178,12 +221,36 @@ export default async function updateObjectDetails(_: Parent, args: MutationUpdat if (data.Item) { const { EntireSubject, Altitude, Latitude, Longitude, R0, R1, R2, R3, TS0, TS1, TS2 } = data.Item; const Item = await DBAPI.Item.fetch(idObject); - if (Item) { Item.Name = data.Name; - if (!isNull(EntireSubject) && !isUndefined(EntireSubject)) Item.EntireSubject = EntireSubject; + if (!isNull(EntireSubject) && !isUndefined(EntireSubject)) + Item.EntireSubject = EntireSubject; - if (!Item.idGeoLocation) { + // update existing geolocation OR create a new one and then connect with item + if (Item.idGeoLocation) { + const GeoLocation = await DBAPI.GeoLocation.fetch(Item.idGeoLocation); + if (!GeoLocation) { + const message = `Unable to fetch GeoLocation with id ${Item.idGeoLocation}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } + GeoLocation.Altitude = maybe(Altitude); + GeoLocation.Latitude = maybe(Latitude); + GeoLocation.Longitude = maybe(Longitude); + GeoLocation.R0 = maybe(R0); + GeoLocation.R1 = maybe(R1); + GeoLocation.R2 = maybe(R2); + GeoLocation.R3 = maybe(R3); + GeoLocation.TS0 = maybe(TS0); + GeoLocation.TS1 = maybe(TS1); + GeoLocation.TS2 = maybe(TS2); + const updateSuccess = await GeoLocation.update(); + if (!updateSuccess) { + const message = `Unable to update GeoLocation with id ${Item.idGeoLocation}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } + } else { const GeoLocationInput = { idGeoLocation: 0, Altitude: maybe(Altitude), @@ -198,41 +265,31 @@ export default async function updateObjectDetails(_: Parent, args: MutationUpdat TS2: maybe(TS2) }; const GeoLocation = new DBAPI.GeoLocation(GeoLocationInput); - await GeoLocation.create(); + const creationSuccess = await GeoLocation.create(); + if (!creationSuccess) { + const message = `Unable to create GeoLocation when updating ${SystemObjectTypeToName(objectType)}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } + Item.idGeoLocation = GeoLocation.idGeoLocation; - await Item.update(); - break; } - await Item.update(); - - if (Item.idGeoLocation) { - const GeoLocation = await DBAPI.GeoLocation.fetch(Item.idGeoLocation); - if (GeoLocation) { - GeoLocation.Altitude = maybe(Altitude); - GeoLocation.Latitude = maybe(Latitude); - GeoLocation.Longitude = maybe(Longitude); - GeoLocation.R0 = maybe(R0); - GeoLocation.R1 = maybe(R1); - GeoLocation.R2 = maybe(R2); - GeoLocation.R3 = maybe(R3); - GeoLocation.TS0 = maybe(TS0); - GeoLocation.TS1 = maybe(TS1); - GeoLocation.TS2 = maybe(TS2); - await GeoLocation.update(); - } + const updateSuccess = await Item.update(); + if (!updateSuccess) { + const message = `Unable to update ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } } else { - return { - success: false, - message: `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed` - }; + const message = `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } } break; } case eSystemObjectType.eCaptureData: { - // TODO: KARAN update/create folders, systemCreated if (data.CaptureData) { const CaptureData = await DBAPI.CaptureData.fetch(idObject); @@ -253,12 +310,46 @@ export default async function updateObjectDetails(_: Parent, args: MutationUpdat backgroundRemovalMethod, clusterType, clusterGeometryFieldId, + folders } = data.CaptureData; + + if (datasetFieldId && !H.Helpers.validFieldId(datasetFieldId)) return { success: false, message: 'Dataset Field ID is invalid; update failed' }; + if (itemPositionFieldId && !H.Helpers.validFieldId(itemPositionFieldId)) return { success: false, message: 'Item Position Field ID is invalid; update failed' }; + if (itemArrangementFieldId && !H.Helpers.validFieldId(itemArrangementFieldId)) return { success: false, message: 'Item Arrangement Field ID is invalid; update failed' }; + if (clusterGeometryFieldId && !H.Helpers.validFieldId(clusterGeometryFieldId)) return { success: false, message: 'Cluster Geometry Field ID is invalid; update failed' }; + CaptureData.DateCaptured = new Date(dateCaptured); if (description) CaptureData.Description = description; if (captureMethod) CaptureData.idVCaptureMethod = captureMethod; + if (folders && folders.length) { + const foldersMap = new Map(); + folders.forEach((folder) => foldersMap.set(folder.name, folder.variantType)); + const CDFiles = await DBAPI.CaptureDataFile.fetchFromCaptureData(CaptureData.idCaptureData); + if (!CDFiles) { + const message = `Unable to fetch Capture Data Files with id ${CaptureData.idCaptureData}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } + for (const file of CDFiles) { + const asset = await DBAPI.Asset.fetch(file.idAsset); + if (!asset) { + const message = `Unable to fetch asset with id ${file.idAsset}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } + const newVariantType = foldersMap.get(asset.FilePath); + file.idVVariantType = newVariantType || file.idVVariantType; + const updateSuccess = await file.update(); + if (!updateSuccess) { + const message = `Unable to update Capture Data File with id ${file.idCaptureDataFile}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } + } + } + const CaptureDataPhoto = await DBAPI.CaptureDataPhoto.fetchFromCaptureData(CaptureData.idCaptureData); if (CaptureDataPhoto && CaptureDataPhoto[0]) { const [CD] = CaptureDataPhoto; @@ -267,30 +358,39 @@ export default async function updateObjectDetails(_: Parent, args: MutationUpdat if (datasetType) CD.idVCaptureDatasetType = datasetType; CD.CaptureDatasetFieldID = maybe(datasetFieldId); CD.idVItemPositionType = maybe(itemPositionType); - CD.idVItemPositionType = maybe(itemPositionFieldId); + CD.ItemPositionFieldID = maybe(itemPositionFieldId); CD.ItemArrangementFieldID = maybe(itemArrangementFieldId); CD.idVFocusType = maybe(focusType); CD.idVLightSourceType = maybe(lightsourceType); CD.idVBackgroundRemovalMethod = maybe(backgroundRemovalMethod); CD.idVClusterType = maybe(clusterType); CD.ClusterGeometryFieldID = maybe(clusterGeometryFieldId); - await CD.update(); + const updateSuccess = await CD.update(); + if (!updateSuccess) { + const message = `Unable to update CaptureDataPhoto with id ${CD.idCaptureData}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } } else { - return { - success: false, - message: `Unable to fetch CaptureDataPhoto with id ${idObject}; update failed` - }; + const message = `Unable to fetch CaptureDataPhoto with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } + const updateSuccess = await CaptureData.update(); + if (!updateSuccess) { + const message = `Unable to update Capture Data with id ${CaptureData.idCaptureData}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } - await CaptureData.update(); } else { - return { - success: false, - message: `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed` - }; + const message = `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } } break; - } case eSystemObjectType.eModel: { + } + case eSystemObjectType.eModel: { if (data.Model) { const Model = await DBAPI.Model.fetch(idObject); if (Model) { @@ -312,72 +412,65 @@ export default async function updateObjectDetails(_: Parent, args: MutationUpdat if (ModelFileType) Model.idVFileType = ModelFileType; Model.DateCreated = new Date(DateCaptured); - // if (Model.idAssetThumbnail) { - // const AssetVersion = await DBAPI.AssetVersion.fetchFromAsset(Model.idAssetThumbnail); - // if (AssetVersion && AssetVersion[0]) { - // const [AV] = AssetVersion; - // if (size) AV.StorageSize = size; - // } - // } - - /* - // TODO: do we want to update the asset name? I don't think so... - // Look up asset using SystemObjectXref, with idSystemObjectMaster = Model's system object ID - const Asset = await DBAPI.Asset.fetch(MGF.idAsset); - if (Asset) { - Asset.FileName = data.Name; - await Asset.update(); - } - */ - try { - if (await Model.update()) { - break; - } else { - throw new Error('error in updating'); - } - } catch (error) { - throw new Error(error); + const updateSuccess = await Model.update(); + if (!updateSuccess) { + const message = `Unable to update ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } } else { - return { - success: false, - message: `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed` - }; + const message = `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } } break; - } case eSystemObjectType.eScene: { + } + case eSystemObjectType.eScene: { const Scene = await DBAPI.Scene.fetch(idObject); if (Scene) { Scene.Name = data.Name; if (data.Scene) { - if (typeof data.Scene.IsOriented === 'boolean') Scene.IsOriented = data.Scene.IsOriented; - if (typeof data.Scene.HasBeenQCd === 'boolean') Scene.HasBeenQCd = data.Scene.HasBeenQCd; + if (typeof data.Scene.PosedAndQCd === 'boolean') Scene.PosedAndQCd = data.Scene.PosedAndQCd; + if (typeof data.Scene.ApprovedForPublication === 'boolean') Scene.ApprovedForPublication = data.Scene.ApprovedForPublication; + } + const updateSuccess = await Scene.update(); + if (!updateSuccess) { + const message = `Unable to update ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } - await Scene.update(); } else { - return { - success: false, - message: `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed` - }; + const message = `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } break; - } case eSystemObjectType.eIntermediaryFile: { + } + case eSystemObjectType.eIntermediaryFile: { const IntermediaryFile = await DBAPI.IntermediaryFile.fetch(idObject); if (IntermediaryFile) { const Asset = await DBAPI.Asset.fetch(IntermediaryFile.idAsset); - if (Asset) { - Asset.FileName = data.Name; - await Asset.update(); + if (!Asset) { + const message = `Unable to fetch Asset using IntermediaryFile.idAsset ${IntermediaryFile.idAsset}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } + Asset.FileName = data.Name; + const updateSuccess = await Asset.update(); + if (!updateSuccess) { + const message = `Unable to update ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } } else { - return { - success: false, - message: `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed` - }; + const message = `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } break; - } case eSystemObjectType.eProjectDocumentation: { + } + case eSystemObjectType.eProjectDocumentation: { const ProjectDocumentation = await DBAPI.ProjectDocumentation.fetch(idObject); if (ProjectDocumentation) { @@ -388,12 +481,16 @@ export default async function updateObjectDetails(_: Parent, args: MutationUpdat if (Description) ProjectDocumentation.Description = Description; } - await ProjectDocumentation.update(); + const updateSuccess = await ProjectDocumentation.update(); + if (!updateSuccess) { + const message = `Unable to update ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } } else { - return { - success: false, - message: `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed` - }; + const message = `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } break; } @@ -409,12 +506,16 @@ export default async function updateObjectDetails(_: Parent, args: MutationUpdat if (AssetType) Asset.idVAssetType = AssetType; } - await Asset.update(); + const updateSuccess = await Asset.update(); + if (!updateSuccess) { + const message = `Unable to update ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } } else { - return { - success: false, - message: `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed` - }; + const message = `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } break; } @@ -430,12 +531,16 @@ export default async function updateObjectDetails(_: Parent, args: MutationUpdat AssetVersion.Ingested = Ingested; } - await AssetVersion.update(); + const updateSuccess = await AssetVersion.update(); + if (!updateSuccess) { + const message = `Unable to update ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } } else { - return { - success: false, - message: `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed` - }; + const message = `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } break; } @@ -446,14 +551,17 @@ export default async function updateObjectDetails(_: Parent, args: MutationUpdat if (data.Actor) { const { OrganizationName } = data.Actor; Actor.OrganizationName = maybe(OrganizationName); - } - await Actor.update(); + const updateSuccess = await Actor.update(); + if (!updateSuccess) { + const message = `Unable to update ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } } else { - return { - success: false, - message: `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed` - }; + const message = `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } break; } @@ -470,12 +578,16 @@ export default async function updateObjectDetails(_: Parent, args: MutationUpdat Stakeholder.PhoneNumberMobile = maybe(PhoneNumberMobile); Stakeholder.PhoneNumberOffice = maybe(PhoneNumberOffice); } - await Stakeholder.update(); + const updateSuccess = await Stakeholder.update(); + if (!updateSuccess) { + const message = `Unable to update ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; + } } else { - return { - success: false, - message: `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed` - }; + const message = `Unable to fetch ${SystemObjectTypeToName(objectType)} with id ${idObject}; update failed`; + LOG.error(message, LOG.LS.eDB); + return { success: false, message }; } break; } diff --git a/server/graphql/schema/systemobject/resolvers/mutations/updateSourceObjects.ts b/server/graphql/schema/systemobject/resolvers/mutations/updateSourceObjects.ts index a9bf46be8..da9be4eac 100644 --- a/server/graphql/schema/systemobject/resolvers/mutations/updateSourceObjects.ts +++ b/server/graphql/schema/systemobject/resolvers/mutations/updateSourceObjects.ts @@ -1,37 +1,45 @@ -import { UpdateSourceObjectsResult, MutationUpdateSourceObjectsArgs } from '../../../../../types/graphql'; +import { UpdateSourceObjectsResult, MutationUpdateSourceObjectsArgs, ExistingRelationship } from '../../../../../types/graphql'; import { Parent } from '../../../../../types/resolvers'; import * as DBAPI from '../../../../../db'; import * as LOG from '../../../../../utils/logger'; +import { isValidParentChildRelationship } from '../../../ingestion/resolvers/mutations/ingestData'; export default async function updateSourceObjects(_: Parent, args: MutationUpdateSourceObjectsArgs): Promise { const { input } = args; - const { idSystemObject, Sources, PreviouslySelected } = input; + const { idSystemObject, Sources, PreviouslySelected, ChildObjectType } = input; const uniqueHash = {}; - PreviouslySelected.forEach((previous) => uniqueHash[previous] = previous); - const newlySelectedArr: number[] = []; - Sources.forEach((source) => { - if (!uniqueHash[source]) { + PreviouslySelected.forEach(previous => (uniqueHash[previous.idSystemObject] = previous)); + const newlySelectedArr: ExistingRelationship[] = []; + Sources.forEach(source => { + if (!uniqueHash[source.idSystemObject]) { newlySelectedArr.push(source); } }); + + const result = { success: true, message: '', status: 'success' }; + if (Sources && Sources.length > 0 && newlySelectedArr.length > 0) { const SO: DBAPI.SystemObject | null = await DBAPI.SystemObject.fetch(idSystemObject); if (SO) { for (const newlySelected of newlySelectedArr) { - const xref: DBAPI.SystemObjectXref = new DBAPI.SystemObjectXref({ - idSystemObjectMaster: newlySelected, - idSystemObjectDerived: SO.idSystemObject, - idSystemObjectXref: 0 - }); - if (!await xref.create()) { - LOG.error(`updateSourceObjects failed to create SystemObjectXref ${JSON.stringify(xref)}`, LOG.LS.eGQL); + const isValidRelationship = isValidParentChildRelationship(newlySelected.objectType, ChildObjectType, Sources, PreviouslySelected, true); + if (!isValidRelationship) { + LOG.error(`updateSourceObjects failed to create connection between ${idSystemObject} and ${newlySelected.idSystemObject}`, LOG.LS.eGQL); + result.status = 'warn'; + result.message += ' ' + newlySelected.idSystemObject; + continue; + } + + const wireSourceToDerived = await DBAPI.SystemObjectXref.wireObjectsIfNeeded(newlySelected.idSystemObject, SO.idSystemObject); + if (!wireSourceToDerived) { + LOG.error(`updateSourceObjects failed to wire SystemObjectXref ${JSON.stringify(wireSourceToDerived)}`, LOG.LS.eGQL); continue; } } } else { LOG.error(`updateSourceObjects failed to fetch system object ${idSystemObject}`, LOG.LS.eGQL); - return { success: false }; + return { success: false, message: `Failed to fetch system object ${idSystemObject}`, status: 'error' }; } } - return { success: true }; -} \ No newline at end of file + return result; +} diff --git a/server/graphql/schema/systemobject/resolvers/queries/getDetailsTabDataForObject.ts b/server/graphql/schema/systemobject/resolvers/queries/getDetailsTabDataForObject.ts index 8dfb6d83e..a75d62345 100644 --- a/server/graphql/schema/systemobject/resolvers/queries/getDetailsTabDataForObject.ts +++ b/server/graphql/schema/systemobject/resolvers/queries/getDetailsTabDataForObject.ts @@ -11,6 +11,7 @@ import { SceneDetailFields } from '../../../../../types/graphql'; import { Parent } from '../../../../../types/resolvers'; +import * as LOG from '../../../../../utils/logger'; export default async function getDetailsTabDataForObject(_: Parent, args: QueryGetDetailsTabDataForObjectArgs): Promise { const { input } = args; @@ -47,6 +48,7 @@ export default async function getDetailsTabDataForObject(_: Parent, args: QueryG const Subject = await DBAPI.Subject.fetch(systemObject.idSubject); + fields = { ...Subject }; if (Subject?.idGeoLocation) { const GeoLocation = await DBAPI.GeoLocation.fetch(Subject.idGeoLocation); fields = { ...fields, ...GeoLocation }; @@ -84,15 +86,16 @@ export default async function getDetailsTabDataForObject(_: Parent, args: QueryG break; case eSystemObjectType.eScene: if (systemObject?.idScene) { - // TODO: KARAN: resolve Links, AssetType, Tours, Annotation when SceneDetailFields is finalized? let fields: SceneDetailFields = { Links: [] }; - const Scene = await DBAPI.Scene.fetch(systemObject.idScene); + const Scene: DBAPI.Scene | null = await DBAPI.Scene.fetch(systemObject.idScene); + if (!Scene) + LOG.error(`getDetailsTabForObject(${systemObject.idSystemObject}) unable to compute Scene details`, LOG.LS.eGQL); + const User: DBAPI.User | null = await DBAPI.Audit.fetchLastUser(systemObject.idSystemObject, DBAPI.eAuditType.eSceneQCd); + fields = { ...fields, - HasBeenQCd: Scene?.HasBeenQCd, - IsOriented: Scene?.IsOriented, CountScene: Scene?.CountScene, CountNode: Scene?.CountNode, CountCamera: Scene?.CountCamera, @@ -102,6 +105,9 @@ export default async function getDetailsTabDataForObject(_: Parent, args: QueryG CountSetup: Scene?.CountSetup, CountTour: Scene?.CountTour, EdanUUID: Scene?.EdanUUID, + ApprovedForPublication: Scene?.ApprovedForPublication, + PublicationApprover: User?.Name ?? null, + PosedAndQCd: Scene?.PosedAndQCd, idScene: systemObject.idScene, }; result.Scene = fields; @@ -162,7 +168,25 @@ async function getCaptureDataDetailFields(idCaptureData: number): Promise(); + + const CDFiles = await DBAPI.CaptureDataFile.fetchFromCaptureData(idCaptureData); + if (CDFiles) { + for (const file of CDFiles) { + const asset = await DBAPI.Asset.fetch(file.idAsset); + if (asset) { + if (!foldersMap.has(asset.FilePath) && file.idVVariantType) { + foldersMap.set(asset.FilePath, file.idVVariantType); + } + } + } + } + + foldersMap.forEach((value, key) => { + fields.folders.push({ name: key, variantType: value }); + }); + const CaptureData = await DBAPI.CaptureData.fetch(idCaptureData); fields = { ...fields, @@ -182,7 +206,7 @@ async function getCaptureDataDetailFields(idCaptureData: number): Promise { -// let fields: ModelDetailFields = { }; - -// // TODO: KARAN resolve uvMaps, systemCreated? -// const modelConstellation = await DBAPI.ModelConstellation.fetch(idModel); -// if (!modelConstellation) -// return fields; - -// const model = modelConstellation.Model; -// fields = { -// ...fields, -// name: model?.Name, -// creationMethod: model?.idVCreationMethod, -// modality: model?.idVModality, -// purpose: model?.idVPurpose, -// units: model?.idVUnits, -// dateCaptured: model?.DateCreated.toISOString(), -// modelFileType: model?.idVFileType, -// }; - -// // TODO: fetch all assets associated with Model and ModelMaterialUVMap's; add up storage size -// if (model?.idAssetThumbnail) { -// const AssetVersion = await DBAPI.AssetVersion.fetchFromAsset(model.idAssetThumbnail); -// if (AssetVersion && AssetVersion[0]) { -// const [AV] = AssetVersion; -// fields = { -// ...fields, -// size: AV.StorageSize -// }; -// } -// } - -// // TODO: fetch Material Channels, etc. -// /* -// const ModelObject = (modelConstellation.ModelObjects && modelConstellation.ModelObjects.length > 0) ? modelConstellation.ModelObjects[0] : null; -// if (ModelObject) { -// fields = { -// ...fields, -// boundingBoxP1X: ModelObject.BoundingBoxP1X, -// boundingBoxP1Y: ModelObject.BoundingBoxP1Y, -// boundingBoxP1Z: ModelObject.BoundingBoxP1Z, -// boundingBoxP2X: ModelObject.BoundingBoxP2X, -// boundingBoxP2Y: ModelObject.BoundingBoxP2Y, -// boundingBoxP2Z: ModelObject.BoundingBoxP2Z, -// countPoint: ModelObject.CountVertices, -// countFace: ModelObject.CountFaces, -// countColorChannel: ModelObject.CountColorChannels, -// countTextureCoordinateChannel: ModelObject.CountTextureCoordinateChannels, -// hasBones: ModelObject.HasBones, -// hasFaceNormals: ModelObject.HasFaceNormals, -// hasTangents: ModelObject.HasTangents, -// hasTextureCoordinates: ModelObject.HasTextureCoordinates, -// hasVertexNormals: ModelObject.HasVertexNormals, -// hasVertexColor: ModelObject.HasVertexColor, -// isTwoManifoldUnbounded: ModelObject.IsTwoManifoldUnbounded, -// isTwoManifoldBounded: ModelObject.IsTwoManifoldBounded, -// isWatertight: ModelObject.IsWatertight, -// selfIntersecting: ModelObject.SelfIntersecting, -// }; -// } -// */ -// return fields; -// } \ No newline at end of file +} \ No newline at end of file diff --git a/server/graphql/schema/systemobject/resolvers/queries/getSystemObjectDetails.ts b/server/graphql/schema/systemobject/resolvers/queries/getSystemObjectDetails.ts index 148364467..3cbc7f6a0 100644 --- a/server/graphql/schema/systemobject/resolvers/queries/getSystemObjectDetails.ts +++ b/server/graphql/schema/systemobject/resolvers/queries/getSystemObjectDetails.ts @@ -11,19 +11,26 @@ import { } from '../../../../../types/graphql'; import { Parent } from '../../../../../types/resolvers'; import * as LOG from '../../../../../utils/logger'; +import * as H from '../../../../../utils/helpers'; export default async function getSystemObjectDetails(_: Parent, args: QueryGetSystemObjectDetailsArgs): Promise { const { input } = args; const { idSystemObject } = input; + // LOG.info('getSystemObjectDetails 0', LOG.LS.eGQL); const oID: DBAPI.ObjectIDAndType | undefined = await CACHE.SystemObjectCache.getObjectFromSystem(idSystemObject); - const { unit, project, subject, item, objectAncestors } = await getObjectAncestors(idSystemObject); + // LOG.info('getSystemObjectDetails 1', LOG.LS.eGQL); + + const OGD: DBAPI.ObjectGraphDatabase = new DBAPI.ObjectGraphDatabase(); + const OG: DBAPI.ObjectGraph = new DBAPI.ObjectGraph(idSystemObject, DBAPI.eObjectGraphMode.eAncestors, 32, OGD); + const { unit, project, subject, item, objectAncestors } = await getObjectAncestors(OG); + // LOG.info('getSystemObjectDetails 2', LOG.LS.eGQL); const systemObject: SystemObject | null = await DBAPI.SystemObject.fetch(idSystemObject); const sourceObjects: RelatedObject[] = await getRelatedObjects(idSystemObject, RelatedObjectType.Source); const derivedObjects: RelatedObject[] = await getRelatedObjects(idSystemObject, RelatedObjectType.Derived); const objectVersions: DBAPI.SystemObjectVersion[] | null = await DBAPI.SystemObjectVersion.fetchFromSystemObject(idSystemObject); - const publishedState: string = await getPublishedState(idSystemObject); + const { publishedState, publishedEnum, publishable } = await getPublishedState(idSystemObject, oID); const identifiers = await getIngestIdentifiers(idSystemObject); if (!oID) { @@ -34,29 +41,30 @@ export default async function getSystemObjectDetails(_: Parent, args: QueryGetSy if (!systemObject) { const message: string = `No system object found for ID: ${idSystemObject}`; - LOG.error(message, LOG.LS.eGQL); + LOG.error(`getSystemObjectDetails: ${message}`, LOG.LS.eGQL); throw new Error(message); } if (!objectVersions) { const message: string = `No SystemObjectVersions found for ID: ${idSystemObject}`; - LOG.error(message, LOG.LS.eGQL); + LOG.error(`getSystemObjectDetails: ${message}`, LOG.LS.eGQL); throw new Error(message); } - const idObject: number = oID.idObject; - const name: string = await resolveNameForObjectType(systemObject, oID.eObjectType); - - const LR: DBAPI.LicenseResolver | undefined = await CACHE.LicenseCache.getLicenseResolver(idSystemObject); + const name: string = await resolveNameForObject(idSystemObject); + const LR: DBAPI.LicenseResolver | undefined = await CACHE.LicenseCache.getLicenseResolver(idSystemObject, OGD); + // LOG.info('getSystemObjectDetails 3', LOG.LS.eGQL); return { idSystemObject, - idObject, + idObject: oID.idObject, name, retired: systemObject.Retired, objectType: oID.eObjectType, allowed: true, // TODO: True until Access control is implemented (Post MVP) publishedState, + publishedEnum, + publishable, thumbnail: null, unit, project, @@ -72,12 +80,22 @@ export default async function getSystemObjectDetails(_: Parent, args: QueryGetSy }; } -async function getPublishedState(idSystemObject: number): Promise { +async function getPublishedState(idSystemObject: number, oID: DBAPI.ObjectIDAndType | undefined): Promise<{ publishedState: string, publishedEnum: DBAPI.ePublishedState, publishable: boolean }> { const systemObjectVersion: DBAPI.SystemObjectVersion | null = await DBAPI.SystemObjectVersion.fetchLatestFromSystemObject(idSystemObject); - return DBAPI.PublishedStateEnumToString(systemObjectVersion ? systemObjectVersion.publishedStateEnum() : DBAPI.ePublishedState.eNotPublished); + const publishedEnum: DBAPI.ePublishedState = systemObjectVersion ? systemObjectVersion.publishedStateEnum() : DBAPI.ePublishedState.eNotPublished; + const publishedState: string = DBAPI.PublishedStateEnumToString(publishedEnum); + let publishable: boolean = false; + if (oID && oID.eObjectType == DBAPI.eSystemObjectType.eScene) { + const scene: DBAPI.Scene | null = await DBAPI.Scene.fetch(oID.idObject); + if (scene) + publishable = scene.ApprovedForPublication && scene.PosedAndQCd; + else + LOG.error(`Unable to compute scene for ${JSON.stringify(oID)}`, LOG.LS.eGQL); + } + return { publishedState, publishedEnum, publishable }; } -async function getRelatedObjects(idSystemObject: number, type: RelatedObjectType): Promise { +export async function getRelatedObjects(idSystemObject: number, type: RelatedObjectType): Promise { let relatedSystemObjects: SystemObject[] | null = []; if (type === RelatedObjectType.Source) { @@ -102,7 +120,7 @@ async function getRelatedObjects(idSystemObject: number, type: RelatedObjectType const sourceObject: RelatedObject = { idSystemObject: relatedSystemObject.idSystemObject, - name: await resolveNameForObjectType(relatedSystemObject, oID.eObjectType), + name: await resolveNameForObject(relatedSystemObject.idSystemObject), identifier: identifier?.[0]?.IdentifierValue ?? null, objectType: oID.eObjectType }; @@ -133,14 +151,13 @@ type GetObjectAncestorsResult = { objectAncestors: RepositoryPath[][]; }; -async function getObjectAncestors(idSystemObject: number): Promise { - const objectGraph = new DBAPI.ObjectGraph(idSystemObject, DBAPI.eObjectGraphMode.eAncestors); +async function getObjectAncestors(OG: DBAPI.ObjectGraph): Promise { let unit: RepositoryPath | null = null; let project: RepositoryPath | null = null; let subject: RepositoryPath | null = null; let item: RepositoryPath | null = null; - if (!(await objectGraph.fetch())) { + if (!(await OG.fetch())) { return { unit, project, @@ -149,43 +166,45 @@ async function getObjectAncestors(idSystemObject: number): Promise { const paths: RepositoryPath[] = []; for (const object of objects) { - let SystemObject: SystemObject | null = null; - - if (object instanceof DBAPI.Unit && objectType === DBAPI.eSystemObjectType.eUnit) - SystemObject = await DBAPI.SystemObject.fetchFromUnitID(object.idUnit); - if (object instanceof DBAPI.Project && objectType === DBAPI.eSystemObjectType.eProject) - SystemObject = await DBAPI.SystemObject.fetchFromProjectID(object.idProject); - if (object instanceof DBAPI.Subject && objectType === DBAPI.eSystemObjectType.eSubject) - SystemObject = await DBAPI.SystemObject.fetchFromSubjectID(object.idSubject); - if (object instanceof DBAPI.Item && objectType === DBAPI.eSystemObjectType.eItem) - SystemObject = await DBAPI.SystemObject.fetchFromItemID(object.idItem); - if (object instanceof DBAPI.CaptureData && objectType === DBAPI.eSystemObjectType.eCaptureData) - SystemObject = await DBAPI.SystemObject.fetchFromCaptureDataID(object.idCaptureData); - if (object instanceof DBAPI.Model && objectType === DBAPI.eSystemObjectType.eModel) - SystemObject = await DBAPI.SystemObject.fetchFromModelID(object.idModel); - if (object instanceof DBAPI.Scene && objectType === DBAPI.eSystemObjectType.eScene) - SystemObject = await DBAPI.SystemObject.fetchFromSceneID(object.idScene); - if (object instanceof DBAPI.IntermediaryFile && objectType === DBAPI.eSystemObjectType.eIntermediaryFile) - SystemObject = await DBAPI.SystemObject.fetchFromIntermediaryFileID(object.idIntermediaryFile); - if (object instanceof DBAPI.ProjectDocumentation && objectType === DBAPI.eSystemObjectType.eProjectDocumentation) - SystemObject = await DBAPI.SystemObject.fetchFromProjectDocumentationID(object.idProjectDocumentation); - if (object instanceof DBAPI.Asset && objectType === DBAPI.eSystemObjectType.eAsset) - SystemObject = await DBAPI.SystemObject.fetchFromAssetID(object.idAsset); - if (object instanceof DBAPI.AssetVersion && objectType === DBAPI.eSystemObjectType.eAssetVersion) - SystemObject = await DBAPI.SystemObject.fetchFromAssetVersionID(object.idAssetVersion); - if (object instanceof DBAPI.Actor && objectType === DBAPI.eSystemObjectType.eActor) - SystemObject = await DBAPI.SystemObject.fetchFromActorID(object.idActor); - if (object instanceof DBAPI.Stakeholder && objectType === DBAPI.eSystemObjectType.eStakeholder) - SystemObject = await DBAPI.SystemObject.fetchFromStakeholderID(object.idStakeholder); - - const path: RepositoryPath = { - idSystemObject: SystemObject?.idSystemObject ?? 0, - name: await resolveNameForObjectType(SystemObject, objectType), - objectType - }; - paths.push(path); + let idObject: number | null = null; + + if (object instanceof DBAPI.Unit) + idObject = object.idUnit; + else if (object instanceof DBAPI.Project) + idObject = object.idProject; + else if (object instanceof DBAPI.Subject) + idObject = object.idSubject; + else if (object instanceof DBAPI.Item) + idObject = object.idItem; + else if (object instanceof DBAPI.CaptureData) + idObject = object.idCaptureData; + else if (object instanceof DBAPI.Model) + idObject = object.idModel; + else if (object instanceof DBAPI.Scene) + idObject = object.idScene; + else if (object instanceof DBAPI.IntermediaryFile) + idObject = object.idIntermediaryFile; + else if (object instanceof DBAPI.ProjectDocumentation) + idObject = object.idProjectDocumentation; + else if (object instanceof DBAPI.Asset) + idObject = object.idAsset; + else if (object instanceof DBAPI.AssetVersion) + idObject = object.idAssetVersion; + else if (object instanceof DBAPI.Actor) + idObject = object.idActor; + else if (object instanceof DBAPI.Stakeholder) + idObject = object.idStakeholder; + else { + LOG.error(`getSystemObjectDetails unable to determine type and id from ${JSON.stringify(object, H.Helpers.saferStringify)}`, LOG.LS.eGQL); + continue; + } + + const oID: DBAPI.ObjectIDAndType | undefined = { idObject, eObjectType: objectType }; + const SOI: DBAPI.SystemObjectInfo | undefined = await CACHE.SystemObjectCache.getSystemFromObjectID(oID); + if (SOI) { + const path: RepositoryPath = { + idSystemObject: SOI.idSystemObject, + name: await resolveNameForObject(SOI.idSystemObject), + objectType + }; + paths.push(path); + } else + LOG.error(`getSystemObjectDetails could not compute system object info from ${JSON.stringify(oID)}`, LOG.LS.eGQL); } + // LOG.info(`getSystemObjectDetails 1b-${DBAPI.eSystemObjectType[objectType]} ${objects.length}`, LOG.LS.eGQL); return paths; } -async function resolveNameForObjectType(systemObject: SystemObject | null, _objectType: DBAPI.eDBObjectType): Promise { - if (!systemObject) - return unknownName; - const name: string | undefined = await CACHE.SystemObjectCache.getObjectNameByID(systemObject.idSystemObject); +async function resolveNameForObject(idSystemObject: number): Promise { + const name: string | undefined = await CACHE.SystemObjectCache.getObjectNameByID(idSystemObject); return name || unknownName; } diff --git a/server/http/auth.ts b/server/http/auth.ts index deddb465c..6a6e64a73 100644 --- a/server/http/auth.ts +++ b/server/http/auth.ts @@ -1,7 +1,8 @@ import { Request } from 'express'; +import * as http from 'http'; const httpAuthRequired: boolean = (process.env.NODE_ENV === 'production'); -export function isAuthenticated(request: Request): boolean { - return (!httpAuthRequired || request.user) ? true : false; +export function isAuthenticated(request: Request | http.IncomingMessage): boolean { + return (!httpAuthRequired || request['user']) ? true : false; } diff --git a/server/http/index.ts b/server/http/index.ts index 40baf81e8..be907854d 100644 --- a/server/http/index.ts +++ b/server/http/index.ts @@ -9,13 +9,14 @@ import { logtest } from './routes/logtest'; import { solrindex, solrindexprofiled } from './routes/solrindex'; import { download } from './routes/download'; import { errorhandler } from './routes/errorhandler'; +import { WebDAVServer } from './routes/WebDAVServer'; import express, { Request } from 'express'; import cors from 'cors'; import { ApolloServer } from 'apollo-server-express'; -import bodyParser from 'body-parser'; import cookieParser from 'cookie-parser'; import { graphqlUploadExpress } from 'graphql-upload'; +import { v2 as webdav } from 'webdav-server'; /** * Singleton instance of HttpServer is retrieved via HttpServer.getInstance() @@ -43,11 +44,12 @@ export class HttpServer { return res; } + static bodyProcessorExclusions: RegExp = /^\/(?!download-wd).*$/; private async configureMiddlewareAndRoutes(): Promise { this.app.use(HttpServer.idRequestMiddleware); this.app.use(cors(authCorsConfig)); - this.app.use(bodyParser.json()); - this.app.use(bodyParser.urlencoded({ extended: true })); + this.app.use(HttpServer.bodyProcessorExclusions, express.json()); // do not extract webdav PUT bodies into request.body element + this.app.use(HttpServer.bodyProcessorExclusions, express.urlencoded({ extended: true })); this.app.use(cookieParser()); this.app.use(authSession); this.app.use(passport.initialize()); @@ -66,7 +68,16 @@ export class HttpServer { this.app.get('/solrindex', solrindex); this.app.get('/solrindexprofiled', solrindexprofiled); this.app.get('/download', download); + this.app.get('/download/*', HttpServer.idRequestMiddleware2); this.app.get('/download/*', download); + + const WDSV: WebDAVServer | null = await WebDAVServer.server(); + if (WDSV) { + this.app.use('/download-wd', HttpServer.idRequestMiddleware2); + this.app.use(webdav.extensions.express('/download-wd', WDSV.webdav())); + } else + LOG.error('HttpServer.configureMiddlewareAndRoutes failed to initialize WebDAV server', LOG.LS.eHTTP); + this.app.use(errorhandler); // keep last if (process.env.NODE_ENV !== 'test') { @@ -79,7 +90,10 @@ export class HttpServer { // creates a LocalStore populated with the next requestID private static idRequestMiddleware(req: Request, _res, next): void { - if (!req.originalUrl.startsWith('/auth/') && !req.originalUrl.startsWith('/graphql')) { + if (!req.originalUrl.startsWith('/auth/') && + !req.originalUrl.startsWith('/graphql') && + !req.originalUrl.startsWith('/download/') && + !req.originalUrl.startsWith('/download-wd/')) { const user = req['user']; const idUser = user ? user['idUser'] : undefined; ASL.run(new LocalStore(true, idUser), () => { diff --git a/server/http/routes/DownloaderParser.ts b/server/http/routes/DownloaderParser.ts new file mode 100644 index 000000000..f3893976c --- /dev/null +++ b/server/http/routes/DownloaderParser.ts @@ -0,0 +1,286 @@ +import * as DBAPI from '../../db'; +import * as LOG from '../../utils/logger'; +import * as H from '../../utils/helpers'; + +import { ParsedQs, parse } from 'qs'; + +export enum eDownloadMode { + eAssetVersion, + eAsset, + eSystemObject, + eSystemObjectVersion, + eWorkflow, + eWorkflowReport, + eWorkflowSet, + eJobRun, + eUnknown +} + +export interface DownloaderParserResults { + success: boolean; + statusCode?: number; // undefined means 200 + matchedPartialPath?: string; // when set, indicates the parser matched the specified partial path, typically for a system object that has subelements (such as a scene and its articles) + message?: string; // when set, we ignore processing of items below + assetVersion?: DBAPI.AssetVersion; + assetVersions?: DBAPI.AssetVersion[]; + WFReports?: DBAPI.WorkflowReport[]; + jobRun?: DBAPI.JobRun; +} + +export class DownloaderParser { + private eMode: eDownloadMode = eDownloadMode.eUnknown; + private idAssetVersion: number | null = null; + private idAsset: number | null = null; + private idSystemObject: number | null = null; + private idSystemObjectVersion: number | null = null; + + private idWorkflow: number | null = null; + private idWorkflowReport: number | null = null; + private idWorkflowSet: number | null = null; + private idJobRun: number | null = null; + + private systemObjectPath: string | null = null; // path of asset (e.g. /FOO/BAR) to be downloaded when accessed via e.g. /download/idSystemObject-ID/FOO/BAR + private fileMap: Map = new Map(); // Map of asset files path -> asset version + + private rootURL: string; + private requestPath: string; + private requestQuery?: ParsedQs; + private regexDownload: RegExp; + + constructor(rootURL: string, requestPath: string, requestQuery?: ParsedQs) { + this.rootURL = rootURL; + this.requestPath = requestPath; + this.requestQuery = requestQuery; + this.regexDownload = new RegExp(`${rootURL}/idSystemObject-(\\d*)(/.*)?`, 'i'); + } + + get eModeV(): eDownloadMode { return this.eMode; } + get idAssetVersionV(): number | null { return this.idAssetVersion; } + get idAssetV(): number | null { return this.idAsset; } + get idSystemObjectV(): number | null { return this.idSystemObject; } + get idSystemObjectVersionV(): number | null { return this.idSystemObjectVersion; } + + get idWorkflowV(): number | null { return this.idWorkflow; } + get idWorkflowReportV(): number | null { return this.idWorkflowReport; } + get idWorkflowSetV(): number | null { return this.idWorkflowSet; } + get idJobRunV(): number | null { return this.idJobRun; } + + get systemObjectPathV(): string | null { return this.systemObjectPath; } + get fileMapV(): Map { return this.fileMap; } + + /** Returns success: false if arguments are invalid */ + async parseArguments(allowUnmatchedPaths?: boolean, collectPaths?: boolean): Promise { + // /download/idSystemObject-ID: Computes the assets attached to this system object. If just one, downloads it alone. If multiple, computes a zip and downloads that zip. + // /download/idSystemObject-ID/FOO/BAR: Computes the asset attached to this system object, found at the path /FOO/BAR. + let idSystemObjectU: string | string[] | ParsedQs | ParsedQs[] | undefined = undefined; + let matchedPartialPath: string | undefined = undefined; + + const downloadMatch: RegExpMatchArray | null = this.requestPath.match(this.regexDownload); + if (downloadMatch && downloadMatch.length >= 2) { + idSystemObjectU = downloadMatch[1]; + if (downloadMatch.length >= 3) + this.systemObjectPath = downloadMatch[2]; + } + + if (!this.requestQuery) + this.requestQuery = parse(this.requestPath); + + if (!idSystemObjectU) + idSystemObjectU = this.requestQuery.idSystemObject; + // LOG.info(`DownloadParser.parseArguments(${this.requestPath}), idSystemObjectU = ${idSystemObjectU}`, LOG.LS.eHTTP); + + const idSystemObjectVersionU = this.requestQuery.idSystemObjectVersion; + const idAssetU = this.requestQuery.idAsset; + const idAssetVersionU = this.requestQuery.idAssetVersion; + const idWorkflowU = this.requestQuery.idWorkflow; + const idWorkflowReportU = this.requestQuery.idWorkflowReport; + const idWorkflowSetU = this.requestQuery.idWorkflowSet; + const idJobRunU = this.requestQuery.idJobRun; + + const urlParamCount: number = (idSystemObjectU ? 1 : 0) + (idSystemObjectVersionU ? 1 : 0) + (idAssetU ? 1 : 0) + + (idAssetVersionU ? 1 : 0) + (idWorkflowU ? 1 : 0) + (idWorkflowReportU ? 1 : 0) + (idWorkflowSetU ? 1 : 0) + + (idJobRunU ? 1 : 0); + if (urlParamCount != 1) { + LOG.error(`DownloadParser called with ${urlParamCount} parameters, expected 1`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + + if (idAssetVersionU) { + this.idAssetVersion = H.Helpers.safeNumber(idAssetVersionU); + if (!this.idAssetVersion) { + LOG.error(`${this.rootURL}?idAssetVersion=${idAssetVersionU}, invalid parameter`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + this.eMode = eDownloadMode.eAssetVersion; + + const assetVersion: DBAPI.AssetVersion | null = await DBAPI.AssetVersion.fetch(this.idAssetVersion!); // eslint-disable-line @typescript-eslint/no-non-null-assertion + if (!assetVersion) { + LOG.error(`${this.rootURL}?idAssetVersion=${this.idAssetVersion} invalid parameter`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + return { success: true, assetVersion }; + } + + if (idAssetU) { + this.idAsset = H.Helpers.safeNumber(idAssetU); + if (!this.idAsset) { + LOG.error(`${this.rootURL}?idAsset=${idAssetU}, invalid parameter`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + this.eMode = eDownloadMode.eAsset; + + const assetVersion: DBAPI.AssetVersion | null = await DBAPI.AssetVersion.fetchLatestFromAsset(this.idAsset!); // eslint-disable-line @typescript-eslint/no-non-null-assertion + if (!assetVersion) { + LOG.error(`${this.rootURL}?idAsset=${this.idAsset} unable to fetch asset version`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + return { success: true, assetVersion }; // this.emitDownload(assetVersion); + } + + if (idSystemObjectU) { + this.idSystemObject = H.Helpers.safeNumber(idSystemObjectU); + if (!this.idSystemObject) { + LOG.error(`${this.rootURL}?idSystemObject=${idSystemObjectU} invalid parameter`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + this.eMode = eDownloadMode.eSystemObject; + + const assetVersions: DBAPI.AssetVersion[] | null = await DBAPI.AssetVersion.fetchLatestFromSystemObject(this.idSystemObject!); // eslint-disable-line @typescript-eslint/no-non-null-assertion + if (!assetVersions) { + LOG.error(`${this.reconstructSystemObjectLink()} unable to fetch asset versions`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + if (assetVersions.length == 0) + return { success: true, message: `No Assets are connected to idSystemObject ${this.idSystemObject}` }; + + // if we don't have a system object path, return the zip of all assets + if (!this.systemObjectPath || this.systemObjectPath === '/') + return { success: true, assetVersions }; // await this.emitDownloadZip(assetVersions); + + // otherwise, find the specified asset by path + const pathToMatch: string = this.systemObjectPath.toLowerCase(); + let assetVersionMatch: DBAPI.AssetVersion | null = null; + for (const assetVersion of assetVersions) { + const asset: DBAPI.Asset | null = await DBAPI.Asset.fetch(assetVersion.idAsset); + if (!asset) { + LOG.error(`${this.reconstructSystemObjectLink()} unable to fetch asset from assetVersion ${JSON.stringify(assetVersion, H.Helpers.saferStringify)}`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + + const pathAssetVersion: string = ((asset.FilePath !== '' && asset.FilePath !== '.') ? `/${asset.FilePath}` : '') + `/${assetVersion.FileName}`; + const pathAssetVersionNorm: string = pathAssetVersion.toLowerCase(); + if (pathToMatch === pathAssetVersionNorm) { + assetVersionMatch = assetVersion; + if (!collectPaths) + break; + } else { + if (pathAssetVersionNorm.startsWith(pathToMatch)) + matchedPartialPath = pathToMatch; + } + this.fileMap.set(pathAssetVersion, assetVersion); + } + + if (assetVersionMatch) + return { success: true, assetVersion: assetVersionMatch }; + + if (!allowUnmatchedPaths) { + LOG.error(`${this.reconstructSystemObjectLink()} unable to find assetVersion with path ${pathToMatch}`, LOG.LS.eHTTP); + return this.recordStatus(404); + } else { + LOG.info(`${this.reconstructSystemObjectLink()} unable to find assetVersion with path ${pathToMatch}`, LOG.LS.eHTTP); + return { success: true, matchedPartialPath }; // this.emitDownload(assetVersion); + } + } + + if (idSystemObjectVersionU) { + this.idSystemObjectVersion = H.Helpers.safeNumber(idSystemObjectVersionU); + if (!this.idSystemObjectVersion) { + LOG.error(`${this.rootURL}?idSystemObjectVersion=${idSystemObjectVersionU} invalid parameter`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + this.eMode = eDownloadMode.eSystemObjectVersion; + + const assetVersions: DBAPI.AssetVersion[] | null = await DBAPI.AssetVersion.fetchFromSystemObjectVersion(this.idSystemObjectVersion!); // eslint-disable-line @typescript-eslint/no-non-null-assertion + if (!assetVersions) { + LOG.error(`${this.rootURL}?idSystemObjectVersion=${this.idSystemObjectVersion} unable to fetch asset versions`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + if (assetVersions.length == 0) + return { success: true, message: `No Assets are connected to idSystemObjectVersion ${this.idSystemObjectVersion}` }; + return { success: true, assetVersions }; // await this.emitDownloadZip(assetVersions); + } + + if (idWorkflowU) { + this.idWorkflow = H.Helpers.safeNumber(idWorkflowU); + if (!this.idWorkflow) { + LOG.error(`${this.rootURL}?idWorkflow=${idWorkflowU} invalid parameter`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + this.eMode = eDownloadMode.eWorkflow; + + const WFReports: DBAPI.WorkflowReport[] | null = await DBAPI.WorkflowReport.fetchFromWorkflow(this.idWorkflow!); // eslint-disable-line @typescript-eslint/no-non-null-assertion + if (!WFReports || WFReports.length === 0) { + LOG.error(`${this.rootURL}?idWorkflow=${this.idWorkflow} invalid parameter`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + return { success: true, WFReports }; // this.emitDownloadReports(WFReports); + } + + if (idWorkflowReportU) { + this.idWorkflowReport = H.Helpers.safeNumber(idWorkflowReportU); + if (!this.idWorkflowReport) { + LOG.error(`${this.rootURL}?idWorkflowReport=${idWorkflowReportU} invalid parameter`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + this.eMode = eDownloadMode.eWorkflowReport; + + const WFReport: DBAPI.WorkflowReport | null = await DBAPI.WorkflowReport.fetch(this.idWorkflowReport!); // eslint-disable-line @typescript-eslint/no-non-null-assertion + if (!WFReport) { + LOG.error(`${this.rootURL}?idWorkflowReport=${this.idWorkflowReport} invalid parameter`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + return { success: true, WFReports: [WFReport] }; // this.emitDownloadReports([WFReport]); + } + + if (idWorkflowSetU) { + this.idWorkflowSet = H.Helpers.safeNumber(idWorkflowSetU); + if (!this.idWorkflowSet) { + LOG.error(`${this.rootURL}?idWorkflowSet=${idWorkflowSetU} invalid parameter`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + this.eMode = eDownloadMode.eWorkflowSet; + + const WFReports: DBAPI.WorkflowReport[] | null = await DBAPI.WorkflowReport.fetchFromWorkflowSet(this.idWorkflowSet!); // eslint-disable-line @typescript-eslint/no-non-null-assertion + if (!WFReports || WFReports.length === 0) { + LOG.error(`${this.rootURL}?idWorkflowSet=${this.idWorkflowSet} invalid parameter`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + return { success: true, WFReports }; // this.emitDownloadReports(WFReports); + } + + if (idJobRunU) { + this.idJobRun = H.Helpers.safeNumber(idJobRunU); + if (!this.idJobRun) { + LOG.error(`${this.rootURL}?idJobRun=${idJobRunU} invalid parameter`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + this.eMode = eDownloadMode.eJobRun; + + const jobRun: DBAPI.JobRun | null = await DBAPI.JobRun.fetch(this.idJobRun!); // eslint-disable-line @typescript-eslint/no-non-null-assertion + if (!jobRun) { + LOG.error(`${this.rootURL}?idJobRun=${this.idJobRun} invalid parameter`, LOG.LS.eHTTP); + return this.recordStatus(404); + } + return { success: true, jobRun }; // this.emitDownloadJobRun(jobRun); + } + return this.recordStatus(404); + } + + private recordStatus(statusCode: number, message?: string | undefined): DownloaderParserResults { + return { success: false, statusCode, message }; + } + + reconstructSystemObjectLink(): string { + return (this.systemObjectPath) ? `${this.rootURL}/idSystemObject-${this.idSystemObject}${this.systemObjectPath}` :`/download?idSystemObject=${this.idSystemObject}`; + } +} diff --git a/server/http/routes/WebDAVServer.ts b/server/http/routes/WebDAVServer.ts new file mode 100644 index 000000000..2ec596705 --- /dev/null +++ b/server/http/routes/WebDAVServer.ts @@ -0,0 +1,522 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-ts-comment */ +import * as LOG from '../../utils/logger'; +import * as STORE from '../../storage/interface'; +import * as DBAPI from '../../db'; +import * as CACHE from '../../cache'; +import * as H from '../../utils/helpers'; +import { ASL, LocalStore } from '../../utils/localStore'; +import { isAuthenticated } from '../auth'; +import { DownloaderParser, DownloaderParserResults } from './DownloaderParser'; + +import { Readable, Writable, PassThrough /* , Duplex, Transform, TransformOptions, TransformCallback */ } from 'stream'; + +import { v2 as webdav } from 'webdav-server'; +import mime from 'mime'; // const mime = require('mime-types'); // can't seem to make this work using "import * as mime from 'mime'"; subsequent calls to mime.lookup freeze! +import path from 'path'; + +//////////////////////////////////////////////////////////////////////////////// + +export class WebDAVServer { + protected server: webdav.WebDAVServer; + protected auth: webdav.HTTPAuthentication; + protected WDFS: WebDAVFileSystem | null = null; + + private static _webDavServer: WebDAVServer | null = null; + + static async server(): Promise { + if (!WebDAVServer._webDavServer) { + const WDS: WebDAVServer = new WebDAVServer(); + if (!await WDS.initializeFileSystem()) + return null; + WebDAVServer._webDavServer = WDS; + } + return WebDAVServer._webDavServer; + } + + /** Needed for express integration; not intended for client use */ + public webdav(): webdav.WebDAVServer { + return this.server; + } + + public async initializeFileSystem(): Promise { + if (this.WDFS) + return true; + + const WDFS: WebDAVFileSystem = new WebDAVFileSystem(); + + let ret: boolean = true; + this.server.setFileSystem('/', WDFS, success => { + if (success) + this.WDFS = WDFS; + else { + LOG.info('WebDAVServer.initializeFileSystem failed to set WebDAV file system', LOG.LS.eHTTP); + ret = false; + } + }); + + return ret; + } + + private constructor() { + this.auth = new WebDAVAuthentication(); + this.server = new webdav.WebDAVServer({ + httpAuthentication: this.auth, + // respondWithPaths: true, + // port: webDAVPort + }); + this.server.beforeRequest((ctx, next) => { + LOG.info(`WEBDAV ${ctx.request.method} ${ctx.request.url} START`, LOG.LS.eHTTP); + next(); + }); + this.server.afterRequest((ctx, next) => { + // Display the method, the URI, the returned status code and the returned message + LOG.info(`WEBDAV ${ctx.request.method} ${ctx.request.url} END ${ctx.response.statusCode} ${ctx.response.statusMessage}`, LOG.LS.eHTTP); + next(); + }); + } +} + +class WebDAVAuthentication implements webdav.HTTPAuthentication { + askForAuthentication(_ctx: webdav.HTTPRequestContext): { [headeName: string]: string; } { + return { }; + } + + async getUser(ctx: webdav.HTTPRequestContext, callback: (error: Error, user?: webdav.IUser) => void): Promise { + if (isAuthenticated(ctx.request)) { + const LS: LocalStore | undefined = ASL.getStore(); + const idUser: number | undefined | null = LS?.idUser; + const user: DBAPI.User | undefined = idUser ? await CACHE.UserCache.getUser(idUser) : undefined; + if (user) { + // LOG.info(`WEBDAV ${ctx.request.url} authenticated for UserID ${user.idUser}`, LOG.LS.eHTTP); + // @ts-ignore: ts(2345) + callback(null, { uid: user.idUser.toString(), username: user.Name }); + return; + } + } + + LOG.error(`WEBDAV ${ctx.request.url} not authenticated`, LOG.LS.eHTTP); + callback(new Error('Not Authenticated'), { uid: '', username: 'Default', isDefaultUser: true }); + return; + } + +} + +// Adapted from https://github.com/OpenMarshal/npm-WebDAV-Server-Types/blob/master/repositories/http/HTTPFileSystem.ts +class WebDAVSerializer implements webdav.FileSystemSerializer { + uid(): string { return 'Packrat-WebDAVSerializer-v1.0.0'; } + + serialize(fs: WebDAVFileSystem, callback: webdav.ReturnCallback): void { + LOG.info('WebDAVSerializer.serialize', LOG.LS.eHTTP); + callback(undefined, { fs }); + } + + unserialize(serializedData: any, callback: webdav.ReturnCallback): void { + LOG.info('WebDAVSerializer.unserialize', LOG.LS.eHTTP); + const fs = new WebDAVFileSystem(serializedData); + callback(undefined, fs); + } +} + +class WebDAVManagers { + propertyManager: webdav.LocalPropertyManager; // The name of this member is important as it matches method names in webdav.FileSystem; don't change it! + lockManager: webdav.LocalLockManager; // The name of this member is important as it matches method names in webdav.FileSystem; don't change it! + + constructor() { + this.propertyManager = new webdav.LocalPropertyManager(); + this.lockManager = new webdav.LocalLockManager(); + } +} + +class FileSystemResource { + type: webdav.ResourceType; // The name of this member is important as it matches method names in webdav.FileSystem; don't change it! + size: number | undefined; // The name of this member is important as it matches method names in webdav.FileSystem; don't change it! + readDir: string[] | undefined; // The name of this member is important as it matches method names in webdav.FileSystem; don't change it! + etag: string; // The name of this member is important as it matches method names in webdav.FileSystem; don't change it! + lastModifiedDate: number; // The name of this member is important as it matches method names in webdav.FileSystem; don't change it! + creationDate: number; // The name of this member is important as it matches method names in webdav.FileSystem; don't change it! + + private resourceSet: Set = new Set(); + cacheDate: Date = new Date(); + + constructor(resourceType: webdav.ResourceType, fileSize: number | bigint | undefined, hash: string, lastModifiedDate, creationDate) { + this.type = resourceType; + this.setSize(fileSize); + this.readDir = undefined; + this.etag = hash; + this.lastModifiedDate = lastModifiedDate; + this.creationDate = creationDate; + } + + /** Returns true if new item is added, and false if item has already been added */ + addChild(childPath: string): boolean { + if (this.resourceSet.has(childPath)) + return false; + this.resourceSet.add(childPath); + if (!this.readDir) + this.readDir = []; + this.readDir.push(childPath); + return true; + } + + setSize(fileSize: number | bigint | undefined): void { + try { + this.size = fileSize !== undefined ? Number(fileSize) : undefined; + } catch { + this.size = undefined; + } + } +} + +// Adapted from https://github.com/OpenMarshal/npm-WebDAV-Server-Types/blob/master/repositories/http/HTTPFileSystem.ts +class WebDAVFileSystem extends webdav.FileSystem { + resources: Map; + managers: Map; + // private static lockExclusiveWrite: webdav.LockKind = new webdav.LockKind(webdav.LockScope.Exclusive, webdav.LockType.Write, 300); + + constructor(WDFS?: WebDAVFileSystem) { + super(new WebDAVSerializer()); + + this.resources = WDFS ? WDFS.resources : new Map(); + this.managers = WDFS ? WDFS.managers : new Map(); + this.resources.set('/', new FileSystemResource(webdav.ResourceType.Directory, undefined, '/', 0, 0)); + this.managers.set('/', new WebDAVManagers()); + } + + private getManagers(pathS: string): WebDAVManagers { + let managers: WebDAVManagers | undefined = this.managers.get(pathS); + if (!managers) { + managers = new WebDAVManagers(); + this.managers.set(pathS, managers); + } + return managers; + } + + private getResource(pathS: string): FileSystemResource | undefined { + let resource: FileSystemResource | undefined = this.resources.get(pathS); + if (resource && resource.type === webdav.ResourceType.File) { + const age: number = new Date().getTime() - resource.cacheDate.getTime(); + if (age >= 10000) { + resource = undefined; + this.resources.delete(pathS); + } + } + return resource; + } + + /** Returns true if caller should try again, calling callback when done */ + protected async getPropertyFromResource(pathWD: webdav.Path, propertyName: string, allowMissing: boolean, callback: webdav.ReturnCallback): Promise { + try { + const pathS: string = pathWD.toString(); + const logPrefix: string = `WebDAVFileSystem._${propertyName}(${pathS})`; + let resource: FileSystemResource | undefined = this.getResource(pathS); + if (!resource) { + const DP: DownloaderParser = new DownloaderParser('', pathS); + const DPResults: DownloaderParserResults = await DP.parseArguments(true, true); // true, true -> collect all paths + if (!DPResults.success || !DP.idSystemObjectV) { + const error: string = `${logPrefix} failed: ${DPResults.statusCode}${DPResults.message ? ' (' + DPResults.message + ')' : ''}`; + LOG.error(error, LOG.LS.eHTTP); + callback(new Error(error)); + return; + } + + const prefix: string = `/idSystemObject-${DP.idSystemObjectV}`; + for (const [ fileName, assetVersion ] of DP.fileMapV) { + const fileNamePrefixed: string = `${prefix}${fileName}`; + // LOG.info(`${logPrefix} considering ${fileNamePrefixed}`, LOG.LS.eHTTP); + + const utcMS: number = assetVersion.DateCreated.getTime(); + let resLookup: FileSystemResource | undefined = this.getResource(fileNamePrefixed); + if (!resLookup) { + resLookup = new FileSystemResource(webdav.ResourceType.File, assetVersion.StorageSize, assetVersion.StorageHash, utcMS, utcMS); + this.resources.set(fileNamePrefixed, resLookup); + } + + if (fileNamePrefixed === pathS) { + // LOG.info(`${logPrefix} FOUND ${fileNamePrefixed}`, LOG.LS.eHTTP); + resource = resLookup; + } + + this.addParentResources(fileNamePrefixed, utcMS); + } + + if (!resource) { + if (!allowMissing) { + const error: string = `${logPrefix} failed to compute resource`; + LOG.error(error, LOG.LS.eHTTP); + callback(new Error(error)); + return; + } + LOG.info(`${logPrefix} failed to compute resource, adding`, LOG.LS.eHTTP); + + const utcMS: number = (new Date()).getTime(); + resource = new FileSystemResource(webdav.ResourceType.File, 0, '', utcMS, utcMS); + this.resources.set(pathS, resource); + } + } + /* + if (propertyName === 'type') + LOG.info(`${logPrefix}: ${resource.type === webdav.ResourceType.Directory ? 'Directory' : 'File'}`, LOG.LS.eHTTP); + else if (propertyName === 'readDir') + LOG.info(`${logPrefix}: DIR Contents ${JSON.stringify(resource.readDir)}`, LOG.LS.eHTTP); + else if (propertyName === 'etag' || propertyName === 'creationDate' || propertyName === 'lastModifiedDate' || propertyName === 'size') + LOG.info(`${logPrefix}: ${resource[propertyName]}`, LOG.LS.eHTTP); + else + LOG.info(logPrefix, LOG.LS.eHTTP); + */ + if (propertyName !== 'create') + callback(undefined, resource[propertyName]); + else + callback(undefined); + } catch (error) { + LOG.error(`WebDAVFileSystem.getPropertyFromResource(${pathWD})`, LOG.LS.eHTTP, error); + } + } + + private addParentResources(pathS: string, utcMS: number): void { + let count: number = 0; + let dirWalker: string = pathS; + while (count++ <= 100) { + const dir = path.posix.dirname(dirWalker); + if (!dir|| dir === '/') + break; + let resDirectory: FileSystemResource | undefined = this.getResource(dir); + if (!resDirectory) { + // LOG.info(`${logPrefix} recording DIR ${dir}`, LOG.LS.eHTTP); + resDirectory = new FileSystemResource(webdav.ResourceType.Directory, undefined, dir, utcMS, utcMS); // HERE: need a better hash than dir here + this.resources.set(dir, resDirectory); + } + + let childPath: string; + // let entryType: string; + if (count === 1) { // record file with parent directory + childPath = path.basename(pathS); + // entryType = 'FILE'; + } else { // record directory with parent directory + childPath = path.basename(dirWalker); + // entryType = 'DIR'; + } + resDirectory.addChild(childPath); + // if (resDirectory.addChild(childPath)) + // LOG.info(`${logPrefix} adding to DIR ${dir} ${entryType} ${childPath}`, LOG.LS.eHTTP); + dirWalker = dir; + } + } + + /* + private async setLock(pathWD: webdav.Path, ctx: webdav.RequestContext, callback: webdav.ReturnCallback): Promise { + const LM: webdav.ILockManagerAsync = await this.lockManagerAsync(ctx, pathWD); + const lock: webdav.Lock = new webdav.Lock(WebDAVFileSystem.lockExclusiveWrite, '', ''); + + try { + await LM.setLockAsync(lock); + } catch (error) { + LOG.error(`WebDAVFileSystem.setLock(${pathWD}) failed to acquire lock`, LOG.LS.eHTTP, error); + callback(error as Error); + return undefined; + } + LOG.info(`WebDAVFileSystem.setLock(${pathWD}): ${lock.uuid}`, LOG.LS.eHTTP); + return lock.uuid; + } + + private async removeLock(pathWD: webdav.Path, ctx: webdav.RequestContext, uuid: string): Promise { + const LM: webdav.ILockManagerAsync = await this.lockManagerAsync(ctx, pathWD); + const Locks: webdav.Lock[] = await LM.getLocksAsync(); + LOG.info(`WebDAVFileSystem.removeLock(${pathWD}, ${uuid}): current locks ${JSON.stringify(Locks)}`, LOG.LS.eHTTP); + const success: boolean = await LM.removeLockAsync(uuid); + LOG.info(`WebDAVFileSystem.removeLock(${pathWD}): ${uuid}${success ? '' : ' FAILED'}`, LOG.LS.eHTTP); + } + */ + async _openReadStream(pathWD: webdav.Path, _info: webdav.OpenReadStreamInfo, callback: webdav.ReturnCallback): Promise { + try { + const pathS: string = pathWD.toString(); + LOG.info(`WebDAVFileSystem._openReadStream(${pathS})`, LOG.LS.eHTTP); + + const DP: DownloaderParser = new DownloaderParser('', pathS); + const DPResults: DownloaderParserResults = await DP.parseArguments(); + if (!DPResults.success) { + const error: string = `WebDAVFileSystem._openReadStream(${pathS}) failed: ${DPResults.statusCode}${DPResults.message ? ' (' + DPResults.message + ')' : ''}`; + LOG.error(error, LOG.LS.eHTTP); + callback(new Error(error)); + return; + } + + if (!DPResults.assetVersion) { + const error: string = `WebDAVFileSystem._openReadStream(${pathS}) called without an assetVersion`; + LOG.error(error, LOG.LS.eHTTP); + callback(new Error(error)); + return; + } + + const res: STORE.ReadStreamResult = await STORE.AssetStorageAdapter.readAssetVersion(DPResults.assetVersion); + if (!res.success || !res.readStream) { + const error: string = `WebDAVFileSystem._openReadStream(${pathS}) idAssetVersion=${DPResults.assetVersion} unable to read from storage: ${res.error}`; + LOG.error(error, LOG.LS.eHTTP); + callback(new Error(error)); + return; + } + callback(undefined, (res.readStream as any) as Readable); + } catch (error) { + LOG.error(`WebDAVFileSystem._openReadStream(${pathWD})`, LOG.LS.eHTTP, error); + } + } + + async _openWriteStream(pathWD: webdav.Path, _info: webdav.OpenWriteStreamInfo, callback: webdav.ReturnCallback, callbackComplete: webdav.SimpleCallback): Promise { + try { + /* + const lockUUID: string | undefined = await this.setLock(pathWD, _info.context, callback); + if (lockUUID === undefined) + return; + */ + + const pathS: string = pathWD.toString(); + const DP: DownloaderParser = new DownloaderParser('', pathS); + const DPResults: DownloaderParserResults = await DP.parseArguments(); + if (!DPResults.success && !DP.idSystemObjectV) { + const error: string = `WebDAVFileSystem._openWriteStream(${pathS}) failed: ${DPResults.statusCode}${DPResults.message ? ' (' + DPResults.message + ')' : ''}`; + LOG.error(error, LOG.LS.eHTTP); + callback(new Error(error)); + // await this.removeLock(pathWD, info.context, lockUUID); + return; + } + + const SOP: DBAPI.SystemObjectPairs | null = (DP.idSystemObjectV) ? await DBAPI.SystemObjectPairs.fetch(DP.idSystemObjectV) : null; + const SOBased: DBAPI.SystemObjectBased | null = SOP ? SOP.SystemObjectBased : null; + if (!SOBased) { + const error: string = `WebDAVFileSystem._openWriteStream(${pathS}) failed: unable to fetch system object details with idSystemObject ${DP.idSystemObjectV}`; + LOG.error(error, LOG.LS.eHTTP); + callback(new Error(error)); + // await this.removeLock(pathWD, info.context, lockUUID); + return; + } + + const assetVersion: DBAPI.AssetVersion | undefined = DPResults.assetVersion; + const asset: DBAPI.Asset | null = assetVersion ? await DBAPI.Asset.fetch(assetVersion.idAsset) : null; + + const secondSlashIndex: number = pathS.indexOf('/', 1); // skip first slash with 1 + const FilePath: string = (secondSlashIndex >= 0) ? path.dirname(pathS.substring(secondSlashIndex + 1)) : ''; + const FileName: string = path.basename(pathS); + + let eVocab: CACHE.eVocabularyID = CACHE.eVocabularyID.eAssetAssetTypeOther; + if (FileName.toLowerCase().endsWith('.svx.json')) + eVocab = CACHE.eVocabularyID.eAssetAssetTypeScene; + else if (await CACHE.VocabularyCache.mapModelFileByExtensionID(FileName) !== undefined) + eVocab = CACHE.eVocabularyID.eAssetAssetTypeModelGeometryFile; + const VAssetType: DBAPI.Vocabulary | undefined = await CACHE.VocabularyCache.vocabularyByEnum(eVocab); + if (VAssetType === undefined) { + const error: string = `WebDAVFileSystem._openWriteStream(${pathS}) failed: unable to compute asset type for ${FileName}`; + LOG.error(error, LOG.LS.eHTTP); + callback(new Error(error)); + // await this.removeLock(pathWD, info.context, lockUUID); + return; + } + + LOG.info(`WebDAVFileSystem._openWriteStream(${pathS}), FileName ${FileName}, FilePath ${FilePath}, asset type ${CACHE.eVocabularyID[eVocab]}, SOBased ${JSON.stringify(SOBased, H.Helpers.saferStringify)}`, LOG.LS.eHTTP); + + const LS: LocalStore = await ASL.getOrCreateStore(); + const idUserCreator: number = LS?.idUser ?? 0; + const PT: PassThrough = new PassThrough(); + // PT.on('pipe', async () => { LOG.info(`WebDAVFileSystem._openWriteStream: (W) onPipe for ${asset ? JSON.stringify(asset, H.Helpers.saferStringify) : 'new asset'}`, LOG.LS.eHTTP); }); + // PT.on('unpipe', async () => { LOG.info(`WebDAVFileSystem._openWriteStream: (W) onUnPipe for ${asset ? JSON.stringify(asset, H.Helpers.saferStringify) : 'new asset'}`, LOG.LS.eHTTP); }); + // PT.on('close', async () => { LOG.info(`WebDAVFileSystem._openWriteStream: (W) onClose for ${asset ? JSON.stringify(asset, H.Helpers.saferStringify) : 'new asset'}`, LOG.LS.eHTTP); }); + PT.on('finish', async () => { + try { + LOG.info(`WebDAVFileSystem._openWriteStream: (W) onFinish for ${asset ? JSON.stringify(asset, H.Helpers.saferStringify) : 'new asset'}`, LOG.LS.eHTTP); + const ISI: STORE.IngestStreamOrFileInput = { + readStream: PT, + localFilePath: null, + asset, + FileName, + FilePath, + idAssetGroup: 0, + idVAssetType: VAssetType.idVocabulary, + allowZipCracking: false, + idUserCreator, + SOBased, + }; + const ISR: STORE.IngestStreamOrFileResult = await STORE.AssetStorageAdapter.ingestStreamOrFile(ISI); + if (!ISR.success) + LOG.error(`WebDAVFileSystem._openWriteStream(${pathS}) (W) onFinish failed to ingest new asset version: ${ISR.error}`, LOG.LS.eHTTP); + + const assetVersion: DBAPI.AssetVersion | null | undefined = ISR.assetVersion; + if (!assetVersion) { + LOG.error(`WebDAVFileSystem._openWriteStream(${pathS}) (W) onFinish failed to create new asset version`, LOG.LS.eHTTP); + // await this.removeLock(pathWD, info.context, lockUUID); + return; + } + + // Update WebDAV resource + const utcMS: number = assetVersion.DateCreated.getTime(); + let resource: FileSystemResource | undefined = this.getResource(pathS); + if (!resource) { + resource = new FileSystemResource(webdav.ResourceType.File, assetVersion.StorageSize, assetVersion.StorageHash, utcMS, utcMS); + this.resources.set(pathS, resource); + } else { + resource.setSize(assetVersion.StorageSize); + resource.etag = assetVersion.StorageHash; + resource.lastModifiedDate = utcMS; + } + + // Update WebDAV resource parent + this.addParentResources(pathS, utcMS); + // await this.removeLock(pathWD, info.context, lockUUID); + } catch (error) { + LOG.error(`WebDAVFileSystem._openWriteStream(${pathWD}) (W) onFinish`, LOG.LS.eHTTP, error); + } finally { + callbackComplete(undefined); + } + }); + + LOG.info('WebDAVFileSystem._openWriteStream callback()', LOG.LS.eHTTP); + callback(undefined, PT); + } catch (error) { + LOG.error(`WebDAVFileSystem._openWriteStream(${pathWD})`, LOG.LS.eHTTP, error); + } + } + + _mimeType(pathWD: webdav.Path, _info: webdav.MimeTypeInfo, callback: webdav.ReturnCallback): void { + const filePath: string = pathWD.toString(); + const fileName: string = path.basename(filePath); + const mimeType: string = mime.lookup(fileName) || 'application/octet-stream'; + + // LOG.info(`WebDAVFileSystem._mimeType(${filePath}): ${mimeType}`, LOG.LS.eHTTP); + callback(undefined, mimeType); + } + + _propertyManager(pathWD: webdav.Path, _info: webdav.PropertyManagerInfo, callback: webdav.ReturnCallback): void { + const WDM: WebDAVManagers = this.getManagers(pathWD.toString()); + callback(undefined, WDM.propertyManager); + } + + _lockManager(pathWD: webdav.Path, _info: webdav.LockManagerInfo, callback: webdav.ReturnCallback): void { + const WDM: WebDAVManagers = this.getManagers(pathWD.toString()); + callback(undefined, WDM.lockManager); + } + + async _type(pathWD: webdav.Path, _info: webdav.TypeInfo, callback: webdav.ReturnCallback): Promise { + await this.getPropertyFromResource(pathWD, 'type', true, callback); + } + + async _readDir(pathWD: webdav.Path, _info: webdav.ReadDirInfo, callback: webdav.ReturnCallback): Promise { + await this.getPropertyFromResource(pathWD, 'readDir', false, callback); + } + + async _size(pathWD: webdav.Path, _info: webdav.SizeInfo, callback: webdav.ReturnCallback): Promise { + await this.getPropertyFromResource(pathWD, 'size', false, callback); + } + + async _etag(pathWD: webdav.Path, _info: webdav.SizeInfo, callback: webdav.ReturnCallback): Promise { + await this.getPropertyFromResource(pathWD, 'etag', false, callback); + } + + async _creationDate(pathWD: webdav.Path, _info: webdav.SizeInfo, callback: webdav.ReturnCallback): Promise { + await this.getPropertyFromResource(pathWD, 'creationDate', false, callback); + } + + async _lastModifiedDate(pathWD: webdav.Path, _info: webdav.SizeInfo, callback: webdav.ReturnCallback): Promise { + await this.getPropertyFromResource(pathWD, 'lastModifiedDate', false, callback); + } + + async _create(pathWD: webdav.Path, _info: webdav.CreateInfo, callback: webdav.SimpleCallback): Promise { + await this.getPropertyFromResource(pathWD, 'create', true, callback); + } +} diff --git a/server/http/routes/download.ts b/server/http/routes/download.ts index f45b861a3..714835755 100644 --- a/server/http/routes/download.ts +++ b/server/http/routes/download.ts @@ -4,13 +4,15 @@ import * as LOG from '../../utils/logger'; import * as H from '../../utils/helpers'; import * as ZIP from '../../utils/zipStream'; import * as STORE from '../../storage/interface'; +import { DownloaderParser, DownloaderParserResults, eDownloadMode } from './DownloaderParser'; import { isAuthenticated } from '../auth'; import { Request, Response } from 'express'; -import { ParsedQs } from 'qs'; import mime from 'mime'; // const mime = require('mime-types'); // can't seem to make this work using "import * as mime from 'mime'"; subsequent calls to mime.lookup freeze! import path from 'path'; +const rootURL: string = '/download'; + /** Used to provide download access to assets and reports. Access with one of the following URL patterns: * ASSETS: * /download?idAssetVersion=ID: Downloads the specified version of the specified asset @@ -31,268 +33,70 @@ export async function download(request: Request, response: Response): Promise { if (!isAuthenticated(this.request)) { - LOG.error('/download not authenticated', LOG.LS.eHTTP); + LOG.error(`${rootURL} not authenticated`, LOG.LS.eHTTP); return this.sendError(403); } - if (!this.parseArguments()) - return false; - - switch (this.eMode) { - case eDownloadMode.eAssetVersion: { - const assetVersion: DBAPI.AssetVersion | null = await DBAPI.AssetVersion.fetch(this.idAssetVersion!); // eslint-disable-line @typescript-eslint/no-non-null-assertion - if (!assetVersion) { - LOG.error(`/download?idAssetVersion=${this.idAssetVersion} invalid parameter`, LOG.LS.eHTTP); - return this.sendError(404); - } - return this.emitDownload(assetVersion); - } - - case eDownloadMode.eAsset: { - const assetVersion: DBAPI.AssetVersion | null = await DBAPI.AssetVersion.fetchLatestFromAsset(this.idAsset!); // eslint-disable-line @typescript-eslint/no-non-null-assertion - if (!assetVersion) { - LOG.error(`/download?idAsset=${this.idAsset} unable to fetch asset version`, LOG.LS.eHTTP); - return this.sendError(404); - } - return await this.emitDownload(assetVersion); - } - - case eDownloadMode.eSystemObject: { - const assetVersions: DBAPI.AssetVersion[] | null = await DBAPI.AssetVersion.fetchLatestFromSystemObject(this.idSystemObject!); // eslint-disable-line @typescript-eslint/no-non-null-assertion - if (!assetVersions) { - LOG.error(`${this.reconstructSystemObjectLink()} unable to fetch asset versions`, LOG.LS.eHTTP); - return this.sendError(404); - } - if (assetVersions.length == 0) { - this.response.send(`No Assets are connected to idSystemObject ${this.idSystemObject}`); - return true; - } + const DPResults: DownloaderParserResults = await this.downloaderParser.parseArguments(); + if (!DPResults.success) + return this.sendError(DPResults.statusCode ?? 200, DPResults.message); - // if we don't have a system object path, return the zip of all assets - if (!this.systemObjectPath || this.systemObjectPath === '/') - return await this.emitDownloadZip(assetVersions); + if (DPResults.message) { + this.response.send(DPResults.message); + return true; + } - // otherwise, find the specified asset by path - const pathToMatch: string = this.systemObjectPath.toLowerCase(); - let pathsConsidered: string = '\n'; - for (const assetVersion of assetVersions) { - const asset: DBAPI.Asset | null = await DBAPI.Asset.fetch(assetVersion.idAsset); - if (!asset) { - LOG.error(`${this.reconstructSystemObjectLink()} unable to fetch asset from assetVersion ${JSON.stringify(assetVersion, H.Helpers.saferStringify)}`, LOG.LS.eHTTP); - return this.sendError(404); - } - const pathAssetVersion: string = (((asset.FilePath !== '' && asset.FilePath !== '.') ? `/${asset.FilePath}` : '') - + `/${assetVersion.FileName}`).toLowerCase(); - if (pathToMatch === pathAssetVersion) - return this.emitDownload(assetVersion); - else - pathsConsidered += `${pathAssetVersion}\n`; - } + switch (this.downloaderParser.eModeV) { + case eDownloadMode.eAssetVersion: + return (DPResults.assetVersion) ? await this.emitDownload(DPResults.assetVersion) : this.sendError(404); - LOG.error(`${this.reconstructSystemObjectLink()} unable to find assetVersion with path ${pathToMatch} from ${pathsConsidered}`, LOG.LS.eHTTP); - return this.sendError(404); - } + case eDownloadMode.eAsset: + return (DPResults.assetVersion) + ? await this.emitDownload(DPResults.assetVersion) + : this.sendError(404, `${rootURL}?idAsset=${this.downloaderParser.idAssetV} unable to fetch asset version`); - case eDownloadMode.eSystemObjectVersion: { - const assetVersions: DBAPI.AssetVersion[] | null = await DBAPI.AssetVersion.fetchFromSystemObjectVersion(this.idSystemObjectVersion!); // eslint-disable-line @typescript-eslint/no-non-null-assertion - if (!assetVersions) { - LOG.error(`/download?idSystemObjectVersion=${this.idSystemObjectVersion} unable to fetch asset versions`, LOG.LS.eHTTP); - return this.sendError(404); - } - if (assetVersions.length == 0) { - this.response.send(`No Assets are connected to idSystemObjectVersion ${this.idSystemObjectVersion}`); + case eDownloadMode.eSystemObject: + if (DPResults.assetVersions) + return await this.emitDownloadZip(DPResults.assetVersions); + else if (DPResults.assetVersion) + return await this.emitDownload(DPResults.assetVersion); + else return true; - } - return await this.emitDownloadZip(assetVersions); - } - case eDownloadMode.eWorkflow: { - const WFReports: DBAPI.WorkflowReport[] | null = await DBAPI.WorkflowReport.fetchFromWorkflow(this.idWorkflow!); // eslint-disable-line @typescript-eslint/no-non-null-assertion - if (!WFReports || WFReports.length === 0) { - LOG.error(`/download?idWorkflow=${this.idWorkflow} invalid parameter`, LOG.LS.eHTTP); - return this.sendError(404); - } - return this.emitDownloadReports(WFReports); - } + case eDownloadMode.eSystemObjectVersion: + if (DPResults.assetVersions) + return await this.emitDownloadZip(DPResults.assetVersions); + return true; - case eDownloadMode.eWorkflowReport: { - const WFReport: DBAPI.WorkflowReport | null = await DBAPI.WorkflowReport.fetch(this.idWorkflowReport!); // eslint-disable-line @typescript-eslint/no-non-null-assertion - if (!WFReport) { - LOG.error(`/download?idWorkflowReport=${this.idWorkflowReport} invalid parameter`, LOG.LS.eHTTP); - return this.sendError(404); - } - return this.emitDownloadReports([WFReport]); - } - - case eDownloadMode.eWorkflowSet: { - const WFReports: DBAPI.WorkflowReport[] | null = await DBAPI.WorkflowReport.fetchFromWorkflowSet(this.idWorkflowSet!); // eslint-disable-line @typescript-eslint/no-non-null-assertion - if (!WFReports || WFReports.length === 0) { - LOG.error(`/download?idWorkflowSet=${this.idWorkflowSet} invalid parameter`, LOG.LS.eHTTP); - return this.sendError(404); - } - return this.emitDownloadReports(WFReports); - } + case eDownloadMode.eWorkflow: + case eDownloadMode.eWorkflowReport: + case eDownloadMode.eWorkflowSet: + return DPResults.WFReports ? await this.emitDownloadReports(DPResults.WFReports) : this.sendError(404); - case eDownloadMode.eJobRun: { - const jobRun: DBAPI.JobRun | null = await DBAPI.JobRun.fetch(this.idJobRun!); // eslint-disable-line @typescript-eslint/no-non-null-assertion - if (!jobRun) { - LOG.error(`/download?idJobRun=${this.idJobRun} invalid parameter`, LOG.LS.eHTTP); - return this.sendError(404); - } - return this.emitDownloadJobRun(jobRun); - } + case eDownloadMode.eJobRun: + return DPResults.jobRun ? await this.emitDownloadJobRun(DPResults.jobRun) : this.sendError(404); } return this.sendError(404); } - /** Returns false if arguments are invalid */ - private parseArguments(): boolean { - // /download/idSystemObject-ID: Computes the assets attached to this system object. If just one, downloads it alone. If multiple, computes a zip and downloads that zip. - // /download/idSystemObject-ID/FOO/BAR: Computes the asset attached to this system object, found at the path /FOO/BAR. - let idSystemObjectU: string | string[] | ParsedQs | ParsedQs[] | undefined = undefined; - - const downloadMatch: RegExpMatchArray | null = this.request.path.match(Downloader.regexDownload); - if (downloadMatch && downloadMatch.length >= 2) { - idSystemObjectU = downloadMatch[1]; - if (downloadMatch.length >= 3) - this.systemObjectPath = downloadMatch[2]; - } - - if (!idSystemObjectU) - idSystemObjectU = this.request.query.idSystemObject; - const idSystemObjectVersionU = this.request.query.idSystemObjectVersion; - const idAssetU = this.request.query.idAsset; - const idAssetVersionU = this.request.query.idAssetVersion; - const idWorkflowU = this.request.query.idWorkflow; - const idWorkflowReportU = this.request.query.idWorkflowReport; - const idWorkflowSetU = this.request.query.idWorkflowSet; - const idJobRunU = this.request.query.idJobRun; - - const urlParamCount: number = (idSystemObjectU ? 1 : 0) + (idSystemObjectVersionU ? 1 : 0) + (idAssetU ? 1 : 0) - + (idAssetVersionU ? 1 : 0) + (idWorkflowU ? 1 : 0) + (idWorkflowReportU ? 1 : 0) + (idWorkflowSetU ? 1 : 0) - + (idJobRunU ? 1 : 0); - if (urlParamCount != 1) { - LOG.error(`/download called with ${urlParamCount} parameters, expected 1`, LOG.LS.eHTTP); - return this.sendError(404); - } - - if (idAssetVersionU) { - this.idAssetVersion = H.Helpers.safeNumber(idAssetVersionU); - if (!this.idAssetVersion) { - LOG.error(`/download?idAssetVersion=${idAssetVersionU}, invalid parameter`, LOG.LS.eHTTP); - return this.sendError(404); - } - this.eMode = eDownloadMode.eAssetVersion; - } - - if (idAssetU) { - this.idAsset = H.Helpers.safeNumber(idAssetU); - if (!this.idAsset) { - LOG.error(`/download?idAsset=${idAssetU}, invalid parameter`, LOG.LS.eHTTP); - return this.sendError(404); - } - this.eMode = eDownloadMode.eAsset; - } - - if (idSystemObjectU) { - this.idSystemObject = H.Helpers.safeNumber(idSystemObjectU); - if (!this.idSystemObject) { - LOG.error(`/download?idSystemObject=${idSystemObjectU} invalid parameter`, LOG.LS.eHTTP); - return this.sendError(404); - } - this.eMode = eDownloadMode.eSystemObject; - } - - if (idSystemObjectVersionU) { - this.idSystemObjectVersion = H.Helpers.safeNumber(idSystemObjectVersionU); - if (!this.idSystemObjectVersion) { - LOG.error(`/download?idSystemObjectVersion=${idSystemObjectVersionU} invalid parameter`, LOG.LS.eHTTP); - return this.sendError(404); - } - this.eMode = eDownloadMode.eSystemObjectVersion; - } - - if (idWorkflowU) { - this.idWorkflow = H.Helpers.safeNumber(idWorkflowU); - if (!this.idWorkflow) { - LOG.error(`/download?idWorkflow=${idWorkflowU} invalid parameter`, LOG.LS.eHTTP); - return this.sendError(404); - } - this.eMode = eDownloadMode.eWorkflow; - } - - if (idWorkflowReportU) { - this.idWorkflowReport = H.Helpers.safeNumber(idWorkflowReportU); - if (!this.idWorkflowReport) { - LOG.error(`/download?idWorkflowReport=${idWorkflowReportU} invalid parameter`, LOG.LS.eHTTP); - return this.sendError(404); - } - this.eMode = eDownloadMode.eWorkflowReport; - } - - if (idWorkflowSetU) { - this.idWorkflowSet = H.Helpers.safeNumber(idWorkflowSetU); - if (!this.idWorkflowSet) { - LOG.error(`/download?idWorkflowSet=${idWorkflowSetU} invalid parameter`, LOG.LS.eHTTP); - return this.sendError(404); - } - this.eMode = eDownloadMode.eWorkflowSet; - } - - if (idJobRunU) { - this.idJobRun = H.Helpers.safeNumber(idJobRunU); - if (!this.idJobRun) { - LOG.error(`/download?idJobRun=${idJobRunU} invalid parameter`, LOG.LS.eHTTP); - return this.sendError(404); - } - this.eMode = eDownloadMode.eJobRun; - } - return true; - } - private async emitDownload(assetVersion: DBAPI.AssetVersion): Promise { const idAssetVersion: number = assetVersion.idAssetVersion; const res: STORE.ReadStreamResult = await STORE.AssetStorageAdapter.readAssetVersion(assetVersion); @@ -305,27 +109,23 @@ class Downloader { return this.emitDownloadFromStream(res.readStream, fileName, mimeType); } - private reconstructSystemObjectLink(): string { - return (this.systemObjectPath) ? `/download/idSystemObject-${this.idSystemObject}${this.systemObjectPath}` :`/download?idSystemObject=${this.idSystemObject}`; - } - private async emitDownloadZip(assetVersions: DBAPI.AssetVersion[]): Promise { let errorMsgBase: string = ''; - let idSystemObject: number = this.idSystemObject ?? 0; + let idSystemObject: number = this.downloaderParser.idSystemObjectV ?? 0; if (idSystemObject) - errorMsgBase = this.reconstructSystemObjectLink(); - else if (this.idSystemObjectVersion) { - errorMsgBase = `/download?idSystemObjectVersion=${this.idSystemObjectVersion}`; - const SOV: DBAPI.SystemObjectVersion | null = await DBAPI.SystemObjectVersion.fetch(this.idSystemObjectVersion); + errorMsgBase = this.downloaderParser.reconstructSystemObjectLink(); + else if (this.downloaderParser.idSystemObjectVersionV) { + errorMsgBase = `${rootURL}?idSystemObjectVersion=${this.downloaderParser.idSystemObjectVersionV}`; + const SOV: DBAPI.SystemObjectVersion | null = await DBAPI.SystemObjectVersion.fetch(this.downloaderParser.idSystemObjectVersionV); if (SOV) idSystemObject = SOV.idSystemObject; else { - LOG.error(`${errorMsgBase} failed to laod SystemObjectVersion by id ${this.idSystemObjectVersion}`, LOG.LS.eHTTP); + LOG.error(`${errorMsgBase} failed to laod SystemObjectVersion by id ${this.downloaderParser.idSystemObjectVersionV}`, LOG.LS.eHTTP); return false; } } else { - LOG.error('/download emitDownloadZip called with unexpected parameters', LOG.LS.eHTTP); + LOG.error(`${rootURL} emitDownloadZip called with unexpected parameters`, LOG.LS.eHTTP); return false; } @@ -368,7 +168,7 @@ class Downloader { const mimeType: string = WFReports[0].MimeType; const idWorkflowReport: number = WFReports[0].idWorkflowReport; - this.response.setHeader('Content-disposition', `attachment; filename=WorkflowReport.${idWorkflowReport}.htm`); + this.response.setHeader('Content-disposition', `inline; filename=WorkflowReport.${idWorkflowReport}.htm`); if (mimeType) this.response.setHeader('Content-type', mimeType); let first: boolean = true; @@ -384,7 +184,7 @@ class Downloader { } private async emitDownloadJobRun(jobRun: DBAPI.JobRun): Promise { - this.response.setHeader('Content-disposition', `attachment; filename=JobRun.${jobRun.idJobRun}.htm`); + this.response.setHeader('Content-disposition', `inline; filename=JobRun.${jobRun.idJobRun}.htm`); this.response.setHeader('Content-type', 'application/json'); this.response.write(jobRun.Output ?? ''); this.response.end(); @@ -395,7 +195,7 @@ class Downloader { const fileName: string = fileNameIn.replace(/,/g, '_'); // replace commas with underscores to avoid ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION browser error if (!mimeType) mimeType = mime.lookup(fileName) || 'application/octet-stream'; - LOG.info(`/download emitDownloadFromStream filename=${fileName}, mimetype=${mimeType}`, LOG.LS.eHTTP); + LOG.info(`${rootURL} emitDownloadFromStream filename=${fileName}, mimetype=${mimeType}`, LOG.LS.eHTTP); this.response.setHeader('Content-disposition', 'attachment; filename=' + fileName); if (mimeType) diff --git a/server/job/impl/Cook/JobCookSIPackratInspect.ts b/server/job/impl/Cook/JobCookSIPackratInspect.ts index b30df93a8..ee9caa377 100644 --- a/server/job/impl/Cook/JobCookSIPackratInspect.ts +++ b/server/job/impl/Cook/JobCookSIPackratInspect.ts @@ -113,11 +113,12 @@ export class JobCookSIPackratInspectOutput implements H.IOResults { // map and validate assets: if (this.modelConstellation.ModelAssets) { for (const modelAsset of this.modelConstellation.ModelAssets) { - let mappedId: number | undefined = assetMap.get(modelAsset.Asset.FileName); + const fileName: string = modelAsset.Asset.FileName.trim(); + let mappedId: number | undefined = assetMap.get(fileName); if (!mappedId) // try again, with just filename - mappedId = assetMap.get(path.basename(modelAsset.Asset.FileName)); + mappedId = assetMap.get(path.basename(fileName)); if (!mappedId) { - const error: string = `Missing ${modelAsset.Asset.FileName} and ${path.basename(modelAsset.Asset.FileName)} from assetMap ${JSON.stringify(assetMap, H.Helpers.saferStringify)}`; + const error: string = `Missing ${fileName} and ${path.basename(fileName)} from assetMap ${JSON.stringify(assetMap, H.Helpers.saferStringify)}`; LOG.error(`JobCookSIPackratInspectOutput.persist: ${error}`, LOG.LS.eJOB); // return { success: false, error }; continue; @@ -416,6 +417,7 @@ export class JobCookSIPackratInspectOutput implements H.IOResults { case 'uri': materialUri = maybeString(value); if (materialUri) { + materialUri = materialUri.trim(); // detect and handle UV Maps embedded in the geometry file: if (materialUri.toLowerCase().startsWith('embedded*')) { materialUri = null; @@ -702,6 +704,7 @@ export class JobCookSIPackratInspect extends JobCook= -1) { + if (errMessage.indexOf('BatchCluster has ended, cannot enqueue') > -1) { LOG.info('ExtractorImageExiftool.handleExiftoolException restarting exiftool', LOG.LS.eMETA); await ExtractorImageExiftool.exiftool.end(); ExtractorImageExiftool.exiftool = new ExifTool(); diff --git a/server/navigation/impl/NavigationSolr/IndexSolr.ts b/server/navigation/impl/NavigationSolr/IndexSolr.ts index a4f9e1bf3..84a5985c7 100644 --- a/server/navigation/impl/NavigationSolr/IndexSolr.ts +++ b/server/navigation/impl/NavigationSolr/IndexSolr.ts @@ -757,8 +757,6 @@ export class IndexSolr implements NAV.IIndexer { return false; } doc.CommonName = scene.Name; - doc.SceneIsOriented = scene.IsOriented; - doc.SceneHasBeenQCd = scene.HasBeenQCd; doc.SceneCountScene = scene.CountScene; doc.SceneCountNode = scene.CountNode; doc.SceneCountCamera = scene.CountCamera; @@ -767,6 +765,9 @@ export class IndexSolr implements NAV.IIndexer { doc.SceneCountMeta = scene.CountMeta; doc.SceneCountSetup = scene.CountSetup; doc.SceneCountTour = scene.CountTour; + doc.SceneEdanUUID = scene.EdanUUID; + doc.ScenePosedAndQCd = scene.PosedAndQCd; + doc.SceneApprovedForPublication = scene.ApprovedForPublication; this.countScene++; return true; } diff --git a/server/navigation/impl/NavigationSolr/NavigationSolr.ts b/server/navigation/impl/NavigationSolr/NavigationSolr.ts index a0d969869..49913e7c6 100644 --- a/server/navigation/impl/NavigationSolr/NavigationSolr.ts +++ b/server/navigation/impl/NavigationSolr/NavigationSolr.ts @@ -313,8 +313,9 @@ export class NavigationSolr implements NAV.INavigation { case NAV.eMetadata.eModelChannelPosition: metadata.push(this.computeMetadataFromNumberArray(doc.ModelChannelPosition)); break; case NAV.eMetadata.eModelChannelWidth: metadata.push(this.computeMetadataFromNumberArray(doc.ModelChannelWidth)); break; case NAV.eMetadata.eModelUVMapType: metadata.push(this.computeMetadataFromStringArray(doc.ModelUVMapType)); break; - case NAV.eMetadata.eSceneIsOriented: metadata.push(this.computeMetadataFromBoolean(doc.SceneIsOriented)); break; - case NAV.eMetadata.eSceneHasBeenQCd: metadata.push(this.computeMetadataFromBoolean(doc.SceneHasBeenQCd)); break; + case NAV.eMetadata.eSceneEdanUUID: metadata.push(this.computeMetadataFromBoolean(doc.SceneEdanUUID)); break; + case NAV.eMetadata.eScenePosedAndQCd: metadata.push(this.computeMetadataFromBoolean(doc.ScenePosedAndQCd)); break; + case NAV.eMetadata.eSceneApprovedForPublication: metadata.push(this.computeMetadataFromBoolean(doc.SceneApprovedForPublication)); break; case NAV.eMetadata.eSceneCountScene: metadata.push(this.computeMetadataFromNumber(doc.SceneCountScene)); break; case NAV.eMetadata.eSceneCountNode: metadata.push(this.computeMetadataFromNumber(doc.SceneCountNode)); break; case NAV.eMetadata.eSceneCountCamera: metadata.push(this.computeMetadataFromNumber(doc.SceneCountCamera)); break; diff --git a/server/navigation/interface/INavigation.ts b/server/navigation/interface/INavigation.ts index 6974e038b..816eaed86 100644 --- a/server/navigation/interface/INavigation.ts +++ b/server/navigation/interface/INavigation.ts @@ -50,8 +50,6 @@ export enum eMetadata { eModelChannelPosition, eModelChannelWidth, eModelUVMapType, - eSceneIsOriented, - eSceneHasBeenQCd, eSceneCountScene, eSceneCountNode, eSceneCountCamera, @@ -60,6 +58,9 @@ export enum eMetadata { eSceneCountMeta, eSceneCountSetup, eSceneCountTour, + eSceneEdanUUID, + eScenePosedAndQCd, + eSceneApprovedForPublication, eAssetFileName, eAssetFilePath, eAssetType, diff --git a/server/package.json b/server/package.json index a1bbba130..7b97c79a5 100644 --- a/server/package.json +++ b/server/package.json @@ -42,6 +42,7 @@ "reset": "yarn dropdb && yarn initdb && rm -rf var/Storage && rm -rf config/solr/data/packrat/data/index && rm -rf config/solr/data/packrat/data/snapshot_metadata && rm -rf config/solr/data/packrat/data/tlog && rm -rf config/solr/data/packratMeta/data/index && rm -rf config/solr/data/packratMeta/data/snapshot_metadata && rm -rf config/solr/data/packratMeta/data/tlog" }, "dependencies": { + "ajv": "6.10.2", "apollo-server-express": "2.21.1", "async-mutex": "0.3.1", "axios": "0.21.1", @@ -69,12 +70,14 @@ "node-stream-zip": "1.13.2", "passport": "0.4.1", "passport-local": "1.0.0", + "sharp": "0.29.1", "solr-client": "0.8.0", "supertest": "4.0.2", "tmp-promise": "3.0.2", "ts-node": "9.1.1", "uuid": "8.3.2", "webdav": "4.2.1", + "webdav-server": "https://github.com/Smithsonian/npm-WebDAV-Server.git", "winston": "3.3.3" }, "devDependencies": { @@ -83,7 +86,7 @@ "@graphql-codegen/typescript": "1.21.1", "@graphql-codegen/typescript-operations": "1.17.15", "@graphql-codegen/typescript-react-apollo": "2.2.3", - "@prisma/client": "2.25.0", + "@prisma/client": "3.4.0", "@types/express": "4.17.11", "@types/fs-extra": "9.0.11", "@types/jest": "26.0.21", @@ -93,10 +96,11 @@ "@types/node-schedule": "1.3.1", "@types/passport": "1.0.4", "@types/passport-local": "1.0.33", + "@types/sharp": "0.29.2", "@types/solr-client": "0.7.4", "@types/supertest": "2.0.10", "jest": "26.6.3", - "prisma": "2.25.0", + "prisma": "3.4.0", "ts-jest": "26.5.4" }, "optionalDependencies": { diff --git a/server/storage/interface/AssetStorageAdapter.ts b/server/storage/interface/AssetStorageAdapter.ts index f6c07484e..a7ae0e9f6 100644 --- a/server/storage/interface/AssetStorageAdapter.ts +++ b/server/storage/interface/AssetStorageAdapter.ts @@ -487,6 +487,7 @@ export class AssetStorageAdapter { const assets: DBAPI.Asset[] = []; const assetVersions: DBAPI.AssetVersion[] = []; const eAssetTypeMaster: eVocabularyID | undefined = await VocabularyCache.vocabularyIdToEnum(asset.idVAssetType); + let IAR: IngestAssetResult = { success: true, error: '' }; // for bulk ingest, the folder from the zip from which to extract assets is specified in asset.FilePath const fileID = bulkIngest ? `/${BAGIT_DATA_DIRECTORY}${asset.FilePath}/` : ''; @@ -500,12 +501,16 @@ export class AssetStorageAdapter { if (!inputStream) { const error: string = `AssetStorageAdapter.ingestAssetBulkZipWorker unable to stream entry ${entry} of AssetVersion ${JSON.stringify(assetVersion, H.Helpers.saferStringify)}`; LOG.error(error, LOG.LS.eSTR); - return { success: false, error, assets, assetVersions, systemObjectVersion: null }; + if (IAR.success) + IAR = { success: false, error, assets, assetVersions, systemObjectVersion: null }; + continue; } const hashResults: H.HashResults = await H.Helpers.computeHashFromStream(inputStream, ST.OCFLDigestAlgorithm); /* istanbul ignore next */ if (!hashResults.success) { LOG.error(hashResults.error, LOG.LS.eSTR); - return { success: false, error: hashResults.error, assets, assetVersions, systemObjectVersion: null }; + if (IAR.success) + IAR = { success: false, error: hashResults.error, assets, assetVersions, systemObjectVersion: null }; + continue; } // Get a second readstream to that part of the zip, to reset stream position after computing the hash @@ -513,7 +518,9 @@ export class AssetStorageAdapter { if (!inputStream) { const error: string = `AssetStorageAdapter.ingestAssetBulkZipWorker unable to stream entry ${entry} of AssetVersion ${JSON.stringify(assetVersion, H.Helpers.saferStringify)}`; LOG.error(error, LOG.LS.eSTR); - return { success: false, error, assets, assetVersions, systemObjectVersion: null }; + if (IAR.success) + IAR = { success: false, error, assets, assetVersions, systemObjectVersion: null }; + continue; } // Determine asset type @@ -557,7 +564,9 @@ export class AssetStorageAdapter { if (!idVAssetType) { const error: string = `AssetStorageAdapter.ingestAssetBulkZipWorker unable to compute asset type of Asset ${JSON.stringify(asset, H.Helpers.saferStringify)}`; LOG.error(error, LOG.LS.eSTR); - return { success: false, error, assets, assetVersions, systemObjectVersion: null }; + if (IAR.success) + IAR = { success: false, error, assets, assetVersions, systemObjectVersion: null }; + continue; } // find/create asset and asset version @@ -601,7 +610,9 @@ export class AssetStorageAdapter { if (!assetVersionComponent) { const error: string = `AssetStorageAdapter.ingestAssetBulkZipWorker unable to create AssetVersion from Asset ${JSON.stringify(asset, H.Helpers.saferStringify)}`; LOG.error(error, LOG.LS.eSTR); - return { success: false, error, assets, assetVersions, systemObjectVersion: null }; + if (IAR.success) + IAR = { success: false, error, assets, assetVersions, systemObjectVersion: null }; + continue; } // Create a storage key, Promote the asset, Update the asset @@ -610,7 +621,9 @@ export class AssetStorageAdapter { if (!ASR.success) { const error: string = `AssetStorageAdapter.ingestAssetBulkZipWorker unable to promote Asset ${JSON.stringify(asset, H.Helpers.saferStringify)}: ${ASR.error}`; LOG.error(error, LOG.LS.eSTR); - return { success: false, error, assets, assetVersions, systemObjectVersion: null }; + if (IAR.success) + IAR = { success: false, error, assets, assetVersions, systemObjectVersion: null }; + continue; } } @@ -618,6 +631,9 @@ export class AssetStorageAdapter { assetVersions.push(assetVersionComponent); } + if (!IAR.success) + return IAR; + // If no other assets exist for this bulk ingest, retire the asset version and remove the staged file const relatedAV: DBAPI.AssetVersion[] | null = await DBAPI.AssetVersion.fetchByStorageKeyStaging(assetVersion.StorageKeyStaging); if (relatedAV && relatedAV.length == 1) { @@ -742,7 +758,7 @@ export class AssetStorageAdapter { } else LOG.error(`AssetStorageAdapter.promoteAssetWorker unable to extract metadata for asset ${JSON.stringify(asset, H.Helpers.saferStringify)}: ${res.error}`, LOG.LS.eSTR); - return { asset, assetVersion, success: true, error: '' }; + return { asset, assetVersion, success: res.success, error: '' }; } static async ingestStreamOrFile(ISI: IngestStreamOrFileInput): Promise { diff --git a/server/tests/cache/LicenseCacheTest.test.ts b/server/tests/cache/LicenseCacheTest.test.ts index ca3476063..da196656f 100644 --- a/server/tests/cache/LicenseCacheTest.test.ts +++ b/server/tests/cache/LicenseCacheTest.test.ts @@ -69,7 +69,7 @@ function licenseCacheTestWorker(eMode: eCacheTestMode): void { } expect(await LicenseCache.getLicense(-1)).toBeUndefined(); - expect(await LicenseCache.getLicenseByPublishedState(-1)).toBeUndefined(); + expect(await LicenseCache.getLicenseByEnum(-1)).toBeUndefined(); expect(await LicenseCache.getLicenseResolver(-1)).toBeUndefined(); expect(await LicenseCache.clearAssignment(-1)).toBeTruthy(); if (licenseCC0) @@ -77,10 +77,10 @@ function licenseCacheTestWorker(eMode: eCacheTestMode): void { }); test('Cache: LicenseCache.getLicenseByPublishedState ' + description, async () => { - expect(await LicenseCache.getLicenseByPublishedState(DBAPI.ePublishedState.eViewDownloadCC0)).toEqual(licenseCC0); - expect(await LicenseCache.getLicenseByPublishedState(DBAPI.ePublishedState.eViewDownloadRestriction)).toEqual(licenseDownload); - expect(await LicenseCache.getLicenseByPublishedState(DBAPI.ePublishedState.eViewOnly)).toEqual(licenseView); - expect(await LicenseCache.getLicenseByPublishedState(DBAPI.ePublishedState.eRestricted)).toEqual(licenseRestricted); + expect(await LicenseCache.getLicenseByEnum(DBAPI.eLicense.eViewDownloadCC0)).toEqual(licenseCC0); + expect(await LicenseCache.getLicenseByEnum(DBAPI.eLicense.eViewDownloadRestriction)).toEqual(licenseDownload); + expect(await LicenseCache.getLicenseByEnum(DBAPI.eLicense.eViewOnly)).toEqual(licenseView); + expect(await LicenseCache.getLicenseByEnum(DBAPI.eLicense.eRestricted)).toEqual(licenseRestricted); }); }); } diff --git a/server/tests/collections/EdanCollection.test.ts b/server/tests/collections/EdanCollection.test.ts index 81d59c42f..38201135a 100644 --- a/server/tests/collections/EdanCollection.test.ts +++ b/server/tests/collections/EdanCollection.test.ts @@ -192,9 +192,9 @@ function nextID(): string { // #region Create EDAN 3D Package function executeTestCreateEdan3DPackage(ICol: COL.ICollection): void { // executeTestCreateEdan3DPackageWorker(ICol, 'file:///' + mockScenePath.replace(/\\/g, '/'), 'scene.svx.json'); - // executeTestCreateEdan3DPackageWorker(ICol, 'nfs:///ifs/smb/ocio/ocio-3ddigip01/upload/ff607e3c-3d88-4422-a246-3976aa4839dc.zip', 'scene.svx.json'); - // executeTestCreateEdan3DPackageWorker(ICol, 'nfs:///ifs/smb/ocio/ocio-3ddigip01/upload/fbcc6998-41a8-41cf-af57-81a82098f3ca.zip', 'scene.svx.json'); - executeTestCreateEdan3DPackageWorker(ICol, 'nfs:///ifs/smb/ocio/ocio-3ddigip01/upload/f550015a-7e43-435b-90dc-e7c1367bc5fb.zip', 'scene.svx.json'); + // executeTestCreateEdan3DPackageWorker(ICol, 'nfs:///si-3ddigi-staging/upload/ff607e3c-3d88-4422-a246-3976aa4839dc.zip', 'scene.svx.json'); + // executeTestCreateEdan3DPackageWorker(ICol, 'nfs:///si-3ddigi-staging/upload/fbcc6998-41a8-41cf-af57-81a82098f3ca.zip', 'scene.svx.json'); + executeTestCreateEdan3DPackageWorker(ICol, 'nfs:///si-3ddigi-staging/upload/f550015a-7e43-435b-90dc-e7c1367bc5fb.zip', 'scene.svx.json'); } function executeTestCreateEdan3DPackageWorker(ICol: COL.ICollection, path: string, scene: string): void { diff --git a/server/tests/db/api/Sentinel.util.ts b/server/tests/db/api/Sentinel.util.ts new file mode 100644 index 000000000..060552a73 --- /dev/null +++ b/server/tests/db/api/Sentinel.util.ts @@ -0,0 +1,10 @@ +import * as DBAPI from '../../../db'; +import { Sentinel as SentinelBase } from '@prisma/client'; + +export async function createSentinelTest(base: SentinelBase): Promise { + const sentinel: DBAPI.Sentinel = new DBAPI.Sentinel(base); + const created: boolean = await sentinel.create(); + expect(created).toBeTruthy(); + expect(sentinel.idSentinel).toBeGreaterThan(0); + return sentinel; +} \ No newline at end of file diff --git a/server/tests/db/api/index.ts b/server/tests/db/api/index.ts index 158f72256..3d26d18e1 100644 --- a/server/tests/db/api/index.ts +++ b/server/tests/db/api/index.ts @@ -11,6 +11,7 @@ export * from './Model.util'; export * from './Project.util'; export * from './ProjectDocumentation.util'; export * from './Scene.util'; +export * from './Sentinel.util'; export * from './Stakeholder.util'; export * from './Subject.util'; export * from './SystemObjectXref.util'; diff --git a/server/tests/db/composite/LicenseResolver.test.ts b/server/tests/db/composite/LicenseResolver.test.ts index 7fd780011..b9635ee6d 100644 --- a/server/tests/db/composite/LicenseResolver.test.ts +++ b/server/tests/db/composite/LicenseResolver.test.ts @@ -35,10 +35,10 @@ describe('DB Composite LicenseResolver', () => { }); test('LicenseCache', async () => { - expect(await CACHE.LicenseCache.getLicenseByPublishedState(DBAPI.ePublishedState.eViewDownloadCC0)).toEqual(OGTS.licenseCC0); - expect(await CACHE.LicenseCache.getLicenseByPublishedState(DBAPI.ePublishedState.eViewDownloadRestriction)).toEqual(OGTS.licenseDownload); - expect(await CACHE.LicenseCache.getLicenseByPublishedState(DBAPI.ePublishedState.eViewOnly)).toEqual(OGTS.licenseView); - expect(await CACHE.LicenseCache.getLicenseByPublishedState(DBAPI.ePublishedState.eRestricted)).toEqual(OGTS.licenseRestricted); + expect(await CACHE.LicenseCache.getLicenseByEnum(DBAPI.eLicense.eViewDownloadCC0)).toEqual(OGTS.licenseCC0); + expect(await CACHE.LicenseCache.getLicenseByEnum(DBAPI.eLicense.eViewDownloadRestriction)).toEqual(OGTS.licenseDownload); + expect(await CACHE.LicenseCache.getLicenseByEnum(DBAPI.eLicense.eViewOnly)).toEqual(OGTS.licenseView); + expect(await CACHE.LicenseCache.getLicenseByEnum(DBAPI.eLicense.eRestricted)).toEqual(OGTS.licenseRestricted); }); }); diff --git a/server/tests/db/composite/ObjectGraph.setup.ts b/server/tests/db/composite/ObjectGraph.setup.ts index 141db8144..fa0c60c8d 100644 --- a/server/tests/db/composite/ObjectGraph.setup.ts +++ b/server/tests/db/composite/ObjectGraph.setup.ts @@ -165,7 +165,7 @@ export class ObjectGraphTestSetup { this.assetVersion8a = await UTIL.createAssetVersionTest({ idAsset: this.asset8.idAsset, idUserCreator: this.user1.idUser, DateCreated: UTIL.nowCleansed(), StorageHash: 'OA Test', StorageSize: BigInt(500), idAssetVersion: 0, Ingested: true, BulkIngest: false, FileName: 'OA Test', StorageKeyStaging: '', Version: 0 }); this.assetVersion8b = await UTIL.createAssetVersionTest({ idAsset: this.asset8.idAsset, idUserCreator: this.user1.idUser, DateCreated: UTIL.nowCleansed(), StorageHash: 'OA Test', StorageSize: BigInt(500), idAssetVersion: 0, Ingested: true, BulkIngest: false, FileName: 'OA Test', StorageKeyStaging: '', Version: 0 }); this.assetVersion8c = await UTIL.createAssetVersionTest({ idAsset: this.asset8.idAsset, idUserCreator: this.user1.idUser, DateCreated: UTIL.nowCleansed(), StorageHash: 'OA Test', StorageSize: BigInt(500), idAssetVersion: 0, Ingested: true, BulkIngest: false, FileName: 'OA Test', StorageKeyStaging: '', Version: 0 }); - this.scene1 = await UTIL.createSceneTest({ Name: 'OA Test', idAssetThumbnail: this.assetT5.idAsset, IsOriented: true, HasBeenQCd: true, CountScene: 0, CountNode: 0, CountCamera: 0, CountLight: 0, CountModel: 0, CountMeta: 0, CountSetup: 0, CountTour: 0, EdanUUID: null, idScene: 0 }); + this.scene1 = await UTIL.createSceneTest({ Name: 'OA Test', idAssetThumbnail: this.assetT5.idAsset, CountScene: 0, CountNode: 0, CountCamera: 0, CountLight: 0, CountModel: 0, CountMeta: 0, CountSetup: 0, CountTour: 0, EdanUUID: null, PosedAndQCd: true, ApprovedForPublication: true, idScene: 0 }); assigned = await this.asset8.assignOwner(this.scene1); expect(assigned).toBeTruthy(); assigned = await this.assetT5.assignOwner(this.scene1); expect(assigned).toBeTruthy(); @@ -251,10 +251,10 @@ export class ObjectGraphTestSetup { } async assignLicenses(): Promise { - this.licenseCC0 = await CACHE.LicenseCache.getLicenseByPublishedState(DBAPI.ePublishedState.eViewDownloadCC0) ?? null; - this.licenseDownload = await CACHE.LicenseCache.getLicenseByPublishedState(DBAPI.ePublishedState.eViewDownloadRestriction) ?? null; - this.licenseView = await CACHE.LicenseCache.getLicenseByPublishedState(DBAPI.ePublishedState.eViewOnly) ?? null; - this.licenseRestricted = await CACHE.LicenseCache.getLicenseByPublishedState(DBAPI.ePublishedState.eRestricted) ?? null; + this.licenseCC0 = await CACHE.LicenseCache.getLicenseByEnum(DBAPI.eLicense.eViewDownloadCC0) ?? null; + this.licenseDownload = await CACHE.LicenseCache.getLicenseByEnum(DBAPI.eLicense.eViewDownloadRestriction) ?? null; + this.licenseView = await CACHE.LicenseCache.getLicenseByEnum(DBAPI.eLicense.eViewOnly) ?? null; + this.licenseRestricted = await CACHE.LicenseCache.getLicenseByEnum(DBAPI.eLicense.eRestricted) ?? null; if (!this.licenseCC0 || !this.licenseDownload || !this.licenseView || !this.licenseRestricted) { LOG.error('ObjectGraphTestSetup.assignLicenses unable to fetch cached licenses', LOG.LS.eTEST); diff --git a/server/tests/db/dbcreation.test.ts b/server/tests/db/dbcreation.test.ts index c9184d4c0..e1ba60151 100644 --- a/server/tests/db/dbcreation.test.ts +++ b/server/tests/db/dbcreation.test.ts @@ -85,6 +85,7 @@ let project2: DBAPI.Project | null; let projectDocumentation: DBAPI.ProjectDocumentation | null; let scene: DBAPI.Scene | null; let sceneNulls: DBAPI.Scene | null; +let sentinel: DBAPI.Sentinel | null; let stakeholder: DBAPI.Stakeholder | null; let subject: DBAPI.Subject | null; let subjectWithPreferredID: DBAPI.Subject | null; @@ -421,8 +422,6 @@ describe('DB Creation Test Suite', () => { scene = await UTIL.createSceneTest({ Name: 'Test Scene', idAssetThumbnail: assetThumbnail.idAsset, - IsOriented: true, - HasBeenQCd: true, CountScene: 0, CountNode: 0, CountCamera: 0, @@ -432,6 +431,8 @@ describe('DB Creation Test Suite', () => { CountSetup: 0, CountTour: 0, EdanUUID: null, + PosedAndQCd: true, + ApprovedForPublication: true, idScene: 0 }); expect(scene).toBeTruthy(); @@ -441,8 +442,6 @@ describe('DB Creation Test Suite', () => { sceneNulls = await UTIL.createSceneTest({ Name: 'Test Scene', idAssetThumbnail: null, - IsOriented: true, - HasBeenQCd: false, CountScene: 0, CountNode: 0, CountCamera: 0, @@ -452,6 +451,8 @@ describe('DB Creation Test Suite', () => { CountSetup: 0, CountTour: 0, EdanUUID: null, + PosedAndQCd: true, + ApprovedForPublication: false, idScene: 0 }); expect(sceneNulls).toBeTruthy(); @@ -1079,20 +1080,19 @@ describe('DB Creation Test Suite', () => { }); test('DB Creation: Model With Nulls', async () => { - if (vocabulary) - modelNulls = await UTIL.createModelTest({ - Name: 'Test Model with Nulls', - DateCreated: UTIL.nowCleansed(), - idVCreationMethod: null, - idVModality: null, - idVUnits: null, - idVPurpose: null, - idVFileType: null, - idAssetThumbnail: null, - CountAnimations: 0, CountCameras: 0, CountFaces: 0, CountLights: 0, CountMaterials: 0, CountMeshes: 0, CountVertices: 0, - CountEmbeddedTextures: 0, CountLinkedTextures: 0, FileEncoding: 'BINARY', IsDracoCompressed: false, AutomationTag: null, - idModel: 0 - }); + modelNulls = await UTIL.createModelTest({ + Name: 'Test Model with Nulls', + DateCreated: UTIL.nowCleansed(), + idVCreationMethod: null, + idVModality: null, + idVUnits: null, + idVPurpose: null, + idVFileType: null, + idAssetThumbnail: null, + CountAnimations: 0, CountCameras: 0, CountFaces: 0, CountLights: 0, CountMaterials: 0, CountMeshes: 0, CountVertices: 0, + CountEmbeddedTextures: 0, CountLinkedTextures: 0, FileEncoding: 'BINARY', IsDracoCompressed: false, AutomationTag: null, + idModel: 0 + }); expect(modelNulls).toBeTruthy(); }); @@ -1543,6 +1543,17 @@ describe('DB Creation Test Suite', () => { expect(projectDocumentation).toBeTruthy(); }); + test('DB Creation: Sentinel', async () => { + if (userActive) + sentinel = await UTIL.createSentinelTest({ + URLBase: 'Test Sentinel URLBase ' + H.Helpers.randomSlug(), + ExpirationDate: UTIL.nowCleansed(), + idUser: userActive.idUser, + idSentinel: 0 + }); + expect(sentinel).toBeTruthy(); + }); + test('DB Creation: Stakeholder', async () => { stakeholder = await UTIL.createStakeholderTest({ IndividualName: 'Test Stakeholder Name', @@ -2232,6 +2243,7 @@ describe('DB Fetch By ID Test Suite', () => { expect(audit).toMatchObject(auditFetch); } + const eAuditTypeOrig: DBAPI.eAuditType = audit.getAuditType(); audit.setAuditType(DBAPI.eAuditType.eUnknown); expect(audit.getAuditType()).toEqual(DBAPI.eAuditType.eUnknown); audit.setAuditType(DBAPI.eAuditType.eAuthLogin); @@ -2244,17 +2256,31 @@ describe('DB Fetch By ID Test Suite', () => { expect(audit.getAuditType()).toEqual(DBAPI.eAuditType.eDBUpdate); audit.setAuditType(DBAPI.eAuditType.eDBDelete); expect(audit.getAuditType()).toEqual(DBAPI.eAuditType.eDBDelete); + audit.setAuditType(eAuditTypeOrig); + expect(audit.getAuditType()).toEqual(eAuditTypeOrig); + const eDBObjectTypeOrig: DBAPI.eDBObjectType = audit.getDBObjectType(); audit.setDBObjectType(null); expect(audit.getDBObjectType()).toEqual(DBAPI.eSystemObjectType.eUnknown); audit.setDBObjectType(DBAPI.eSystemObjectType.eUnit); expect(audit.getDBObjectType()).toEqual(DBAPI.eSystemObjectType.eUnit); audit.setDBObjectType(DBAPI.eNonSystemObjectType.eUnitEdan); expect(audit.getDBObjectType()).toEqual(DBAPI.eNonSystemObjectType.eUnitEdan); + audit.setDBObjectType(eDBObjectTypeOrig); + expect(audit.getDBObjectType()).toEqual(eDBObjectTypeOrig); } expect(auditFetch).toBeTruthy(); }); + test('DB Fetch Audit: Audit.fetchLastUser', async () => { + // LOG.info(`Audit: ${JSON.stringify(audit, H.Helpers.saferStringify)}`, LOG.LS.eTEST); + let userFetch: DBAPI.User | null = null; + if (audit) + userFetch = await DBAPI.Audit.fetchLastUser(audit.idSystemObject ?? 0, audit.getAuditType()); + expect(userFetch).toBeTruthy(); + expect(userFetch?.idUser).toEqual(userActive?.idUser); + }); + test('DB Fetch By ID: CaptureData', async () => { let captureDataFetch: DBAPI.CaptureData | null = null; if (captureData) { @@ -2781,6 +2807,18 @@ describe('DB Fetch By ID Test Suite', () => { expect(sceneFetch).toBeTruthy(); }); + test('DB Fetch By ID: Sentinel', async () => { + let sentinelFetch: DBAPI.Sentinel | null = null; + if (sentinel) { + sentinelFetch = await DBAPI.Sentinel.fetch(sentinel.idSentinel); + if (sentinelFetch) { + expect(sentinelFetch).toMatchObject(sentinel); + expect(sentinel).toMatchObject(sentinelFetch); + } + } + expect(sentinelFetch).toBeTruthy(); + }); + test('DB Fetch By ID: Stakeholder', async () => { let stakeholderFetch: DBAPI.Stakeholder | null = null; if (stakeholder) { @@ -4100,13 +4138,30 @@ describe('DB Fetch SystemObject Fetch Pair Test Suite', () => { expect(SYOP).toBeTruthy(); }); + test('DB Fetch SystemObject: LicenseEnumToString', async () => { + expect(DBAPI.LicenseEnumToString(-1)).toEqual('Restricted'); + expect(DBAPI.LicenseEnumToString(DBAPI.eLicense.eViewDownloadCC0)).toEqual('View and Download CC0'); + expect(DBAPI.LicenseEnumToString(DBAPI.eLicense.eViewDownloadRestriction)).toEqual('View and Download with usage restrictions'); + expect(DBAPI.LicenseEnumToString(DBAPI.eLicense.eViewOnly)).toEqual('View Only'); + expect(DBAPI.LicenseEnumToString(DBAPI.eLicense.eRestricted)).toEqual('Restricted'); + }); + test('DB Fetch SystemObject: PublishedStateEnumToString', async () => { expect(DBAPI.PublishedStateEnumToString(-1)).toEqual('Not Published'); expect(DBAPI.PublishedStateEnumToString(DBAPI.ePublishedState.eNotPublished)).toEqual('Not Published'); - expect(DBAPI.PublishedStateEnumToString(DBAPI.ePublishedState.eRestricted)).toEqual('Restricted'); - expect(DBAPI.PublishedStateEnumToString(DBAPI.ePublishedState.eViewOnly)).toEqual('View Only'); - expect(DBAPI.PublishedStateEnumToString(DBAPI.ePublishedState.eViewDownloadRestriction)).toEqual('View and Download with usage restrictions'); - expect(DBAPI.PublishedStateEnumToString(DBAPI.ePublishedState.eViewDownloadCC0)).toEqual('View and Download CC0'); + expect(DBAPI.PublishedStateEnumToString(DBAPI.ePublishedState.eAPIOnly)).toEqual('API Only'); + expect(DBAPI.PublishedStateEnumToString(DBAPI.ePublishedState.ePublished)).toEqual('Published'); + }); + + test('DB Fetch SystemObject: LicenseRestrictLevelToPublishedStateEnum', async () => { + expect(DBAPI.LicenseRestrictLevelToPublishedStateEnum(-1)).toEqual(DBAPI.ePublishedState.ePublished); + expect(DBAPI.LicenseRestrictLevelToPublishedStateEnum(10)).toEqual(DBAPI.ePublishedState.ePublished); + expect(DBAPI.LicenseRestrictLevelToPublishedStateEnum(15)).toEqual(DBAPI.ePublishedState.ePublished); + expect(DBAPI.LicenseRestrictLevelToPublishedStateEnum(20)).toEqual(DBAPI.ePublishedState.ePublished); + expect(DBAPI.LicenseRestrictLevelToPublishedStateEnum(25)).toEqual(DBAPI.ePublishedState.ePublished); + expect(DBAPI.LicenseRestrictLevelToPublishedStateEnum(30)).toEqual(DBAPI.ePublishedState.ePublished); + expect(DBAPI.LicenseRestrictLevelToPublishedStateEnum(35)).toEqual(DBAPI.ePublishedState.eNotPublished); + expect(DBAPI.LicenseRestrictLevelToPublishedStateEnum(1000)).toEqual(DBAPI.ePublishedState.eNotPublished); }); test('DB Fetch SystemObject: SystemObjectTypeToName', async () => { @@ -4189,6 +4244,7 @@ describe('DB Fetch SystemObject Fetch Pair Test Suite', () => { expect(DBAPI.DBObjectTypeToName(DBAPI.eNonSystemObjectType.eModelProcessingAction)).toEqual('ModelProcessingAction'); expect(DBAPI.DBObjectTypeToName(DBAPI.eNonSystemObjectType.eModelProcessingActionStep)).toEqual('ModelProessingActionStep'); expect(DBAPI.DBObjectTypeToName(DBAPI.eNonSystemObjectType.eModelSceneXref)).toEqual('ModelSceneXref'); + expect(DBAPI.DBObjectTypeToName(DBAPI.eNonSystemObjectType.eSentinel)).toEqual('Sentinel'); expect(DBAPI.DBObjectTypeToName(DBAPI.eNonSystemObjectType.eSystemObject)).toEqual('SystemObject'); expect(DBAPI.DBObjectTypeToName(DBAPI.eNonSystemObjectType.eSystemObjectVersion)).toEqual('SystemObjectVersion'); expect(DBAPI.DBObjectTypeToName(DBAPI.eNonSystemObjectType.eSystemObjectXref)).toEqual('SystemObjectXref'); @@ -4272,6 +4328,7 @@ describe('DB Fetch SystemObject Fetch Pair Test Suite', () => { expect(DBAPI.DBObjectNameToType('Model Proessing Action Step')).toEqual(DBAPI.eNonSystemObjectType.eModelProcessingActionStep); expect(DBAPI.DBObjectNameToType('ModelSceneXref')).toEqual(DBAPI.eNonSystemObjectType.eModelSceneXref); expect(DBAPI.DBObjectNameToType('Model Scene Xref')).toEqual(DBAPI.eNonSystemObjectType.eModelSceneXref); + expect(DBAPI.DBObjectNameToType('Sentinel')).toEqual(DBAPI.eNonSystemObjectType.eSentinel); expect(DBAPI.DBObjectNameToType('SystemObject')).toEqual(DBAPI.eNonSystemObjectType.eSystemObject); expect(DBAPI.DBObjectNameToType('System Object')).toEqual(DBAPI.eNonSystemObjectType.eSystemObject); expect(DBAPI.DBObjectNameToType('SystemObjectVersion')).toEqual(DBAPI.eNonSystemObjectType.eSystemObjectVersion); @@ -4885,6 +4942,48 @@ describe('DB Fetch Special Test Suite', () => { expect(modelFetch).toBeTruthy(); }); + test('DB Fetch Special: Model.cloneData', async () => { + const modelClone: DBAPI.Model = await UTIL.createModelTest({ + Name: 'Test Model with Nulls', + DateCreated: UTIL.nowCleansed(), + idVCreationMethod: null, + idVModality: null, + idVUnits: null, + idVPurpose: null, + idVFileType: null, + idAssetThumbnail: null, + CountAnimations: 0, CountCameras: 0, CountFaces: 0, CountLights: 0, CountMaterials: 0, CountMeshes: 0, CountVertices: 0, + CountEmbeddedTextures: 0, CountLinkedTextures: 0, FileEncoding: 'BINARY', IsDracoCompressed: false, AutomationTag: null, + idModel: 0 + }); + expect(modelClone).toBeTruthy(); + expect(model).toBeTruthy(); + if (model) { + modelClone.cloneData(model); + expect(modelClone.idModel).not.toEqual(model.idModel); + expect(modelClone.Name).toEqual(model.Name); + expect(modelClone.DateCreated).toEqual(model.DateCreated); + expect(modelClone.idVCreationMethod).toEqual(model.idVCreationMethod); + expect(modelClone.idVModality).toEqual(model.idVModality); + expect(modelClone.idVPurpose).toEqual(model.idVPurpose); + expect(modelClone.idVUnits).toEqual(model.idVUnits); + expect(modelClone.idVFileType).toEqual(model.idVFileType); + expect(modelClone.idAssetThumbnail).toEqual(model.idAssetThumbnail); + expect(modelClone.CountAnimations).toEqual(model.CountAnimations); + expect(modelClone.CountCameras).toEqual(model.CountCameras); + expect(modelClone.CountFaces).toEqual(model.CountFaces); + expect(modelClone.CountLights).toEqual(model.CountLights); + expect(modelClone.CountMaterials).toEqual(model.CountMaterials); + expect(modelClone.CountMeshes).toEqual(model.CountMeshes); + expect(modelClone.CountVertices).toEqual(model.CountVertices); + expect(modelClone.CountEmbeddedTextures).toEqual(model.CountEmbeddedTextures); + expect(modelClone.CountLinkedTextures).toEqual(model.CountLinkedTextures); + expect(modelClone.FileEncoding).toEqual(model.FileEncoding); + expect(modelClone.IsDracoCompressed).toEqual(model.IsDracoCompressed); + expect(modelClone.AutomationTag).toEqual(model.AutomationTag); + } + }); + test('DB Fetch Special: Model.fetchByFileNameAndAssetType', async () => { const modelFetch: DBAPI.Model[] | null = await DBAPI.Model.fetchByFileNameAndAssetType('zzzOBVIOUSLY_INVALID_NAMEzzz', [0]); expect(modelFetch).toBeTruthy(); @@ -5279,6 +5378,16 @@ describe('DB Fetch Special Test Suite', () => { expect(sceneFetch).toBeTruthy(); }); + test('DB Fetch Special: Sentinel.fetchByURLBase', async () => { + let sentinelFetch: DBAPI.Sentinel[] | null = null; + if (sentinel) { + sentinelFetch = await DBAPI.Sentinel.fetchByURLBase(sentinel.URLBase); + if (sentinelFetch) + expect(sentinelFetch).toEqual(expect.arrayContaining([sentinel])); + } + expect(sentinelFetch).toBeTruthy(); + }); + test('DB Fetch Special: Stakeholder.fetchAll', async () => { let stakeholderFetch: DBAPI.Stakeholder[] | null = null; if (stakeholder) { @@ -6584,7 +6693,7 @@ describe('DB Update Test Suite', () => { const SOOld: DBAPI.SystemObject | null = await sceneNulls.fetchSystemObject(); expect(SOOld).toBeTruthy(); - sceneNulls.HasBeenQCd = true; + sceneNulls.ApprovedForPublication = true; sceneNulls.idAssetThumbnail = assetThumbnail.idAsset; bUpdated = await sceneNulls.update(); @@ -6631,6 +6740,21 @@ describe('DB Update Test Suite', () => { expect(bUpdated).toBeTruthy(); }); + test('DB Update: Sentinel.update', async () => { + let bUpdated: boolean = false; + if (sentinel) { + const updatedURLBase: string = 'Updated Sentinel URLBase ' + H.Helpers.randomSlug(); + sentinel.URLBase = updatedURLBase; + bUpdated = await sentinel.update(); + + const sentinelFetch: DBAPI.Sentinel | null = await DBAPI.Sentinel.fetch(sentinel.idSentinel); + expect(sentinelFetch).toBeTruthy(); + if (sentinelFetch) + expect(sentinelFetch.URLBase).toBe(updatedURLBase); + } + expect(bUpdated).toBeTruthy(); + }); + test('DB Update: Stakeholder.update', async () => { let bUpdated: boolean = false; if (stakeholder) { @@ -6771,20 +6895,16 @@ describe('DB Update Test Suite', () => { if (systemObjectVersion) { systemObjectVersion.setPublishedState(DBAPI.ePublishedState.eNotPublished); expect(systemObjectVersion.publishedStateEnum()).toEqual(DBAPI.ePublishedState.eNotPublished); - systemObjectVersion.setPublishedState(DBAPI.ePublishedState.eRestricted); - expect(systemObjectVersion.publishedStateEnum()).toEqual(DBAPI.ePublishedState.eRestricted); - systemObjectVersion.setPublishedState(DBAPI.ePublishedState.eViewOnly); - expect(systemObjectVersion.publishedStateEnum()).toEqual(DBAPI.ePublishedState.eViewOnly); - systemObjectVersion.setPublishedState(DBAPI.ePublishedState.eViewDownloadRestriction); - expect(systemObjectVersion.publishedStateEnum()).toEqual(DBAPI.ePublishedState.eViewDownloadRestriction); - systemObjectVersion.setPublishedState(DBAPI.ePublishedState.eViewDownloadCC0); - expect(systemObjectVersion.publishedStateEnum()).toEqual(DBAPI.ePublishedState.eViewDownloadCC0); + systemObjectVersion.setPublishedState(DBAPI.ePublishedState.eAPIOnly); + expect(systemObjectVersion.publishedStateEnum()).toEqual(DBAPI.ePublishedState.eAPIOnly); + systemObjectVersion.setPublishedState(DBAPI.ePublishedState.ePublished); + expect(systemObjectVersion.publishedStateEnum()).toEqual(DBAPI.ePublishedState.ePublished); bUpdated = await systemObjectVersion.update(); const systemObjectVersionFetch: DBAPI.SystemObjectVersion | null = await DBAPI.SystemObjectVersion.fetch(systemObjectVersion.idSystemObjectVersion); expect(systemObjectVersionFetch).toBeTruthy(); if (systemObjectVersionFetch) - expect(systemObjectVersionFetch.publishedStateEnum()).toEqual(DBAPI.ePublishedState.eViewDownloadCC0); + expect(systemObjectVersionFetch.publishedStateEnum()).toEqual(DBAPI.ePublishedState.ePublished); } expect(bUpdated).toBeTruthy(); }); @@ -7416,6 +7536,8 @@ describe('DB Null/Zero ID Test', () => { expect(await DBAPI.AssetVersion.fetchFromUserByIngested(0, true, true)).toBeNull(); expect(await DBAPI.AssetVersion.fetchByAssetAndVersion(0, 1)).toBeNull(); expect(await DBAPI.Audit.fetch(0)).toBeNull(); + expect(await DBAPI.Audit.fetchLastUser(0, DBAPI.eAuditType.eAuthLogin)).toBeNull(); + expect(await DBAPI.Audit.fetchLastUser(-1, DBAPI.eAuditType.eUnknown)).toBeNull(); expect(await DBAPI.CaptureData.fetch(0)).toBeNull(); expect(await DBAPI.CaptureData.fetchFromXref(0)).toBeNull(); expect(await DBAPI.CaptureData.fetchFromCaptureDataPhoto(0)).toBeNull(); @@ -7509,6 +7631,7 @@ describe('DB Null/Zero ID Test', () => { expect(await DBAPI.Scene.fetchFromXref(0)).toBeNull(); expect(await DBAPI.Scene.fetchDerivedFromItems([])).toBeNull(); expect(await DBAPI.Scene.fetchChildrenScenes(0)).toBeNull(); + expect(await DBAPI.Sentinel.fetch(0)).toBeNull(); expect(await DBAPI.Stakeholder.fetch(0)).toBeNull(); expect(await DBAPI.Stakeholder.fetchDerivedFromProjects([])).toBeNull(); expect(await DBAPI.Subject.clearPreferredIdentifier(0)).toBeFalsy(); diff --git a/server/tests/graphql/utils/index.ts b/server/tests/graphql/utils/index.ts index f6719d250..e07d38599 100644 --- a/server/tests/graphql/utils/index.ts +++ b/server/tests/graphql/utils/index.ts @@ -99,8 +99,8 @@ class TestSuiteUtils { createSceneInput = (): CreateSceneInput => { return { Name: 'Test Scene', - HasBeenQCd: true, - IsOriented: true + ApprovedForPublication: true, + PosedAndQCd: true }; }; diff --git a/server/tests/mock/utils/bagit/PackratTestBulkIngest.Scene.zip b/server/tests/mock/utils/bagit/PackratTestBulkIngest.Scene.zip index 0f8d6b4be..9a6570b39 100644 Binary files a/server/tests/mock/utils/bagit/PackratTestBulkIngest.Scene.zip and b/server/tests/mock/utils/bagit/PackratTestBulkIngest.Scene.zip differ diff --git a/server/tests/mock/utils/parser/mock.scenes.csv b/server/tests/mock/utils/parser/mock.scenes.csv index d614b6ee6..1dbd50b90 100644 --- a/server/tests/mock/utils/parser/mock.scenes.csv +++ b/server/tests/mock/utils/parser/mock.scenes.csv @@ -1,2 +1,2 @@ -subject_guid,subject_name,unit_guid,unit_name,item_guid,item_name,entire_subject,name,is_oriented,has_been_qcd,directory_path +subject_guid,subject_name,unit_guid,unit_name,item_guid,item_name,entire_subject,name,posed_and_qcd,approved_for_publication,directory_path edanmdm:nmnheducation_10841877,Atlantic Hawksbill Sea Turtle,ISN:0000000123642127,NMNH,,Top and Bottom,1,test scene,0,0,scene diff --git a/server/tests/setEnvVars.ts b/server/tests/setEnvVars.ts index 6f16fd629..5dc342966 100644 --- a/server/tests/setEnvVars.ts +++ b/server/tests/setEnvVars.ts @@ -10,7 +10,7 @@ if (!process.env.PACKRAT_EDAN_3D_API) if (!process.env.PACKRAT_EDAN_APPID) process.env.PACKRAT_EDAN_APPID = 'OCIO3D'; if (!process.env.PACKRAT_EDAN_UPSERT_RESOURCE_ROOT) - process.env.PACKRAT_EDAN_UPSERT_RESOURCE_ROOT = 'nfs:///ifs/smb/ocio/ocio-3ddigip01/upload/'; + process.env.PACKRAT_EDAN_UPSERT_RESOURCE_ROOT = 'nfs:///si-3ddigi-staging/upload/'; if (!process.env.PACKRAT_EDAN_STAGING_ROOT) process.env.PACKRAT_EDAN_STAGING_ROOT = './var/Storage/StagingEdan'; if (!process.env.PACKRAT_EDAN_RESOURCES_HOTFOLDER) diff --git a/server/tests/utils/parser/csvParser.test.ts b/server/tests/utils/parser/csvParser.test.ts index 89326eb4c..3d344838c 100644 --- a/server/tests/utils/parser/csvParser.test.ts +++ b/server/tests/utils/parser/csvParser.test.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import { join } from 'path'; +import * as LOG from '../../../utils/logger'; import * as H from '../../../utils/helpers'; import { CSVParser, CSVTypes, CaptureDataPhotoCSVFields, ModelsCSVFields, ScenesCSVFields } from '../../../utils/parser'; @@ -19,7 +20,8 @@ describe('CSVParser', () => { try { const result = await CSVParser.parse(fileStream, CSVTypes.captureDataPhoto); expect(result).toBeTruthy(); - } catch { + } catch (error) { + LOG.error('CSVParser.parse failed', LOG.LS.eTEST, error); expect('Exception not expected!').toBeFalsy(); } }); @@ -31,7 +33,8 @@ describe('CSVParser', () => { try { const result = await CSVParser.parse(fileStream, CSVTypes.models); expect(result).toBeTruthy(); - } catch { + } catch (error) { + LOG.error('CSVParser.parse failed', LOG.LS.eTEST, error); expect('Exception not expected!').toBeFalsy(); } }); @@ -43,7 +46,8 @@ describe('CSVParser', () => { try { const result = await CSVParser.parse(fileStream, CSVTypes.scenes); expect(result).toBeTruthy(); - } catch { + } catch (error) { + LOG.error('CSVParser.parse failed', LOG.LS.eTEST, error); expect('Exception not expected!').toBeFalsy(); } }); @@ -53,7 +57,7 @@ describe('CSVParser', () => { try { const fileStream = fs.createReadStream(mockPathJunk); await CSVParser.parse(fileStream, CSVTypes.models); - } catch { + } catch (error) { expect('Exception expected').toBeTruthy(); } }); diff --git a/server/tests/utils/parser/svxReader.test.ts b/server/tests/utils/parser/svxReader.test.ts index cd3e4f5a2..b1f79ebdb 100644 --- a/server/tests/utils/parser/svxReader.test.ts +++ b/server/tests/utils/parser/svxReader.test.ts @@ -41,18 +41,17 @@ describe('SvxReader', () => { expect(await validateLoadFromStream('invalid', 'DOES_NOT_EXIST.svx.json', false)).toBeFalsy(); expect(await validateLoadFromStream('invalid', 'invalid.asset.svx.json', false)).toBeFalsy(); expect(await validateLoadFromStream('invalid', 'invalid.asset-type.svx.json', false)).toBeFalsy(); - // expect(await validateLoadFromStream('invalid', 'invalid.asset-version.svx.json', false)).toBeFalsy(); - // expect(await validateLoadFromStream('invalid', 'invalid.cameras.svx.json', false)).toBeFalsy(); - // expect(await validateLoadFromStream('invalid', 'invalid.lights.svx.json', false)).toBeFalsy(); - // expect(await validateLoadFromStream('invalid', 'invalid.metas.svx.json', false)).toBeFalsy(); - // expect(await validateLoadFromStream('invalid', 'invalid.models.svx.json', false)).toBeFalsy(); + expect(await validateLoadFromStream('invalid', 'invalid.asset-version.svx.json', false)).toBeFalsy(); + expect(await validateLoadFromStream('invalid', 'invalid.cameras.svx.json', false)).toBeFalsy(); + expect(await validateLoadFromStream('invalid', 'invalid.lights.svx.json', false)).toBeFalsy(); + expect(await validateLoadFromStream('invalid', 'invalid.metas.svx.json', false)).toBeFalsy(); + expect(await validateLoadFromStream('invalid', 'invalid.models.svx.json', false)).toBeFalsy(); // expect(await validateLoadFromStream('invalid', 'invalid.nodes.svx.json', false)).toBeFalsy(); - // expect(await validateLoadFromStream('invalid', 'invalid.scene.svx.json', false)).toBeFalsy(); - // expect(await validateLoadFromStream('invalid', 'invalid.scenes.svx.json', false)).toBeFalsy(); - // expect(await validateLoadFromStream('invalid', 'invalid.setups.svx.json', false)).toBeFalsy(); + expect(await validateLoadFromStream('invalid', 'invalid.scene.svx.json', false)).toBeFalsy(); + expect(await validateLoadFromStream('invalid', 'invalid.scenes.svx.json', false)).toBeFalsy(); + expect(await validateLoadFromStream('invalid', 'invalid.setups.svx.json', false)).toBeFalsy(); expect(await validateLoadFromStream('invalid', 'invalid.json.svx.json', false)).toBeFalsy(); - - expect(await validateLoadFromStream('invalid', 'invalid.model-trans.svx.json', true)).toBeTruthy(); + expect(await validateLoadFromStream('invalid', 'invalid.model-trans.svx.json', false)).toBeFalsy(); }); test('SvxReader SvxExtraction', async () => { @@ -69,8 +68,8 @@ async function validateLoadFromStream(folder: string, fileName: string, expectSu const svxReader: SvxReader = new SvxReader(); const result: H.IOResults = await svxReader.loadFromStream(readStream); if (!expectSuccess) { - expect(result.success).toBeFalsy(); LOG.info(`SvxReader.loadFromStream ${folder}/${fileName} expected failure: ${result.error}`, LOG.LS.eTEST); + expect(result.success).toBeFalsy(); return null; } diff --git a/server/tsconfig.json b/server/tsconfig.json index 3d3a0de1b..5126610ef 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -5,6 +5,7 @@ "module": "commonjs", "esModuleInterop": true, "moduleResolution": "node", + "resolveJsonModule": true, "rootDir": ".", "outDir": "build", "declaration": true, diff --git a/server/types/graphql.ts b/server/types/graphql.ts index 5daf8257c..4ea8d0dde 100644 --- a/server/types/graphql.ts +++ b/server/types/graphql.ts @@ -354,6 +354,7 @@ export type Mutation = { deleteObjectConnection: DeleteObjectConnectionResult; discardUploadedAssetVersions: DiscardUploadedAssetVersionsResult; ingestData: IngestDataResult; + publish: PublishResult; rollbackSystemObjectVersion: RollbackSystemObjectVersionResult; updateDerivedObjects: UpdateDerivedObjectsResult; updateLicense: CreateLicenseResult; @@ -459,6 +460,11 @@ export type MutationIngestDataArgs = { }; +export type MutationPublishArgs = { + input: PublishInput; +}; + + export type MutationRollbackSystemObjectVersionArgs = { input: RollbackSystemObjectVersionInput; }; @@ -622,8 +628,8 @@ export type IngestScene = { idAssetVersion: Scalars['Int']; systemCreated: Scalars['Boolean']; name: Scalars['String']; - hasBeenQCd: Scalars['Boolean']; - isOriented: Scalars['Boolean']; + approvedForPublication: Scalars['Boolean']; + posedAndQCd: Scalars['Boolean']; directory: Scalars['String']; identifiers: Array; referenceModels: Array; @@ -931,8 +937,8 @@ export type IngestSceneInput = { idAsset?: Maybe; systemCreated: Scalars['Boolean']; name: Scalars['String']; - hasBeenQCd: Scalars['Boolean']; - isOriented: Scalars['Boolean']; + approvedForPublication: Scalars['Boolean']; + posedAndQCd: Scalars['Boolean']; directory: Scalars['String']; identifiers: Array; sourceObjects: Array; @@ -1302,8 +1308,6 @@ export type GetFilterViewDataResult = { export type CreateSceneInput = { Name: Scalars['String']; - HasBeenQCd: Scalars['Boolean']; - IsOriented: Scalars['Boolean']; idAssetThumbnail?: Maybe; CountScene?: Maybe; CountNode?: Maybe; @@ -1314,6 +1318,8 @@ export type CreateSceneInput = { CountSetup?: Maybe; CountTour?: Maybe; EdanUUID?: Maybe; + ApprovedForPublication: Scalars['Boolean']; + PosedAndQCd: Scalars['Boolean']; }; export type CreateSceneResult = { @@ -1342,9 +1348,7 @@ export type GetIntermediaryFileResult = { export type Scene = { __typename?: 'Scene'; idScene: Scalars['Int']; - HasBeenQCd: Scalars['Boolean']; idAssetThumbnail?: Maybe; - IsOriented: Scalars['Boolean']; Name: Scalars['String']; CountScene?: Maybe; CountNode?: Maybe; @@ -1355,6 +1359,8 @@ export type Scene = { CountSetup?: Maybe; CountTour?: Maybe; EdanUUID?: Maybe; + ApprovedForPublication: Scalars['Boolean']; + PosedAndQCd: Scalars['Boolean']; AssetThumbnail?: Maybe; ModelSceneXref?: Maybe>>; SystemObject?: Maybe; @@ -1412,6 +1418,7 @@ export type SubjectDetailFieldsInput = { TS0?: Maybe; TS1?: Maybe; TS2?: Maybe; + idIdentifierPreferred?: Maybe; }; export type ItemDetailFieldsInput = { @@ -1445,6 +1452,7 @@ export type CaptureDataDetailFieldsInput = { clusterType?: Maybe; clusterGeometryFieldId?: Maybe; folders: Array; + isValidData?: Maybe; }; export type ModelDetailFieldsInput = { @@ -1461,8 +1469,8 @@ export type SceneDetailFieldsInput = { AssetType?: Maybe; Tours?: Maybe; Annotation?: Maybe; - HasBeenQCd?: Maybe; - IsOriented?: Maybe; + ApprovedForPublication?: Maybe; + PosedAndQCd?: Maybe; }; export type ProjectDocumentationDetailFieldsInput = { @@ -1519,35 +1527,46 @@ export type UpdateObjectDetailsResult = { message: Scalars['String']; }; +export type ExistingRelationship = { + idSystemObject: Scalars['Int']; + objectType: Scalars['Int']; +}; + export type UpdateDerivedObjectsInput = { idSystemObject: Scalars['Int']; - Derivatives: Array; - PreviouslySelected: Array; + ParentObjectType: Scalars['Int']; + Derivatives: Array; + PreviouslySelected: Array; }; export type UpdateDerivedObjectsResult = { __typename?: 'UpdateDerivedObjectsResult'; success: Scalars['Boolean']; + message: Scalars['String']; + status: Scalars['String']; }; export type UpdateSourceObjectsInput = { idSystemObject: Scalars['Int']; - Sources: Array; - PreviouslySelected: Array; + ChildObjectType: Scalars['Int']; + Sources: Array; + PreviouslySelected: Array; }; export type UpdateSourceObjectsResult = { __typename?: 'UpdateSourceObjectsResult'; success: Scalars['Boolean']; + message: Scalars['String']; + status: Scalars['String']; }; export type UpdateIdentifier = { id: Scalars['Int']; identifier: Scalars['String']; identifierType: Scalars['Int']; - selected: Scalars['Boolean']; idSystemObject: Scalars['Int']; idIdentifier: Scalars['Int']; + preferred?: Maybe; }; export type DeleteObjectConnectionResult = { @@ -1558,7 +1577,9 @@ export type DeleteObjectConnectionResult = { export type DeleteObjectConnectionInput = { idSystemObjectMaster: Scalars['Int']; + objectTypeMaster: Scalars['Int']; idSystemObjectDerived: Scalars['Int']; + objectTypeDerived: Scalars['Int']; }; export type DeleteIdentifierResult = { @@ -1596,7 +1617,18 @@ export type CreateIdentifierInput = { identifierValue: Scalars['String']; identifierType: Scalars['Int']; idSystemObject?: Maybe; - selected: Scalars['Boolean']; + preferred?: Maybe; +}; + +export type PublishInput = { + idSystemObject: Scalars['Int']; + eState: Scalars['Int']; +}; + +export type PublishResult = { + __typename?: 'PublishResult'; + success: Scalars['Boolean']; + message: Scalars['String']; }; @@ -1628,6 +1660,7 @@ export type SubjectDetailFields = { TS0?: Maybe; TS1?: Maybe; TS2?: Maybe; + idIdentifierPreferred?: Maybe; }; export type ItemDetailFields = { @@ -1672,8 +1705,6 @@ export type SceneDetailFields = { AssetType?: Maybe; Tours?: Maybe; Annotation?: Maybe; - HasBeenQCd?: Maybe; - IsOriented?: Maybe; CountScene?: Maybe; CountNode?: Maybe; CountCamera?: Maybe; @@ -1683,6 +1714,9 @@ export type SceneDetailFields = { CountSetup?: Maybe; CountTour?: Maybe; EdanUUID?: Maybe; + ApprovedForPublication?: Maybe; + PublicationApprover?: Maybe; + PosedAndQCd?: Maybe; idScene?: Maybe; }; @@ -1767,6 +1801,8 @@ export type GetSystemObjectDetailsResult = { objectType: Scalars['Int']; allowed: Scalars['Boolean']; publishedState: Scalars['String']; + publishedEnum: Scalars['Int']; + publishable: Scalars['Boolean']; thumbnail?: Maybe; identifiers: Array; objectAncestors: Array>; diff --git a/server/types/voyager/DocumentValidator.ts b/server/types/voyager/DocumentValidator.ts new file mode 100644 index 000000000..edbf7108a --- /dev/null +++ b/server/types/voyager/DocumentValidator.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/brace-style */ +/** + * 3D Foundation Project + * Copyright 2019 Smithsonian Institution + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ajv from 'ajv'; + +import * as documentSchema from './json/document.schema.json'; +import * as commonSchema from './json/common.schema.json'; +import * as metaSchema from './json/meta.schema.json'; +import * as modelSchema from './json/model.schema.json'; +import * as setupSchema from './json/setup.schema.json'; + +import { IDocument } from './document'; + +import * as LOG from '../../utils/logger'; +import * as H from '../../utils/helpers'; + +//////////////////////////////////////////////////////////////////////////////// + +export class DocumentValidator +{ + private _schemaValidator: ajv.Ajv; + private _validateDocument: ajv.ValidateFunction; + + constructor() + { + this._schemaValidator = new ajv({ + schemas: [ + documentSchema, + commonSchema, + metaSchema, + modelSchema, + setupSchema, + ], + allErrors: true + }); + + this._validateDocument = this._schemaValidator.getSchema( + 'https://schemas.3d.si.edu/voyager/document.schema.json' + ); + } + + validate(document: IDocument): H.IOResults + { + if (!this._validateDocument(document)) { + const error: string = this._schemaValidator.errorsText(this._validateDocument.errors, { separator: ', ', dataVar: 'document' }); + LOG.error(error, LOG.LS.eSYS); + return { success: false, error }; + } + + return { success: true, error: '' }; + } +} \ No newline at end of file diff --git a/server/types/voyager/index.ts b/server/types/voyager/index.ts index dc342f815..e386eddf0 100644 --- a/server/types/voyager/index.ts +++ b/server/types/voyager/index.ts @@ -2,4 +2,5 @@ export * from './common'; export * from './document'; export * from './meta'; export * from './model'; -export * from './setup'; \ No newline at end of file +export * from './setup'; +export { DocumentValidator } from './DocumentValidator'; diff --git a/server/types/voyager/json/common.schema.json b/server/types/voyager/json/common.schema.json new file mode 100644 index 000000000..9290c232e --- /dev/null +++ b/server/types/voyager/json/common.schema.json @@ -0,0 +1,97 @@ +{ + "$id": "https://schemas.3d.si.edu/voyager/common.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + + "title": "Math", + "description": "Definitions for mathematical compound objects such as vectors and matrices.", + + "definitions": { + "units": { + "$id": "#units", + "type": "string", + "enum": [ + "inherit", + "mm", + "cm", + "m", + "km", + "in", + "ft", + "yd", + "mi" + ] + }, + "vector2": { + "description": "2-component vector.", + "$id": "#vector2", + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 2, + "default": [ 0, 0 ] + }, + "vector3": { + "description": "3-component vector.", + "$id": "#vector3", + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3, + "default": [ 0, 0, 0 ] + }, + "vector4": { + "description": "4-component vector.", + "$id": "#vector4", + "type": "array", + "items": { + "type": "number" + }, + "minItems": 4, + "maxItems": 4, + "default": [ 0, 0, 0, 0 ] + }, + "matrix3": { + "description": "3 by 3, matrix, storage: column-major.", + "$id": "#matrix3", + "type": "array", + "items": { + "type": "number" + }, + "minItems": 9, + "maxItems": 9, + "default": [ 1, 0, 0, 0, 1, 0, 0, 0, 1 ] + }, + "matrix4": { + "description": "4 by 4 matrix, storage: column-major.", + "$id": "#matrix4", + "type": "array", + "items": { + "type": "number" + }, + "minItems": 16, + "maxItems": 16, + "default": [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ] + }, + "boundingBox": { + "description": "Axis-aligned 3D bounding box.", + "$id": "#boundingBox", + "type": "object", + "properties": { + "min": { + "$ref": "#/definitions/vector3" + }, + "max": { + "$ref": "#/definitions/vector3" + } + }, + "required": [ + "min", + "max" + ] + } + } +} diff --git a/server/types/voyager/json/document.schema.json b/server/types/voyager/json/document.schema.json new file mode 100644 index 000000000..918002106 --- /dev/null +++ b/server/types/voyager/json/document.schema.json @@ -0,0 +1,390 @@ +{ + "$id": "https://schemas.3d.si.edu/voyager/document.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + + "title": "Smithsonian 3D Document", + "description": "Describes a 3D document containing a scene with 3D models.", + + "definitions": { + "scene": { + "$id": "#scene", + "type": "object", + "properties": { + "nodes": { + "type": "array", + "description": "The indices of the scene's root nodes.", + "items": { + "type": "integer", + "minimum": 0 + }, + "uniqueItems": true, + "minItems": 1 + }, + "setup": { + "description": "The index of the scene's setup.", + "type": "integer", + "minimum": 0 + } + } + }, + "node": { + "$id": "#node", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "description": "The indices of this node's children.", + "items": { + "type": "integer", + "minimum": 0 + }, + "uniqueItems": true, + "minItems": 1 + }, + + "matrix": { + "description": "A floating-point 4x4 transformation matrix stored in column-major order.", + "type": "array", + "items": { + "type": "number" + }, + "minItems": 16, + "maxItems": 16, + "default": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] + }, + "translation": { + "description": "The node's translation along the x, y, and z axes.", + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3, + "default": [ 0.0, 0.0, 0.0 ] + }, + "rotation": { + "description": "The node's unit quaternion rotation in the order (x, y, z, w), where w is the scalar.", + "type": "array", + "items": { + "type": "number", + "minimum": -1.0, + "maximum": 1.0 + }, + "minItems": 4, + "maxItems": 4, + "default": [ 0.0, 0.0, 0.0, 1.0 ] + }, + "scale": { + "description": "The node's non-uniform scale, given as the scaling factors along the x, y, and z axes.", + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3, + "default": [ 1.0, 1.0, 1.0 ] + }, + + "camera": { + "description": "The index of the camera component of this node.", + "type": "integer", + "minimum": 0 + }, + "light": { + "description": "The index of the light component of this node.", + "type": "integer", + "minimum": 0 + }, + "meta": { + "description": "The index of the meta data component of this node.", + "type": "integer", + "minimum": 0 + }, + "model": { + "description": "The index of the model component of this node.", + "type": "integer", + "minimum": 0 + } + }, + "not": { + "anyOf": [ + { "required": [ "matrix", "translation" ] }, + { "required": [ "matrix", "rotation" ] }, + { "required": [ "matrix", "scale" ] } + ] + } + }, + + "camera": { + "$id": "#camera", + "type": "object", + "properties": { + "type": { + "description": "Specifies if the camera uses a perspective or orthographic projection.", + "type": "string", + "enum": [ + "perspective", + "orthographic" + ] + }, + "perspective": { + "description": "A perspective camera containing properties to create a perspective projection matrix.", + "type": "object", + "properties": { + "yfov": { + "type": "number", + "description": "The floating-point vertical field of view in radians.", + "exclusiveMinimum": 0.0 + }, + "aspectRatio": { + "type": "number", + "description": "The floating-point aspect ratio of the field of view.", + "exclusiveMinimum": 0.0 + }, + "znear": { + "type": "number", + "description": "The floating-point distance to the near clipping plane.", + "exclusiveMinimum": 0.0 + }, + "zfar": { + "type": "number", + "description": "The floating-point distance to the far clipping plane.", + "exclusiveMinimum": 0.0 + } + }, + "required": [ + "yfov", + "znear" + ] + }, + "orthographic": { + "description": "An orthographic camera containing properties to create an orthographic projection matrix.", + "type": "object", + "properties": { + "xmag": { + "type": "number", + "description": "The floating-point horizontal magnification of the view. Must not be zero." + }, + "ymag": { + "type": "number", + "description": "The floating-point vertical magnification of the view. Must not be zero." + }, + "znear": { + "type": "number", + "description": "The floating-point distance to the near clipping plane.", + "exclusiveMinimum": 0.0 + }, + "zfar": { + "type": "number", + "description": "The floating-point distance to the far clipping plane. `zfar` must be greater than `znear`.", + "exclusiveMinimum": 0.0 + } + }, + "required": [ + "xmag", + "ymag", + "znear", + "zfar" + ] + } + }, + "required": [ + "type" + ], + "not": { + "required": [ "perspective", "orthographic" ] + } + }, + + "light": { + "$id": "#light", + "type": "object", + "properties": { + "type": { + "description": "Specifies the type of the light source.", + "type": "string", + "enum": [ + "ambient", + "directional", + "point", + "spot", + "hemisphere" + ] + }, + "color": { + "$ref": "#/definitions/colorRGB" + }, + "intensity": { + "type": "number", + "minimum": 0, + "default": 1 + }, + "castShadow": { + "type": "boolean", + "default": false + }, + "point": { + "type": "object", + "properties": { + "distance": { + "type": "number", + "minimum": 0 + }, + "decay": { + "type": "number", + "minimum": 0 + } + } + }, + "spot": { + "type": "object", + "properties": { + "distance": { + "type": "number", + "minimum": 0 + }, + "decay": { + "type": "number", + "minimum": 0 + }, + "angle": { + "type": "number", + "minimum": 0 + }, + "penumbra": { + "type": "number", + "minimum": 0 + } + } + }, + "hemisphere": { + "type": "object", + "properties": { + "groundColor": { + "$ref": "#/definitions/colorRGB" + } + } + } + }, + "required": [ + "type" + ], + "not": { + "required": [ "point", "spot", "hemisphere" ] + } + }, + + "colorRGB": { + "$id": "#colorRGB", + "type": "array", + "items": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "minItems": 3, + "maxItems": 3, + "default": [ 1.0, 1.0, 1.0 ] + } + }, + + "type": "object", + "properties": { + "asset": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "application/si-dpo-3d.document+json" + }, + "version": { + "description": "Version of this presentation description.", + "type": "string", + "minLength": 1 + }, + "copyright": { + "description": "A copyright message to credit the content creator.", + "type": "string", + "minLength": 1 + }, + "generator": { + "description": "Tool that generated this presentation description.", + "type": "string", + "minLength": 1 + } + }, + "required": [ + "type", + "version" + ] + }, + "scene": { + "description": "Index of the root scene of the document.", + "type": "integer", + "minimum": 0 + }, + "scenes": { + "description": "An array of scenes.", + "type": "array", + "items": { + "$ref": "#/definitions/scene" + } + }, + "nodes": { + "description": "An array of nodes.", + "type": "array", + "items": { + "$ref": "#/definitions/node" + }, + "minItems": 1 + }, + "metas": { + "description": "An array of meta data components.", + "type": "array", + "items": { + "$ref": "./meta.schema.json" + } + }, + "setups": { + "description": "An array of setup components.", + "type": "array", + "items": { + "$ref": "./setup.schema.json" + }, + "minItems": 1 + }, + "cameras": { + "description": "An array of camera components.", + "type": "array", + "items": { + "$ref": "#/definitions/camera" + }, + "minItems": 1 + }, + "lights": { + "description": "An array of light components.", + "type": "array", + "items": { + "$ref": "#/definitions/light" + }, + "minItems": 1 + }, + "models": { + "description": "An array of model components.", + "type": "array", + "items": { + "$ref": "./model.schema.json" + }, + "minItems": 1 + } + }, + "required": [ + "asset", + "scene", + "scenes" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/server/types/voyager/json/meta.schema.json b/server/types/voyager/json/meta.schema.json new file mode 100644 index 000000000..ef0fe26d8 --- /dev/null +++ b/server/types/voyager/json/meta.schema.json @@ -0,0 +1,133 @@ +{ + "$id": "https://schemas.3d.si.edu/voyager/meta.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + + "title": "Meta", + "description": "Meta data for a scene or model item.", + + "definitions": { + "image": { + "$id": "image", + "description": "Reference to a preview image", + "type": "object", + "properties": { + "uri": { + "description": "Location of the image resource, absolute URL or path relative to this document", + "type": "string", + "minLength": 1 + }, + "quality": { + "type": "string", + "enum": [ "Thumb", "Low", "Medium", "High" ] + }, + "byteSize": { + "type": "integer", + "minimum": 1 + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "uri", + "quality" + ] + }, + "article": { + "$id": "#article", + "description": "Reference to an external document (HTML)", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "description": "Short title.", + "type": "string" + }, + "titles": { + "description": "Short title with language key.", + "type": "object" + }, + "lead": { + "description": "Short lead text.", + "type": "string" + }, + "leads": { + "description": "Short lead text with language key.", + "type": "object" + }, + "tags": { + "description": "Array of tags, categorizing the article.", + "type": "array", + "items": { + "type": "string" + } + }, + "taglist": { + "description": "Array of tags, categorizing the annotation with language key.", + "type": "object" + }, + "uri": { + "description": "Location of the article resource, absolute URL or path relative to this document", + "type": "string", + "minLength": 1 + }, + "uris": { + "description": "Location of the article resource, absolute URL or path relative to this document with language key", + "type": "object" + }, + "mimeType": { + "description": "MIME type of the resource.", + "type": "string" + }, + "thumbnailUri": { + "description": "Location of a thumbnail/preview image of the resource.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + } + }, + + "type": "object", + "properties": { + "collection": { + "description": "Information retrieved from the collection record for the item.", + "type": "object" + }, + "process": { + "description": "Information about how the item was processed.", + "type": "object" + }, + "images": { + "type": "array", + "items": { + "$ref": "#/definitions/image" + }, + "minItems": 1 + }, + "articles": { + "type": "array", + "items": { + "$ref": "#/definitions/article" + }, + "minItems": 1 + }, + "leadArticle": { + "description": "Index of the main article. This is the default article displayed with the item.", + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/server/types/voyager/json/model.schema.json b/server/types/voyager/json/model.schema.json new file mode 100644 index 000000000..3c7444f56 --- /dev/null +++ b/server/types/voyager/json/model.schema.json @@ -0,0 +1,274 @@ +{ + "$id": "https://schemas.3d.si.edu/voyager/model.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + + "title": "Model", + "description": "Describes the visual representations (models, derivatives) of a 3D item.", + + "definitions": { + "annotation": { + "description": "Spatial annotation (hot spot, hot zone) on a model. Annotations can reference articles.", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string" + }, + "titles": { + "description": "Short title with language key.", + "type": "object" + }, + "lead": { + "type": "string" + }, + "leads": { + "description": "Short lead text with language key.", + "type": "object" + }, + "marker": { + "type": "string" + }, + "tags": { + "description": "Array of tags, categorizing the annotation.", + "type": "array", + "items": { + "type": "string" + } + }, + "taglist": { + "description": "Array of tags, categorizing the annotation with language key.", + "type": "object" + }, + "articleId": { + "description": "Id of an article related to this annotation.", + "type": "string", + "minLength": 1 + }, + "imageUri": { + "description": "URI of an image resource for this annotation.", + "type": "string", + "minLength": 1 + }, + "style": { + "type": "string" + }, + "visible": { + "description": "Flag indicating whether the annotation is visible.", + "type": "boolean", + "default": true + }, + "expanded": { + "description": "Flag indicating whether the annotation is displayed in expanded state.", + "type": "boolean", + "default": false + }, + "scale": { + "description": "Scales the annotation relative to its default size.", + "type": "number", + "default": 1 + }, + "offset": { + "description": "Offsets the annotation along its direction.", + "type": "number", + "default": 0 + }, + "tilt": { + "description": "Tilt angle of the annotation relative to the direction vector in degrees.", + "type": "number", + "default": 0 + }, + "azimuth": { + "description": "Azimuth angle of a tilted annotation.", + "type": "number", + "default": 0 + }, + "color": { + "description": "Color of the annotation", + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "number" + } + }, + "position": { + "description": "Position where the annotation is anchored, in local item coordinates.", + "$ref": "./common.schema.json#/definitions/vector3" + }, + "direction": { + "description": "Direction of the stem of this annotation, usually corresponds to the surface normal.", + "$ref": "./common.schema.json#/definitions/vector3" + }, + "zoneIndex": { + "description": "Index of the zone on the zone texture.", + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "derivative": { + "description": "Visual representation derived from the master model.", + "type": "object", + "properties": { + "usage": { + "description": "usage categories for a derivative.", + "type": "string", + "enum": [ + "Image2D", + "Web3D", + "App3D", + "iOSApp3D", + "Print3D", + "Editorial3D" + ] + }, + "quality": { + "type": "string", + "enum": [ + "Thumb", + "Low", + "Medium", + "High", + "Highest", + "LOD", + "Stream", + "AR" + ] + }, + "assets": { + "description": "List of individual resources this derivative is composed of.", + "type": "array", + "items": { + "$ref": "#/definitions/asset" + } + } + } + }, + "asset": { + "description": "an individual resource for a 3D model.", + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": [ + "Model", + "Geometry", + "Image", + "Texture", + "Points", + "Volume" + ] + }, + "part": { + "type": "string", + "minLength": 1 + }, + "mimeType": { + "type": "string", + "minLength": 1 + }, + "byteSize": { + "type": "integer", + "minimum": 1 + }, + "numFaces": { + "type": "integer", + "minimum": 1 + }, + "imageSize": { + "type": "integer", + "minimum": 1 + }, + "mapType": { + "type": "string", + "enum": [ + "Color", + "Normal", + "Occlusion", + "Emissive", + "MetallicRoughness", + "Zone" + ] + } + }, + "required": [ + "uri", + "type" + ] + }, + "material": { + "description": "Surface properties for this model, shared by all derivatives.", + "type": "object", + "properties": { + } + } + }, + + "type": "object", + "properties": { + "units": { + "$ref": "./common.schema.json#/definitions/units" + }, + "tags": { + "type": "string" + }, + "visible": { + "type": "boolean" + }, + "renderOrder": { + "type": "number" + }, + "shadowSide": { + "type": "string", + "enum": [ + "Front", + "Back", + "Double" + ] + }, + "derivatives": { + "type": "array", + "items": { + "$ref": "#/definitions/derivative" + } + }, + "translation": { + "description": "Translation vector. Must be applied to bring model into its 'neutral' pose.", + "$ref": "./common.schema.json#/definitions/vector3" + }, + "rotation": { + "description": "Rotation quaternion. Must be applied to bring model into its 'neutral' pose.", + "$ref": "./common.schema.json#/definitions/vector4" + }, + "boundingBox": { + "description": "Bounding box for this model, shared by all derivatives.", + "$ref": "./common.schema.json#/definitions/boundingBox" + }, + "material": { + "$ref": "#/definitions/material" + }, + "annotations": { + "description": "List of annotations to be displayed with the model", + "type": "array", + "items": { + "$ref": "#/definitions/annotation" + } + } + }, + "required": [ + "units", + "derivatives" + ], + "additionalProperties": false +} diff --git a/server/types/voyager/json/setup.schema.json b/server/types/voyager/json/setup.schema.json new file mode 100644 index 000000000..414ba900e --- /dev/null +++ b/server/types/voyager/json/setup.schema.json @@ -0,0 +1,308 @@ +{ + "$id": "https://schemas.3d.si.edu/voyager/setup.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + + "title": "Setup", + "description": "Tours and settings for explorer documents (background, interface, etc.)", + + "definitions": { + "viewer": { + "type": "object", + "properties": { + "shader": { + "type": "string" + }, + "exposure": { + "type": "number" + }, + "gamma": { + "type": "number" + }, + "annotationsVisible": { + "type": "boolean" + }, + "activeTags": { + "type": "string" + }, + "sortedTags": { + "type": "string" + }, + "radioTags": { + "type": "boolean" + } + }, + "required": [ + "shader", + "exposure", + "gamma" + ] + }, + "reader": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "position": { + "type": "string" + }, + "articleId": { + "description": "Id of the article currently displayed in the reader.", + "type": "string", + "minLength": 1 + } + } + }, + "interface": { + "type": "object", + "properties": { + "visible": { + "type": "boolean" + }, + "logo": { + "type": "boolean" + }, + "menu": { + "type": "boolean" + }, + "tools": { + "type": "boolean" + } + } + }, + "navigation": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "Orbit", "Walk" + ] + }, + "enabled": { + "type": "boolean" + }, + "autoZoom": { + "type": "boolean" + }, + "orbit": { + "$comment": "TODO: Implement", + + "type": "object", + "properties": { + + } + }, + "walk": { + "$comment": "TODO: Implement", + + "type": "object", + "properties": { + } + } + } + }, + "background": { + "type": "object", + "properties": { + "style": { + "type": "string", + "enum": [ + "Solid", "LinearGradient", "RadialGradient" + ] + }, + "color0": { + "$ref": "./common.schema.json#/definitions/vector3" + }, + "color1": { + "$ref": "./common.schema.json#/definitions/vector3" + } + } + }, + "floor": { + "type": "object", + "properties": { + "visible": { + "type": "boolean" + }, + "position": { + "$ref": "./common.schema.json#/definitions/vector3" + }, + "size": { + "type": "number" + }, + "color": { + "$ref": "./common.schema.json#/definitions/vector3" + }, + "opacity": { + "type": "number" + }, + "receiveShadow": { + "type": "boolean" + } + } + }, + "grid": { + "type": "object", + "properties": { + "visible": { + "type": "boolean" + }, + "color": { + "$ref": "./common.schema.json#/definitions/vector3" + } + } + }, + "tape": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "startPosition": { + "$ref": "./common.schema.json#/definitions/vector3" + }, + "startDirection": { + "$ref": "./common.schema.json#/definitions/vector3" + }, + "endPosition": { + "$ref": "./common.schema.json#/definitions/vector3" + }, + "endDirection": { + "$ref": "./common.schema.json#/definitions/vector3" + } + } + }, + "slicer": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "axis": { + "type": "string", + "enum": [ "X", "Y", "Z" ] + }, + "inverted": { + "type": "boolean" + }, + "position": { + "type": "number" + } + } + }, + "tours": { + "description": "Animated tours.", + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "titles": { + "description": "Short title with language key.", + "type": "object" + }, + "lead": { + "type": "string" + }, + "leads": { + "description": "Short lead text with language key.", + "type": "object" + }, + "tags": { + "description": "Array of tags, categorizing the tour.", + "type": "array", + "items": { + "type": "string" + } + }, + "taglist": { + "description": "Array of tags, categorizing the annotation with language key.", + "type": "object" + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "titles": { + "description": "Short title with language key.", + "type": "object" + }, + "id": { + "type": "string" + } + } + } + } + } + } + }, + "snapshots": { + "description": "Snapshots are animatable scene states.", + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "type": "string" + } + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "states": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + + "type": "object", + "properties": { + "units": { + "$ref": "./common.schema.json#/definitions/units" + }, + "interface": { + "$ref": "#/definitions/interface" + }, + "viewer": { + "$ref": "#/definitions/viewer" + }, + "reader": { + "$ref": "#/definitions/reader" + }, + "navigation": { + "$ref": "#/definitions/navigation" + }, + "background": { + "$ref": "#/definitions/background" + }, + "floor": { + "$ref": "#/definitions/floor" + }, + "grid": { + "$ref": "#/definitions/grid" + }, + "tape": { + "$ref": "#/definitions/tape" + }, + "slicer": { + "$ref": "#/definitions/slicer" + }, + "tours": { + "$ref": "#/definitions/tours" + } + } +} diff --git a/server/types/voyager/model.ts b/server/types/voyager/model.ts index 75ca6239f..4c7f7ff8e 100644 --- a/server/types/voyager/model.ts +++ b/server/types/voyager/model.ts @@ -23,10 +23,9 @@ * limitations under the License. */ -// import { Index, Dictionary } from "@ff/core/types"; -type Dictionary = Record; - +// import { Dictionary } from "@ff/core/types"; // import { ColorRGB, ColorRGBA, EUnitType, TUnitType, Vector3, Vector4 } from "./common"; +type Dictionary = Record; import { ColorRGBA, TUnitType, Vector3, Vector4 } from "./common"; //////////////////////////////////////////////////////////////////////////////// @@ -147,6 +146,6 @@ export interface IPBRMaterialSettings //emissiveFactor?: ColorRGB; //alphaMode?: any; // TODO //alphaCutoff?: number; - //doubleSided?: boolean; + doubleSided?: boolean; normalSpace?: TNormalSpaceType; } diff --git a/server/types/voyager/setup.ts b/server/types/voyager/setup.ts index f10c4e99d..a8f6aa0b9 100644 --- a/server/types/voyager/setup.ts +++ b/server/types/voyager/setup.ts @@ -72,6 +72,7 @@ export interface IViewer { shader: TShaderMode; exposure: number; + toneMapping: boolean; gamma: number; annotationsVisible?: boolean; activeTags?: string; diff --git a/server/utils/helpers.ts b/server/utils/helpers.ts index 545649b80..f361cef16 100644 --- a/server/utils/helpers.ts +++ b/server/utils/helpers.ts @@ -537,4 +537,9 @@ export class Helpers { return anchor; return `${Helpers.escapeHTMLEntity(anchor)}`; } + + static validFieldId(value: any): boolean { + if (typeof value === 'number' && value > 0 && value < 2147483648) return true; + return false; + } } \ No newline at end of file diff --git a/server/utils/localStore.ts b/server/utils/localStore.ts index 2a121d287..489d667f9 100644 --- a/server/utils/localStore.ts +++ b/server/utils/localStore.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { AsyncLocalStorage } from 'async_hooks'; -import * as LOG from './logger'; +// import * as LOG from './logger'; export class LocalStore { idRequest: number; @@ -9,6 +9,7 @@ export class LocalStore { private idWorkflowStep?: number | undefined; private idWorkflowReport?: number | undefined; idWorkflowSet?: number | undefined; + transactionNumber?: number | undefined; private static idRequestNext: number = 0; private static getIDRequestNext(): number { @@ -23,19 +24,19 @@ export class LocalStore { getWorkflowID(): number | undefined { const idWorkflow: number | undefined = this.idWorkflow.length > 0 ? this.idWorkflow[0] : undefined; - LOG.info(`LocalStore.getWorkflowID() = ${idWorkflow}: ${JSON.stringify(this.idWorkflow)}`, LOG.LS.eSYS); + // LOG.info(`LocalStore.getWorkflowID() = ${idWorkflow}: ${JSON.stringify(this.idWorkflow)}`, LOG.LS.eSYS); return idWorkflow; } pushWorkflow(idWorkflow: number, idWorkflowStep?: number | undefined): void { this.idWorkflow.unshift(idWorkflow); - LOG.info(`LocalStore.pushWorkflow(${idWorkflow}): ${JSON.stringify(this.idWorkflow)}`, LOG.LS.eSYS); + // LOG.info(`LocalStore.pushWorkflow(${idWorkflow}): ${JSON.stringify(this.idWorkflow)}`, LOG.LS.eSYS); this.idWorkflowStep = idWorkflowStep; } popWorkflowID(): void { this.idWorkflow.shift(); - LOG.info(`LocalStore.popWorkflowID: ${JSON.stringify(this.idWorkflow)}`, LOG.LS.eSYS); + // LOG.info(`LocalStore.popWorkflowID: ${JSON.stringify(this.idWorkflow)}`, LOG.LS.eSYS); this.idWorkflowReport = undefined; } diff --git a/server/utils/parser/bulkIngestReader.ts b/server/utils/parser/bulkIngestReader.ts index d50f6601e..4e41a16f3 100644 --- a/server/utils/parser/bulkIngestReader.ts +++ b/server/utils/parser/bulkIngestReader.ts @@ -120,7 +120,7 @@ export class BulkIngestReader { return (obj as IngestModel).modality !== undefined; } static ingestedObjectIsScene(obj: (IngestPhotogrammetry | IngestModel | IngestScene)): obj is IngestScene { - return (obj as IngestScene).hasBeenQCd !== undefined; + return (obj as IngestScene).approvedForPublication !== undefined; } static async computeProjects(ingestMetadata: IngestMetadata): Promise { @@ -436,8 +436,8 @@ export class BulkIngestReader { idAssetVersion: 0, systemCreated: true, name: bagitScene.name, - hasBeenQCd: bagitScene.has_been_qcd !== 'false' && bagitScene.has_been_qcd !== '0', - isOriented: bagitScene.is_oriented !== 'false' && bagitScene.is_oriented !== '0', + approvedForPublication: bagitScene.approved_for_publication !== 'false' && bagitScene.approved_for_publication !== '0', + posedAndQCd: bagitScene.posed_and_qcd !== 'false' && bagitScene.posed_and_qcd !== '0', directory: bagitScene.directory_path, identifiers: [], referenceModels: [], diff --git a/server/utils/parser/csvParser.ts b/server/utils/parser/csvParser.ts index 850aa2ed2..e51546a31 100644 --- a/server/utils/parser/csvParser.ts +++ b/server/utils/parser/csvParser.ts @@ -24,7 +24,7 @@ export class CSVParser { stream.on('error', () => reject()); stream.on('end', () => resolve(rows)); } catch (error) /* istanbul ignore next */ { - LOG.error(error, LOG.LS.eSYS); + LOG.error('CSVParser.parse', LOG.LS.eSYS, error); reject(); } }); diff --git a/server/utils/parser/csvTypes.ts b/server/utils/parser/csvTypes.ts index 7b5e5121f..834e84f9d 100644 --- a/server/utils/parser/csvTypes.ts +++ b/server/utils/parser/csvTypes.ts @@ -10,7 +10,7 @@ type CSVHeadersType = Record; export const CSVHeaders: CSVHeadersType = { capture_data_photo: ['subject_guid', 'subject_name', 'unit_guid', 'unit_name', 'item_guid', 'item_name', 'entire_subject', 'name', 'date_captured', 'description', 'capture_dataset_type', 'capture_dataset_field_id', 'item_position_type', 'item_position_field_id', 'item_arrangement_field_id', 'focus_type', 'light_source_type', 'background_removal_method', 'cluster_type', 'cluster_geometry_field_id', 'directory_path'], models: ['subject_guid', 'subject_name', 'unit_guid', 'unit_name', 'item_guid', 'item_name', 'entire_subject', 'name', 'date_created', 'creation_method', 'modality', 'units', 'purpose', 'directory_path'], - scenes: ['subject_guid', 'subject_name', 'unit_guid', 'unit_name', 'item_guid', 'item_name', 'entire_subject', 'name', 'is_oriented', 'has_been_qcd', 'directory_path'], + scenes: ['subject_guid', 'subject_name', 'unit_guid', 'unit_name', 'item_guid', 'item_name', 'entire_subject', 'name', 'posed_and_qcd', 'approved_for_publication', 'directory_path'], }; export type SubjectsCSVFields = { @@ -55,8 +55,8 @@ export type ModelsCSVFields = { export type ScenesCSVFields = { name: string; - is_oriented: string; - has_been_qcd: string; + posed_and_qcd: string; + approved_for_publication: string; directory_path: string; }; diff --git a/server/utils/parser/svxReader.ts b/server/utils/parser/svxReader.ts index 3ef4ea97b..f7f68cf64 100644 --- a/server/utils/parser/svxReader.ts +++ b/server/utils/parser/svxReader.ts @@ -27,8 +27,6 @@ export class SvxExtraction { return new DBAPI.Scene({ Name, idAssetThumbnail: null, - IsOriented: false, - HasBeenQCd: false, CountScene: this.sceneCount, CountNode: this.nodeCount, CountCamera: this.cameraCount, @@ -38,6 +36,8 @@ export class SvxExtraction { CountSetup: this.setupCount, CountTour: this.tourCount, EdanUUID: null, + PosedAndQCd: false, + ApprovedForPublication: false, idScene: 0 }); } @@ -152,6 +152,7 @@ export class SvxExtraction { export class SvxReader { SvxDocument: SVX.IDocument | null = null; SvxExtraction: SvxExtraction | null = null; + static DV: SVX.DocumentValidator = new SVX.DocumentValidator(); async loadFromStream(readStream: NodeJS.ReadableStream): Promise { try { @@ -169,6 +170,13 @@ export class SvxReader { async loadFromJSON(json: string): Promise { try { const obj: any = JSON.parse(json); // may throw an exception, if json is not valid JSON + const validRes: H.IOResults = SvxReader.DV.validate(obj); + if (!validRes.success) { + const error: string = `SVX JSON Validation Failed: ${validRes.error}`; + LOG.error(`SvxReader.loadFromJSON ${error}`, LOG.LS.eSYS); + return { success: false, error }; + } + const { svx, results } = SvxExtraction.extract(obj); if (!results.success) return results; diff --git a/server/workflow/impl/Packrat/WorkflowJob.ts b/server/workflow/impl/Packrat/WorkflowJob.ts index 202d74d04..11ed501c8 100644 --- a/server/workflow/impl/Packrat/WorkflowJob.ts +++ b/server/workflow/impl/Packrat/WorkflowJob.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types, no-constant-condition */ import * as WF from '../../interface'; +import { WorkflowUtil, WorkflowUtilExtractAssetVersions } from './WorkflowUtil'; import * as JOB from '../../../job/interface'; import * as REP from '../../../report/interface'; import * as DBAPI from '../../../db'; @@ -240,20 +241,11 @@ export class WorkflowJob implements WF.IWorkflow { if (!this.workflowParams.idSystemObject) return { success: true, error: '' }; // OK to call without objects to act on, at least at this point -- the job itself may complain once started - this.idAssetVersions = []; - for (const idSystemObject of this.workflowParams.idSystemObject) { - const OID: DBAPI.ObjectIDAndType | undefined = await CACHE.SystemObjectCache.getObjectFromSystem(idSystemObject); - if (!OID) { - const error: string = `WorkflowJob.start unable to compute system object type for ${idSystemObject}`; - LOG.error(error, LOG.LS.eWF); - return { success: false, error }; - } else if (OID.eObjectType != DBAPI.eSystemObjectType.eAssetVersion) { - const error: string = `WorkflowJob.start called with invalid system object type ${JSON.stringify(OID)} for ${idSystemObject}; expected eAssetVersion`; - LOG.error(error, LOG.LS.eWF); - return { success: false, error }; - } - this.idAssetVersions.push(OID.idObject); - } + const WFUVersion: WorkflowUtilExtractAssetVersions = await WorkflowUtil.extractAssetVersions(this.workflowParams.idSystemObject); + if (!WFUVersion.success) + return { success: false, error: WFUVersion.error }; + + this.idAssetVersions = WFUVersion.idAssetVersions; return { success: true, error: '' }; } } \ No newline at end of file diff --git a/server/workflow/impl/Packrat/WorkflowUpload.ts b/server/workflow/impl/Packrat/WorkflowUpload.ts index 410fa287e..7821309aa 100644 --- a/server/workflow/impl/Packrat/WorkflowUpload.ts +++ b/server/workflow/impl/Packrat/WorkflowUpload.ts @@ -1,6 +1,16 @@ import * as WF from '../../interface'; +import { WorkflowUtil, WorkflowUtilExtractAssetVersions } from './WorkflowUtil'; import * as DBAPI from '../../../db'; +import * as STORE from '../../../storage/interface'; +import * as REP from '../../../report/interface'; +import * as LOG from '../../../utils/logger'; import * as H from '../../../utils/helpers'; +import { ZipStream } from '../../../utils/zipStream'; +import { SvxReader } from '../../../utils/parser'; + +// import * as sharp from 'sharp'; +import sharp from 'sharp'; +import * as path from 'path'; // This Workflow represents an ingestion action, typically initiated by a user. // The workflow itself performs no work (ingestion is performed in the graphQl ingestData routine) @@ -8,6 +18,8 @@ import * as H from '../../../utils/helpers'; export class WorkflowUpload implements WF.IWorkflow { private workflowParams: WF.WorkflowParameters; private workflowData: DBAPI.WorkflowConstellation; + private workflowReport: REP.IReport | null = null; + private results: H.IOResults = { success: true, error: '' }; static async constructWorkflow(workflowParams: WF.WorkflowParameters, WFC: DBAPI.WorkflowConstellation): Promise { return new WorkflowUpload(workflowParams, WFC); @@ -20,13 +32,18 @@ export class WorkflowUpload implements WF.IWorkflow { } async start(): Promise { + this.workflowReport = await REP.ReportFactory.getReport(); + const workflowStep: DBAPI.WorkflowStep | null = (!this.workflowData.workflowStep || this.workflowData.workflowStep.length <= 0) ? null : this.workflowData.workflowStep[this.workflowData.workflowStep.length - 1]; if (workflowStep) { workflowStep.setState(DBAPI.eWorkflowJobRunStatus.eRunning); await workflowStep.update(); } - return { success: true, error: '' }; + const validateRes: H.IOResults = await this.validateFiles(); + if (!validateRes.success) + await this.updateStatus(DBAPI.eWorkflowJobRunStatus.eError); + return validateRes; } async update(_workflowStep: DBAPI.WorkflowStep, _jobRun: DBAPI.JobRun): Promise { @@ -49,10 +66,107 @@ export class WorkflowUpload implements WF.IWorkflow { } async waitForCompletion(_timeout: number): Promise { - return { success: true, error: '' }; + return this.results; } async workflowConstellation(): Promise { return this.workflowData; } + + private async validateFiles(): Promise { + this.appendToWFReport('Upload validating files'); + + const WFUVersion: WorkflowUtilExtractAssetVersions = await WorkflowUtil.extractAssetVersions(this.workflowParams.idSystemObject); + if (!WFUVersion.success) { + this.results = { success: false, error: WFUVersion.error }; + return this.results; + } + + if (!WFUVersion.idAssetVersions) + return this.results; + + for (const idAssetVersion of WFUVersion.idAssetVersions) { + const RSR: STORE.ReadStreamResult = await STORE.AssetStorageAdapter.readAssetVersionByID(idAssetVersion); + if (!RSR.success || !RSR.readStream || !RSR.fileName) + return this.handleError(`WorkflowUpload.validateFiles unable to read asset version ${idAssetVersion}: ${RSR.error}`); + + let fileRes: H.IOResults = { success: true, error: '' }; + if (path.extname(RSR.fileName).toLowerCase() !== '.zip') + fileRes = await this.validateFile(RSR.fileName, RSR.readStream); + else { + const ZS: ZipStream = new ZipStream(RSR.readStream); + const zipRes: H.IOResults = await ZS.load(); + if (!zipRes.success) + return this.handleError(`WorkflowUpload.validateFiles unable to read zipped asset version ${idAssetVersion}: ${zipRes.error}`); + + const files: string[] = await ZS.getJustFiles(null); + for (const fileName of files) { + const readStream: NodeJS.ReadableStream | null = await ZS.streamContent(fileName); + if (!readStream) + return this.handleError(`WorkflowUpload.validateFiles unable to fetch read stream for ${fileName} in zip of idAssetVersion ${idAssetVersion}`); + fileRes = await this.validateFile(fileName, readStream); + } + } + + if (!fileRes.success) + return this.results; + } + + return this.results; + } + + private async validateFile(fileName: string, readStream: NodeJS.ReadableStream): Promise { + try { + // validate scene file by loading it: + if (fileName.toLowerCase().endsWith('.svx.json')) { + const svxReader: SvxReader = new SvxReader(); + const svxRes: H.IOResults = await svxReader.loadFromStream(readStream); + // LOG.info(`WorkflowUpload.validateFile validating SVX: ${svxRes.success}`, LOG.LS.eWF); + return (svxRes.success) + ? this.appendToWFReport(`Upload validated ${fileName}`) + : this.handleError(`WorkflowUpload.validateFile failed to parse svx file ${fileName}: ${svxRes.error}`); + } + + const extension: string = path.extname(fileName).toLowerCase(); + switch (extension) { + case '.avif': + case '.gif': + case '.jpg': + case '.jpeg': + case '.png': + case '.svg': + case '.tif': + case '.tiff': + case '.webp': { + const buffer: Buffer | null = await H.Helpers.readFileFromStream(readStream); + if (!buffer) + return this.handleError(`WorkflowUpload.validateFile unable to read stream for ${fileName}`); + const SH: sharp.Sharp = sharp(buffer); + const stats: sharp.Stats = await SH.stats(); + // LOG.info(`WorkflowUpload.validateFile validating image with extension ${extension}, with ${stats.channels.length} channels`, LOG.LS.eWF); + return (stats.channels.length >= 1) + ? this.appendToWFReport(`Upload validated ${fileName}`) + : this.handleError(`WorkflowUpload.validateFile encountered invalid image ${fileName}`); + } + + default: break; + } + } catch (error) { + return this.handleError(`WorkflowUpload.validateFile encountered exception processing ${fileName}${(error instanceof Error) ? ': ' + error.message : ''}`); + } + return { success: true, error: '' }; + } + + private async appendToWFReport(message: string): Promise { + LOG.info(message, LOG.LS.eWF); + return (this.workflowReport) ? this.workflowReport.append(message) : { success: true, error: '' }; + } + + private async handleError(error: string): Promise { + this.appendToWFReport(error); + + LOG.error(error, LOG.LS.eWF); + this.results = { success: false, error }; + return this.results; + } } diff --git a/server/workflow/impl/Packrat/WorkflowUtil.ts b/server/workflow/impl/Packrat/WorkflowUtil.ts new file mode 100644 index 000000000..7fb45edb4 --- /dev/null +++ b/server/workflow/impl/Packrat/WorkflowUtil.ts @@ -0,0 +1,34 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types, no-constant-condition */ +import * as DBAPI from '../../../db'; +import * as CACHE from '../../../cache'; +import * as LOG from '../../../utils/logger'; + +export type WorkflowUtilExtractAssetVersions = { + success: boolean; + error: string; + idAssetVersions: number[] | null; +}; + +export class WorkflowUtil { + static async extractAssetVersions(idSOs: number[] | null): Promise { + // confirm that idSystemObject are asset versions; ultimately, we will want to allow a model and/or capture data, depending on the recipe + if (!idSOs) + return { success: true, error: '', idAssetVersions: null }; // OK to call without objects to act on, at least at this point -- the job itself may complain once started + + const idAssetVersions: number[] | null = []; + for (const idSystemObject of idSOs) { + const OID: DBAPI.ObjectIDAndType | undefined = await CACHE.SystemObjectCache.getObjectFromSystem(idSystemObject); + if (!OID) { + const error: string = `WorkflowUtil.extractAssetVersions unable to compute system object type for ${idSystemObject}`; + LOG.error(error, LOG.LS.eWF); + return { success: false, error, idAssetVersions: null }; + } else if (OID.eObjectType != DBAPI.eSystemObjectType.eAssetVersion) { + const error: string = `WorkflowUtil.extractAssetVersions called with invalid system object type ${JSON.stringify(OID)} for ${idSystemObject}; expected eAssetVersion`; + LOG.error(error, LOG.LS.eWF); + return { success: false, error, idAssetVersions: null }; + } + idAssetVersions.push(OID.idObject); + } + return { success: true, error: '', idAssetVersions }; + } +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 0ffd56526..4c97001b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3213,22 +3213,22 @@ native-url "^0.2.6" schema-utils "^2.6.5" -"@prisma/client@2.25.0": - version "2.25.0" - resolved "https://registry.yarnpkg.com/@prisma/client/-/client-2.25.0.tgz#a81cdf93ce93128eb35298cf8935480f3da6cca3" - integrity sha512-JDrAJ+oemiYAwgpYNJvCVT59S9bMbqkx78q2OT54xmmBoyYWWnn6t6oS6q8gKMiKHS6rzm/jdh3sy+2E0R+NAQ== +"@prisma/client@3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.4.0.tgz#d94eca61847be13c505751b8875d7cb8951476ad" + integrity sha512-rjnp7dPVArqT/VgeWllUN+5927u224Gud//d16omL2hyXl9BnWzmeZJQ0plTYuP5yCR/M1N4iUC8ysDPlhA08g== dependencies: - "@prisma/engines-version" "2.25.0-36.c838e79f39885bc8e1611849b1eb28b5bb5bc922" + "@prisma/engines-version" "3.4.0-27.1c9fdaa9e2319b814822d6dbfd0a69e1fcc13a85" -"@prisma/engines-version@2.25.0-36.c838e79f39885bc8e1611849b1eb28b5bb5bc922": - version "2.25.0-36.c838e79f39885bc8e1611849b1eb28b5bb5bc922" - resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-2.25.0-36.c838e79f39885bc8e1611849b1eb28b5bb5bc922.tgz#b353576a97d0c1952fd4f4201189e845aaafbea8" - integrity sha512-uZaonv3ZzLYAi99AooOe2BOBmb3k+ibVsJyZ5J3F6U1uFHTtTI9AVzC51mE09iNcgq3ZBt2CZNi5CDQZedMWyA== +"@prisma/engines-version@3.4.0-27.1c9fdaa9e2319b814822d6dbfd0a69e1fcc13a85": + version "3.4.0-27.1c9fdaa9e2319b814822d6dbfd0a69e1fcc13a85" + resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.4.0-27.1c9fdaa9e2319b814822d6dbfd0a69e1fcc13a85.tgz#87868281c28b6b9b9e13ee844bd93edf0f38390a" + integrity sha512-acL30wD3lj6qGAN9JIA+fhcd8j5I+Db9d1Pge2jwmK/QVbmbnH+lhTMV5pYIdgxbZWs9R+DoNnOHvUQCOW1xeQ== -"@prisma/engines@2.25.0-36.c838e79f39885bc8e1611849b1eb28b5bb5bc922": - version "2.25.0-36.c838e79f39885bc8e1611849b1eb28b5bb5bc922" - resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-2.25.0-36.c838e79f39885bc8e1611849b1eb28b5bb5bc922.tgz#68d7850d311df6d017e1b878adb19ec21483bcf0" - integrity sha512-vjLCk8AFRZu3D8h/SMcWDzTo0xkMuUDyXQzXekn8gzAGjb47B6LQXGR6rDoZ3/uPM13JNTLPvF62mtVaY6fVeQ== +"@prisma/engines@3.4.0-27.1c9fdaa9e2319b814822d6dbfd0a69e1fcc13a85": + version "3.4.0-27.1c9fdaa9e2319b814822d6dbfd0a69e1fcc13a85" + resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.4.0-27.1c9fdaa9e2319b814822d6dbfd0a69e1fcc13a85.tgz#5ec71566f215f6834b0f4be267cd9690e086210a" + integrity sha512-jyCjXhX1ZUbzA7+6Hm0iEdeY+qFfpD/RB7iSwMrMoIhkVYvnncSdCLBgbK0yqxTJR2nglevkDY2ve3QDxFciMA== "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" @@ -3989,6 +3989,13 @@ "@types/mime" "^1" "@types/node" "*" +"@types/sharp@0.29.2": + version "0.29.2" + resolved "https://registry.yarnpkg.com/@types/sharp/-/sharp-0.29.2.tgz#b4e932e982e258d1013236c8b4bcc14f9883c9a3" + integrity sha512-tIbMvtPa8kMyFMKNhpsPT1HO3CgXLuiCAA8bxHAGAZLyALpYvYc4hUu3pu0+3oExQA5LwvHrWp+OilgXCYVQgg== + dependencies: + "@types/node" "*" + "@types/solr-client@0.7.4": version "0.7.4" resolved "https://registry.yarnpkg.com/@types/solr-client/-/solr-client-0.7.4.tgz#d5c2a671491bc3e22967da28f29c025a095cb086" @@ -4545,6 +4552,16 @@ ajv-keywords@^3.1.0, ajv-keywords@^3.4.1, ajv-keywords@^3.5.2: resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== +ajv@6.10.2: + version "6.10.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52" + integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw== + dependencies: + fast-deep-equal "^2.0.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.6: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -5512,6 +5529,15 @@ bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" +bl@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + bluebird@^3.5.1, bluebird@^3.5.3, bluebird@^3.5.5, bluebird@^3.7.2: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -5752,7 +5778,7 @@ buffer@^4.3.0: ieee754 "^1.1.4" isarray "^1.0.0" -buffer@^5.7.0: +buffer@^5.5.0, buffer@^5.7.0: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== @@ -6365,6 +6391,14 @@ color-string@^1.5.2, color-string@^1.5.4: color-name "^1.0.0" simple-swizzle "^0.2.2" +color-string@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312" + integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + color@3.0.x: version "3.0.0" resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a" @@ -6381,6 +6415,14 @@ color@^3.0.0: color-convert "^1.9.1" color-string "^1.5.4" +color@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/color/-/color-4.0.1.tgz#21df44cd10245a91b1ccf5ba031609b0e10e7d67" + integrity sha512-rpZjOKN5O7naJxkH2Rx1sZzzBgaiWECc6BYXjeCE6kF0kcASJYbUq02u7JqIHwCb/j3NhV+QhRL2683aICeGZA== + dependencies: + color-convert "^2.0.1" + color-string "^1.6.0" + colorette@^1.2.1: version "1.2.2" resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" @@ -7363,6 +7405,13 @@ decompress-response@^3.3.0: dependencies: mimic-response "^1.0.0" +decompress-response@^4.2.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986" + integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw== + dependencies: + mimic-response "^2.0.0" + dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" @@ -7520,6 +7569,11 @@ detect-indent@^6.0.0: resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.0.0.tgz#0abd0f549f69fc6659a254fe96786186b6f528fd" integrity sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA== +detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= + detect-newline@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" @@ -7877,7 +7931,7 @@ encoding@^0.1.11: dependencies: iconv-lite "^0.6.2" -end-of-stream@^1.0.0, end-of-stream@^1.1.0: +end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== @@ -8490,6 +8544,11 @@ expand-brackets@^2.1.4: snapdragon "^0.8.1" to-regex "^3.0.1" +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + expect@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/expect/-/expect-24.9.0.tgz#b75165b4817074fa4a157794f46fe9f1ba15b6ca" @@ -8629,6 +8688,11 @@ extsprintf@^1.2.0: resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= +fast-deep-equal@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" + integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= + fast-deep-equal@^3.1.1: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -9105,6 +9169,11 @@ fs-capacitor@^6.1.0: resolved "https://registry.yarnpkg.com/fs-capacitor/-/fs-capacitor-6.2.0.tgz#fa79ac6576629163cb84561995602d8999afb7f5" integrity sha512-nKcE1UduoSKX27NSZlg879LdQc94OtbOsEmKMN2MBNudXREvijRKx2GEBsTMTfws+BrbkJoEuynbGSVRSpauvw== +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + fs-extra@9.1.0: version "9.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" @@ -9348,6 +9417,11 @@ gitconfiglocal@^1.0.0: dependencies: ini "^1.3.2" +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4= + glob-parent@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" @@ -13060,6 +13134,11 @@ mime-db@1.47.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.47.0.tgz#8cb313e59965d3c05cfbf898915a267af46a335c" integrity sha512-QBmA/G2y+IfeS4oktet3qRZ+P5kPhCKRXxXnQEudYqUaEioAU1/Lq2us3D/t1Jfo4hE9REQPrbB7K5sOczJVIw== +mime-db@1.50.0: + version "1.50.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.50.0.tgz#abd4ac94e98d3c0e185016c67ab45d5fde40c11f" + integrity sha512-9tMZCDlYHqeERXEHO9f/hKfNXhre5dK2eE/krIvUjZbS2KPcqGDfNShIWS1uW9XOTKQKqK6qbeOci18rbfW77A== + mime-types@2.1.30: version "2.1.30" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.30.tgz#6e7be8b4c479825f85ed6326695db73f9305d62d" @@ -13074,6 +13153,13 @@ mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: dependencies: mime-db "1.46.0" +mime-types@^2.1.18: + version "2.1.33" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.33.tgz#1fa12a904472fafd068e48d9e8401f74d3f70edb" + integrity sha512-plLElXp7pRDd0bNZHw+nMd52vRYjLwQjygaNg7ddJ2uJtTlmnTCjWuPKxVu6//AdaRuME84SvLW91sIkBqGT0g== + dependencies: + mime-db "1.50.0" + mime@1.6.0, mime@^1.4.1: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" @@ -13099,6 +13185,11 @@ mimic-response@^1.0.0, mimic-response@^1.0.1: resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== +mimic-response@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" + integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== + min-indent@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" @@ -13156,7 +13247,7 @@ minimist-options@^3.0.1: arrify "^1.0.1" is-plain-obj "^1.1.0" -minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5: +minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== @@ -13236,6 +13327,11 @@ mixin-object@^2.0.1: for-in "^0.1.3" is-extendable "^0.1.1" +mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + mkdirp-promise@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/mkdirp-promise/-/mkdirp-promise-5.0.1.tgz#e9b8f68e552c68a9c1713b84883f7a1dd039b8a1" @@ -13378,6 +13474,11 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" +napi-build-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" + integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== + native-url@^0.2.6: version "0.2.6" resolved "https://registry.yarnpkg.com/native-url/-/native-url-0.2.6.tgz#ca1258f5ace169c716ff44eccbddb674e10399ae" @@ -13423,6 +13524,18 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" +node-abi@^2.21.0: + version "2.30.1" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-2.30.1.tgz#c437d4b1fe0e285aaf290d45b45d4d7afedac4cf" + integrity sha512-/2D0wOQPgaUWzVSVgRMx+trKJRC2UG4SUc4oCJoXx9Uxjtp0Vy3/kt7zcbxHF8+Z/pK3UloLWzBISg72brfy1w== + dependencies: + semver "^5.4.1" + +node-addon-api@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.2.0.tgz#117cbb5a959dff0992e1c586ae0393573e4d2a87" + integrity sha512-eazsqzwG2lskuzBqCGPi7Ac2UgOoMz8JVOXVhTvvPDYhthvNpefx8jWD8Np7Gv+2Sz0FlPWZk0nJV0z598Wn8Q== + node-fetch-npm@^2.0.2: version "2.0.4" resolved "https://registry.yarnpkg.com/node-fetch-npm/-/node-fetch-npm-2.0.4.tgz#6507d0e17a9ec0be3bec516958a497cec54bf5a4" @@ -13704,7 +13817,7 @@ npm-run-path@^4.0.0: dependencies: path-key "^3.0.0" -npmlog@^4.1.2: +npmlog@^4.0.1, npmlog@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -15231,6 +15344,25 @@ postcss@^7, postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.17, po source-map "^0.6.1" supports-color "^6.1.0" +prebuild-install@^6.1.4: + version "6.1.4" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-6.1.4.tgz#ae3c0142ad611d58570b89af4986088a4937e00f" + integrity sha512-Z4vpywnK1lBg+zdPCVCsKq0xO66eEV9rWo2zrROGGiRS4JtueBOdlB1FnY8lcy7JsUud/Q3ijUxyWN26Ika0vQ== + dependencies: + detect-libc "^1.0.3" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^1.0.1" + node-abi "^2.21.0" + npmlog "^4.0.1" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^3.0.3" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + precond@0.2: version "0.2.3" resolved "https://registry.yarnpkg.com/precond/-/precond-0.2.3.tgz#aa9591bcaa24923f1e0f4849d240f47efc1075ac" @@ -15306,12 +15438,12 @@ pretty-format@^26.0.0, pretty-format@^26.6.2: ansi-styles "^4.0.0" react-is "^17.0.1" -prisma@2.25.0: - version "2.25.0" - resolved "https://registry.yarnpkg.com/prisma/-/prisma-2.25.0.tgz#1ebfef3e945a22c673b3e3c5100f098da475700d" - integrity sha512-AdAlP+PShvugljIx62Omu+eLKu6Cozz06dehmClIHSb0/yFiVnyBtrRVV4LZus+QX6Ayg7CTDvtzroACAWl+Zw== +prisma@3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.4.0.tgz#3ac310c9bf390c366b198d4aec14f728e390752d" + integrity sha512-W0AFjVxPOLW5SEnf0ZwbOu4k8ElX98ioFC1E8Gb9Q/nuO2brEwxFJebXglfG+N6zphGbu2bG1I3VAu7aYzR3VA== dependencies: - "@prisma/engines" "2.25.0-36.c838e79f39885bc8e1611849b1eb28b5bb5bc922" + "@prisma/engines" "3.4.0-27.1c9fdaa9e2319b814822d6dbfd0a69e1fcc13a85" process-nextick-args@~2.0.0: version "2.0.1" @@ -15628,7 +15760,7 @@ raw-body@2.4.0: iconv-lite "0.4.24" unpipe "1.0.0" -rc@^1.2.8: +rc@^1.2.7, rc@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== @@ -16869,6 +17001,13 @@ semver@7.x, semver@^7.3.2: dependencies: lru-cache "^6.0.0" +semver@^7.3.5: + version "7.3.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" + integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== + dependencies: + lru-cache "^6.0.0" + send@0.17.1: version "0.17.1" resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" @@ -16997,6 +17136,20 @@ shallow-clone@^3.0.0: dependencies: kind-of "^6.0.2" +sharp@0.29.1: + version "0.29.1" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.29.1.tgz#f60b50f24f399464a24187c86bd2da41aae50b85" + integrity sha512-DpgdAny9TuS+oWCQ7MRS8XyY9x6q1+yW3a5wNx0J3HrGuB/Jot/8WcT+lElHY9iJu2pwtegSGxqMaqFiMhs4rQ== + dependencies: + color "^4.0.1" + detect-libc "^1.0.3" + node-addon-api "^4.1.0" + prebuild-install "^6.1.4" + semver "^7.3.5" + simple-get "^3.1.0" + tar-fs "^2.1.1" + tunnel-agent "^0.6.0" + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -17050,6 +17203,20 @@ signedsource@^1.0.0: resolved "https://registry.yarnpkg.com/signedsource/-/signedsource-1.0.0.tgz#1ddace4981798f93bd833973803d80d52e93ad6a" integrity sha1-HdrOSYF5j5O9gzlzgD2A1S6TrWo= +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^3.0.3, simple-get@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3" + integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA== + dependencies: + decompress-response "^4.2.0" + once "^1.3.1" + simple-concat "^1.0.0" + simple-swizzle@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" @@ -17883,6 +18050,27 @@ tapable@^1.0.0, tapable@^1.1.3: resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== +tar-fs@^2.0.0, tar-fs@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" + integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + +tar-stream@^2.1.4: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + tar@^4.4.10, tar@^4.4.12, tar@^4.4.8: version "4.4.13" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" @@ -18922,6 +19110,14 @@ wcwidth@^1.0.0: dependencies: defaults "^1.0.3" +"webdav-server@https://github.com/Smithsonian/npm-WebDAV-Server.git": + version "2.6.2" + resolved "https://github.com/Smithsonian/npm-WebDAV-Server.git#35c998f79cc40b005381f165c4b2fa74b948ca09" + dependencies: + mime-types "^2.1.18" + winston "^3.3.3" + xml-js-builder "^1.0.3" + webdav@4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/webdav/-/webdav-4.2.1.tgz#fb1708f04ef810c4d61f779309fe53ed27ea3ece" @@ -19182,7 +19378,7 @@ winston-transport@^4.4.0: readable-stream "^2.3.7" triple-beam "^1.2.0" -winston@3.3.3: +winston@3.3.3, winston@^3.3.3: version "3.3.3" resolved "https://registry.yarnpkg.com/winston/-/winston-3.3.3.tgz#ae6172042cafb29786afa3d09c8ff833ab7c9170" integrity sha512-oEXTISQnC8VlSAKf1KYSSd7J6IWuRPQqDdo8eoRNaYKLvwSb5+79Z3Yi1lrl6KDpU6/VWaxpakDAtb1oQ4n9aw== @@ -19500,6 +19696,20 @@ xdg-basedir@^4.0.0: resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== +xml-js-builder@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/xml-js-builder/-/xml-js-builder-1.0.3.tgz#91d275cb926f9dc4167f029357a5b2875b5d3894" + integrity sha512-BoLgG/glT45M0jK5PGh9h+iGrQxa8jJk9ofR63GroRifl2tbGB3/yYiVY3wQWHrZgWWfl9+7fhEB/VoD9mWnSg== + dependencies: + xml-js "^1.6.2" + +xml-js@^1.6.2: + version "1.6.11" + resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9" + integrity sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g== + dependencies: + sax "^1.2.4" + xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"