import { btoa } from 'abab' import axios from 'axios' import { startsWith } from 'lodash' import qs from 'qs' import URI from 'urijs' import { ElementType } from '../../interfaces' import { AttributeData, AttributeValueTypeElement, CloneConfig, ElementCreateData, ElementData, ElementDataWrapper, ElementRaw, ElementSchemaVersion, ElementTemplateV1, ElementTypeDefinition, ElementTypePrivilegesList, GetElementV1Response, GetElementV2Response, GoUrlsResponse, MembraneGetError, Operation, RestApiLink, StartChecklistData, StartChecklistResponse, } from '../../interfaces/element.interface' import { ELEMENT_PARENT } from '../../models' import type { Api, ApiResponse } from '../api' import { CONTENT_TYPES, ENDPOINTS } from '.' export interface ElementPrivileges { type: string[] attributes: { [attribute: string]: string[] } } interface ElementPrivilegesResponse { 'element-type': { id: string } organization: { id: string } privileges: ElementPrivileges user: { id: string } } export interface MetadataResponse { elements: { [hash: string]: { element: ElementData } } } export interface ElementSearchHit extends AttributeValueTypeElement { 'hits-by-attributes': HitsByAttributes[] /** * Attributes projected onto the result card, keyed by attribute name and * rendered in the same shape as a normal element load. Present only when the * request carried a `projection` map with entries for this element's type. */ 'projected-attributes'?: Record /** * Attributes whose value matched the search term but that are not already * projected, rendered in the same shape as a normal element load. Present only * when the request set `matched: true`; the card shows and highlights these * below the projected attributes. */ 'matched-attributes'?: Record } interface HitsByAttributes { [attributeKey: string]: string[] } export interface ElementTypeCount { type: string count: number } export interface GetElementSearch { all_count: number from: number to: number 'hits-by-attributes': HitsByAttributes data: ElementSearchHit[] /** * Present only when the request was made with `categorize: true`. * Full list of matching element types with per-type counts, sorted by * count desc — drives the result tabs. */ 'element-types'?: ElementTypeCount[] /** * Present only when the request was made with `categorize: true`. * The element type id used to populate `data`; `all_count` reflects this * type's total. Absent when there are no matches. */ 'selected-type'?: string } export interface GetElementSearchMembrane extends GetElementSearch { encounteredVisibilityExceptions?: boolean } export interface ElementFulltextSearchBody { query: string page?: number archived?: boolean per_page?: number /** Opt-in: when omitted/false the response is the legacy flat shape. */ categorize?: boolean /** * Ordered list of preferred element type ids. Present types lead the tabs in * this order; absent ones are ignored. Ignored unless `categorize` is true. */ type?: string[] /** Restrict the search (and its type tabs) to elements owned by this organization. */ organization?: string /** * Per-type map of attribute keys to project onto result cards * (`{ [typeId]: string[] }`). Each hit is projected using only its own type's * list. Ignored unless `categorize` is true. */ projection?: Record /** * Opt-in: also return the attributes whose value matched the search term (and * aren't already projected) as `matched-attributes`, so the card can highlight * where the term was found. Absent/false leaves the response unchanged. */ matched?: boolean } export interface ElementSearchOptions { page: number archived: boolean per_page?: number /** Restrict the search to elements owned by this organization. */ organization?: string } export interface CategorizedSearchOptions { page?: number archived?: boolean per_page?: number /** * Ordered list of preferred element type ids. Present types lead the tabs in * this order and the first present one becomes the active tab; absent ones are * ignored (falls back to the most-popular type). */ type?: string[] /** Restrict the search (and its type tabs) to elements owned by this organization. */ organization?: string /** * Per-type map of attribute keys to project onto result cards * (`{ [typeId]: string[] }`). Each hit is projected using only its own type's * list. */ projection?: Record /** * Opt-in: also return the attributes whose value matched the search term (and * aren't already projected) as `matched-attributes`, for highlighting on the * result cards. */ matched?: boolean } export interface CategorizedSearchResult { /** Full tab list: every matching element type with its count badge. */ tabs: ElementTypeCount[] /** Element type id used to populate `data`; undefined when there are no matches. */ selectedType?: string /** Results of `selectedType` only. */ data: ElementSearchHit[] /** Total count for `selectedType` — use for the active tab's pagination. */ allCount: number from: number to: number } type MultipartFile = { filename?: string contentType?: string content: Buffer | string } type MultipartImportPayload = { author: string organization: string just_validate: string | boolean testing?: string | boolean file: MultipartFile | string } export const FILES_API = '/service/files' export const EDITORS_V1_API = '/service/editors/api/v1' export const ELEMENTS_API = '/service/elements-api' export const ELEMENT_PRIVILEGES = '/service/nugeta/user-privileges/element-types' export const INVENTORY_PROCCESS_API = '/service/inventory-process' export const GO_URLS_API = '/service/go-urls' export const IMAGE_FILE_EXTENSIONS = [ 'jpg', 'jpeg', 'jpe', 'jif', 'jfif', 'png', 'gif', 'svg', 'webp', ] export const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg'] export const AUDIO_FILE_EXTENSIONS = ['mp3', 'wav', 'ogg'] export const TYPE_LIST_PER_PAGE = 100 type ElementScopes = | 'predecessors' | 'element' | 'children' | 'references' | 'parent' | 'workRecords' export interface ElementGetQueryParams extends Record { childrenAtt?: string[] // load certain attributes from children elements scope?: ElementScopes[] templateId?: string relsAtt?: string[] elementRelAtt?: string[] // load certain attributes from elements in ATEs forceVersion?: ElementSchemaVersion children?: { page?: number 'per-page'?: number } predecessors?: { page?: number 'per-page'?: number } predecessorsAtt?: string[] q?: string // base64(jsonStringify(params)) } export interface ElementTypeWrapper { id: ElementType } export class Elements { api: Api constructor(api: Api) { this.api = api } async get( hash: string, type?: string, useCache?: boolean, queryParams: ElementGetQueryParams = {}, ): Promise< ApiResponse > { let query: ElementGetQueryParams if (queryParams.q) { query = queryParams // leave as-is } else { query = { // use q param because it's newer and supports granular scopes q: btoa(JSON.stringify(queryParams)) || undefined, forceVersion: queryParams.forceVersion, } } const data = await this.api.apisauce.get< GetElementV1Response | GetElementV2Response, MembraneGetError >( `${ENDPOINTS.MEMBRANE}/elements/${type || '_PLACEHOLDER'}/${hash}`, query, { paramsSerializer: serializer, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: useCache is not a valid property of type 'undefined' cache: useCache, id: `element-get-${hash}-${type}`, }, ) return data } async getRaw( hash: string, type = '_TYPE_PLACEHOLDER', useCache?: boolean, params?: Record, ) { const element = await this.api.apisauce.get( `${ELEMENTS_API}/elements/${type}/${hash}`, params, { cache: useCache ? undefined : false, id: `element-get-raw-${hash}-${type}`, }, ) return element.data } getTypeDataV1(type: string) { return this.api.apisauce.get( `${EDITORS_V1_API}/elementtypes/${type}`, ) } getTemplateV1(type: string, organization: string) { return this.api.apisauce.get( `${EDITORS_V1_API}/templates/${type}/${organization}/default`, ) } async getElementTypes( organizationId: string, page = 1, ): Promise { const elementTypesResponse = await this.api.apisauce.get< ElementTypeWrapper[] >( `${ELEMENT_PRIVILEGES}/${organizationId}?page=${page}&per-page=100&with-right=read`, ) if (!elementTypesResponse.ok || !elementTypesResponse.data) { throw new Error('error_loading_element_definitions') } return elementTypesResponse.data } getPrivileges(organization: string, type: string) { return this.api.apisauce.get( `${ELEMENT_PRIVILEGES}/${organization}/${type}`, ) } async pairQr( elementHash: string, organizationId: string, url?: string, ): Promise> { return this.api.apisauce.post( `${INVENTORY_PROCCESS_API}/pair/prepare`, { element: elementHash, organization: organizationId, url, }, ) } async generateGoUrls(count: number): Promise> { return this.api.apisauce.post( `${GO_URLS_API}/generate-urls`, undefined, { params: { count, }, }, ) } async patch( type: string, hash: string, operations: Operation[], organization: string, queryParams?: Record, ): Promise> { const data = { operations: operations, organization: { id: organization, }, } return this.api.apisauce.patch( `${ENDPOINTS.MEMBRANE}/elements/${type}/${hash}`, data, { cache: { update: { [`element-get-${hash}-${type}`]: 'delete', [`element-get-raw-${hash}-${type}`]: 'delete', }, }, params: queryParams, headers: { 'Content-Type': CONTENT_TYPES.ELEMENT, }, }, ) } async search( query: string, options?: ElementSearchOptions, ): Promise { const body: ElementFulltextSearchBody = { query, page: options?.page, archived: options?.archived, per_page: options?.per_page, organization: options?.organization, } const response = await this.api.apisauce.post( `${ENDPOINTS.MEMBRANE}/search/fulltext`, body, ) if (!response.ok || !response.data) { throw new Error('error_element_search') } return response.data } async searchCategorized( query: string, options?: CategorizedSearchOptions, ): Promise { const body: ElementFulltextSearchBody = { query, page: options?.page, archived: options?.archived, per_page: options?.per_page, categorize: true, type: options?.type, organization: options?.organization, projection: options?.projection, matched: options?.matched, } const response = await this.api.apisauce.post( `${ENDPOINTS.MEMBRANE}/search/fulltext`, body, ) if (!response.ok || !response.data) { throw new Error('error_element_search') } const { data } = response return { tabs: data['element-types'] ?? [], selectedType: data['selected-type'], data: data.data, allCount: data.all_count, from: data.from, to: data.to, } } async create( type: string, operations: Operation[], organization: string, parent?: string, queryParams?: Record, ) { const filteredParentOperations = parent ? operations.filter( (operation) => operation.path !== `/element/${ELEMENT_PARENT}`, ) : operations // We need atleast a parent operation (not passed through operation but parent arg if no operations are passed) if (filteredParentOperations.length === 0 && !parent) { throw new Error('Create element: Operations empty') } const data: ElementCreateData = { operations: filteredParentOperations, organization: { id: organization, }, } if (parent) { data.parent = { hash: parent } } const response = await this.api.apisauce.post< { element: { hash: string } }, { error: { code: string description: string } } >(`${ENDPOINTS.MEMBRANE}/elements/${type}`, data, { params: queryParams, headers: { 'content-type': CONTENT_TYPES.ELEMENT, }, }) return response } async getUserElement(organization: string, type: string) { return this.api.apisauce.get( `${ENDPOINTS.MEMBRANE}/user/${organization}/${type}`, ) } async startChecklist(data: StartChecklistData) { return this.api.apisauce.post< StartChecklistResponse, { error: { code: string description: string } } >(`${ENDPOINTS.MEMBRANE}/checklist`, data) } async setParent( type: string, hash: string, parentHash: string, ): Promise> { const data = { parent: { hash: parentHash, }, } return this.api.apisauce.patch( `${ELEMENTS_API}/elements/${type}/${hash}/parent`, data, ) } async copyElement( newParentType: string, newParentHash: string, copiedHash: string, ) { return this.api.apisauce.post<{ element: { hash: string } links: RestApiLink[] }>( `${ELEMENTS_API}/elements/${newParentType}/${newParentHash}/copy-to-children/${copiedHash}`, {}, ) } async cloneElementTree( root: string, organization: string, newParent?: string, cloneConfig?: CloneConfig, maxDepth?: number, ) { return this.api.apisauce.post( `${ENDPOINTS.MEMBRANE}/elements/clone`, { root, newParent, organization, cloneConfig, maxDepth, }, ) } async archive(type: string, hash: string) { await this.api.apisauce.delete(`${ELEMENTS_API}/elements/${type}/${hash}`) } async unarchive(type: string, hash: string) { await this.api.apisauce.post(`${ELEMENTS_API}/elements/${type}/${hash}`) } async getElementHashFromQR(url: string) { let link = new URI(url) const hostname = link.hostname() const edocuUrl = new RegExp(/edocu\.eu$/) if (!edocuUrl.test(hostname)) { console.warn('Not to edocu', hostname) return null } if (this.api.config.stage === 'stage') { link = link.hostname('stage.edocu.eu') } let path = link.path() if (startsWith(path, '/go/')) { try { const goElement = await axios(link.toString()) const goUrl = new URI(goElement.request.responseURL) path = goUrl.path() } catch (e) { return null } } // Hashes are 40 characters long a-z (lowercase only) and 0-9 const matches = path.match(/\/([a-z|\d]{32,40})\/?/) if (!matches) { return null } // Return last member of array (group1) return matches.pop() } async getElementTypeList(organization: string, right = 'read', page = 1) { const typesList = await this.api.apisauce.get( `${ELEMENT_PRIVILEGES}/${organization}?page=${page}&per-page=${TYPE_LIST_PER_PAGE}&with-right=${right}`, ) if (!typesList.ok) { throw new Error('Cannot load element types') } return typesList.data } async importElements(data: MultipartImportPayload) { return this.api.apisauce.post( `${ENDPOINTS.MEMBRANE}/import/elements`, data, { headers: { 'Content-Type': 'multipart/form-data', }, }, ) } } export const serializer = (queryParams: Record) => { return qs.stringify(queryParams, { arrayFormat: 'repeat' }) }