diff --git a/client/src/components/ContentNavigator/ContentAdapterFactory.ts b/client/src/components/ContentNavigator/ContentAdapterFactory.ts new file mode 100644 index 000000000..52bb3caad --- /dev/null +++ b/client/src/components/ContentNavigator/ContentAdapterFactory.ts @@ -0,0 +1,26 @@ +// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import SASContentAdapter from "../../connection/rest/SASContentAdapter"; +import { ConnectionType } from "../profile"; +import { + ContentAdapter, + ContentNavigatorConfig, + ContentSourceType, +} from "./types"; + +class ContentAdapterFactory { + // TODO #889 Update this to return RestSASServerAdapter & ITCSASServerAdapter + public create( + connectionType: ConnectionType, + sourceType: ContentNavigatorConfig["sourceType"], + ): ContentAdapter { + const key = `${connectionType}.${sourceType}`; + switch (key) { + case `${ConnectionType.Rest}.${ContentSourceType.SASContent}`: + default: + return new SASContentAdapter(); + } + } +} + +export default ContentAdapterFactory; diff --git a/client/src/components/ContentNavigator/ContentDataProvider.ts b/client/src/components/ContentNavigator/ContentDataProvider.ts index f8b000b59..d774b8463 100644 --- a/client/src/components/ContentNavigator/ContentDataProvider.ts +++ b/client/src/components/ContentNavigator/ContentDataProvider.ts @@ -43,31 +43,17 @@ import { ViyaProfile } from "../profile"; import { ContentModel } from "./ContentModel"; import { FAVORITES_FOLDER_TYPE, - MYFOLDER_TYPE, Messages, ROOT_FOLDER_TYPE, TRASH_FOLDER_TYPE, } from "./const"; -import { convertNotebookToFlow } from "./convert"; -import { ContentItem, FileManipulationEvent } from "./types"; import { - getCreationDate, - getFileStatement, - getId, - isContainer as getIsContainer, - getLabel, - getLink, - getModifyDate, - getResourceIdFromItem, - getTypeName, - getUri, - isContainer, - isItemInRecycleBin, - isReference, - resourceType, -} from "./utils"; - -const contentItemMimeType = "application/vnd.code.tree.contentdataprovider"; + ContentItem, + ContentNavigatorConfig, + FileManipulationEvent, +} from "./types"; +import { getFileStatement, isContainer as getIsContainer } from "./utils"; + class ContentDataProvider implements TreeDataProvider, @@ -84,23 +70,31 @@ class ContentDataProvider private _dropEditProvider: Disposable; private readonly model: ContentModel; private extensionUri: Uri; + private mimeType: string; - public dropMimeTypes: string[] = [contentItemMimeType, "text/uri-list"]; - public dragMimeTypes: string[] = [contentItemMimeType]; + public dropMimeTypes: string[]; + public dragMimeTypes: string[]; get treeView(): TreeView { return this._treeView; } - constructor(model: ContentModel, extensionUri: Uri) { + constructor( + model: ContentModel, + extensionUri: Uri, + { mimeType, treeIdentifier }: ContentNavigatorConfig, + ) { this._onDidManipulateFile = new EventEmitter(); this._onDidChangeFile = new EventEmitter(); this._onDidChangeTreeData = new EventEmitter(); this._onDidChange = new EventEmitter(); this.model = model; this.extensionUri = extensionUri; + this.dropMimeTypes = [mimeType, "text/uri-list"]; + this.dragMimeTypes = [mimeType]; + this.mimeType = mimeType; - this._treeView = window.createTreeView("contentdataprovider", { + this._treeView = window.createTreeView(treeIdentifier, { treeDataProvider: this, dragAndDropController: this, canSelectMany: true, @@ -131,7 +125,7 @@ class ContentDataProvider } switch (mimeType) { - case contentItemMimeType: + case this.mimeType: await Promise.all( item.value.map( async (contentItem: ContentItem) => @@ -210,14 +204,9 @@ class ContentDataProvider public async getTreeItem(item: ContentItem): Promise { const isContainer = getIsContainer(item); - - const uri = await this.getUri(item, false); + const uri = await this.model.getUri(item, false); return { - iconPath: this.iconPathForItem(item), - contextValue: resourceType(item), - id: getId(item), - label: getLabel(item), collapsibleState: isContainer ? TreeItemCollapsibleState.Collapsed : undefined, @@ -228,6 +217,10 @@ class ContentDataProvider arguments: [uri], title: "Open SAS File", }, + contextValue: item.contextValue, + iconPath: this.iconPathForItem(item), + id: item.uid, + label: item.name, }; } @@ -247,14 +240,9 @@ class ContentDataProvider } public async stat(uri: Uri): Promise { - return await this.model.getResourceByUri(uri).then( - (resource): FileStat => ({ - type: getIsContainer(resource) ? FileType.Directory : FileType.File, - ctime: getCreationDate(resource), - mtime: getModifyDate(resource), - size: 0, - }), - ); + return await this.model + .getResourceByUri(uri) + .then((resource): FileStat => resource.fileStat); } public async readFile(uri: Uri): Promise { @@ -263,10 +251,6 @@ class ContentDataProvider .then((content) => new TextEncoder().encode(content)); } - public getUri(item: ContentItem, readOnly: boolean): Promise { - return this.model.getUri(item, readOnly); - } - public async createFolder( item: ContentItem, folderName: string, @@ -274,7 +258,7 @@ class ContentDataProvider const newItem = await this.model.createFolder(item, folderName); if (newItem) { this.refresh(); - return getUri(newItem); + return newItem.vscUri; } } @@ -286,7 +270,7 @@ class ContentDataProvider const newItem = await this.model.createFile(item, fileName, buffer); if (newItem) { this.refresh(); - return getUri(newItem); + return newItem.vscUri; } } @@ -301,14 +285,14 @@ class ContentDataProvider const newItem = await this.model.renameResource(item, name); if (newItem) { - const newUri = getUri(newItem); + const newUri = newItem.vscUri; if (closing !== true) { // File was open before rename, so re-open it commands.executeCommand("vscode.open", newUri); } this._onDidManipulateFile.fire({ type: "rename", - uri: getUri(item), + uri: item.vscUri, newUri, }); return newUri; @@ -326,50 +310,40 @@ class ContentDataProvider const success = await this.model.delete(item); if (success) { this.refresh(); - this._onDidManipulateFile.fire({ type: "delete", uri: getUri(item) }); + this._onDidManipulateFile.fire({ type: "delete", uri: item.vscUri }); } return success; } public async recycleResource(item: ContentItem): Promise { - const recycleBin = this.model.getDelegateFolder("@myRecycleBin"); - if (!recycleBin) { - // fallback to delete - return this.deleteResource(item); - } - const recycleBinUri = getLink(recycleBin.links, "GET", "self")?.uri; - if (!recycleBinUri) { - return false; - } if (!(await closeFileIfOpen(item))) { return false; } - const success = await this.model.moveTo(item, recycleBinUri); - if (success) { + const { newUri, oldUri } = await this.model.recycleResource(item); + + if (newUri) { this.refresh(); // update the text document content as well just in case that this file was just restored and updated - this._onDidChange.fire(getUri(item, true)); + this._onDidChange.fire(newUri); this._onDidManipulateFile.fire({ type: "recycle", - uri: getUri(item), + uri: oldUri, }); } - return success; + + return !!newUri; } public async restoreResource(item: ContentItem): Promise { - const previousParentUri = getLink(item.links, "GET", "previousParent")?.uri; - if (!previousParentUri) { - return false; - } if (!(await closeFileIfOpen(item))) { return false; } - const success = await this.model.moveTo(item, previousParentUri); + const success = await this.model.restoreResource(item); if (success) { this.refresh(); } + return success; } @@ -415,67 +389,6 @@ class ContentDataProvider this.reveal(resource); } - public async acquireStudioSessionId(endpoint: string): Promise { - if (endpoint && !this.model.connected()) { - await this.connect(endpoint); - } - return await this.model.acquireStudioSessionId(); - } - - public async convertNotebookToFlow( - inputName: string, - outputName: string, - content: string, - studioSessionId: string, - parentItem?: ContentItem, - ): Promise { - if (!parentItem) { - const rootFolders = await this.model.getChildren(); - const myFolder = rootFolders.find( - (rootFolder) => rootFolder.type === MYFOLDER_TYPE, - ); - if (!myFolder) { - return ""; - } - parentItem = myFolder; - } - - try { - // convert the notebook file to a .flw file - const flowDataString = convertNotebookToFlow( - content, - inputName, - outputName, - ); - const flowDataUint8Array = new TextEncoder().encode(flowDataString); - if (flowDataUint8Array.length === 0) { - window.showErrorMessage(Messages.NoCodeToConvert); - return; - } - const newUri = await this.createFile( - parentItem, - outputName, - flowDataUint8Array, - ); - this.handleCreationResponse( - parentItem, - newUri, - l10n.t(Messages.NewFileCreationError, { name: inputName }), - ); - // associate the new .flw file with SAS Studio - await this.model.associateFlowFile( - outputName, - newUri, - parentItem, - studioSessionId, - ); - } catch (error) { - window.showErrorMessage(error); - } - - return parentItem.name; - } - public refresh(): void { this._onDidChangeTreeData.fire(undefined); } @@ -546,7 +459,7 @@ class ContentDataProvider ): Promise { for (let i = 0; i < selections.length; ++i) { const selection = selections[i]; - if (isContainer(selection)) { + if (getIsContainer(selection)) { const newFolderUri = Uri.joinPath(folderUri, selection.name); const selectionsWithinFolder = await this.childrenSelections( selection, @@ -591,14 +504,14 @@ class ContentDataProvider let message = Messages.FileDropError; if (item.flags.isInRecycleBin) { message = Messages.FileDragFromTrashError; - } else if (isReference(item)) { + } else if (item.isReference) { message = Messages.FileDragFromFavorites; } else if (target.type === TRASH_FOLDER_TYPE) { success = await this.recycleResource(item); } else if (target.type === FAVORITES_FOLDER_TYPE) { success = await this.addToMyFavorites(item); } else { - const targetUri = getResourceIdFromItem(target); + const targetUri = target.resourceId; if (targetUri) { success = await this.model.moveTo(item, targetUri); } @@ -714,7 +627,7 @@ class ContentDataProvider const isContainer = getIsContainer(item); let icon = ""; if (isContainer) { - const type = getTypeName(item); + const type = item.typeName; switch (type) { case ROOT_FOLDER_TYPE: icon = "sasFolders"; @@ -750,7 +663,7 @@ class ContentDataProvider export default ContentDataProvider; const closeFileIfOpen = (item: ContentItem) => { - const fileUri = getUri(item, isItemInRecycleBin(item)); + const fileUri = item.vscUri; const tabs: Tab[] = window.tabGroups.all.map((tg) => tg.tabs).flat(); const tab = tabs.find( (tab) => diff --git a/client/src/components/ContentNavigator/ContentModel.ts b/client/src/components/ContentNavigator/ContentModel.ts index 00d78c795..2e204ebc7 100644 --- a/client/src/components/ContentNavigator/ContentModel.ts +++ b/client/src/components/ContentNavigator/ContentModel.ts @@ -1,697 +1,147 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Uri, authentication } from "vscode"; +import { Uri } from "vscode"; -import axios, { - AxiosError, - AxiosInstance, - AxiosRequestConfig, - AxiosResponse, -} from "axios"; - -import { - associateFlowObject, - createStudioSession, -} from "../../connection/studio"; -import { SASAuthProvider } from "../AuthProvider"; -import { - DEFAULT_FILE_CONTENT_TYPE, - FILE_TYPES, - FOLDER_TYPES, - Messages, - ROOT_FOLDER, - TRASH_FOLDER_TYPE, -} from "./const"; -import { ContentItem, Link } from "./types"; -import { - getFileContentType, - getItemContentType, - getLink, - getPermission, - getResourceId, - getResourceIdFromItem, - getTypeName, - getUri, - isContainer, -} from "./utils"; - -interface AddMemberProperties { - name?: string; - contentType?: string; - type?: string; -} +import { Messages, ROOT_FOLDERS } from "./const"; +import { ContentAdapter, ContentItem } from "./types"; export class ContentModel { - private connection: AxiosInstance; - private fileMetadataMap: { - [id: string]: { etag: string; lastModified: string; contentType: string }; - }; - private authorized: boolean; - private viyaCadence: string; - private delegateFolders: { [name: string]: ContentItem }; + private contentAdapter: ContentAdapter; - constructor() { - this.fileMetadataMap = {}; - this.authorized = false; - this.delegateFolders = {}; - this.viyaCadence = ""; + constructor(contentAdapter: ContentAdapter) { + this.contentAdapter = contentAdapter; } public connected(): boolean { - return this.authorized; + return this.contentAdapter.connected(); } public async connect(baseURL: string): Promise { - this.connection = axios.create({ baseURL }); - this.connection.interceptors.response.use( - (response: AxiosResponse) => response, - async (error: AxiosError) => { - const originalRequest: AxiosRequestConfig & { _retry?: boolean } = - error.config; - - if (error.response?.status === 401 && !originalRequest._retry) { - originalRequest._retry = true; - await this.updateAccessToken(); - return this.connection(originalRequest); - } + await this.contentAdapter.connect(baseURL); + } - return Promise.reject(error); - }, - ); - await this.updateAccessToken(); - this.viyaCadence = ""; - this.authorized = true; + public getAdapter() { + return this.contentAdapter; } public async getChildren(item?: ContentItem): Promise { - if (!this.authorized) { + if (!this.connected()) { return []; } if (!item) { - return this.getRootChildren(); - } - - if (!this.viyaCadence) { - this.viyaCadence = await this.getViyaCadence(); - } - - const parentIsContent = item.uri === ROOT_FOLDER.uri; - const typeQuery = this.generateTypeQuery(parentIsContent); - - const membersLink = getLink(item.links, "GET", "members"); - let membersUrl = membersLink ? membersLink.uri : null; - if (!membersUrl && item.uri) { - membersUrl = `${item.uri}/members`; - } - - if (!membersUrl) { - const selfLink = getLink(item.links, "GET", "self"); - if (!selfLink) { - console.error( - "Invalid state: FolderService object has no self link : " + item.name, - ); - return Promise.reject({ status: 404 }); - } - membersUrl = selfLink.uri + "/members"; - } - - membersUrl = membersUrl + "?limit=1000000"; - - const filters = []; - - if (parentIsContent) { - filters.push("isNull(parent)"); - } - if (typeQuery) { - filters.push(typeQuery); - } - - if (filters.length === 1) { - membersUrl = membersUrl + "&filter=" + filters[0]; - } else if (filters.length > 1) { - membersUrl = membersUrl + "&filter=and(" + filters.join(",") + ")"; - } - membersUrl = - membersUrl + - `&sortBy=${ - parentIsContent || this.viyaCadence === "2023.03" // 2023.03 fails query with this sortBy param - ? "" - : "eq(contentType,'folder'):descending," - }name:primary:ascending,type:ascending`; - - const res = await this.connection.get(membersUrl); - const result = res.data; - if (!result.items) { - return Promise.reject(); + return Object.entries(await this.contentAdapter.getRootItems()) + .sort( + // sort the delegate folders as the order in the supportedDelegateFolders + (a, b) => ROOT_FOLDERS.indexOf(a[0]) - ROOT_FOLDERS.indexOf(b[0]), + ) + .map((entry) => entry[1]); } - const myFavoritesFolder = this.getDelegateFolder("@myFavorites"); - const isInRecycleBin = - TRASH_FOLDER_TYPE === getTypeName(item) || item.flags?.isInRecycleBin; - const isInMyFavorites = - getResourceIdFromItem(item) === getResourceIdFromItem(myFavoritesFolder); - const all_favorites = isInMyFavorites - ? [] - : await this.getChildren(myFavoritesFolder); - return result.items.map((childItem: ContentItem, index) => ({ - ...childItem, - uid: `${item.uid}/${index}`, - permission: getPermission(childItem), - flags: { - isInRecycleBin, - isInMyFavorites, - hasFavoriteId: all_favorites.find( - (favorite) => - getResourceIdFromItem(favorite) === - getResourceIdFromItem(childItem), - )?.id, - }, - })); + return await this.contentAdapter.getChildItems(item); } public async getParent(item: ContentItem): Promise { - const ancestorsLink = getLink(item.links, "GET", "ancestors"); - if (!ancestorsLink) { - return; - } - const resp = await this.connection.get(ancestorsLink.uri); - if (resp.data && resp.data.length > 0) { - return resp.data[0]; - } + return await this.contentAdapter.getParentOfItem(item); } public async getResourceByUri(uri: Uri): Promise { - const resourceId = getResourceId(uri); - return (await this.getResourceById(resourceId)).data; + return await this.contentAdapter.getItemOfUri(uri); } public async getContentByUri(uri: Uri): Promise { - const resourceId = getResourceId(uri); - let res; + let data; try { - res = await this.connection.get(resourceId + "/content", { - transformResponse: (response) => response, - }); + data = (await this.contentAdapter.getContentOfUri(uri)).toString(); } catch (e) { throw new Error(Messages.FileOpenError); } // We expect the returned data to be a string. If this isn't a string, // we can't really open it - if (typeof res.data === "object") { + if (typeof data === "object") { throw new Error(Messages.FileOpenError); } - return res.data; + return data; } public async downloadFile(item: ContentItem): Promise { - const uri = getUri(item); - const resourceId = getResourceId(uri); - try { - const res = await this.connection.get(resourceId + "/content", { - responseType: "arraybuffer", - }); + const data = await this.contentAdapter.getContentOfItem(item); - return Buffer.from(res.data, "binary"); + return Buffer.from(data, "binary"); } catch (e) { throw new Error(Messages.FileDownloadError); } } public async createFile( - item: ContentItem, + parentItem: ContentItem, fileName: string, buffer?: ArrayBufferLike, ): Promise { - const typeDef = await this.getTypeDefinition(fileName); - let createdResource: ContentItem; - try { - const fileCreationResponse = await this.connection.post( - `/files/files#rawUpload?typeDefName=${typeDef}`, - buffer || Buffer.from("", "binary"), - { - headers: { - "Content-Type": getFileContentType(fileName), - "Content-Disposition": `filename*=UTF-8''${encodeURI(fileName)}`, - Accept: "application/vnd.sas.file+json", - }, - }, - ); - createdResource = fileCreationResponse.data; - } catch (error) { - return; - } - - const fileLink: Link | null = getLink(createdResource.links, "GET", "self"); - const memberAdded = await this.addMember( - fileLink?.uri, - getLink(item.links, "POST", "addMember")?.uri, - { - name: fileName, - contentType: typeDef, - }, + return await this.contentAdapter.createNewItem( + parentItem, + fileName, + buffer, ); - if (!memberAdded) { - return; - } - - return createdResource; } public async createFolder( item: ContentItem, name: string, ): Promise { - const parentFolderUri = - item.uri || getLink(item.links || [], "GET", "self")?.uri || null; - if (!parentFolderUri) { - return; - } - - try { - const createFolderResponse = await this.connection.post( - `/folders/folders?parentFolderUri=${parentFolderUri}`, - { - name, - }, - ); - return createFolderResponse.data; - } catch (error) { - return; - } + return await this.contentAdapter.createNewFolder(item, name); } public async renameResource( item: ContentItem, name: string, ): Promise { - const itemIsReference = item.type === "reference"; - const uri = itemIsReference - ? getLink(item.links, "GET", "self").uri - : item.uri; - - try { - const validationUri = getLink(item.links, "PUT", "validateRename")?.uri; - if (validationUri) { - await this.connection.put( - validationUri - .replace("{newname}", encodeURI(name)) - .replace("{newtype}", getTypeName(item)), - ); - } - - // not sure why but the response of moveTo request does not return the latest etag so request it every time - const { data: fileData } = await this.getResourceById(uri); - const contentType = getFileContentType(name); - const fileMetadata = this.fileMetadataMap[uri]; - const patchResponse = await this.connection.put( - uri, - { ...fileData, name }, - { - headers: { - "If-Unmodified-Since": fileMetadata.lastModified, - "If-Match": fileMetadata.etag, - "Content-Type": getItemContentType(item), - }, - }, - ); - - this.updateFileMetadata(uri, patchResponse, contentType); - - // The links in My Favorites are of type reference. Instead of passing - // back the reference objects, we want to pass back the underlying source - // objects. - if (itemIsReference) { - return (await this.getResourceById(item.uri)).data; - } - - return patchResponse.data; - } catch (error) { - return; - } - } - - private getFileInfo(resourceId: string): { - etag: string; - lastModified: string; - contentType: string; - } { - if (resourceId in this.fileMetadataMap) { - return this.fileMetadataMap[resourceId]; - } - const now = new Date(); - const timestamp = now.toUTCString(); - return { - etag: "", - lastModified: timestamp, - contentType: DEFAULT_FILE_CONTENT_TYPE, - }; + return await this.contentAdapter.renameItem(item, name); } public async saveContentToUri(uri: Uri, content: string): Promise { - const resourceId = getResourceId(uri); - const { etag, lastModified, contentType } = this.getFileInfo(resourceId); - const headers = { - "Content-Type": contentType, - "If-Unmodified-Since": lastModified, - }; - if (etag !== "") { - headers["If-Match"] = etag; - } - try { - const res = await this.connection.put(resourceId + "/content", content, { - headers, - }); - this.updateFileMetadata(resourceId, res, contentType); - } catch (error) { - console.log(error); - } - } - - public async acquireStudioSessionId(): Promise { - try { - const result = await createStudioSession(this.connection); - return result; - } catch (error) { - return ""; - } - } - - public async associateFlowFile( - name: string, - uri: Uri, - parent: ContentItem, - studioSessionId: string, - ): Promise { - try { - return await associateFlowObject( - name, - getResourceId(uri), - getResourceIdFromItem(parent), - studioSessionId, - this.connection, - ); - } catch (error) { - console.log(error); - } + await this.contentAdapter.updateContentOfItem(uri, content); } public async getUri(item: ContentItem, readOnly: boolean): Promise { - if (item.type !== "reference") { - return getUri(item, readOnly); - } - - // If we're attempting to open a favorite, open the underlying file instead. - try { - const resp = await this.connection.get(item.uri); - return getUri(resp.data, readOnly); - } catch (error) { - return getUri(item, readOnly); - } + return await this.contentAdapter.getUriOfItem(item, readOnly); } public async delete(item: ContentItem): Promise { - // folder service will return 409 error if the deleting folder has non-folder item even if add recursive parameter - // delete the resource or move item to recycle bin will automatically delete the favorites as well. - return await (isContainer(item) - ? this.deleteFolder(item) - : this.deleteResource(item)); + return await this.contentAdapter.deleteItem(item); } - private async deleteFolder(item: ContentItem): Promise { - try { - const children = await this.getChildren(item); - await Promise.all(children.map((child) => this.delete(child))); - const deleteRecursivelyLink = getLink( - item.links, - "DELETE", - "deleteRecursively", - )?.uri; - const deleteResourceLink = getLink( - item.links, - "DELETE", - "deleteResource", - )?.uri; - if (!deleteRecursivelyLink && !deleteResourceLink) { - return false; - } - const deleteLink = - deleteRecursivelyLink ?? `${deleteResourceLink}?recursive=true`; - await this.connection.delete(deleteLink); - } catch (error) { - return false; - } - return true; + public async addFavorite(item: ContentItem): Promise { + return await this.contentAdapter.addItemToFavorites(item); } - private async deleteResource(item: ContentItem): Promise { - const deleteResourceLink = getLink( - item.links, - "DELETE", - "deleteResource", - )?.uri; - if (!deleteResourceLink) { - return false; - } - try { - await this.connection.delete(deleteResourceLink); - } catch (error) { - return false; - } - // Due to delay in folders service's automatic deletion of associated member we need - // to attempt manual deletion of member to ensure subsequent data refreshes don't occur before - // member is deleted. Per Gary Williams, we must do these steps sequentially not concurrently. - // If member already deleted, server treats this call as NO-OP. - try { - const deleteLink = getLink(item.links, "DELETE", "delete")?.uri; - if (deleteLink) { - await this.connection.delete(deleteLink); - } - } catch (error) { - return error.response.status === 404 || error.response.status === 403; - } - return true; + public async removeFavorite(item: ContentItem): Promise { + return await this.contentAdapter.removeItemFromFavorites(item); } public async moveTo( item: ContentItem, targetParentFolderUri: string, ): Promise { - const newItemData = { - ...item, - parentFolderUri: targetParentFolderUri, - }; - const updateLink = getLink(item.links, "PUT", "update"); - try { - await this.connection.put(updateLink.uri, newItemData); - } catch (error) { - return false; - } - return true; - } - - public async addMember( - uri: string | undefined, - addMemberUri: string | undefined, - properties: AddMemberProperties, - ): Promise { - if (!uri || !addMemberUri) { - return false; - } - - try { - await this.connection.post(addMemberUri, { - uri, - type: "CHILD", - ...properties, - }); - } catch (error) { - return false; - } - - return true; - } - - public async addFavorite(item: ContentItem): Promise { - const myFavorites = this.getDelegateFolder("@myFavorites"); - return await this.addMember( - getResourceIdFromItem(item), - getLink(myFavorites.links, "POST", "addMember").uri, - { - type: "reference", - name: item.name, - contentType: item.contentType, - }, - ); - } - - public async removeFavorite(item: ContentItem): Promise { - const deleteMemberUri = item.flags?.isInMyFavorites - ? getLink(item.links, "DELETE", "delete")?.uri - : item.flags?.hasFavoriteId - ? `${getResourceIdFromItem( - this.getDelegateFolder("@myFavorites"), - )}/members/${item.flags?.hasFavoriteId}` - : undefined; - if (!deleteMemberUri) { - return false; - } - try { - await this.connection.delete(deleteMemberUri); - } catch (error) { - return false; - } - return true; - } - - private generateTypeQuery(parentIsContent: boolean): string { - // Generate type query segment if applicable - let typeQuery = ""; - // Determine the set of types on which to filter - const includedTypes = FILE_TYPES.concat(FOLDER_TYPES); - - // Generate type query string - typeQuery = "in(" + (parentIsContent ? "type" : "contentType") + ","; - for (let i = 0; i < includedTypes.length; i++) { - typeQuery += "'" + includedTypes[i] + "'"; - if (i !== includedTypes.length - 1) { - typeQuery += ","; - } - } - typeQuery += ")"; - - return typeQuery; - } - - private async getTypeDefinition(fileName: string): Promise { - const defaultContentType = "file"; - const ext = fileName.split(".").pop().toLowerCase(); - if (ext === "sas") { - return "programFile"; - } - - try { - const typeResponse = await this.connection.get( - `/types/types?filter=contains('extensions', '${ext}')`, - ); - - if (typeResponse.data.items && typeResponse.data.items.length !== 0) { - return typeResponse.data.items[0].name; - } - } catch { - return defaultContentType; - } - - return defaultContentType; - } - - private async getRootChildren(): Promise { - const supportedDelegateFolders = [ - "@myFavorites", - "@myFolder", - "@sasRoot", - "@myRecycleBin", - ]; - let numberCompletedServiceCalls = 0; - - return new Promise((resolve) => { - supportedDelegateFolders.forEach(async (sDelegate, index) => { - let result; - if (sDelegate === "@sasRoot") { - result = { - data: ROOT_FOLDER, - }; - } else { - result = await this.connection.get(`/folders/folders/${sDelegate}`); - } - this.delegateFolders[sDelegate] = { - ...result.data, - uid: `${index}`, - permission: getPermission(result.data), - }; - - numberCompletedServiceCalls++; - if (numberCompletedServiceCalls === supportedDelegateFolders.length) { - resolve( - Object.entries(this.delegateFolders) - .sort( - // sort the delegate folders as the order in the supportedDelegateFolders - (a, b) => - supportedDelegateFolders.indexOf(a[0]) - - supportedDelegateFolders.indexOf(b[0]), - ) - .map((entry) => entry[1]), - ); - } - }); - }); + return await this.contentAdapter.moveItem(item, targetParentFolderUri); } public getDelegateFolder(name: string): ContentItem | undefined { - return this.delegateFolders[name]; - } - - private async updateAccessToken(): Promise { - const session = await authentication.getSession(SASAuthProvider.id, [], { - createIfNone: true, - }); - this.connection.defaults.headers.common.Authorization = `Bearer ${session.accessToken}`; - } - - private async getViyaCadence(): Promise { - try { - const { data } = await this.connection.get( - "/deploymentData/cadenceVersion", - ); - return data.cadenceVersion; - } catch (e) { - console.error("fail to retrieve the viya cadence"); - } - return "unknown"; + return this.contentAdapter.getRootFolder(name); } public async getFileFolderPath(contentItem: ContentItem): Promise { - if (isContainer(contentItem)) { - return ""; - } - - const filePathParts = []; - let currentContentItem: Pick = - contentItem; - do { - try { - const { data: parentData } = await this.connection.get( - currentContentItem.parentFolderUri, - ); - currentContentItem = parentData; - } catch (e) { - return ""; - } - - filePathParts.push(currentContentItem.name); - } while (currentContentItem.parentFolderUri); - - return "/" + filePathParts.reverse().join("/"); + return await this.contentAdapter.getFolderPathForItem(contentItem); } - private updateFileMetadata( - id: string, - { headers, data }: AxiosResponse, - contentType?: string, - ) { - this.fileMetadataMap[id] = { - etag: headers.etag, - lastModified: headers["last-modified"], - contentType: contentType || data.contentType, - }; + public async recycleResource(item: ContentItem) { + return await this.contentAdapter.recycleItem(item); } - private async getResourceById(id: string): Promise { - const res = await this.connection.get(id); - this.updateFileMetadata(id, res); - return res; + public async restoreResource(item: ContentItem) { + return await this.contentAdapter.restoreItem(item); } } diff --git a/client/src/components/ContentNavigator/const.ts b/client/src/components/ContentNavigator/const.ts index 98cd83585..b03420e3a 100644 --- a/client/src/components/ContentNavigator/const.ts +++ b/client/src/components/ContentNavigator/const.ts @@ -46,6 +46,13 @@ export const FOLDER_TYPES = [ TRASH_FOLDER_TYPE, ]; +export const ROOT_FOLDERS = [ + "@myFavorites", + "@myFolder", + "@sasRoot", + "@myRecycleBin", +]; + export const Messages = { AddFileToMyFolderFailure: l10n.t("Unable to add file to my folder."), AddFileToMyFolderSuccess: l10n.t("File added to my folder."), diff --git a/client/src/components/ContentNavigator/convert.ts b/client/src/components/ContentNavigator/convert.ts index eca5bc5c5..4bc2b56b5 100644 --- a/client/src/components/ContentNavigator/convert.ts +++ b/client/src/components/ContentNavigator/convert.ts @@ -1,9 +1,20 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { l10n, workspace } from "vscode"; +import { Uri, l10n, workspace } from "vscode"; +import { basename } from "path"; import { v4 } from "uuid"; +import SASContentAdapter from "../../connection/rest/SASContentAdapter"; +import { + associateFlowObject, + createStudioSession, +} from "../../connection/studio"; +import { ContentModel } from "./ContentModel"; +import { MYFOLDER_TYPE, Messages } from "./const"; +import { ContentItem } from "./types"; +import { isContentItem } from "./utils"; + const stepRef: Record = { sas: "a7190700-f59c-4a94-afe2-214ce639fcde", sql: "a7190700-f59c-4a94-afe2-214ce639fcde", @@ -325,3 +336,101 @@ export function convertNotebookToFlow( const flowDataString = JSON.stringify(flowData, null, 0); return flowDataString; } + +export class NotebookToFlowConverter { + protected studioSessionId: string; + public constructor( + protected readonly resource: ContentItem | Uri, + protected readonly contentModel: ContentModel, + protected readonly viyaEndpoint: string, + ) {} + + public get inputName() { + return isContentItem(this.resource) + ? this.resource.name + : basename(this.resource.fsPath); + } + + private get connection() { + return ( + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (this.contentModel.getAdapter() as SASContentAdapter).getConnection() + ); + } + + private async parent() { + const parentItem = isContentItem(this.resource) + ? await this.contentModel.getParent(this.resource) + : undefined; + + if (parentItem) { + return parentItem; + } + + const rootFolders = await this.contentModel.getChildren(); + const myFolder = rootFolders.find( + (rootFolder) => rootFolder.type === MYFOLDER_TYPE, + ); + if (!myFolder) { + return undefined; + } + + return myFolder; + } + + public async content() { + return isContentItem(this.resource) + ? await this.contentModel.getContentByUri(this.resource.vscUri) + : (await workspace.fs.readFile(this.resource)).toString(); + } + + public async establishConnection() { + if (!this.contentModel.connected()) { + await this.contentModel.connect(this.viyaEndpoint); + } + + try { + const result = await createStudioSession(this.connection); + this.studioSessionId = result; + } catch (error) { + this.studioSessionId = ""; + } + + return this.studioSessionId; + } + + public async convert(outputName: string) { + const flowDataString = convertNotebookToFlow( + await this.content(), + this.inputName, + outputName, + ); + const flowDataUint8Array = new TextEncoder().encode(flowDataString); + if (flowDataUint8Array.length === 0) { + throw new Error(Messages.NoCodeToConvert); + } + + const parentItem = await this.parent(); + const newItem = await this.contentModel.createFile( + parentItem, + outputName, + flowDataUint8Array, + ); + if (!newItem) { + throw new Error( + l10n.t(Messages.NewFileCreationError, { name: this.inputName }), + ); + } + + // associate the new .flw file with SAS Studio + const folderName = await associateFlowObject( + outputName, + newItem.resourceId, + parentItem.resourceId, + this.studioSessionId, + this.connection, + ); + + return { folderName, parentItem }; + } +} diff --git a/client/src/components/ContentNavigator/index.ts b/client/src/components/ContentNavigator/index.ts index fecaa3d6a..88980006a 100644 --- a/client/src/components/ContentNavigator/index.ts +++ b/client/src/components/ContentNavigator/index.ts @@ -14,21 +14,22 @@ import { workspace, } from "vscode"; -import { basename } from "path"; - import { profileConfig } from "../../commands/profile"; import { SubscriptionProvider } from "../SubscriptionProvider"; import { ConnectionType } from "../profile"; +import ContentAdapterFactory from "./ContentAdapterFactory"; import ContentDataProvider from "./ContentDataProvider"; import { ContentModel } from "./ContentModel"; import { Messages } from "./const"; -import { ContentItem, FileManipulationEvent } from "./types"; +import { NotebookToFlowConverter } from "./convert"; import { - isContainer as getIsContainer, - getUri, - isContentItem, - isItemInRecycleBin, -} from "./utils"; + ContentAdapter, + ContentItem, + ContentNavigatorConfig, + ContentSourceType, + FileManipulationEvent, +} from "./types"; +import { isContainer as getIsContainer, isItemInRecycleBin } from "./utils"; const fileValidator = (value: string): string | null => /^([^/<>;\\{}?#]+)\.\w+$/.test( @@ -51,16 +52,26 @@ const folderValidator = (value: string): string | null => class ContentNavigator implements SubscriptionProvider { private contentDataProvider: ContentDataProvider; + private contentModel: ContentModel; + private sourceType: ContentNavigatorConfig["sourceType"]; - constructor(context: ExtensionContext) { + constructor(context: ExtensionContext, config: ContentNavigatorConfig) { + this.contentModel = new ContentModel( + this.contentAdapterForConnectionType(), + ); this.contentDataProvider = new ContentDataProvider( - new ContentModel(), + this.contentModel, context.extensionUri, + config, ); + this.sourceType = config.sourceType; - workspace.registerFileSystemProvider("sas", this.contentDataProvider); + workspace.registerFileSystemProvider( + config.sourceType, + this.contentDataProvider, + ); workspace.registerTextDocumentContentProvider( - "sasReadOnly", + `${config.sourceType}ReadOnly`, this.contentDataProvider, ); } @@ -70,10 +81,11 @@ class ContentNavigator implements SubscriptionProvider { } public getSubscriptions(): Disposable[] { + const SAS = `SAS.${this.sourceType === ContentSourceType.SASContent ? "content" : "server"}`; return [ ...this.contentDataProvider.getSubscriptions(), commands.registerCommand( - "SAS.deleteResource", + `${SAS}.deleteResource`, async (item: ContentItem) => { this.treeViewSelections(item).forEach( async (resource: ContentItem) => { @@ -107,7 +119,7 @@ class ContentNavigator implements SubscriptionProvider { }, ), commands.registerCommand( - "SAS.restoreResource", + `${SAS}.restoreResource`, async (item: ContentItem) => { this.treeViewSelections(item).forEach( async (resource: ContentItem) => { @@ -123,7 +135,7 @@ class ContentNavigator implements SubscriptionProvider { ); }, ), - commands.registerCommand("SAS.emptyRecycleBin", async () => { + commands.registerCommand(`${SAS}.emptyRecycleBin`, async () => { if ( !(await window.showWarningMessage( Messages.EmptyRecycleBinWarningMessage, @@ -137,11 +149,11 @@ class ContentNavigator implements SubscriptionProvider { window.showErrorMessage(Messages.EmptyRecycleBinError); } }), - commands.registerCommand("SAS.refreshContent", () => + commands.registerCommand(`${SAS}.refreshContent`, () => this.contentDataProvider.refresh(), ), commands.registerCommand( - "SAS.addFileResource", + `${SAS}.addFileResource`, async (resource: ContentItem) => { const fileName = await window.showInputBox({ prompt: Messages.NewFilePrompt, @@ -168,7 +180,7 @@ class ContentNavigator implements SubscriptionProvider { }, ), commands.registerCommand( - "SAS.addFolderResource", + `${SAS}.addFolderResource`, async (resource: ContentItem) => { const folderName = await window.showInputBox({ prompt: Messages.NewFolderPrompt, @@ -191,7 +203,7 @@ class ContentNavigator implements SubscriptionProvider { }, ), commands.registerCommand( - "SAS.renameResource", + `${SAS}.renameResource`, async (resource: ContentItem) => { const isContainer = getIsContainer(resource); @@ -226,34 +238,50 @@ class ContentNavigator implements SubscriptionProvider { }, ), commands.registerCommand( - "SAS.addToFavorites", - async (resource: ContentItem) => { - if (!(await this.contentDataProvider.addToMyFavorites(resource))) { - window.showErrorMessage(Messages.AddToFavoritesError); - } + `${SAS}.addToFavorites`, + async (item: ContentItem) => { + this.treeViewSelections(item).forEach( + async (resource: ContentItem) => { + if ( + !(await this.contentDataProvider.addToMyFavorites(resource)) + ) { + window.showErrorMessage(Messages.AddToFavoritesError); + } + }, + ); }, ), commands.registerCommand( - "SAS.removeFromFavorites", - async (resource: ContentItem) => { - if ( - !(await this.contentDataProvider.removeFromMyFavorites(resource)) - ) { - window.showErrorMessage(Messages.RemoveFromFavoritesError); - } + `${SAS}.removeFromFavorites`, + async (item: ContentItem) => { + this.treeViewSelections(item).forEach( + async (resource: ContentItem) => { + if ( + !(await this.contentDataProvider.removeFromMyFavorites( + resource, + )) + ) { + window.showErrorMessage(Messages.RemoveFromFavoritesError); + } + }, + ); }, ), - commands.registerCommand("SAS.collapseAllContent", () => { + commands.registerCommand(`${SAS}.collapseAllContent`, () => { commands.executeCommand( "workbench.actions.treeView.contentdataprovider.collapseAll", ); }), commands.registerCommand( - "SAS.convertNotebookToFlow", + `${SAS}.convertNotebookToFlow`, async (resource: ContentItem | Uri) => { - const inputName = isContentItem(resource) - ? resource.name - : basename(resource.fsPath); + const notebookToFlowConverter = new NotebookToFlowConverter( + resource, + this.contentModel, + this.viyaEndpoint(), + ); + + const inputName = notebookToFlowConverter.inputName; // Open window to chose the name and location of the new .flw file const outputName = await window.showInputBox({ prompt: Messages.ConvertNotebookToFlowPrompt, @@ -272,47 +300,37 @@ class ContentNavigator implements SubscriptionProvider { title: l10n.t("Converting SAS notebook to flow..."), }, async () => { - // Make sure we're connected - const endpoint = this.viyaEndpoint(); - const studioSessionId = - await this.contentDataProvider.acquireStudioSessionId(endpoint); - if (!studioSessionId) { + if (!(await notebookToFlowConverter.establishConnection())) { window.showErrorMessage(Messages.StudioConnectionError); return; } - const content = isContentItem(resource) - ? await this.contentDataProvider.provideTextDocumentContent( - getUri(resource), - ) - : (await workspace.fs.readFile(resource)).toString(); - - const folderName = - await this.contentDataProvider.convertNotebookToFlow( - inputName, - outputName, - content, - studioSessionId, - isContentItem(resource) - ? await this.contentDataProvider.getParent(resource) - : undefined, - ); + let parentItem; + try { + const response = + await notebookToFlowConverter.convert(outputName); + parentItem = response.parentItem; + if (!response.folderName) { + throw new Error(Messages.NotebookToFlowConversionError); + } - if (folderName) { window.showInformationMessage( l10n.t(Messages.NotebookToFlowConversionSuccess, { - folderName, + folderName: response.folderName, }), ); - } else { - window.showErrorMessage(Messages.NotebookToFlowConversionError); + + this.contentDataProvider.refresh(); + } catch (e) { + window.showErrorMessage(e.message); + this.contentDataProvider.reveal(parentItem); } }, ); }, ), commands.registerCommand( - "SAS.downloadResource", + `${SAS}.downloadResource`, async (resource: ContentItem) => { const selections = this.treeViewSelections(resource); const uris = await window.showOpenDialog({ @@ -348,16 +366,16 @@ class ContentNavigator implements SubscriptionProvider { // that isn't Mac, we list a distinct upload file(s) or upload folder(s) command. // See the `OpenDialogOptions` interface for more information. commands.registerCommand( - "SAS.uploadResource", + `${SAS}.uploadResource`, async (resource: ContentItem) => this.uploadResource(resource), ), commands.registerCommand( - "SAS.uploadFileResource", + `${SAS}.uploadFileResource`, async (resource: ContentItem) => this.uploadResource(resource, { canSelectFolders: false }), ), commands.registerCommand( - "SAS.uploadFolderResource", + `${SAS}.uploadFolderResource`, async (resource: ContentItem) => this.uploadResource(resource, { canSelectFiles: false }), ), @@ -423,6 +441,21 @@ class ContentNavigator implements SubscriptionProvider { ({ parentFolderUri }: ContentItem) => !uris.includes(parentFolderUri), ); } + + private contentAdapterForConnectionType(): ContentAdapter | undefined { + const activeProfile = profileConfig.getProfileByName( + profileConfig.getActiveProfile(), + ); + + if (!activeProfile) { + return; + } + + return new ContentAdapterFactory().create( + activeProfile.connectionType, + this.sourceType, + ); + } } export default ContentNavigator; diff --git a/client/src/components/ContentNavigator/types.ts b/client/src/components/ContentNavigator/types.ts index 3b496b8d1..f42c55852 100644 --- a/client/src/components/ContentNavigator/types.ts +++ b/client/src/components/ContentNavigator/types.ts @@ -1,25 +1,33 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Uri } from "vscode"; +import { FileStat, Uri } from "vscode"; export interface ContentItem { - id: string; + // Data returned from service contentType?: string; creationTimeStamp: number; + id: string; links: Link[]; + memberCount?: number; modifiedTimeStamp: number; name: string; + parentFolderUri?: string; type?: string; uri: string; - uid?: string; + // UI properties inferred from service data + contextValue?: string; + fileStat?: FileStat; flags?: { isInRecycleBin?: boolean; isInMyFavorites?: boolean; - hasFavoriteId?: string; + favoriteUri?: string; }; - memberCount?: number; + isReference?: boolean; permission: Permission; - parentFolderUri?: string; + resourceId?: string; + typeName?: string; + uid?: string; + vscUri?: Uri; } export interface Link { @@ -41,3 +49,65 @@ export interface FileManipulationEvent { uri: Uri; newUri?: Uri; } + +export type RootFolderMap = { [name: string]: ContentItem }; + +export interface AddChildItemProperties { + name?: string; + contentType?: string; + type?: string; +} + +export interface ContentAdapter { + addChildItem: ( + childItemUri: string | undefined, + parentItemUri: string | undefined, + properties: AddChildItemProperties, + ) => Promise; + addItemToFavorites: (item: ContentItem) => Promise; + connect: (baseUrl: string) => Promise; + connected: () => boolean; + createNewFolder: ( + parentItem: ContentItem, + folderName: string, + ) => Promise; + createNewItem: ( + parentItem: ContentItem, + fileName: string, + buffer?: ArrayBufferLike, + ) => Promise; + deleteItem: (item: ContentItem) => Promise; + getChildItems: (parentItem: ContentItem) => Promise; + getContentOfItem: (item: ContentItem) => Promise; + getContentOfUri: (uri: Uri) => Promise; + getFolderPathForItem: (item: ContentItem) => Promise; + getItemOfId: (id: string) => Promise; + getItemOfUri: (uri: Uri) => Promise; + getParentOfItem: (item: ContentItem) => Promise; + getRootFolder: (name: string) => ContentItem | undefined; + getRootItems: () => Promise; + getUriOfItem: (item: ContentItem, readOnly: boolean) => Promise; + moveItem: ( + item: ContentItem, + targetParentFolderUri: string, + ) => Promise; + recycleItem: (item: ContentItem) => Promise<{ newUri?: Uri; oldUri?: Uri }>; + removeItemFromFavorites: (item: ContentItem) => Promise; + renameItem: ( + item: ContentItem, + newName: string, + ) => Promise; + restoreItem: (item: ContentItem) => Promise; + updateContentOfItem(uri: Uri, content: string): Promise; +} + +export enum ContentSourceType { + SASContent = "sasContent", + SASServer = "sasServer", +} + +export interface ContentNavigatorConfig { + treeIdentifier: string; + mimeType: string; + sourceType: ContentSourceType; +} diff --git a/client/src/components/ContentNavigator/utils.ts b/client/src/components/ContentNavigator/utils.ts index 9d49f6f5d..84afb107c 100644 --- a/client/src/components/ContentNavigator/utils.ts +++ b/client/src/components/ContentNavigator/utils.ts @@ -1,113 +1,13 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { SnippetString, Uri } from "vscode"; +import { FileType, SnippetString } from "vscode"; -import { - DATAFLOW_TYPE, - DEFAULT_FILE_CONTENT_TYPE, - FAVORITES_FOLDER_TYPE, - FILE_TYPES, - FOLDER_TYPE, - FOLDER_TYPES, - TRASH_FOLDER_TYPE, -} from "./const"; +import { DEFAULT_FILE_CONTENT_TYPE } from "./const"; import mimeTypes from "./mime-types"; -import { ContentItem, Link, Permission } from "./types"; +import { ContentItem } from "./types"; -export const getLink = ( - links: Array, - method: string, - relationship: string, -): Link | null => - !links || links.length === 0 - ? null - : links.find((link) => link.method === method && link.rel === relationship); - -export const getResourceId = (uri: Uri): string => uri.query.substring(3); // ?id=... - -export const getId = (item: ContentItem): string | null => - item.uid || getLink(item.links, "GET", "self")?.uri + item.type || null; - -export const getResourceIdFromItem = (item: ContentItem): string | null => { - // Only members have uri attribute. - if (item.uri) { - return item.uri; - } - - return getLink(item.links, "GET", "self")?.uri || null; -}; - -export const getLabel = (item: ContentItem): string => item.name; - -export const getTypeName = (item: ContentItem): string => - item.contentType || item.type; - -export const isContainer = (item: ContentItem, bStrict?: boolean): boolean => { - const typeName = getTypeName(item); - if (!bStrict && isItemInRecycleBin(item) && isReference(item)) { - return false; - } - if (FOLDER_TYPES.indexOf(typeName) >= 0) { - return true; - } - return false; -}; - -export const resourceType = (item: ContentItem): string | undefined => { - if (!isValidItem(item)) { - return; - } - const { write, delete: del, addMember } = item.permission; - const isRecycled = isItemInRecycleBin(item); - const actions = [ - addMember && !isRecycled && "createChild", - del && !item.flags?.isInMyFavorites && "delete", - write && (!isRecycled ? "update" : "restore"), - ].filter((action) => !!action); - - const type = getTypeName(item); - if (type === TRASH_FOLDER_TYPE && item?.memberCount) { - actions.push("empty"); - } - - if (item.flags?.isInMyFavorites || item.flags?.hasFavoriteId) { - actions.push("removeFromFavorites"); - } else if ( - item.type !== "reference" && - [FOLDER_TYPE, ...FILE_TYPES].includes(type) && - !isRecycled - ) { - actions.push("addToFavorites"); - } - - // if item is a notebook file add action - if (item?.name?.endsWith(".sasnb")) { - actions.push("convertNotebookToFlow"); - } - - if (!isContainer(item)) { - actions.push("allowDownload"); - } - - if (actions.length === 0) { - return; - } - - return actions.sort().join("-"); -}; - -export const getUri = (item: ContentItem, readOnly?: boolean): Uri => - Uri.parse( - `${readOnly ? "sasReadOnly" : "sas"}:/${getLabel( - item, - )}?id=${getResourceIdFromItem(item)}`, - ); - -export const getModifyDate = (item: ContentItem): number => - item.modifiedTimeStamp; - -export const getCreationDate = (item: ContentItem): number => - item.creationTimeStamp; +export const isContainer = (item: ContentItem): boolean => + item.fileStat.type === FileType.Directory; export const isReference = (item: ContentItem): boolean => !!item && item?.type === "reference"; @@ -144,38 +44,6 @@ export const getFileStatement = ( ); }; -export const getPermission = (item: ContentItem): Permission => { - const itemType = getTypeName(item); - return [FOLDER_TYPE, ...FILE_TYPES].includes(itemType) // normal folders and files - ? { - write: !!getLink(item.links, "PUT", "update"), - delete: !!getLink(item.links, "DELETE", "deleteResource"), - addMember: !!getLink(item.links, "POST", "createChild"), - } - : { - // delegate folders, user folder and user root folder - write: false, - delete: false, - addMember: - itemType !== TRASH_FOLDER_TYPE && - itemType !== FAVORITES_FOLDER_TYPE && - !!getLink(item.links, "POST", "createChild"), - }; -}; - export const getFileContentType = (fileName: string) => mimeTypes[fileName.split(".").pop().toLowerCase()] || DEFAULT_FILE_CONTENT_TYPE; - -export const getItemContentType = (item: ContentItem): string | undefined => { - const itemIsReference = item.type === "reference"; - if (itemIsReference || isContainer(item)) { - return undefined; - } - - if (item.contentType === DATAFLOW_TYPE) { - return "application/json"; - } - - return "application/vnd.sas.file+json"; -}; diff --git a/client/src/connection/rest/SASContentAdapter.ts b/client/src/connection/rest/SASContentAdapter.ts new file mode 100644 index 000000000..b25e8c9f9 --- /dev/null +++ b/client/src/connection/rest/SASContentAdapter.ts @@ -0,0 +1,731 @@ +// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { FileType, Uri, authentication } from "vscode"; + +import axios, { + AxiosError, + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, +} from "axios"; + +import { SASAuthProvider } from "../../components/AuthProvider"; +import { + DEFAULT_FILE_CONTENT_TYPE, + FILE_TYPES, + FOLDER_TYPES, + ROOT_FOLDER, + ROOT_FOLDERS, + TRASH_FOLDER_TYPE, +} from "../../components/ContentNavigator/const"; +import { + AddChildItemProperties, + ContentAdapter, + ContentItem, + Link, + RootFolderMap, +} from "../../components/ContentNavigator/types"; +import { + getFileContentType, + isContainer, + isItemInRecycleBin, + isReference, +} from "../../components/ContentNavigator/utils"; +import { + getItemContentType, + getLink, + getPermission, + getResourceId, + getResourceIdFromItem, + getTypeName, + getUri, + resourceType, +} from "./util"; + +class SASContentAdapter implements ContentAdapter { + private connection: AxiosInstance; + private authorized: boolean; + private viyaCadence: string; + private rootFolders: RootFolderMap; + private fileMetadataMap: { + [id: string]: { etag: string; lastModified: string; contentType: string }; + }; + + public constructor() { + this.rootFolders = {}; + this.fileMetadataMap = {}; + } + + public connected(): boolean { + return this.authorized; + } + + public async connect(baseURL: string): Promise { + this.connection = axios.create({ baseURL }); + this.connection.interceptors.response.use( + (response: AxiosResponse) => response, + async (error: AxiosError) => { + const originalRequest: AxiosRequestConfig & { _retry?: boolean } = + error.config; + + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + await this.updateAccessToken(); + return this.connection(originalRequest); + } + + return Promise.reject(error); + }, + ); + await this.updateAccessToken(); + + this.authorized = true; + this.viyaCadence = await this.getViyaCadence(); + } + + public getConnection() { + return this.connection; + } + + public getRootFolder(name: string): ContentItem | undefined { + return this.rootFolders[name]; + } + + public get myFavoritesFolder(): ContentItem | undefined { + return this.getRootFolder("@myFavorites"); + } + + public async getParentOfItem( + item: ContentItem, + ): Promise { + const ancestorsLink = getLink(item.links, "GET", "ancestors"); + if (!ancestorsLink) { + return; + } + const { data } = await this.connection.get(ancestorsLink.uri); + if (data && data.length > 0) { + return this.enrichWithDataProviderProperties(data[0]); + } + } + + public async getChildItems(parentItem: ContentItem): Promise { + const { data: result } = await this.connection.get( + await this.generatedMembersUrlForParentItem(parentItem), + ); + if (!result.items) { + return Promise.reject(); + } + + const myFavoritesFolder = this.myFavoritesFolder; + const isInRecycleBin = + TRASH_FOLDER_TYPE === getTypeName(parentItem) || + parentItem.flags?.isInRecycleBin; + const parentIdIsFavoritesFolder = + getResourceIdFromItem(parentItem) === + getResourceIdFromItem(myFavoritesFolder); + const allFavorites = parentIdIsFavoritesFolder + ? [] + : await this.getChildItems(myFavoritesFolder); + + const items = result.items.map( + (childItem: ContentItem, index): ContentItem => { + const favoriteUri = fetchFavoriteUri(childItem); + return { + ...childItem, + uid: `${parentItem.uid}/${index}`, + ...this.enrichWithDataProviderProperties(childItem, { + isInRecycleBin, + isInMyFavorites: parentIdIsFavoritesFolder || !!favoriteUri, + favoriteUri, + }), + }; + }, + ); + + return items; + + function fetchFavoriteUri(childItem: ContentItem) { + if (parentIdIsFavoritesFolder) { + return getLink(childItem.links, "DELETE", "delete")?.uri; + } + const favoriteId = allFavorites.find( + (favorite) => + getResourceIdFromItem(favorite) === getResourceIdFromItem(childItem), + )?.id; + if (!favoriteId) { + return undefined; + } + + return `${getResourceIdFromItem(myFavoritesFolder)}/members/${favoriteId}`; + } + } + + public async getFolderPathForItem(item: ContentItem): Promise { + if (!item) { + return ""; + } + + const filePathParts = []; + let currentContentItem: Pick = + item; + do { + try { + const { data: parentData } = await this.connection.get( + currentContentItem.parentFolderUri, + ); + currentContentItem = parentData; + } catch (e) { + return ""; + } + + filePathParts.push(currentContentItem.name); + } while (currentContentItem.parentFolderUri); + + return "/" + filePathParts.reverse().join("/"); + } + + public async moveItem( + item: ContentItem, + parentFolderUri: string, + ): Promise { + const newItemData = { ...item, parentFolderUri }; + const updateLink = getLink(item.links, "PUT", "update"); + try { + await this.connection.put(updateLink.uri, newItemData); + } catch (error) { + return false; + } + return true; + } + + private async generatedMembersUrlForParentItem( + parentItem: ContentItem, + ): Promise { + const parentIsContent = parentItem.uri === ROOT_FOLDER.uri; + const typeQuery = generateTypeQuery(parentIsContent); + + const membersLink = getLink(parentItem.links, "GET", "members"); + let membersUrl = membersLink ? membersLink.uri : null; + if (!membersUrl && parentItem.uri) { + membersUrl = `${parentItem.uri}/members`; + } + + if (!membersUrl) { + const selfLink = getLink(parentItem.links, "GET", "self"); + if (!selfLink) { + console.error( + "Invalid state: FolderService object has no self link : " + + parentItem.name, + ); + return Promise.reject({ status: 404 }); + } + membersUrl = selfLink.uri + "/members"; + } + + membersUrl = membersUrl + "?limit=1000000"; + + const filters = []; + + if (parentIsContent) { + filters.push("isNull(parent)"); + } + if (typeQuery) { + filters.push(typeQuery); + } + + if (filters.length === 1) { + membersUrl = membersUrl + "&filter=" + filters[0]; + } else if (filters.length > 1) { + membersUrl = membersUrl + "&filter=and(" + filters.join(",") + ")"; + } + + membersUrl = + membersUrl + + `&sortBy=${ + parentIsContent || this.viyaCadence === "2023.03" // 2023.03 fails query with this sortBy param + ? "" + : "eq(contentType,'folder'):descending," + }name:primary:ascending,type:ascending`; + + return membersUrl; + + function generateTypeQuery(parentIsContent: boolean): string { + // Generate type query segment if applicable + let typeQuery = ""; + // Determine the set of types on which to filter + const includedTypes = FILE_TYPES.concat(FOLDER_TYPES); + + // Generate type query string + typeQuery = "in(" + (parentIsContent ? "type" : "contentType") + ","; + for (let i = 0; i < includedTypes.length; i++) { + typeQuery += "'" + includedTypes[i] + "'"; + if (i !== includedTypes.length - 1) { + typeQuery += ","; + } + } + typeQuery += ")"; + + return typeQuery; + } + } + + public async getRootItems(): Promise { + for (let index = 0; index < ROOT_FOLDERS.length; ++index) { + const delegateFolderName = ROOT_FOLDERS[index]; + const result = + delegateFolderName === "@sasRoot" + ? { data: ROOT_FOLDER } + : await this.connection.get(`/folders/folders/${delegateFolderName}`); + + this.rootFolders[delegateFolderName] = { + ...result.data, + uid: `${index}`, + ...this.enrichWithDataProviderProperties(result.data), + }; + } + + return this.rootFolders; + } + + public async getItemOfId(id: string): Promise { + const response = await this.connection.get(id); + this.updateFileMetadata(id, response); + + return this.enrichWithDataProviderProperties(response.data); + } + + public async getItemOfUri(uri: Uri): Promise { + const resourceId = getResourceId(uri); + return await this.getItemOfId(resourceId); + } + + public async getContentOfUri(uri: Uri): Promise { + const resourceId = getResourceId(uri); + const { data } = await this.connection.get(resourceId + "/content", { + responseType: "arraybuffer", + }); + + return data; + } + + public async getContentOfItem(item: ContentItem): Promise { + return await this.getContentOfUri(item.vscUri); + } + + public async createNewFolder( + parentItem: ContentItem, + folderName: string, + ): Promise { + const parentFolderUri = + parentItem.uri || + getLink(parentItem.links || [], "GET", "self")?.uri || + null; + if (!parentFolderUri) { + return; + } + + try { + const createFolderResponse = await this.connection.post( + `/folders/folders?parentFolderUri=${parentFolderUri}`, + { name: folderName }, + ); + return this.enrichWithDataProviderProperties(createFolderResponse.data); + } catch (error) { + return; + } + } + + private enrichWithDataProviderProperties( + item: ContentItem, + flags?: ContentItem["flags"], + ): ContentItem { + item.flags = flags; + return { + ...item, + permission: getPermission(item), + contextValue: resourceType(item), + fileStat: { + ctime: item.creationTimeStamp, + mtime: item.modifiedTimeStamp, + size: 0, + type: getIsContainer(item) ? FileType.Directory : FileType.File, + }, + isReference: isReference(item), + resourceId: getResourceIdFromItem(item), + vscUri: getUri(item, flags?.isInRecycleBin || false), + typeName: getTypeName(item), + }; + + function getIsContainer(item: ContentItem): boolean { + const typeName = getTypeName(item); + if (isItemInRecycleBin(item) && isReference(item)) { + return false; + } + if (FOLDER_TYPES.indexOf(typeName) >= 0) { + return true; + } + return false; + } + } + + public async renameItem( + item: ContentItem, + newName: string, + ): Promise { + const itemIsReference = item.type === "reference"; + const uri = itemIsReference + ? getLink(item.links, "GET", "self").uri + : item.uri; + + try { + const validationUri = getLink(item.links, "PUT", "validateRename")?.uri; + if (validationUri) { + await this.connection.put( + validationUri + .replace("{newname}", encodeURI(newName)) + .replace("{newtype}", getTypeName(item)), + ); + } + + // not sure why but the response of moveTo request does not return the latest etag so request it every time + const fileData = await this.getItemOfId(uri); + const contentType = getFileContentType(newName); + const fileMetadata = this.fileMetadataMap[uri]; + + const patchResponse = await this.connection.put( + uri, + { ...fileData, name: newName }, + { + headers: { + "If-Unmodified-Since": fileMetadata.lastModified, + "If-Match": fileMetadata.etag, + "Content-Type": getItemContentType(item), + }, + }, + ); + + this.updateFileMetadata(uri, patchResponse, contentType); + + // The links in My Favorites are of type reference. Instead of passing + // back the reference objects, we want to pass back the underlying source + // objects. + if (itemIsReference) { + return await this.getItemOfId(item.uri); + } + + return this.enrichWithDataProviderProperties(patchResponse.data); + } catch (error) { + return; + } + } + + public async createNewItem( + parentItem: ContentItem, + fileName: string, + buffer?: ArrayBufferLike, + ): Promise { + const typeDef = await this.getTypeDefinition(fileName); + let createdResource: ContentItem; + try { + const fileCreationResponse = await this.connection.post( + `/files/files#rawUpload?typeDefName=${typeDef}`, + buffer || Buffer.from("", "binary"), + { + headers: { + "Content-Type": getFileContentType(fileName), + "Content-Disposition": `filename*=UTF-8''${encodeURI(fileName)}`, + Accept: "application/vnd.sas.file+json", + }, + }, + ); + createdResource = fileCreationResponse.data; + } catch (error) { + return; + } + + const fileLink: Link | null = getLink(createdResource.links, "GET", "self"); + const memberAdded = await this.addChildItem( + fileLink?.uri, + getLink(parentItem.links, "POST", "addMember")?.uri, + { + name: fileName, + contentType: typeDef, + }, + ); + if (!memberAdded) { + return; + } + + return this.enrichWithDataProviderProperties(createdResource); + } + + public async addChildItem( + childItemUri: string | undefined, + parentItemUri: string | undefined, + properties: AddChildItemProperties, + ): Promise { + if (!childItemUri || !parentItemUri) { + return false; + } + + try { + await this.connection.post(parentItemUri, { + uri: childItemUri, + type: "CHILD", + ...properties, + }); + } catch (error) { + return false; + } + + return true; + } + + public async updateContentOfItem(uri: Uri, content: string): Promise { + const resourceId = getResourceId(uri); + const { etag, lastModified, contentType } = this.getFileInfo(resourceId); + const headers = { + "Content-Type": contentType, + "If-Unmodified-Since": lastModified, + }; + if (etag !== "") { + headers["If-Match"] = etag; + } + try { + const res = await this.connection.put(resourceId + "/content", content, { + headers, + }); + this.updateFileMetadata(resourceId, res, contentType); + } catch (error) { + console.log(error); + } + } + + public async getUriOfItem(item: ContentItem): Promise { + if (item.type !== "reference") { + return item.vscUri; + } + + // If we're attempting to open a favorite, open the underlying file instead. + try { + return (await this.getItemOfId(item.uri)).vscUri; + } catch (error) { + return item.vscUri; + } + } + + public async deleteItem(item: ContentItem): Promise { + // folder service will return 409 error if the deleting folder has non-folder item even if add recursive parameter + // delete the resource or move item to recycle bin will automatically delete the favorites as well. + return await (isContainer(item) + ? this.deleteFolder(item) + : this.deleteResource(item)); + } + + public async addItemToFavorites(item: ContentItem): Promise { + return await this.addChildItem( + getResourceIdFromItem(item), + getLink(this.myFavoritesFolder.links, "POST", "addMember").uri, + { + type: "reference", + name: item.name, + contentType: item.contentType, + }, + ); + } + + public async removeItemFromFavorites(item: ContentItem): Promise { + const deleteFavoriteUri = item.flags.favoriteUri; + if (!deleteFavoriteUri) { + return false; + } + + try { + await this.connection.delete(deleteFavoriteUri); + } catch (error) { + return false; + } + return true; + } + + public async recycleItem( + item: ContentItem, + ): Promise<{ newUri?: Uri; oldUri?: Uri }> { + const recycleBin = this.getRootFolder("@myRecycleBin"); + if (!recycleBin) { + // fallback to delete + return recycleItemResponse(await this.deleteItem(item)); + } + const recycleBinUri = getLink(recycleBin.links, "GET", "self")?.uri; + if (!recycleBinUri) { + return {}; + } + + const success = await this.moveItem(item, recycleBinUri); + return recycleItemResponse(success); + + function recycleItemResponse(success: boolean) { + if (!success) { + return {}; + } + + return { + newUri: getUri(item, true), + oldUri: getUri(item), + }; + } + } + + public async restoreItem(item: ContentItem): Promise { + const previousParentUri = getLink(item.links, "GET", "previousParent")?.uri; + if (!previousParentUri) { + return false; + } + return await this.moveItem(item, previousParentUri); + } + + private async updateAccessToken(): Promise { + const session = await authentication.getSession(SASAuthProvider.id, [], { + createIfNone: true, + }); + this.connection.defaults.headers.common.Authorization = `Bearer ${session.accessToken}`; + } + + private updateFileMetadata( + id: string, + { headers, data }: AxiosResponse, + contentType?: string, + ) { + this.fileMetadataMap[id] = { + etag: headers.etag, + lastModified: headers["last-modified"], + contentType: contentType || data.contentType, + }; + } + + private getFileInfo(resourceId: string): { + etag: string; + lastModified: string; + contentType: string; + } { + if (resourceId in this.fileMetadataMap) { + return this.fileMetadataMap[resourceId]; + } + const now = new Date(); + const timestamp = now.toUTCString(); + return { + etag: "", + lastModified: timestamp, + contentType: DEFAULT_FILE_CONTENT_TYPE, + }; + } + + private async deleteFolder(item: ContentItem): Promise { + try { + const children = await this.getChildItems(item); + await Promise.all(children.map((child) => this.deleteItem(child))); + const deleteRecursivelyLink = getLink( + item.links, + "DELETE", + "deleteRecursively", + )?.uri; + const deleteResourceLink = getLink( + item.links, + "DELETE", + "deleteResource", + )?.uri; + if (!deleteRecursivelyLink && !deleteResourceLink) { + return false; + } + const deleteLink = + deleteRecursivelyLink ?? `${deleteResourceLink}?recursive=true`; + await this.connection.delete(deleteLink); + } catch (error) { + return false; + } + return true; + } + + private async deleteResource(item: ContentItem): Promise { + const deleteResourceLink = getLink( + item.links, + "DELETE", + "deleteResource", + )?.uri; + if (!deleteResourceLink) { + return false; + } + try { + await this.connection.delete(deleteResourceLink); + } catch (error) { + return false; + } + // Due to delay in folders service's automatic deletion of associated member we need + // to attempt manual deletion of member to ensure subsequent data refreshes don't occur before + // member is deleted. Per Gary Williams, we must do these steps sequentially not concurrently. + // If member already deleted, server treats this call as NO-OP. + try { + const deleteLink = getLink(item.links, "DELETE", "delete")?.uri; + if (deleteLink) { + await this.connection.delete(deleteLink); + } + } catch (error) { + return error.response.status === 404 || error.response.status === 403; + } + return true; + } + + private async getViyaCadence(): Promise { + try { + const { data } = await this.connection.get( + "/deploymentData/cadenceVersion", + ); + return data.cadenceVersion; + } catch (e) { + console.error("fail to retrieve the viya cadence"); + } + return "unknown"; + } + + private async getTypeDefinition(fileName: string): Promise { + const defaultContentType = "file"; + const ext = fileName.split(".").pop().toLowerCase(); + if (ext === "sas") { + return "programFile"; + } + + try { + const typeResponse = await this.connection.get( + `/types/types?filter=contains('extensions', '${ext}')`, + ); + + if (typeResponse.data.items && typeResponse.data.items.length !== 0) { + return typeResponse.data.items[0].name; + } + } catch { + return defaultContentType; + } + + return defaultContentType; + } + + private async deleteMemberUriForFavorite(item: ContentItem): Promise { + if (item.flags?.isInMyFavorites) { + return getLink(item.links, "DELETE", "delete")?.uri; + } + + const myFavoritesFolder = this.getRootFolder("@myFavorites"); + const allFavorites = await this.getChildItems(myFavoritesFolder); + const favoriteId = allFavorites.find( + (favorite) => + getResourceIdFromItem(favorite) === getResourceIdFromItem(item), + )?.id; + if (!favoriteId) { + return undefined; + } + + return `${getResourceIdFromItem(myFavoritesFolder)}/members/${favoriteId}`; + } +} + +export default SASContentAdapter; diff --git a/client/src/connection/rest/util.ts b/client/src/connection/rest/util.ts new file mode 100644 index 000000000..55e1cb9a9 --- /dev/null +++ b/client/src/connection/rest/util.ts @@ -0,0 +1,139 @@ +// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { Uri } from "vscode"; + +import { + DATAFLOW_TYPE, + FAVORITES_FOLDER_TYPE, + FILE_TYPES, + FOLDER_TYPE, + FOLDER_TYPES, + TRASH_FOLDER_TYPE, +} from "../../components/ContentNavigator/const"; +import { + ContentItem, + ContentSourceType, + Link, + Permission, +} from "../../components/ContentNavigator/types"; +import { + isItemInRecycleBin, + isReference, + isValidItem, +} from "../../components/ContentNavigator/utils"; + +export const isContainer = (item: ContentItem, bStrict?: boolean): boolean => { + const typeName = item.typeName; + if (!bStrict && isItemInRecycleBin(item) && isReference(item)) { + return false; + } + if (FOLDER_TYPES.indexOf(typeName) >= 0) { + return true; + } + return false; +}; + +export const getLink = ( + links: Array, + method: string, + relationship: string, +): Link | null => + !links || links.length === 0 + ? null + : links.find((link) => link.method === method && link.rel === relationship); + +export const getResourceIdFromItem = (item: ContentItem): string | null => { + // Only members have uri attribute. + if (item.uri) { + return item.uri; + } + + return getLink(item.links, "GET", "self")?.uri || null; +}; + +export const resourceType = (item: ContentItem): string | undefined => { + if (!isValidItem(item)) { + return; + } + const { write, delete: del, addMember } = getPermission(item); + const isRecycled = isItemInRecycleBin(item); + const actions = [ + addMember && !isRecycled && "createChild", + del && !item.flags?.isInMyFavorites && "delete", + write && (!isRecycled ? "update" : "restore"), + ].filter((action) => !!action); + + const type = getTypeName(item); + if (type === TRASH_FOLDER_TYPE && item?.memberCount) { + actions.push("empty"); + } + + if (item.flags?.isInMyFavorites) { + actions.push("removeFromFavorites"); + } else if ( + item.type !== "reference" && + [FOLDER_TYPE, ...FILE_TYPES].includes(type) && + !isRecycled + ) { + actions.push("addToFavorites"); + } + + // if item is a notebook file add action + if (item?.name?.endsWith(".sasnb")) { + actions.push("convertNotebookToFlow"); + } + + if (!isContainer(item)) { + actions.push("allowDownload"); + } + + if (actions.length === 0) { + return; + } + + return actions.sort().join("-"); +}; + +export const getUri = (item: ContentItem, readOnly?: boolean): Uri => + Uri.parse( + `${readOnly ? `${ContentSourceType.SASContent}ReadOnly` : ContentSourceType.SASContent}:/${ + item.name + }?id=${getResourceIdFromItem(item)}`, + ); + +export const getPermission = (item: ContentItem): Permission => { + const itemType = getTypeName(item); + return [FOLDER_TYPE, ...FILE_TYPES].includes(itemType) // normal folders and files + ? { + write: !!getLink(item.links, "PUT", "update"), + delete: !!getLink(item.links, "DELETE", "deleteResource"), + addMember: !!getLink(item.links, "POST", "createChild"), + } + : { + // delegate folders, user folder and user root folder + write: false, + delete: false, + addMember: + itemType !== TRASH_FOLDER_TYPE && + itemType !== FAVORITES_FOLDER_TYPE && + !!getLink(item.links, "POST", "createChild"), + }; +}; + +export const getItemContentType = (item: ContentItem): string | undefined => { + const itemIsReference = item.type === "reference"; + if (itemIsReference || isContainer(item)) { + return undefined; + } + + if (item.contentType === DATAFLOW_TYPE) { + return "application/json"; + } + + return "application/vnd.sas.file+json"; +}; + +export const getResourceId = (uri: Uri): string => uri.query.substring(3); // ?id=... + +export const getTypeName = (item: ContentItem): string => + item.contentType || item.type; diff --git a/client/src/node/extension.ts b/client/src/node/extension.ts index b92aba359..025f242a6 100644 --- a/client/src/node/extension.ts +++ b/client/src/node/extension.ts @@ -35,6 +35,7 @@ import { run, runRegion, runSelected } from "../commands/run"; import { SASAuthProvider } from "../components/AuthProvider"; import { installCAs } from "../components/CAHelper"; import ContentNavigator from "../components/ContentNavigator"; +import { ContentSourceType } from "../components/ContentNavigator/types"; import { setContext } from "../components/ExtensionContext"; import LibraryNavigator from "../components/LibraryNavigator"; import { @@ -102,7 +103,26 @@ export function activate(context: ExtensionContext): void { setContext(context); const libraryNavigator = new LibraryNavigator(context); - const contentNavigator = new ContentNavigator(context); + + // Below we have two content navigators. We'll have one to navigate + // SAS Content and another to navigate SAS Server. Both of these will + // also determine which adapter to use for processing. The options look + // like this: + // - rest connection w/ sourceType="sasContent" uses a SASContentAdapter + // - rest connection w/ sourceType="sasServer" uses a RestSASServerAdapter + // - itc/iom connection w/ sourceType="sasServer" uses ITCSASServerAdapter + const sasContentNavigator = new ContentNavigator(context, { + mimeType: "application/vnd.code.tree.contentdataprovider", + sourceType: ContentSourceType.SASContent, + treeIdentifier: "contentdataprovider", + }); + // TODO #889 Create/use this + // const sasServerNavigator = new ContentNavigator(context, { + // mimeType: "application/vnd.code.tree.serverdataprovider", + // sourceType: "sasServer", + // treeIdentifier: "serverdataprovider", + // }); + const resultPanelSubscriptionProvider = new ResultPanelSubscriptionProvider(); window.registerWebviewPanelSerializer(SAS_RESULT_PANEL, { @@ -147,9 +167,9 @@ export function activate(context: ExtensionContext): void { ), getStatusBarItem(), ...libraryNavigator.getSubscriptions(), - ...contentNavigator.getSubscriptions(), + ...sasContentNavigator.getSubscriptions(), ...resultPanelSubscriptionProvider.getSubscriptions(), - contentNavigator.onDidManipulateFile((e) => { + sasContentNavigator.onDidManipulateFile((e) => { switch (e.type) { case "rename": sasDiagnostic.updateDiagnosticUri(e.uri, e.newUri); diff --git a/client/test/components/ContentNavigator/ContentDataProvider.test.ts b/client/test/components/ContentNavigator/ContentDataProvider.test.ts index f93d27610..fef0e5686 100644 --- a/client/test/components/ContentNavigator/ContentDataProvider.test.ts +++ b/client/test/components/ContentNavigator/ContentDataProvider.test.ts @@ -21,51 +21,71 @@ import { ROOT_FOLDER, TRASH_FOLDER_TYPE, } from "../../../src/components/ContentNavigator/const"; -import { ContentItem } from "../../../src/components/ContentNavigator/types"; import { - getLink, - getUri, -} from "../../../src/components/ContentNavigator/utils"; + ContentItem, + ContentSourceType, +} from "../../../src/components/ContentNavigator/types"; +import SASContentAdapter from "../../../src/connection/rest/SASContentAdapter"; +import { getUri } from "../../../src/connection/rest/util"; import { getUri as getTestUri } from "../../utils"; let stub; let axiosInstance: StubbedInstance; +const defaultConfig = { + mimeType: "application/vnd.code.tree.contentdataprovider", + sourceType: ContentSourceType.SASContent, + treeIdentifier: "contentdataprovider", +}; + const mockContentItem = ( - contentItem: Partial = {}, -): ContentItem => ({ - id: "abc123", - type: "file", - creationTimeStamp: 1234, - links: [ - { - rel: "self", - uri: "uri://self", - method: "GET", - href: "uri://self", - type: "test", + initialContentItem: Partial = {}, +): ContentItem => { + const contentItem = { + id: "abc123", + type: "file", + fileStat: { + type: FileType.File, + ctime: 1234, + mtime: 1234, + size: 0, }, - ], - modifiedTimeStamp: 1234, - name: "testFile", - uri: "uri://test", - permission: { - write: false, - addMember: false, - delete: false, - }, - flags: { - isInRecycleBin: false, - isInMyFavorites: false, - }, - uid: "unique-id", - ...contentItem, -}); + creationTimeStamp: 1234, + links: [ + { + rel: "self", + uri: "uri://self", + method: "GET", + href: "uri://self", + type: "test", + }, + ], + modifiedTimeStamp: 1234, + name: "testFile", + uri: "uri://test", + permission: { + write: false, + addMember: false, + delete: false, + }, + flags: { + isInRecycleBin: false, + isInMyFavorites: false, + }, + uid: "unique-id", + ...initialContentItem, + }; + + return { + ...contentItem, + vscUri: getUri(contentItem), + }; +}; const createDataProvider = () => { - const model = new ContentModel(); - const mockGetDelegateFolder = sinon.stub(model, "getDelegateFolder"); - mockGetDelegateFolder.withArgs("@myRecycleBin").returns( + const adapter = new SASContentAdapter(); + const mockGetRootFolder = sinon.stub(adapter, "getRootFolder"); + mockGetRootFolder.withArgs("@myRecycleBin").returns( mockContentItem({ type: "trashFolder", name: "Recycle Bin", @@ -81,7 +101,7 @@ const createDataProvider = () => { uri: "uri://recyleBin", }), ); - mockGetDelegateFolder.withArgs("@myFavorites").returns( + mockGetRootFolder.withArgs("@myFavorites").returns( mockContentItem({ type: "favoritesFolder", name: "My Favorites", @@ -97,7 +117,12 @@ const createDataProvider = () => { uri: "uri://myFavorites", }), ); - return new ContentDataProvider(model, Uri.from({ scheme: "http" })); + + return new ContentDataProvider( + new ContentModel(adapter), + Uri.from({ scheme: "http" }), + defaultConfig, + ); }; describe("ContentDataProvider", async function () { @@ -148,7 +173,7 @@ describe("ContentDataProvider", async function () { const dataProvider = createDataProvider(); const treeItem = await dataProvider.getTreeItem(contentItem); - const uri = await dataProvider.getUri(contentItem, false); + const uri = contentItem.vscUri; const expectedTreeItem: TreeItem = { iconPath: ThemeIcon.File, id: "unique-id", @@ -224,7 +249,6 @@ describe("ContentDataProvider", async function () { flags: { isInMyFavorites: true, isInRecycleBin: false, - hasFavoriteId: undefined, }, uid: "my-favorite/0", }); @@ -455,12 +479,10 @@ describe("ContentDataProvider", async function () { data: origItem, headers: { etag: "1234", "last-modified": "5678" }, }); - axiosInstance.put - .withArgs("uri://rename", { ...origItem, name: "new-file.sas" }) - .resolves({ - data: { ...origItem, name: "new-file.sas" }, - headers: { etag: "1234", "last-modified": "5678" }, - }); + axiosInstance.put.withArgs("uri://rename").resolves({ + data: { ...origItem, name: "new-file.sas" }, + headers: { etag: "1234", "last-modified": "5678" }, + }); const dataProvider = createDataProvider(); @@ -469,6 +491,7 @@ describe("ContentDataProvider", async function () { origItem, "new-file.sas", ); + expect(uri).to.deep.equal(getUri(newItem)); }); @@ -490,12 +513,10 @@ describe("ContentDataProvider", async function () { data: item, headers: { etag: "1234", "last-modified": "5678" }, }); - axiosInstance.put - .withArgs("uri://self", { ...item, name: "favorite-link.sas" }) - .resolves({ - data: { ...item, name: "favorite-link.sas" }, - headers: { etag: "1234", "last-modified": "5678" }, - }); + axiosInstance.put.withArgs("uri://self").resolves({ + data: { ...item, name: "favorite-link.sas" }, + headers: { etag: "1234", "last-modified": "5678" }, + }); axiosInstance.get.withArgs("uri://rename").resolves({ data: referencedFile, headers: { etag: "1234", "last-modified": "5678" }, @@ -656,43 +677,17 @@ describe("ContentDataProvider", async function () { expect(success).to.equal(true); }); - it("remove from favorites - Remove the reference of an item from My Favorites folder", async function () { - const item = mockContentItem({ - type: "reference", - name: "file.sas", - links: [ - { - rel: "delete", - uri: "uri://delete", - method: "DELETE", - href: "uri://delete", - type: "test", - }, - ], - flags: { - isInMyFavorites: true, - }, - }); - const dataProvider = createDataProvider(); - - axiosInstance.delete.withArgs("uri://delete").resolves({ data: {} }); - - await dataProvider.connect("http://test.io"); - const success = await dataProvider.removeFromMyFavorites(item); - - expect(success).to.equal(true); - }); - it("remove from favorites - Remove the reference of an item from the resource", async function () { const item = mockContentItem({ type: "file", name: "file.sas", flags: { - hasFavoriteId: "favorite-id", + favoriteUri: "uri://myFavorites/members/favorite-id", }, }); const dataProvider = createDataProvider(); + axiosInstance.get.resolves({ data: { items: [item] } }); axiosInstance.delete .withArgs("uri://myFavorites/members/favorite-id") .resolves({ data: {} }); @@ -712,12 +707,13 @@ describe("ContentDataProvider", async function () { const uri = getTestUri("SampleCode.sas").toString(); const item = mockContentItem(); - const model = new ContentModel(); + const model = new ContentModel(new SASContentAdapter()); const stub: sinon.SinonStub = sinon.stub(model, "createFile"); const dataProvider = new ContentDataProvider( model, Uri.from({ scheme: "http" }), + defaultConfig, ); const dataTransfer = new DataTransfer(); @@ -745,13 +741,14 @@ describe("ContentDataProvider", async function () { const uri = uriObject.toString(); const item = mockContentItem(); - const model = new ContentModel(); + const model = new ContentModel(new SASContentAdapter()); const createFileStub: sinon.SinonStub = sinon.stub(model, "createFile"); const createFolderStub: sinon.SinonStub = sinon.stub(model, "createFolder"); const dataProvider = new ContentDataProvider( model, Uri.from({ scheme: "http" }), + defaultConfig, ); const dataTransfer = new DataTransfer(); @@ -774,18 +771,20 @@ describe("ContentDataProvider", async function () { it("handleDrop - allows dropping content items", async function () { const parentItem = mockContentItem({ - type: "folder", name: "parent", + resourceId: "/resource-id", + type: "folder", }); const item = mockContentItem(); - const model = new ContentModel(); + const model = new ContentModel(new SASContentAdapter()); const stub: sinon.SinonStub = sinon.stub(model, "moveTo"); stub.returns(new Promise((resolve) => resolve(true))); const dataProvider = new ContentDataProvider( model, Uri.from({ scheme: "http" }), + defaultConfig, ); const dataTransfer = new DataTransfer(); @@ -797,7 +796,7 @@ describe("ContentDataProvider", async function () { await dataProvider.handleDrop(parentItem, dataTransfer); - expect(stub.calledWith(item, parentItem.uri)).to.be.true; + expect(stub.calledWith(item, "/resource-id")).to.be.true; }); it("handleDrop - allows dropping content items to favorites", async function () { @@ -827,15 +826,17 @@ describe("ContentDataProvider", async function () { ], }); - const model = new ContentModel(); - const stub: sinon.SinonStub = sinon.stub(model, "addMember"); + const adapter = new SASContentAdapter(); + const model = new ContentModel(adapter); + const stub: sinon.SinonStub = sinon.stub(adapter, "addChildItem"); stub.returns(new Promise((resolve) => resolve(true))); - sinon.stub(model, "getDelegateFolder").returns(parentItem); + sinon.stub(adapter, "getRootFolder").returns(parentItem); const dataProvider = new ContentDataProvider( model, Uri.from({ scheme: "http" }), + defaultConfig, ); const dataTransfer = new DataTransfer(); @@ -866,15 +867,19 @@ describe("ContentDataProvider", async function () { }); const item = mockContentItem(); - const model = new ContentModel(); - const stub: sinon.SinonStub = sinon.stub(model, "moveTo"); - stub.returns(new Promise((resolve) => resolve(true))); + const adapter = new SASContentAdapter(); + const model = new ContentModel(adapter); + const stub: sinon.SinonStub = sinon.stub(model, "recycleResource"); + stub.returns( + new Promise((resolve) => resolve({ newUri: "new", oldUri: "old" })), + ); - sinon.stub(model, "getDelegateFolder").returns(parentItem); + sinon.stub(adapter, "getRootFolder").returns(parentItem); const dataProvider = new ContentDataProvider( model, Uri.from({ scheme: "http" }), + defaultConfig, ); const dataTransfer = new DataTransfer(); @@ -886,8 +891,7 @@ describe("ContentDataProvider", async function () { await dataProvider.handleDrop(parentItem, dataTransfer); - expect(stub.calledWith(item, getLink(parentItem.links, "GET", "self")?.uri)) - .to.be.true; + expect(stub.calledWith(item)).to.be.true; }); it("getFileFolderPath - returns empty path for folder", async function () { @@ -896,10 +900,11 @@ describe("ContentDataProvider", async function () { name: "folder", }); - const model = new ContentModel(); + const model = new ContentModel(new SASContentAdapter()); const dataProvider = new ContentDataProvider( model, Uri.from({ scheme: "http" }), + defaultConfig, ); await dataProvider.connect("http://test.io"); @@ -931,10 +936,11 @@ describe("ContentDataProvider", async function () { parentFolderUri: "/id/parent", }); - const model = new ContentModel(); + const model = new ContentModel(new SASContentAdapter()); const dataProvider = new ContentDataProvider( model, Uri.from({ scheme: "http" }), + defaultConfig, ); axiosInstance.get.withArgs("/id/parent").resolves({ diff --git a/package.json b/package.json index 36f2114e3..c836bfc5d 100644 --- a/package.json +++ b/package.json @@ -551,52 +551,52 @@ "category": "SAS" }, { - "command": "SAS.deleteResource", + "command": "SAS.content.deleteResource", "title": "%commands.SAS.deleteResource%", "category": "SAS" }, { - "command": "SAS.addFileResource", + "command": "SAS.content.addFileResource", "title": "%commands.SAS.addFileResource%", "category": "SAS" }, { - "command": "SAS.addFolderResource", + "command": "SAS.content.addFolderResource", "title": "%commands.SAS.addFolderResource%", "category": "SAS" }, { - "command": "SAS.renameResource", + "command": "SAS.content.renameResource", "title": "%commands.SAS.renameResource%", "category": "SAS" }, { - "command": "SAS.restoreResource", + "command": "SAS.content.restoreResource", "title": "%commands.SAS.restoreResource%", "category": "SAS" }, { - "command": "SAS.emptyRecycleBin", + "command": "SAS.content.emptyRecycleBin", "title": "%commands.SAS.emptyRecycleBin%", "category": "SAS" }, { - "command": "SAS.addToFavorites", + "command": "SAS.content.addToFavorites", "title": "%commands.SAS.addToFavorites%", "category": "SAS" }, { - "command": "SAS.convertNotebookToFlow", + "command": "SAS.content.convertNotebookToFlow", "title": "%commands.SAS.convertNotebookToFlow%", "category": "SAS" }, { - "command": "SAS.removeFromFavorites", + "command": "SAS.content.removeFromFavorites", "title": "%commands.SAS.removeFromFavorites%", "category": "SAS" }, { - "command": "SAS.refreshContent", + "command": "SAS.content.refreshContent", "title": "%commands.SAS.refresh%", "category": "SAS", "icon": "$(refresh)" @@ -608,7 +608,7 @@ "icon": "$(refresh)" }, { - "command": "SAS.collapseAllContent", + "command": "SAS.content.collapseAllContent", "title": "%commands.SAS.collapseAll%", "category": "SAS", "icon": "$(collapse-all)" @@ -642,22 +642,22 @@ "category": "SAS" }, { - "command": "SAS.downloadResource", + "command": "SAS.content.downloadResource", "title": "%commands.SAS.download%", "category": "SAS" }, { - "command": "SAS.uploadResource", + "command": "SAS.content.uploadResource", "title": "%commands.SAS.upload%", "category": "SAS" }, { - "command": "SAS.uploadFileResource", + "command": "SAS.content.uploadFileResource", "title": "%commands.SAS.uploadFiles%", "category": "SAS" }, { - "command": "SAS.uploadFolderResource", + "command": "SAS.content.uploadFolderResource", "title": "%commands.SAS.uploadFolders%", "category": "SAS" }, @@ -688,12 +688,12 @@ ], "view/title": [ { - "command": "SAS.refreshContent", + "command": "SAS.content.refreshContent", "when": "view == contentdataprovider", "group": "navigation@0" }, { - "command": "SAS.collapseAllContent", + "command": "SAS.content.collapseAllContent", "when": "view == contentdataprovider", "group": "navigation@1" }, @@ -710,7 +710,7 @@ ], "explorer/context": [ { - "command": "SAS.convertNotebookToFlow", + "command": "SAS.content.convertNotebookToFlow", "when": "resourceExtname == .sasnb && SAS.connectionType == rest && !SAS.connection.direct", "group": "actionsgroup@0" } @@ -727,67 +727,67 @@ "group": "download@0" }, { - "command": "SAS.addFolderResource", + "command": "SAS.content.addFolderResource", "when": "viewItem =~ /createChild/ && view == contentdataprovider", "group": "addgroup@0" }, { - "command": "SAS.addFileResource", + "command": "SAS.content.addFileResource", "when": "viewItem =~ /createChild/ && view == contentdataprovider", "group": "addgroup@1" }, { - "command": "SAS.addToFavorites", + "command": "SAS.content.addToFavorites", "when": "viewItem =~ /addToFavorites/ && view == contentdataprovider", "group": "favoritesgroup@0" }, { - "command": "SAS.removeFromFavorites", + "command": "SAS.content.removeFromFavorites", "when": "viewItem =~ /removeFromFavorites/ && view == contentdataprovider", "group": "favoritesgroup@1" }, { - "command": "SAS.renameResource", + "command": "SAS.content.renameResource", "when": "viewItem =~ /update/ && view == contentdataprovider && !listMultiSelection", "group": "delrenamegroup@0" }, { - "command": "SAS.deleteResource", + "command": "SAS.content.deleteResource", "when": "viewItem =~ /delete/ && view == contentdataprovider", "group": "delrenamegroup@1" }, { - "command": "SAS.convertNotebookToFlow", + "command": "SAS.content.convertNotebookToFlow", "when": "viewItem =~ /convertNotebookToFlow/ && view == contentdataprovider", "group": "actionsgroup@0" }, { - "command": "SAS.restoreResource", + "command": "SAS.content.restoreResource", "when": "viewItem =~ /restore/ && view == contentdataprovider", "group": "restoregroup@0" }, { - "command": "SAS.emptyRecycleBin", + "command": "SAS.content.emptyRecycleBin", "when": "viewItem =~ /empty/ && view == contentdataprovider", "group": "emptygroup@0" }, { - "command": "SAS.downloadResource", + "command": "SAS.content.downloadResource", "when": "(viewItem =~ /update/ || viewItem =~ /createChild/) && view == contentdataprovider", "group": "uploaddownloadgroup@0" }, { - "command": "SAS.uploadResource", + "command": "SAS.content.uploadResource", "when": "(viewItem =~ /update/ || viewItem =~ /createChild/) && view == contentdataprovider && !listMultiSelection && workspacePlatform == mac", "group": "uploaddownloadgroup@1" }, { - "command": "SAS.uploadFileResource", + "command": "SAS.content.uploadFileResource", "when": "(viewItem =~ /update/ || viewItem =~ /createChild/) && view == contentdataprovider && !listMultiSelection && workspacePlatform != mac", "group": "uploaddownloadgroup@1" }, { - "command": "SAS.uploadFolderResource", + "command": "SAS.content.uploadFolderResource", "when": "(viewItem =~ /update/ || viewItem =~ /createChild/) && view == contentdataprovider && !listMultiSelection && workspacePlatform != mac", "group": "uploaddownloadgroup@1" } @@ -834,39 +834,39 @@ }, { "when": "false", - "command": "SAS.restoreResource" + "command": "SAS.content.restoreResource" }, { "when": "false", - "command": "SAS.deleteResource" + "command": "SAS.content.deleteResource" }, { "when": "false", - "command": "SAS.convertNotebookToFlow" + "command": "SAS.content.convertNotebookToFlow" }, { "when": "false", - "command": "SAS.addFileResource" + "command": "SAS.content.addFileResource" }, { "when": "false", - "command": "SAS.addFolderResource" + "command": "SAS.content.addFolderResource" }, { "when": "false", - "command": "SAS.addToFavorites" + "command": "SAS.content.addToFavorites" }, { "when": "false", - "command": "SAS.removeFromFavorites" + "command": "SAS.content.removeFromFavorites" }, { "when": "false", - "command": "SAS.renameResource" + "command": "SAS.content.renameResource" }, { "when": "false", - "command": "SAS.refreshContent" + "command": "SAS.content.refreshContent" }, { "when": "false", @@ -874,7 +874,7 @@ }, { "when": "false", - "command": "SAS.collapseAllContent" + "command": "SAS.content.collapseAllContent" }, { "when": "false", @@ -890,19 +890,19 @@ }, { "when": "false", - "command": "SAS.downloadResource" + "command": "SAS.content.downloadResource" }, { "when": "false", - "command": "SAS.uploadResource" + "command": "SAS.content.uploadResource" }, { "when": "false", - "command": "SAS.uploadFileResource" + "command": "SAS.content.uploadFileResource" }, { "when": "false", - "command": "SAS.uploadFolderResource" + "command": "SAS.content.uploadFolderResource" }, { "when": "false",