/* tslint:disable:no-null-keyword max-line-length typedef no-any no-string-literal variable-name */ /** * This code is a copy from the library @pnp/sp-dev-fx-controls-react */ import { SPHttpClient, SPHttpClientResponse, ISPHttpClientOptions } from '@microsoft/sp-http'; import { ITermStore, ITerms, ITerm, IGroup, ITermSet, ISuggestTerm } from './ISPTermStorePickerService'; import { findIndex } from '@microsoft/sp-lodash-subset'; import { ISPTermStorePickerServiceProps } from './ISPTermStorePickerServiceProps'; import { IPickerTerm } from './IPickerTerm'; import { ApplicationCustomizerContext } from '@microsoft/sp-application-base'; import { debugError } from './MegaMenuDebug'; const EmptyGuid: string = '00000000-0000-0000-0000-000000000000'; const TermCachePrefix: string = 'MegaMenu:TermSet:'; const PickerCachePrefix: string = 'MegaMenu:PickerTerms:'; interface ITermCacheEntry { expiresAt: number; value: ITermSet; } interface IPickerTermCacheEntry { expiresAt: number; value: IPickerTerm[]; } /** * Service implementation to manage term stores in SharePoint */ export default class SPTermStorePickerService { private clientServiceUrl: string; private suggestionServiceUrl: string; /** * Service constructor */ constructor(private props: ISPTermStorePickerServiceProps, private context: ApplicationCustomizerContext, private debug: boolean = false) { this.clientServiceUrl = this.context.pageContext.web.absoluteUrl + '/_vti_bin/client.svc/ProcessQuery'; this.suggestionServiceUrl = this.context.pageContext.web.absoluteUrl + '/_vti_bin/TaxonomyInternalService.json/GetSuggestions'; } public async getTermLabels(termId: string): Promise { let result: string[] = null; try { const data = `${termId}`; const reqHeaders = new Headers(); reqHeaders.append('accept', 'application/json'); reqHeaders.append('content-type', 'application/xml'); const httpPostOptions: ISPHttpClientOptions = { headers: reqHeaders, body: data }; const callResult = await this.context.spHttpClient.post(this.clientServiceUrl, SPHttpClient.configurations.v1, httpPostOptions); this.ensureSuccessfulResponse(callResult, 'Term labels'); const jsonResult = await callResult.json(); const node = jsonResult.find(x => x._ObjectType_ === 'SP.Taxonomy.Term'); if (node && node.Labels && node.Labels._Child_Items_) { result = node.Labels._Child_Items_.map(termLabel => termLabel.Value); } } catch (error) { result = null; debugError(this.debug, 'SPTermStorePickerService', 'Error reading term labels.', error); } return result; } /** * Gets the collection of term stores in the current SharePoint env */ public getTermStores(): Promise { // Retrieve the term store name, groups, and term sets const data = ''; const reqHeaders = new Headers(); reqHeaders.append('accept', 'application/json'); reqHeaders.append('content-type', 'application/xml'); const httpPostOptions: ISPHttpClientOptions = { headers: reqHeaders, body: data }; return this.context.spHttpClient.post(this.clientServiceUrl, SPHttpClient.configurations.v1, httpPostOptions).then((serviceResponse: SPHttpClientResponse) => { this.ensureSuccessfulResponse(serviceResponse, 'Term stores'); return serviceResponse.json().then((serviceJSONResponse: any) => { // Construct results const termStoreResult: ITermStore[] = serviceJSONResponse.filter((r: { [x: string]: string; }) => r['_ObjectType_'] === 'SP.Taxonomy.TermStore'); // Check if term store was retrieved if (termStoreResult.length > 0) { // Check if the termstore needs to be filtered or limited if (this.props.termsetNameOrID) { return termStoreResult.map(termstore => { let termGroups = termstore.Groups._Child_Items_; // Check if the groups have to be limited to a specific term set if (this.props.termsetNameOrID) { const termsetNameOrId = this.props.termsetNameOrID; termGroups = termGroups.map((group: IGroup) => { group.TermSets._Child_Items_ = group.TermSets._Child_Items_.filter((termSet: ITermSet) => termSet.Name === termsetNameOrId || this.cleanGuid(termSet.Id).toLowerCase() === this.cleanGuid(termsetNameOrId).toLowerCase()); return group; }); } // Filter out all systen groups termGroups = termGroups.filter(group => !group.IsSystemGroup); // Filter out empty groups termGroups = termGroups.filter((group: IGroup) => group.TermSets._Child_Items_.length > 0); // Map the new groups termstore.Groups._Child_Items_ = termGroups; return termstore; }); } // Return the term store results return termStoreResult; } return []; }); }); } /** * Gets the current term set */ public async getTermSet(): Promise { const termStore = await this.getTermStores(); return this.getTermSetId(termStore, this.props.termsetNameOrID); } /** * Retrieve all terms for the given term set * @param termset */ public async getAllTerms( termset: string, hideDeprecatedTags?: boolean, hideTagsNotAvailableForTagging?: boolean, useSessionStorage: boolean = true, cacheMinutes: number = 15 ): Promise { let termsetId: string = termset; // Check if the provided term set property is a GUID or string if (!this.isGuid(termset)) { // Fetch the term store information const termStore = await this.getTermStores(); // Get the ID of the provided term set name const crntTermSet = this.getTermSetId(termStore, termset); if (crntTermSet) { termsetId = this.cleanGuid(crntTermSet.Id); } else { return null; } } const childTerms = this.getCachedTermSet(termsetId, useSessionStorage); if (childTerms) { return childTerms; } // Request body to retrieve all terms for the given term set const data = `${termsetId}`; const reqHeaders = new Headers(); reqHeaders.append('accept', 'application/json'); reqHeaders.append('content-type', 'application/xml'); const httpPostOptions: ISPHttpClientOptions = { headers: reqHeaders, body: data }; return this.context.spHttpClient.post(this.clientServiceUrl, SPHttpClient.configurations.v1, httpPostOptions).then((serviceResponse: SPHttpClientResponse) => { this.ensureSuccessfulResponse(serviceResponse, 'Terms'); return serviceResponse.json().then((serviceJSONResponse: any) => { if (!Array.isArray(serviceJSONResponse)) { throw new Error('The taxonomy service returned an unexpected response.'); } const termStoreResultTermSets: ITermSet[] = serviceJSONResponse.filter((r: { [x: string]: string; }) => r['_ObjectType_'] === 'SP.Taxonomy.TermSet'); if (termStoreResultTermSets.length > 0) { const termStoreResultTermSet = termStoreResultTermSets[0]; termStoreResultTermSet.Terms = []; // Retrieve the term collection results const termStoreResultTerms: ITerms[] = serviceJSONResponse.filter((r: { [x: string]: string; }) => r['_ObjectType_'] === 'SP.Taxonomy.TermCollection'); if (termStoreResultTerms.length > 0) { // Retrieve all terms let terms = termStoreResultTerms[0]._Child_Items_; if (hideDeprecatedTags === true) { terms = terms.filter(d => d.IsDeprecated === false); } if (hideTagsNotAvailableForTagging === true) { terms = terms.filter(d => d.IsAvailableForTagging === true); } // Clean the term ID and specify the path depth terms = terms.map(term => { if (term.IsRoot) { term.CustomSortOrderIndex = (termStoreResultTermSet.CustomSortOrder) ? termStoreResultTermSet.CustomSortOrder.split(':').indexOf(this.cleanGuid(term.Id)) : -1; } else { term.CustomSortOrderIndex = (term['Parent'].CustomSortOrder) ? term['Parent'].CustomSortOrder.split(':').indexOf(this.cleanGuid(term.Id)) : -1; } term.Id = this.cleanGuid(term.Id); term['PathDepth'] = term.PathOfTerm.split(';').length; term.TermSet = { Id: this.cleanGuid(termStoreResultTermSet.Id), Name: termStoreResultTermSet.Name }; if (term['Parent']) { term.ParentId = this.cleanGuid(term['Parent'].Id); } return term; }); // Check if the term set was not empty if (terms.length > 0) { // Sort the terms by PathOfTerm and their depth terms = this.sortTerms(terms); termStoreResultTermSet.Terms = terms; } } try { if (useSessionStorage && window.sessionStorage) { const ttlMinutes: number = this.normalizeCacheMinutes(cacheMinutes); const cacheEntry: ITermCacheEntry = { expiresAt: Date.now() + ttlMinutes * 60 * 1000, value: termStoreResultTermSet }; window.sessionStorage.setItem(TermCachePrefix + termsetId, JSON.stringify(cacheEntry)); } } catch (error) { // Do nothing, sometimes "storage quota exceeded" error if too many items } return termStoreResultTermSet; } return null; }); }); } /** * Retrieve all terms that starts with the searchText * @param searchText */ public searchTermsByName(searchText: string): Promise { return this.searchTermsByTermSet(searchText); } public async searchTermsByTermId(searchText: string, termId: string): Promise { const { useSessionStorage } = this.props; const childTerms = this.getCachedPickerTerms(termId, useSessionStorage); if (childTerms) { return this.searchTermsBySearchText(childTerms, searchText); } else { const { termsetNameOrID, hideDeprecatedTags, hideTagsNotAvailableForTagging } = this.props; const terms = await this.getAllTermsByAnchorId( termsetNameOrID, termId, hideDeprecatedTags, hideTagsNotAvailableForTagging, useSessionStorage); if (terms) { return this.searchTermsBySearchText(terms, searchText); } } return null; } /** * Retrieve all terms for the given term set and anchorId */ public async getAllTermsByAnchorId(termsetNameOrID: string, anchorId: string, hideDeprecatedTags?: boolean, hideTagsNotAvailableForTagging?: boolean, useSessionStorage: boolean = true): Promise { const returnTerms: IPickerTerm[] = []; const childTerms = this.getCachedPickerTerms(anchorId, useSessionStorage); if (childTerms) { return childTerms; } const termSet = await this.getAllTerms( termsetNameOrID, hideDeprecatedTags, hideTagsNotAvailableForTagging, useSessionStorage, this.props.cacheMinutes ); const terms = termSet.Terms; if (anchorId) { const anchorTerm = terms.filter(t => t.Id.toLowerCase() === anchorId.toLowerCase()).shift(); if (anchorTerm) { // Append ';' separator, as a suffix to anchor term path. const anchorTermPath = `${anchorTerm.PathOfTerm};`; const anchorTerms: ITerm[] = terms.filter(t => t.PathOfTerm.substring(0, anchorTermPath.length) === anchorTermPath && t.Id !== anchorTerm.Id); anchorTerms.forEach(term => { returnTerms.push(this.convertTermToPickerTerm(term)); }); try { if (useSessionStorage && window.sessionStorage) { const cacheEntry: IPickerTermCacheEntry = { expiresAt: Date.now() + this.normalizeCacheMinutes(this.props.cacheMinutes) * 60 * 1000, value: returnTerms }; window.sessionStorage.setItem(PickerCachePrefix + anchorId, JSON.stringify(cacheEntry)); } } catch (error) { // Do nothing } } } else { terms.forEach(term => { returnTerms.push(this.convertTermToPickerTerm(term)); }); } return returnTerms; } /** * Clean the Guid from the Web Service response * @param guid */ public cleanGuid(guid: string): string { if (guid !== undefined) { return guid.replace('/Guid(', '').replace('/', '').replace(')', ''); } else { return ''; } } /** * Get the term set ID by its name * @param termstore * @param termset */ private getTermSetId(termstore: ITermStore[], termsetName: string): ITermSet { if (termstore && termstore.length > 0 && termsetName) { // Get the first term store const ts = termstore[0]; // Check if the term store contains groups if (ts.Groups && ts.Groups._Child_Items_) { for (const group of ts.Groups._Child_Items_) { // Check if the group contains term sets if (group.TermSets && group.TermSets._Child_Items_) { for (const termSet of group.TermSets._Child_Items_) { // Check if the term set is found if (termSet.Name === termsetName) { return termSet; } } } } } } return null; } private getCachedTermSet(termId: string, useSessionStorage: boolean = true): ITermSet { try { if (useSessionStorage && window.sessionStorage) { const key: string = TermCachePrefix + termId; const terms: string = window.sessionStorage.getItem(key); if (!terms) { return null; } const cacheEntry: ITermCacheEntry = JSON.parse(terms) as ITermCacheEntry; if (!cacheEntry || !cacheEntry.value || !cacheEntry.expiresAt || cacheEntry.expiresAt <= Date.now()) { window.sessionStorage.removeItem(key); return null; } return cacheEntry.value; } else { return null; } } catch (error) { return null; } } private getCachedPickerTerms(termId: string, useSessionStorage: boolean = true): IPickerTerm[] { try { if (!useSessionStorage || !window.sessionStorage) { return null; } const key: string = PickerCachePrefix + termId; const serialized: string = window.sessionStorage.getItem(key); if (!serialized) { return null; } const cacheEntry: IPickerTermCacheEntry = JSON.parse(serialized) as IPickerTermCacheEntry; if (!cacheEntry || !cacheEntry.value || !cacheEntry.expiresAt || cacheEntry.expiresAt <= Date.now()) { window.sessionStorage.removeItem(key); return null; } return cacheEntry.value; } catch (error) { return null; } } private normalizeCacheMinutes(value: number): number { if (typeof value !== 'number' || !isFinite(value)) { return 15; } return Math.min(Math.max(Math.floor(value), 1), 1440); } private ensureSuccessfulResponse(response: SPHttpClientResponse, operation: string): void { if (!response.ok) { throw new Error(operation + ' request failed (' + response.status + ' ' + response.statusText + ').'); } } private searchTermsBySearchText(terms, searchText) { if (terms) { return terms.filter((t) => { return t.name.toLowerCase().indexOf(searchText.toLowerCase()) > -1; }); } else { return []; } } /** * Searches terms for the given term set * @param searchText * @param termsetId */ private searchTermsByTermSet(searchText: string): Promise { return new Promise(resolve => { this.getTermStores().then(termStore => { let termSetId = this.props.termsetNameOrID; if (!this.isGuid(termSetId)) { // Get the ID of the provided term set name const crntTermSet = this.getTermSetId(termStore, termSetId); if (crntTermSet) { termSetId = this.cleanGuid(crntTermSet.Id); } else { resolve(null); return; } } if (termStore === undefined || termStore.length === 0) { resolve(null); return; } const loc: number = this.context.pageContext.cultureInfo.currentUICultureName === 'de-de' ? 1031 : 1033; const data: any = { start: searchText, lcid: loc !== 0 ? loc : this.context.pageContext.web.language, sspList: this.cleanGuid(termStore[0].Id), termSetList: termSetId, anchorId: this.props.anchorId ? this.props.anchorId : EmptyGuid, isSpanTermStores: false, isSpanTermSets: false, isIncludeUnavailable: this.props.hideTagsNotAvailableForTagging === true, isIncludeDeprecated: this.props.hideDeprecatedTags === true, isAddTerms: false, isIncludePathData: false, excludeKeyword: false, excludedTermset: EmptyGuid }; const reqHeaders: Headers = new Headers(); reqHeaders.append('accept', 'application/json'); reqHeaders.append('content-type', 'application/json'); const httpPostOptions: ISPHttpClientOptions = { headers: reqHeaders, body: JSON.stringify(data) }; return this.context.spHttpClient.post(this.suggestionServiceUrl, SPHttpClient.configurations.v1, httpPostOptions).then((serviceResponse: SPHttpClientResponse) => { return serviceResponse.json().then((serviceJSONResponse: any) => { const groups = serviceJSONResponse.d.Groups; if (groups && groups.length > 0) { // Retrieve the term collection results const terms: ISuggestTerm[] = groups[0].Suggestions; if (terms.length > 0) { // Retrieve all terms const returnTerms: IPickerTerm[] = terms.map((term: ISuggestTerm) => this.convertSuggestTermToPickerTerm(term)); resolve(returnTerms); return; } } resolve([]); }); }); }); }); } private isGuid(strGuid: string): boolean { return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(strGuid); } /** * Sorting terms based on their path and depth * * @param terms */ private sortTerms(terms: ITerm[]) { // Start sorting by depth let newTermsOrder: ITerm[] = []; let itemsToSort: boolean = true; let pathLevel: number = 1; while (itemsToSort) { // Get terms for the current level let crntTerms = terms.filter(term => term.PathDepth === pathLevel); if (crntTerms && crntTerms.length > 0) { crntTerms = crntTerms.sort(this.sortTermByPath); if (pathLevel !== 1) { crntTerms = crntTerms.reverse(); for (const crntTerm of crntTerms) { const pathElms: string[] = crntTerm.PathOfTerm.split(';'); // Last item is not needed for parent path pathElms.pop(); // Find the parent item and add the new item const idx: number = findIndex(newTermsOrder, term => term.PathOfTerm === pathElms.join(';')); if (idx !== -1) { newTermsOrder.splice(idx + 1, 0, crntTerm); } else { // Push the item at the end if the parent couldn't be found newTermsOrder.push(crntTerm); } } } else { newTermsOrder = crntTerms; } ++pathLevel; } else { itemsToSort = false; } } return newTermsOrder; } /** * Sort the terms by their path * * @param a term 2 * @param b term 2 */ private sortTermByPath(a: ITerm, b: ITerm) { if (a.CustomSortOrderIndex === -1) { if (a.PathOfTerm.toLowerCase() < b.PathOfTerm.toLowerCase()) { return -1; } if (a.PathOfTerm.toLowerCase() > b.PathOfTerm.toLowerCase()) { return 1; } return 0; } else { if (a.CustomSortOrderIndex < b.CustomSortOrderIndex) { return -1; } if (a.CustomSortOrderIndex > b.CustomSortOrderIndex) { return 1; } return 0; } } private convertTermToPickerTerm(term: ITerm): IPickerTerm { return { key: this.cleanGuid(term.Id), name: term.Name, path: term.PathOfTerm, termSet: this.cleanGuid(term.TermSet.Id), termSetName: term.TermSet.Name }; } private convertSuggestTermToPickerTerm(term: ISuggestTerm): IPickerTerm { let path: string = ''; let termSetName: string = ''; if (term.Paths && term.Paths.length > 0) { const fullPath: string = term.Paths[0].replace(/^\[/, '').replace(/\]$/, ''); const fullPathParts: string[] = fullPath.split(':'); path = fullPathParts.join(';') + ';' + term.DefaultLabel; termSetName = fullPathParts[0]; } return { key: this.cleanGuid(term.Id), name: term.DefaultLabel, path: path, termSet: EmptyGuid, // TermSet Guid is not given with suggestion termSetName: termSetName }; } }