1
0
mirror of https://github.com/oceanprotocol/market.git synced 2024-12-02 05:57:29 +01:00

move asset selection to compute helper

This commit is contained in:
Bogdan Fazakas 2022-02-10 16:33:16 +02:00
parent 24ad553765
commit cd2a4da8c5
5 changed files with 189 additions and 111 deletions

View File

@ -45,7 +45,7 @@ export function generateBaseQuery(
filter: [ filter: [
...(baseQueryParams.filters || []), ...(baseQueryParams.filters || []),
getFilterTerm('chainId', baseQueryParams.chainIds), getFilterTerm('chainId', baseQueryParams.chainIds),
getFilterTerm('_index', 'aquarius'), // getFilterTerm('_index', 'aquarius'),
...(baseQueryParams.ignorePurgatory ...(baseQueryParams.ignorePurgatory
? [] ? []
: [getFilterTerm('purgatory.state', false)]) : [getFilterTerm('purgatory.state', false)])

View File

@ -15,14 +15,22 @@ import {
ComputeAlgorithm, ComputeAlgorithm,
Service, Service,
LoggerInstance, LoggerInstance,
ProviderInstance ProviderInstance,
PublisherTrustedAlgorithm
} from '@oceanprotocol/lib' } from '@oceanprotocol/lib'
import { CancelToken } from 'axios' import { CancelToken } from 'axios'
import { gql } from 'urql' import { gql } from 'urql'
import { queryMetadata, getFilterTerm, generateBaseQuery } from './aquarius' import {
queryMetadata,
getFilterTerm,
generateBaseQuery,
transformDDOToAssetSelection
} from './aquarius'
import { fetchDataForMultipleChains } from './subgraph' import { fetchDataForMultipleChains } from './subgraph'
import { getServiceById } from './ddo' import { getServiceById, getServiceByName } from './ddo'
import { getOceanConfig } from './ocean' import { getOceanConfig } from './ocean'
import { SortTermOptions } from 'src/@types/aquarius/SearchQuery'
import { AssetSelectionAsset } from '@shared/FormFields/AssetSelection'
const getComputeOrders = gql` const getComputeOrders = gql`
query ComputeOrders($user: String!) { query ComputeOrders($user: String!) {
@ -115,6 +123,74 @@ export async function isOrderable(
} }
} }
function getQuerryString(
trustedAlgorithmList: PublisherTrustedAlgorithm[],
chainId?: number
): SearchQuery {
const algorithmDidList = trustedAlgorithmList.map((x) => x.did)
const baseParams = {
chainIds: [chainId],
sort: { sortBy: SortTermOptions.Created },
filters: [
getFilterTerm('metadata.type', 'algorithm'),
getFilterTerm('id', algorithmDidList)
]
} as BaseQueryParams
const query = generateBaseQuery(baseParams)
return query
}
export async function getAlgorithmsForAsset(
asset: Asset,
token: CancelToken
): Promise<Asset[]> {
const computeService: Service = getServiceByName(asset, 'compute')
let algorithms: Asset[]
if (
!computeService.compute ||
!computeService.compute.publisherTrustedAlgorithms ||
computeService.compute.publisherTrustedAlgorithms.length === 0
) {
algorithms = []
} else {
const gueryResults = await queryMetadata(
getQuerryString(
computeService.compute.publisherTrustedAlgorithms,
asset.chainId
),
token
)
algorithms = gueryResults?.results
}
return algorithms
}
export async function getAlgorithmAssetSelectionList(
asset: Asset,
algorithms: Asset[],
token: CancelToken
): Promise<AssetSelectionAsset[]> {
const computeService: Service = getServiceByName(asset, 'compute')
let algorithmSelectionList: AssetSelectionAsset[]
if (
!computeService.compute ||
!computeService.compute.publisherTrustedAlgorithms ||
computeService.compute.publisherTrustedAlgorithms.length === 0
) {
algorithmSelectionList = []
} else {
algorithmSelectionList = await transformDDOToAssetSelection(
computeService?.serviceEndpoint,
algorithms,
[],
token
)
}
return algorithmSelectionList
}
function getServiceEndpoints(data: TokenOrder[], assets: Asset[]): string[] { function getServiceEndpoints(data: TokenOrder[], assets: Asset[]): string[] {
// const serviceEndpoints: string[] = [] // const serviceEndpoints: string[] = []

View File

@ -17,7 +17,8 @@ import {
approve, approve,
TokenInOutMarket, TokenInOutMarket,
AmountsInMaxFee, AmountsInMaxFee,
AmountsOutMaxFee AmountsOutMaxFee,
Service
} from '@oceanprotocol/lib' } from '@oceanprotocol/lib'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import Price from '@shared/Price' import Price from '@shared/Price'
@ -39,8 +40,14 @@ import FormStartComputeDataset from './FormComputeDataset'
import styles from './index.module.css' import styles from './index.module.css'
import SuccessConfetti from '@shared/SuccessConfetti' import SuccessConfetti from '@shared/SuccessConfetti'
import { getServiceByName, secondsToString } from '@utils/ddo' import { getServiceByName, secondsToString } from '@utils/ddo'
import { isOrderable } from '@utils/compute' import {
import { AssetSelectionAsset } from '@shared/FormFields/AssetSelection' isOrderable,
getAlgorithmAssetSelectionList,
getAlgorithmsForAsset
} from '@utils/compute'
import AssetSelection, {
AssetSelectionAsset
} from '@shared/FormFields/AssetSelection'
import AlgorithmDatasetsListForCompute from './AlgorithmDatasetsListForCompute' import AlgorithmDatasetsListForCompute from './AlgorithmDatasetsListForCompute'
import { getPreviousOrders } from '@utils/subgraph' import { getPreviousOrders } from '@utils/subgraph'
import AssetActionHistoryTable from '../AssetActionHistoryTable' import AssetActionHistoryTable from '../AssetActionHistoryTable'
@ -52,10 +59,9 @@ import { Decimal } from 'decimal.js'
import { TransactionReceipt } from 'web3-core' import { TransactionReceipt } from 'web3-core'
import { useAbortController } from '@hooks/useAbortController' import { useAbortController } from '@hooks/useAbortController'
import { getAccessDetails } from '@utils/accessDetailsAndPricing' import { getAccessDetails } from '@utils/accessDetailsAndPricing'
import AssetDetails from '../..'
export default function Compute({ export default function Compute({
ddo, asset,
accessDetails, accessDetails,
dtBalance, dtBalance,
file, file,
@ -63,7 +69,7 @@ export default function Compute({
isConsumable, isConsumable,
consumableFeedback consumableFeedback
}: { }: {
ddo: Asset asset: Asset
accessDetails: AccessDetails accessDetails: AccessDetails
dtBalance: string dtBalance: string
file: FileMetadata file: FileMetadata
@ -94,9 +100,9 @@ export default function Compute({
useState<string>() useState<string>()
const [datasetTimeout, setDatasetTimeout] = useState<string>() const [datasetTimeout, setDatasetTimeout] = useState<string>()
const [algorithmTimeout, setAlgorithmTimeout] = useState<string>() const [algorithmTimeout, setAlgorithmTimeout] = useState<string>()
const newCancelToken = useCancelToken()
const hasDatatoken = Number(dtBalance) >= 1 const hasDatatoken = Number(dtBalance) >= 1
const isMounted = useIsMounted() const isMounted = useIsMounted()
const newCancelToken = useCancelToken()
const [isConsumablePrice, setIsConsumablePrice] = useState(true) const [isConsumablePrice, setIsConsumablePrice] = useState(true)
const [isAlgoConsumablePrice, setIsAlgoConsumablePrice] = useState(true) const [isAlgoConsumablePrice, setIsAlgoConsumablePrice] = useState(true)
const isComputeButtonDisabled = const isComputeButtonDisabled =
@ -107,13 +113,13 @@ export default function Compute({
!hasAlgoAssetDatatoken && !hasAlgoAssetDatatoken &&
!isAlgoConsumablePrice) !isAlgoConsumablePrice)
const { timeout } = ddo?.services[0] const { timeout } = asset?.services[0]
async function checkPreviousOrders(ddo: DDO) { async function checkPreviousOrders(asset: DDO) {
const { type } = ddo.metadata const { type } = asset.metadata
const orderId = await getPreviousOrders( const orderId = await getPreviousOrders(
ddo.services[0].datatokenAddress?.toLowerCase(), asset.services[0].datatokenAddress?.toLowerCase(),
accountId?.toLowerCase(), accountId?.toLowerCase(),
timeout.toString() timeout.toString()
) )
@ -138,60 +144,11 @@ export default function Compute({
setHasAlgoAssetDatatoken(Number(AssetDtBalance) >= 1) setHasAlgoAssetDatatoken(Number(AssetDtBalance) >= 1)
} }
function getQuerryString( const initMetadata = useCallback(async (asset: Asset): Promise<void> => {
trustedAlgorithmList: PublisherTrustedAlgorithm[], if (!asset) return
chainId?: number
): SearchQuery {
const algorithmDidList = trustedAlgorithmList.map((x) => x.did)
const baseParams = {
chainIds: [chainId],
sort: { sortBy: SortTermOptions.Created },
filters: [
getFilterTerm('service.attributes.main.type', 'algorithm'),
getFilterTerm('id', algorithmDidList)
]
} as BaseQueryParams
const query = generateBaseQuery(baseParams)
return query
}
async function getAlgorithmList(): Promise<AssetSelectionAsset[]> {
const source = axios.CancelToken.source()
const computeService = ddo.services[0]
let algorithmSelectionList: AssetSelectionAsset[]
if (
!computeService.compute ||
!computeService.compute.publisherTrustedAlgorithms ||
computeService.compute.publisherTrustedAlgorithms.length === 0
) {
algorithmSelectionList = []
} else {
const gueryResults = await queryMetadata(
getQuerryString(
computeService.compute.publisherTrustedAlgorithms,
ddo.chainId
),
source.token
)
setDdoAlgorithmList(gueryResults.results)
algorithmSelectionList = await transformDDOToAssetSelection(
computeService?.serviceEndpoint,
gueryResults.results,
[],
newCancelToken()
)
}
return algorithmSelectionList
}
const initMetadata = useCallback(async (ddo: Asset): Promise<void> => {
if (!ddo) return
const accessDetails = await getAccessDetails( const accessDetails = await getAccessDetails(
ddo.chainId, asset.chainId,
ddo.services[0].datatokenAddress asset.services[0].datatokenAddress
) )
setAlgorithmConsumeDetails(accessDetails) setAlgorithmConsumeDetails(accessDetails)
}, []) }, [])
@ -213,23 +170,31 @@ export default function Compute({
// }, [ddo]) // }, [ddo])
useEffect(() => { useEffect(() => {
if (!ddo) return if (!asset) return
getAlgorithmList().then((algorithms) => {
setAlgorithmList(algorithms) getAlgorithmsForAsset(asset, newCancelToken()).then((algorithmsAssets) => {
setDdoAlgorithmList(algorithmsAssets)
getAlgorithmAssetSelectionList(
asset,
algorithmsAssets,
newCancelToken()
).then((algorithmSelectionList) => {
setAlgorithmList(algorithmSelectionList)
})
}) })
}, [ddo]) }, [asset])
useEffect(() => { useEffect(() => {
if (!accountId) return if (!accountId) return
checkPreviousOrders(ddo) checkPreviousOrders(asset)
}, [ddo, accountId]) }, [asset, accountId])
useEffect(() => { useEffect(() => {
if (!selectedAlgorithmAsset) return if (!selectedAlgorithmAsset) return
initMetadata(selectedAlgorithmAsset) initMetadata(selectedAlgorithmAsset)
const { timeout } = ddo.services[0] const { timeout } = asset.services[0]
// setAlgorithmTimeout(secondsToString(timeout)) // setAlgorithmTimeout(secondsToString(timeout))
@ -248,7 +213,7 @@ export default function Compute({
} }
} }
checkAssetDTBalance(selectedAlgorithmAsset) checkAssetDTBalance(selectedAlgorithmAsset)
}, [ddo, selectedAlgorithmAsset, accountId, hasPreviousAlgorithmOrder]) }, [asset, selectedAlgorithmAsset, accountId, hasPreviousAlgorithmOrder])
// Output errors in toast UI // Output errors in toast UI
useEffect(() => { useEffect(() => {
@ -257,13 +222,13 @@ export default function Compute({
toast.error(newError) toast.error(newError)
}, [error, pricingError]) }, [error, pricingError])
async function startJob(algorithmId: string) { async function startJob(algorithmId: string): Promise<string> {
try { try {
setIsJobStarting(true) setIsJobStarting(true)
setIsPublished(false) // would be nice to rename this setIsPublished(false) // would be nice to rename this
setError(undefined) setError(undefined)
const computeService = getServiceByName(ddo, 'compute') const computeService = getServiceByName(asset, 'compute')
const serviceAlgo = getServiceByName(selectedAlgorithmAsset, 'access') const serviceAlgo = getServiceByName(selectedAlgorithmAsset, 'access')
? getServiceByName(selectedAlgorithmAsset, 'access') ? getServiceByName(selectedAlgorithmAsset, 'access')
: getServiceByName(selectedAlgorithmAsset, 'compute') : getServiceByName(selectedAlgorithmAsset, 'compute')
@ -274,7 +239,7 @@ export default function Compute({
} }
const allowed = await isOrderable( const allowed = await isOrderable(
ddo, asset,
computeService.id, computeService.id,
computeAlgorithm, computeAlgorithm,
selectedAlgorithmAsset selectedAlgorithmAsset
@ -296,11 +261,11 @@ export default function Compute({
if (!hasPreviousDatasetOrder) { if (!hasPreviousDatasetOrder) {
// going to move/replace part of this logic when the use consume hook will be ready // going to move/replace part of this logic when the use consume hook will be ready
const initializeData = await ProviderInstance.initialize( const initializeData = await ProviderInstance.initialize(
ddo.id, asset.id,
ddo.services[0].id, asset.services[0].id,
0, 0,
accountId, accountId,
ddo.services[0].serviceEndpoint //to check asset.services[0].serviceEndpoint // to check
) )
const providerFees: ProviderFees = { const providerFees: ProviderFees = {
providerFeeAddress: initializeData.providerFee.providerFeeAddress, providerFeeAddress: initializeData.providerFee.providerFeeAddress,
@ -375,6 +340,8 @@ export default function Compute({
tokenInOutMarket, tokenInOutMarket,
amountsInOutMaxFee amountsInOutMaxFee
) )
break
} }
case 'fixed': { case 'fixed': {
const datatokenInstance = new Datatoken(web3) const datatokenInstance = new Datatoken(web3)
@ -392,12 +359,14 @@ export default function Compute({
marketFeeAddress: appConfig.marketFeeAddress marketFeeAddress: appConfig.marketFeeAddress
} }
tx = await datatokenInstance.buyFromFreAndOrder( tx = await datatokenInstance.buyFromFreAndOrder(
ddo.datatokens[0].address, asset.datatokens[0].address,
accountId, accountId,
order, order,
fre fre
) )
assetOrderId = tx.transactionHash assetOrderId = tx.transactionHash
break
} }
case 'free': { case 'free': {
const datatokenInstance = new Datatoken(web3) const datatokenInstance = new Datatoken(web3)
@ -415,26 +384,27 @@ export default function Compute({
marketFeeAddress: appConfig.marketFeeAddress marketFeeAddress: appConfig.marketFeeAddress
} }
tx = await datatokenInstance.buyFromDispenserAndOrder( tx = await datatokenInstance.buyFromDispenserAndOrder(
ddo.datatokens[0].address, asset.datatokens[0].address,
accountId, accountId,
order, order,
accessDetails.addressOrId accessDetails.addressOrId
) )
assetOrderId = tx.transactionHash assetOrderId = tx.transactionHash
if (!tx) {
setError('Error buying datatoken.')
LoggerInstance.error(
'[compute] Error buying datatoken for data set ',
asset.id
)
return
}
break
} }
} }
if (!tx) {
setError('Error buying datatoken.')
LoggerInstance.error(
'[compute] Error buying datatoken for data set ',
ddo.id
)
return
}
} else { } else {
const datatokenInstance = new Datatoken(web3) const datatokenInstance = new Datatoken(web3)
const tx = await datatokenInstance.startOrder( const tx = await datatokenInstance.startOrder(
ddo.datatokens[0].address, asset.datatokens[0].address,
accountId, accountId,
initializeData.computeAddress, initializeData.computeAddress,
0, 0,
@ -456,7 +426,7 @@ export default function Compute({
selectedAlgorithmAsset.services[0].id, selectedAlgorithmAsset.services[0].id,
0, 0,
accountId, accountId,
selectedAlgorithmAsset.services[0].serviceEndpoint //to check selectedAlgorithmAsset.services[0].serviceEndpoint // to check
) )
const providerFees: ProviderFees = { const providerFees: ProviderFees = {
providerFeeAddress: initializeData.providerFee.providerFeeAddress, providerFeeAddress: initializeData.providerFee.providerFeeAddress,
@ -531,6 +501,7 @@ export default function Compute({
tokenInOutMarket, tokenInOutMarket,
amountsInOutMaxFee amountsInOutMaxFee
) )
break
} }
case 'fixed': { case 'fixed': {
const datatokenInstance = new Datatoken(web3) const datatokenInstance = new Datatoken(web3)
@ -554,6 +525,7 @@ export default function Compute({
fre fre
) )
algorithmAssetOrderId = tx.transactionHash algorithmAssetOrderId = tx.transactionHash
break
} }
case 'free': { case 'free': {
const datatokenInstance = new Datatoken(web3) const datatokenInstance = new Datatoken(web3)
@ -577,6 +549,7 @@ export default function Compute({
algorithmConsumeDetails.addressOrId algorithmConsumeDetails.addressOrId
) )
algorithmAssetOrderId = tx.transactionHash algorithmAssetOrderId = tx.transactionHash
break
} }
} }
} else { } else {
@ -615,8 +588,8 @@ export default function Compute({
LoggerInstance.log('[compute] Starting compute job.') LoggerInstance.log('[compute] Starting compute job.')
const computeAsset: ComputeAsset = { const computeAsset: ComputeAsset = {
documentId: ddo.id, documentId: asset.id,
serviceId: ddo.services[0].id, serviceId: asset.services[0].id,
transferTxId: assetOrderId transferTxId: assetOrderId
} }
computeAlgorithm.transferTxId = algorithmAssetOrderId computeAlgorithm.transferTxId = algorithmAssetOrderId
@ -627,7 +600,7 @@ export default function Compute({
} }
const response = await ProviderInstance.computeStart( const response = await ProviderInstance.computeStart(
ddo.services[0].serviceEndpoint, asset.services[0].serviceEndpoint,
web3, web3,
accountId, accountId,
'env1', 'env1',
@ -646,17 +619,18 @@ export default function Compute({
LoggerInstance.log('[compute] Starting compute job response: ', response) LoggerInstance.log('[compute] Starting compute job response: ', response)
await checkPreviousOrders(selectedAlgorithmAsset) await checkPreviousOrders(selectedAlgorithmAsset)
await checkPreviousOrders(ddo) await checkPreviousOrders(asset)
setIsPublished(true) setIsPublished(true)
} catch (error) { } catch (error) {
await checkPreviousOrders(selectedAlgorithmAsset) await checkPreviousOrders(selectedAlgorithmAsset)
await checkPreviousOrders(ddo) await checkPreviousOrders(asset)
setError('Failed to start job!') setError('Failed to start job!')
LoggerInstance.error('[compute] Failed to start job: ', error.message) LoggerInstance.error('[compute] Failed to start job: ', error.message)
} finally { } finally {
setIsJobStarting(false) setIsJobStarting(false)
} }
} }
return ( return (
<> <>
<div className={styles.info}> <div className={styles.info}>
@ -664,13 +638,16 @@ export default function Compute({
<Price accessDetails={accessDetails} conversion /> <Price accessDetails={accessDetails} conversion />
</div> </div>
{ddo.metadata.type === 'algorithm' ? ( {asset.metadata.type === 'algorithm' ? (
<> <>
<Alert <Alert
text="This algorithm has been set to private by the publisher and can't be downloaded. You can run it against any allowed data sets though!" text="This algorithm has been set to private by the publisher and can't be downloaded. You can run it against any allowed data sets though!"
state="info" state="info"
/> />
<AlgorithmDatasetsListForCompute algorithmDid={ddo.id} ddo={ddo} /> <AlgorithmDatasetsListForCompute
algorithmDid={asset.id}
ddo={asset}
/>
</> </>
) : ( ) : (
<Formik <Formik
@ -691,7 +668,7 @@ export default function Compute({
hasDatatoken={hasDatatoken} hasDatatoken={hasDatatoken}
dtBalance={dtBalance} dtBalance={dtBalance}
datasetLowPoolLiquidity={!isConsumablePrice} datasetLowPoolLiquidity={!isConsumablePrice}
assetType={ddo?.metadata.type} assetType={asset?.metadata.type}
assetTimeout={datasetTimeout} assetTimeout={datasetTimeout}
hasPreviousOrderSelectedComputeAsset={hasPreviousAlgorithmOrder} hasPreviousOrderSelectedComputeAsset={hasPreviousAlgorithmOrder}
hasDatatokenSelectedComputeAsset={hasAlgoAssetDatatoken} hasDatatokenSelectedComputeAsset={hasAlgoAssetDatatoken}

View File

@ -131,7 +131,7 @@ export default function AssetActions({
const UseContent = isCompute ? ( const UseContent = isCompute ? (
<Compute <Compute
ddo={asset} asset={asset}
accessDetails={asset?.accessDetails} accessDetails={asset?.accessDetails}
dtBalance={dtBalance} dtBalance={dtBalance}
file={fileMetadata} file={fileMetadata}

View File

@ -1,4 +1,5 @@
import React, { ReactElement } from 'react' import React, { ReactElement, useEffect, useState } from 'react'
import { useWeb3 } from '@context/Web3'
import Markdown from '@shared/Markdown' import Markdown from '@shared/Markdown'
import MetaFull from './MetaFull' import MetaFull from './MetaFull'
import MetaSecondary from './MetaSecondary' import MetaSecondary from './MetaSecondary'
@ -14,15 +15,39 @@ import styles from './index.module.css'
import NetworkName from '@shared/NetworkName' import NetworkName from '@shared/NetworkName'
import content from '../../../../content/purgatory.json' import content from '../../../../content/purgatory.json'
import { AssetExtended } from 'src/@types/AssetExtended' import { AssetExtended } from 'src/@types/AssetExtended'
import Button from '@shared/atoms/Button'
import { getServiceById, getServiceByName } from '@utils/ddo'
import EditComputeDataset from '../Edit/EditComputeDataset'
export default function AssetContent({ export default function AssetContent({
asset asset
}: { }: {
asset: AssetExtended asset: AssetExtended
}): ReactElement { }): ReactElement {
const { accountId } = useWeb3()
const { debug } = useUserPreferences() const { debug } = useUserPreferences()
const { isInPurgatory, purgatoryData } = useAsset() const { owner, isInPurgatory, purgatoryData, isAssetNetwork } = useAsset()
const [isOwner, setIsOwner] = useState(false)
const [isComputeType, setIsComputeType] = useState<boolean>(false)
const [showEditCompute, setShowEditCompute] = useState<boolean>()
useEffect(() => {
if (!accountId || !owner) return
const isOwner = accountId.toLowerCase() === owner.toLowerCase()
setIsOwner(isOwner)
// setShowPricing(isOwner && price.type === '')
setIsComputeType(Boolean(getServiceByName(asset, 'compute')))
}, [accountId, asset?.accessDetails, owner, asset])
function handleEditComputeButton() {
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
setShowEditCompute(true)
}
// return showEditCompute ? (
// <EditComputeDataset setShowEdit={setShowEditCompute} />
// ) : (
return ( return (
<> <>
<div className={styles.networkWrap}> <div className={styles.networkWrap}>
@ -68,16 +93,16 @@ export default function AssetContent({
with own URL instead of switching out AssetContent in place. with own URL instead of switching out AssetContent in place.
Simple way would be modal usage Simple way would be modal usage
*/} */}
{/* {isOwner && isAssetNetwork && ( {isOwner && isAssetNetwork && (
<div className={styles.ownerActions}> <div className={styles.ownerActions}>
<Button {/* <Button
style="text" style="text"
size="small" size="small"
onClick={handleEditButton} onClick={handleEditComputeButton}
> >
Edit Metadata Edit Metadata
</Button> </Button> */}
{serviceCompute && ddo?.metadata.type === 'dataset' && ( {isComputeType && asset.metadata.type === 'dataset' && (
<> <>
<span className={styles.separator}>|</span> <span className={styles.separator}>|</span>
<Button <Button
@ -90,7 +115,7 @@ export default function AssetContent({
</> </>
)} )}
</div> </div>
)} */} )}
</div> </div>
</article> </article>
</> </>