From 07361f9d989e3dd362f3f1ab402e94b0cfb5581e Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Tue, 12 Sep 2023 14:43:13 -0400 Subject: [PATCH 01/12] allow dragging sas content into editor --- .../ContentNavigator/ContentDataProvider.ts | 35 +++++++++- .../ContentNavigator/ContentModel.ts | 69 +++++++++++++++---- .../ContentDataProvider.test.ts | 2 - 3 files changed, 88 insertions(+), 18 deletions(-) diff --git a/client/src/components/ContentNavigator/ContentDataProvider.ts b/client/src/components/ContentNavigator/ContentDataProvider.ts index de37be914..70d018bba 100644 --- a/client/src/components/ContentNavigator/ContentDataProvider.ts +++ b/client/src/components/ContentNavigator/ContentDataProvider.ts @@ -1,19 +1,23 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { + CancellationToken, DataTransfer, DataTransferItem, Disposable, + DocumentDropEdit, Event, EventEmitter, FileChangeEvent, FileStat, FileSystemProvider, FileType, + Position, ProviderResult, Tab, TabInputNotebook, TabInputText, + TextDocument, TextDocumentContentProvider, ThemeIcon, TreeDataProvider, @@ -24,6 +28,7 @@ import { Uri, commands, l10n, + languages, window, } from "vscode"; @@ -71,6 +76,7 @@ class ContentDataProvider private _onDidChangeTreeData: EventEmitter; private _onDidChange: EventEmitter; private _treeView: TreeView; + private _dropEditProvider: Disposable; private readonly model: ContentModel; private extensionUri: Uri; @@ -93,6 +99,10 @@ class ContentDataProvider dragAndDropController: this, canSelectMany: true, }); + this._dropEditProvider = languages.registerDocumentDropEditProvider( + { language: "sas" }, + this, + ); this._treeView.onDidChangeVisibility(async () => { if (this._treeView.visible) { @@ -140,8 +150,31 @@ class ContentDataProvider dataTransfer.set(this.dragMimeTypes[0], dataTransferItem); } + public async provideDocumentDropEdits( + _document: TextDocument, + position: Position, + dataTransfer: DataTransfer, + token: CancellationToken, + ): Promise { + const dataTransferItem = dataTransfer.get(this.dragMimeTypes[0]); + const contentItem = + dataTransferItem && JSON.parse(dataTransferItem.value)[0]; + if (token.isCancellationRequested || !contentItem) { + return undefined; + } + + const fileFolderPath = await this.model.getFileFolderPath(contentItem); + if (!fileFolderPath) { + return undefined; + } + + return { + insertText: `filename myfile filesrvc folderpath='${fileFolderPath}' filename='${contentItem.name}';\n`, + }; + } + public getSubscriptions(): Disposable[] { - return [this._treeView]; + return [this._treeView, this._dropEditProvider]; } get onDidChangeFile(): Event { diff --git a/client/src/components/ContentNavigator/ContentModel.ts b/client/src/components/ContentNavigator/ContentModel.ts index 2c63ecbd8..88d344838 100644 --- a/client/src/components/ContentNavigator/ContentModel.ts +++ b/client/src/components/ContentNavigator/ContentModel.ts @@ -41,19 +41,21 @@ interface AddMemberProperties { } export class ContentModel { - private connection: AxiosInstance; + public connection: AxiosInstance; private fileTokenMaps: { [id: string]: { etag: string; lastModified: string }; }; private authorized: boolean; private viyaCadence: string; private delegateFolders: { [name: string]: ContentItem }; + private cachedFilePaths: Record; constructor() { this.fileTokenMaps = {}; this.authorized = false; this.delegateFolders = {}; this.viyaCadence = ""; + this.cachedFilePaths = {}; } public async connect(baseURL: string): Promise { @@ -149,20 +151,24 @@ export class ContentModel { ? [] : 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, - }, - })); + const items = await Promise.all( + result.items.map(async (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 items; } public async getParent(item: ContentItem): Promise { @@ -275,6 +281,7 @@ export class ContentModel { item: ContentItem, name: string, ): Promise { + this.cachedFilePaths = {}; const itemIsReference = item.type === "reference"; const uri = itemIsReference ? getLink(item.links, "GET", "self").uri @@ -408,6 +415,7 @@ export class ContentModel { } public async delete(item: ContentItem): Promise { + this.cachedFilePaths = {}; // 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) @@ -640,6 +648,37 @@ export class ContentModel { } return "unknown"; } + + public async getFileFolderPath(contentItem: ContentItem): Promise { + if (isContainer(contentItem)) { + return ""; + } + + const initialParentFolderUri = contentItem.parentFolderUri; + if (this.cachedFilePaths[initialParentFolderUri]) { + return this.cachedFilePaths[initialParentFolderUri]; + } + + const filePathParts = []; + let currentContentItem: ContentItem = contentItem; + do { + try { + const { data: parentData } = await this.connection.get( + currentContentItem.parentFolderUri, + ); + currentContentItem = parentData; + } catch (e) { + return ""; + } + + filePathParts.push(currentContentItem.name); + } while (currentContentItem.parentFolderUri); + + this.cachedFilePaths[initialParentFolderUri] = + "/" + filePathParts.reverse().join("/"); + + return this.cachedFilePaths[initialParentFolderUri]; + } } const getPermission = (item: ContentItem): Permission => { diff --git a/client/test/components/ContentNavigator/ContentDataProvider.test.ts b/client/test/components/ContentNavigator/ContentDataProvider.test.ts index 3608eef37..2015f5674 100644 --- a/client/test/components/ContentNavigator/ContentDataProvider.test.ts +++ b/client/test/components/ContentNavigator/ContentDataProvider.test.ts @@ -722,8 +722,6 @@ describe("ContentDataProvider", async function () { const dataTransferItem = new DataTransferItem(uri); dataTransfer.set("text/uri-list", dataTransferItem); - console.log("this bithc"); - stub.returns(new Promise((resolve) => resolve(item))); await dataProvider.handleDrop(parentItem, dataTransfer); From 04d3f2988a48366a6aba77301a8b7ae90595d345 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Thu, 14 Sep 2023 13:31:11 -0400 Subject: [PATCH 02/12] add test coverage --- .../ContentDataProvider.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/client/test/components/ContentNavigator/ContentDataProvider.test.ts b/client/test/components/ContentNavigator/ContentDataProvider.test.ts index 2015f5674..8331c7aba 100644 --- a/client/test/components/ContentNavigator/ContentDataProvider.test.ts +++ b/client/test/components/ContentNavigator/ContentDataProvider.test.ts @@ -887,4 +887,69 @@ describe("ContentDataProvider", async function () { expect(stub.calledWith(item, getLink(parentItem.links, "GET", "self")?.uri)) .to.be.true; }); + + it("getFileFolderPath - returns empty path for folder", async function () { + const item = mockContentItem({ + type: "folder", + name: "folder", + }); + + const model = new ContentModel(); + const dataProvider = new ContentDataProvider( + model, + Uri.from({ scheme: "http" }), + ); + + await dataProvider.connect("http://test.io"); + const path = await model.getFileFolderPath(item); + + expect(path).to.equal(""); + }); + + it("getFileFolderPath - traverses parentFolderUri to find path", async function () { + const grandparent = mockContentItem({ + type: "folder", + name: "grandparent", + id: "/id/grandparent", + }); + const parent = mockContentItem({ + type: "folder", + name: "parent", + id: "/id/parent", + parentFolderUri: "/id/grandparent", + }); + const item = mockContentItem({ + type: "file", + name: "file.sas", + parentFolderUri: "/id/parent", + }); + const item2 = mockContentItem({ + type: "file", + name: "file2.sas", + parentFolderUri: "/id/parent", + }); + + const model = new ContentModel(); + const dataProvider = new ContentDataProvider( + model, + Uri.from({ scheme: "http" }), + ); + + axiosInstance.get.withArgs("/id/parent").resolves({ + data: parent, + }); + axiosInstance.get.withArgs("/id/grandparent").resolves({ + data: grandparent, + }); + + await dataProvider.connect("http://test.io"); + + // We expect both files to have the same folder path + expect(await model.getFileFolderPath(item)).to.equal("/grandparent/parent"); + expect(await model.getFileFolderPath(item2)).to.equal("/grandparent/parent"); + + // Since our second call is cached, we only expect two axios calls + // (one for parent, one for grandparent) + expect(axiosInstance.get.callCount).to.equal(2); + }); }); From d1c011c4fe4e2d1bf83c71de9f8f868154f74228 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Thu, 14 Sep 2023 13:46:33 -0400 Subject: [PATCH 03/12] format --- .../components/ContentNavigator/ContentDataProvider.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/test/components/ContentNavigator/ContentDataProvider.test.ts b/client/test/components/ContentNavigator/ContentDataProvider.test.ts index 8331c7aba..e5cbdb475 100644 --- a/client/test/components/ContentNavigator/ContentDataProvider.test.ts +++ b/client/test/components/ContentNavigator/ContentDataProvider.test.ts @@ -946,7 +946,9 @@ describe("ContentDataProvider", async function () { // We expect both files to have the same folder path expect(await model.getFileFolderPath(item)).to.equal("/grandparent/parent"); - expect(await model.getFileFolderPath(item2)).to.equal("/grandparent/parent"); + expect(await model.getFileFolderPath(item2)).to.equal( + "/grandparent/parent", + ); // Since our second call is cached, we only expect two axios calls // (one for parent, one for grandparent) From feaa9c12bdea3cf07f177b9d290e5535fcdebfa0 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Thu, 14 Sep 2023 13:47:31 -0400 Subject: [PATCH 04/12] make connection private --- client/src/components/ContentNavigator/ContentModel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/ContentNavigator/ContentModel.ts b/client/src/components/ContentNavigator/ContentModel.ts index 88d344838..87d6d9141 100644 --- a/client/src/components/ContentNavigator/ContentModel.ts +++ b/client/src/components/ContentNavigator/ContentModel.ts @@ -41,7 +41,7 @@ interface AddMemberProperties { } export class ContentModel { - public connection: AxiosInstance; + private connection: AxiosInstance; private fileTokenMaps: { [id: string]: { etag: string; lastModified: string }; }; From 16113d057f728fc5ebd1f9acdbc59f1c211cf742 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Thu, 14 Sep 2023 13:48:57 -0400 Subject: [PATCH 05/12] revert changes in getChildren --- .../ContentNavigator/ContentModel.ts | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/client/src/components/ContentNavigator/ContentModel.ts b/client/src/components/ContentNavigator/ContentModel.ts index 87d6d9141..084e6d824 100644 --- a/client/src/components/ContentNavigator/ContentModel.ts +++ b/client/src/components/ContentNavigator/ContentModel.ts @@ -151,24 +151,20 @@ export class ContentModel { ? [] : await this.getChildren(myFavoritesFolder); - const items = await Promise.all( - result.items.map(async (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 items; + 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, + }, + })); } public async getParent(item: ContentItem): Promise { From 2aec0fde426521a4e8ebac3bf4bc2583a819cc98 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 15 Sep 2023 09:41:45 -0400 Subject: [PATCH 06/12] use extensionless filename as variable --- .../components/ContentNavigator/ContentDataProvider.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/src/components/ContentNavigator/ContentDataProvider.ts b/client/src/components/ContentNavigator/ContentDataProvider.ts index 70d018bba..c1f6ac6d0 100644 --- a/client/src/components/ContentNavigator/ContentDataProvider.ts +++ b/client/src/components/ContentNavigator/ContentDataProvider.ts @@ -33,7 +33,7 @@ import { } from "vscode"; import { lstat, readFile, readdir } from "fs"; -import { basename, join } from "path"; +import { basename, join, parse } from "path"; import { promisify } from "util"; import { profileConfig } from "../../commands/profile"; @@ -168,8 +168,13 @@ class ContentDataProvider return undefined; } + const filename = (parse(contentItem.name).name || contentItem.name).replace( + /[^a-zA-Z0-9]/g, + "", + ); + return { - insertText: `filename myfile filesrvc folderpath='${fileFolderPath}' filename='${contentItem.name}';\n`, + insertText: `filename ${filename} filesrvc folderpath='${fileFolderPath}' filename='${contentItem.name}';\n`, }; } From 9f3a47415c0c80ed096a43a0e1a76c4ab007b87a Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Mon, 18 Sep 2023 11:05:39 -0400 Subject: [PATCH 07/12] Include file name & casing fix --- .../ContentNavigator/ContentDataProvider.ts | 16 ++--- .../src/components/ContentNavigator/utils.ts | 63 +++++++++++++++++++ .../components/ContentNavigator/utils.test.ts | 52 +++++++++++++++ 3 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 client/test/components/ContentNavigator/utils.test.ts diff --git a/client/src/components/ContentNavigator/ContentDataProvider.ts b/client/src/components/ContentNavigator/ContentDataProvider.ts index c1f6ac6d0..70b2742db 100644 --- a/client/src/components/ContentNavigator/ContentDataProvider.ts +++ b/client/src/components/ContentNavigator/ContentDataProvider.ts @@ -33,7 +33,7 @@ import { } from "vscode"; import { lstat, readFile, readdir } from "fs"; -import { basename, join, parse } from "path"; +import { basename, join } from "path"; import { promisify } from "util"; import { profileConfig } from "../../commands/profile"; @@ -50,6 +50,7 @@ import { convertNotebookToFlow } from "./convert"; import { ContentItem } from "./types"; import { getCreationDate, + getFileStatement, getId, isContainer as getIsContainer, getLabel, @@ -151,7 +152,7 @@ class ContentDataProvider } public async provideDocumentDropEdits( - _document: TextDocument, + document: TextDocument, position: Position, dataTransfer: DataTransfer, token: CancellationToken, @@ -168,13 +169,12 @@ class ContentDataProvider return undefined; } - const filename = (parse(contentItem.name).name || contentItem.name).replace( - /[^a-zA-Z0-9]/g, - "", - ); - return { - insertText: `filename ${filename} filesrvc folderpath='${fileFolderPath}' filename='${contentItem.name}';\n`, + insertText: getFileStatement( + contentItem.name, + document.getText(), + fileFolderPath, + ), }; } diff --git a/client/src/components/ContentNavigator/utils.ts b/client/src/components/ContentNavigator/utils.ts index f7c1ba6e2..18b43a089 100644 --- a/client/src/components/ContentNavigator/utils.ts +++ b/client/src/components/ContentNavigator/utils.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { Uri } from "vscode"; +import { parse } from "path"; + import { FILE_TYPE, FOLDER_TYPE, @@ -111,3 +113,64 @@ export const isItemInRecycleBin = (item: ContentItem): boolean => !!item && item.flags?.isInRecycleBin; export const isContentItem = (item): item is ContentItem => isValidItem(item); + +/** + * This attempts to create a new variabe in our SAS program using the following rules: + * - First, we attempt to use only the alphanumeric characters from contentItem.name as + * as our variable name (without the file extension). + * - If that's empty, we instead use `file` + * - Next, we add a numeric suffix to the variable name, and check documentContent to make + * sure what we're creating isn't already in use. + * - If the current variable is in use, we continue to increment our numeric suffix until + * we find one that doesn't exist in our document. + * + * @param contentItemName ContentItem.name used for creating variable name. + * @param documentContent The contents of the editor we're dropping into. + * + * @returns string our new variable name. + */ +const extractVariableName = ( + contentItemName: string, + documentContent: string, +): string => { + const filePieces = parse(contentItemName); + const partialFileName = ( + filePieces.name.replace(/[^a-zA-Z0-9]/g, "") || + `${filePieces.ext.replace(".", "")}file` + ).toLocaleLowerCase(); + + let idx = 0; + const lowercaseDocumentContent = documentContent.toLocaleLowerCase(); + while ( + // The trailing space is intentional here so we don't confuse things like + // `csvfile11` for `csvfile1` + lowercaseDocumentContent.indexOf(`${partialFileName}${++idx} `) !== -1 + ) { + /* empty */ + } + + return `${partialFileName}${idx}`; +}; + +// A document uses uppercase letters _if_ there is more than one +// word (where word means gte 3 characters) that is all uppercase +const documentUsesUppercase = (documentContent: string) => + ( + documentContent + // Exclude anything in quotes from our calculations + .replace(/('|")([^('|")]*)('|")/g, "") + .match(/([A-Z]{3,})\S/g) || [] + ).length > 1; + +export const getFileStatement = ( + contentItemName: string, + documentContent: string, + fileFolderPath: string, +): string => { + const filename = extractVariableName(contentItemName, documentContent); + const usesUppercase = documentUsesUppercase(documentContent); + const cmd = `filename ${filename} filesrvc folderpath='$1' filename='$2';\n`; + return (usesUppercase ? cmd.toUpperCase() : cmd) + .replace("$1", fileFolderPath) + .replace("$2", contentItemName); +}; diff --git a/client/test/components/ContentNavigator/utils.test.ts b/client/test/components/ContentNavigator/utils.test.ts new file mode 100644 index 000000000..b2900d4ea --- /dev/null +++ b/client/test/components/ContentNavigator/utils.test.ts @@ -0,0 +1,52 @@ +import { expect } from "chai"; + +import { getFileStatement } from "../../../src/components/ContentNavigator/utils"; + +const testFileStatement = ( + upperCase: boolean, + expectedVariable: string, + name: string, +) => + upperCase + ? `FILENAME ${expectedVariable} FILESRVC FOLDERPATH='/path' FILENAME='${name}';\n` + : `filename ${expectedVariable} filesrvc folderpath='/path' filename='${name}';\n`; + +describe("utils", async function () { + it("getFileStatement - returns extensionless name + numeric suffix with no content", () => { + expect(getFileStatement("testcsv.csv", "", "/path")).to.equal( + testFileStatement(false, "testcsv1", "testcsv.csv"), + ); + }); + + it("getFileStatement - returns uppercase name + suffix with uppercase content", () => { + expect( + getFileStatement("testcsv.csv", "UPPER CASE CONTENT", "/path"), + ).to.equal(testFileStatement(true, "TESTCSV1", "testcsv.csv")); + }); + + it("getFileStatement - returns generated name + suffix with non alphanumeric item name", () => { + expect(getFileStatement("中文.sas", "sasfile1 ", "/path")).to.equal( + testFileStatement(false, "sasfile2", "中文.sas"), + ); + }); + + it("getFileStatement - excludes paths as part of uppercase name calculation", () => { + expect( + getFileStatement( + "中文.sas", + "file sasfile1 path='/ALL/UPPERCASE/PATH'", + "/path", + ), + ).to.equal(testFileStatement(false, "sasfile2", "中文.sas")); + }); + + it("getFileStatement - returns variable name with correct numeric suffix no matter previous casing used", () => { + expect( + getFileStatement( + "中文.sas", + "FILE sAsFiLe1 FILESVC PATH='/ALL/UPPERCASE/PATH'", + "/path", + ), + ).to.equal(testFileStatement(true, "SASFILE2", "中文.sas")); + }); +}); From 5cf115be586b9c038085375ce8b601a6d32361d4 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 22 Sep 2023 15:26:25 -0400 Subject: [PATCH 08/12] update uppercase/variable naming logic --- .../src/components/ContentNavigator/utils.ts | 44 +++++++------------ .../components/ContentNavigator/utils.test.ts | 29 +++++------- 2 files changed, 26 insertions(+), 47 deletions(-) diff --git a/client/src/components/ContentNavigator/utils.ts b/client/src/components/ContentNavigator/utils.ts index 18b43a089..c57e13728 100644 --- a/client/src/components/ContentNavigator/utils.ts +++ b/client/src/components/ContentNavigator/utils.ts @@ -119,55 +119,41 @@ export const isContentItem = (item): item is ContentItem => isValidItem(item); * - First, we attempt to use only the alphanumeric characters from contentItem.name as * as our variable name (without the file extension). * - If that's empty, we instead use `file` - * - Next, we add a numeric suffix to the variable name, and check documentContent to make - * sure what we're creating isn't already in use. - * - If the current variable is in use, we continue to increment our numeric suffix until - * we find one that doesn't exist in our document. + * - Next, we add a unique suffix to the variable name to avoid collisions with other + * variables. * * @param contentItemName ContentItem.name used for creating variable name. - * @param documentContent The contents of the editor we're dropping into. * * @returns string our new variable name. */ -const extractVariableName = ( - contentItemName: string, - documentContent: string, -): string => { +const extractVariableName = (contentItemName: string): string => { const filePieces = parse(contentItemName); const partialFileName = ( - filePieces.name.replace(/[^a-zA-Z0-9]/g, "") || + filePieces.name.replace(/[^a-zA-Z0-9_]/g, "") || `${filePieces.ext.replace(".", "")}file` ).toLocaleLowerCase(); - let idx = 0; - const lowercaseDocumentContent = documentContent.toLocaleLowerCase(); - while ( - // The trailing space is intentional here so we don't confuse things like - // `csvfile11` for `csvfile1` - lowercaseDocumentContent.indexOf(`${partialFileName}${++idx} `) !== -1 - ) { - /* empty */ - } + const date = new Date(); + const uniqueSuffix = `${date.getHours()}${date.getMinutes()}${date.getSeconds()}`; - return `${partialFileName}${idx}`; + return `${partialFileName}${uniqueSuffix}`; }; -// A document uses uppercase letters _if_ there is more than one -// word (where word means gte 3 characters) that is all uppercase +// A document uses uppercase letters _if_ are no words +// (where word means gte 3 characters) that are lowercase. const documentUsesUppercase = (documentContent: string) => - ( - documentContent - // Exclude anything in quotes from our calculations - .replace(/('|")([^('|")]*)('|")/g, "") - .match(/([A-Z]{3,})\S/g) || [] - ).length > 1; + documentContent && + !documentContent + // Exclude anything in quotes from our calculations + .replace(/('|")([^('|")]*)('|")/g, "") + .match(/([a-z]{3,})\S/g); export const getFileStatement = ( contentItemName: string, documentContent: string, fileFolderPath: string, ): string => { - const filename = extractVariableName(contentItemName, documentContent); + const filename = extractVariableName(contentItemName); const usesUppercase = documentUsesUppercase(documentContent); const cmd = `filename ${filename} filesrvc folderpath='$1' filename='$2';\n`; return (usesUppercase ? cmd.toUpperCase() : cmd) diff --git a/client/test/components/ContentNavigator/utils.test.ts b/client/test/components/ContentNavigator/utils.test.ts index b2900d4ea..7e64948b5 100644 --- a/client/test/components/ContentNavigator/utils.test.ts +++ b/client/test/components/ContentNavigator/utils.test.ts @@ -6,27 +6,30 @@ const testFileStatement = ( upperCase: boolean, expectedVariable: string, name: string, -) => - upperCase - ? `FILENAME ${expectedVariable} FILESRVC FOLDERPATH='/path' FILENAME='${name}';\n` - : `filename ${expectedVariable} filesrvc folderpath='/path' filename='${name}';\n`; +) => { + const date = new Date(); + const uniqueSuffix = `${date.getHours()}${date.getMinutes()}${date.getSeconds()}`; + return upperCase + ? `FILENAME ${expectedVariable}${uniqueSuffix} FILESRVC FOLDERPATH='/path' FILENAME='${name}';\n` + : `filename ${expectedVariable}${uniqueSuffix} filesrvc folderpath='/path' filename='${name}';\n`; +}; describe("utils", async function () { it("getFileStatement - returns extensionless name + numeric suffix with no content", () => { expect(getFileStatement("testcsv.csv", "", "/path")).to.equal( - testFileStatement(false, "testcsv1", "testcsv.csv"), + testFileStatement(false, "testcsv", "testcsv.csv"), ); }); it("getFileStatement - returns uppercase name + suffix with uppercase content", () => { expect( getFileStatement("testcsv.csv", "UPPER CASE CONTENT", "/path"), - ).to.equal(testFileStatement(true, "TESTCSV1", "testcsv.csv")); + ).to.equal(testFileStatement(true, "TESTCSV", "testcsv.csv")); }); it("getFileStatement - returns generated name + suffix with non alphanumeric item name", () => { expect(getFileStatement("中文.sas", "sasfile1 ", "/path")).to.equal( - testFileStatement(false, "sasfile2", "中文.sas"), + testFileStatement(false, "sasfile", "中文.sas"), ); }); @@ -37,16 +40,6 @@ describe("utils", async function () { "file sasfile1 path='/ALL/UPPERCASE/PATH'", "/path", ), - ).to.equal(testFileStatement(false, "sasfile2", "中文.sas")); - }); - - it("getFileStatement - returns variable name with correct numeric suffix no matter previous casing used", () => { - expect( - getFileStatement( - "中文.sas", - "FILE sAsFiLe1 FILESVC PATH='/ALL/UPPERCASE/PATH'", - "/path", - ), - ).to.equal(testFileStatement(true, "SASFILE2", "中文.sas")); + ).to.equal(testFileStatement(false, "sasfile", "中文.sas")); }); }); From c56a2535c142f8260abc314b40e6eb5fe7af26e3 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Tue, 26 Sep 2023 14:39:33 -0400 Subject: [PATCH 09/12] Use tabstop for fileref --- .../src/components/ContentNavigator/utils.ts | 43 ++++--------------- .../components/ContentNavigator/utils.test.ts | 36 +++------------- 2 files changed, 14 insertions(+), 65 deletions(-) diff --git a/client/src/components/ContentNavigator/utils.ts b/client/src/components/ContentNavigator/utils.ts index c57e13728..ce191f30f 100644 --- a/client/src/components/ContentNavigator/utils.ts +++ b/client/src/components/ContentNavigator/utils.ts @@ -1,8 +1,6 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Uri } from "vscode"; - -import { parse } from "path"; +import { SnippetString, Uri } from "vscode"; import { FILE_TYPE, @@ -114,31 +112,6 @@ export const isItemInRecycleBin = (item: ContentItem): boolean => export const isContentItem = (item): item is ContentItem => isValidItem(item); -/** - * This attempts to create a new variabe in our SAS program using the following rules: - * - First, we attempt to use only the alphanumeric characters from contentItem.name as - * as our variable name (without the file extension). - * - If that's empty, we instead use `file` - * - Next, we add a unique suffix to the variable name to avoid collisions with other - * variables. - * - * @param contentItemName ContentItem.name used for creating variable name. - * - * @returns string our new variable name. - */ -const extractVariableName = (contentItemName: string): string => { - const filePieces = parse(contentItemName); - const partialFileName = ( - filePieces.name.replace(/[^a-zA-Z0-9_]/g, "") || - `${filePieces.ext.replace(".", "")}file` - ).toLocaleLowerCase(); - - const date = new Date(); - const uniqueSuffix = `${date.getHours()}${date.getMinutes()}${date.getSeconds()}`; - - return `${partialFileName}${uniqueSuffix}`; -}; - // A document uses uppercase letters _if_ are no words // (where word means gte 3 characters) that are lowercase. const documentUsesUppercase = (documentContent: string) => @@ -152,11 +125,13 @@ export const getFileStatement = ( contentItemName: string, documentContent: string, fileFolderPath: string, -): string => { - const filename = extractVariableName(contentItemName); +): SnippetString => { const usesUppercase = documentUsesUppercase(documentContent); - const cmd = `filename ${filename} filesrvc folderpath='$1' filename='$2';\n`; - return (usesUppercase ? cmd.toUpperCase() : cmd) - .replace("$1", fileFolderPath) - .replace("$2", contentItemName); + const cmd = "filename ${1:fileref} filesrvc folderpath='$1' filename='$2';\n"; + + return new SnippetString( + (usesUppercase ? cmd.toUpperCase() : cmd) + .replace("$1", fileFolderPath) + .replace("$2", contentItemName), + ); }; diff --git a/client/test/components/ContentNavigator/utils.test.ts b/client/test/components/ContentNavigator/utils.test.ts index 7e64948b5..7b279b8e8 100644 --- a/client/test/components/ContentNavigator/utils.test.ts +++ b/client/test/components/ContentNavigator/utils.test.ts @@ -2,44 +2,18 @@ import { expect } from "chai"; import { getFileStatement } from "../../../src/components/ContentNavigator/utils"; -const testFileStatement = ( - upperCase: boolean, - expectedVariable: string, - name: string, -) => { - const date = new Date(); - const uniqueSuffix = `${date.getHours()}${date.getMinutes()}${date.getSeconds()}`; - return upperCase - ? `FILENAME ${expectedVariable}${uniqueSuffix} FILESRVC FOLDERPATH='/path' FILENAME='${name}';\n` - : `filename ${expectedVariable}${uniqueSuffix} filesrvc folderpath='/path' filename='${name}';\n`; -}; - describe("utils", async function () { it("getFileStatement - returns extensionless name + numeric suffix with no content", () => { - expect(getFileStatement("testcsv.csv", "", "/path")).to.equal( - testFileStatement(false, "testcsv", "testcsv.csv"), + expect(getFileStatement("testcsv.csv", "", "/path").value).to.equal( + `filename \${1:fileref} filesrvc folderpath='/path' filename='testcsv.csv';\n`, ); }); it("getFileStatement - returns uppercase name + suffix with uppercase content", () => { expect( - getFileStatement("testcsv.csv", "UPPER CASE CONTENT", "/path"), - ).to.equal(testFileStatement(true, "TESTCSV", "testcsv.csv")); - }); - - it("getFileStatement - returns generated name + suffix with non alphanumeric item name", () => { - expect(getFileStatement("中文.sas", "sasfile1 ", "/path")).to.equal( - testFileStatement(false, "sasfile", "中文.sas"), + getFileStatement("testcsv.csv", "UPPER CASE CONTENT", "/path").value, + ).to.equal( + `FILENAME \${1:FILEREF} FILESRVC FOLDERPATH='/path' FILENAME='testcsv.csv';\n`, ); }); - - it("getFileStatement - excludes paths as part of uppercase name calculation", () => { - expect( - getFileStatement( - "中文.sas", - "file sasfile1 path='/ALL/UPPERCASE/PATH'", - "/path", - ), - ).to.equal(testFileStatement(false, "sasfile", "中文.sas")); - }); }); From 54aa56df983ae88d2694d53dc9a75a7ffbd72d04 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Wed, 27 Sep 2023 11:43:13 -0400 Subject: [PATCH 10/12] fix quotes in strings --- client/src/components/ContentNavigator/utils.ts | 4 ++-- client/test/components/ContentNavigator/utils.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/client/src/components/ContentNavigator/utils.ts b/client/src/components/ContentNavigator/utils.ts index ce191f30f..13ad5e835 100644 --- a/client/src/components/ContentNavigator/utils.ts +++ b/client/src/components/ContentNavigator/utils.ts @@ -131,7 +131,7 @@ export const getFileStatement = ( return new SnippetString( (usesUppercase ? cmd.toUpperCase() : cmd) - .replace("$1", fileFolderPath) - .replace("$2", contentItemName), + .replace("$1", fileFolderPath.replace(/'/g, "''")) + .replace("$2", contentItemName.replace(/'/g, "''")), ); }; diff --git a/client/test/components/ContentNavigator/utils.test.ts b/client/test/components/ContentNavigator/utils.test.ts index 7b279b8e8..5cf907483 100644 --- a/client/test/components/ContentNavigator/utils.test.ts +++ b/client/test/components/ContentNavigator/utils.test.ts @@ -16,4 +16,12 @@ describe("utils", async function () { `FILENAME \${1:FILEREF} FILESRVC FOLDERPATH='/path' FILENAME='testcsv.csv';\n`, ); }); + + it("getFileStatement - returns encoded filename when filename contains quotes", () => { + expect( + getFileStatement("testcsv-'withquotes'.csv", "", "/path").value, + ).to.equal( + `filename \${1:fileref} filesrvc folderpath='/path' filename='testcsv-''withquotes''.csv';\n`, + ); + }); }); From ba12569baedc86e76e53190a9e3fc92458c5f658 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Tue, 10 Oct 2023 15:44:44 -0400 Subject: [PATCH 11/12] revert cache changes --- .../components/ContentNavigator/ContentModel.ts | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/client/src/components/ContentNavigator/ContentModel.ts b/client/src/components/ContentNavigator/ContentModel.ts index 084e6d824..60987bbfd 100644 --- a/client/src/components/ContentNavigator/ContentModel.ts +++ b/client/src/components/ContentNavigator/ContentModel.ts @@ -48,14 +48,12 @@ export class ContentModel { private authorized: boolean; private viyaCadence: string; private delegateFolders: { [name: string]: ContentItem }; - private cachedFilePaths: Record; constructor() { this.fileTokenMaps = {}; this.authorized = false; this.delegateFolders = {}; this.viyaCadence = ""; - this.cachedFilePaths = {}; } public async connect(baseURL: string): Promise { @@ -277,7 +275,6 @@ export class ContentModel { item: ContentItem, name: string, ): Promise { - this.cachedFilePaths = {}; const itemIsReference = item.type === "reference"; const uri = itemIsReference ? getLink(item.links, "GET", "self").uri @@ -411,7 +408,6 @@ export class ContentModel { } public async delete(item: ContentItem): Promise { - this.cachedFilePaths = {}; // 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) @@ -650,13 +646,9 @@ export class ContentModel { return ""; } - const initialParentFolderUri = contentItem.parentFolderUri; - if (this.cachedFilePaths[initialParentFolderUri]) { - return this.cachedFilePaths[initialParentFolderUri]; - } - const filePathParts = []; - let currentContentItem: ContentItem = contentItem; + let currentContentItem: Pick = + contentItem; do { try { const { data: parentData } = await this.connection.get( @@ -670,10 +662,7 @@ export class ContentModel { filePathParts.push(currentContentItem.name); } while (currentContentItem.parentFolderUri); - this.cachedFilePaths[initialParentFolderUri] = - "/" + filePathParts.reverse().join("/"); - - return this.cachedFilePaths[initialParentFolderUri]; + return "/" + filePathParts.reverse().join("/"); } } From df0affe5fcc80b354158a8ed64ae8328d030b663 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Tue, 10 Oct 2023 15:50:47 -0400 Subject: [PATCH 12/12] fix test --- .../components/ContentNavigator/ContentDataProvider.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/client/test/components/ContentNavigator/ContentDataProvider.test.ts b/client/test/components/ContentNavigator/ContentDataProvider.test.ts index e5cbdb475..9b6214585 100644 --- a/client/test/components/ContentNavigator/ContentDataProvider.test.ts +++ b/client/test/components/ContentNavigator/ContentDataProvider.test.ts @@ -949,9 +949,5 @@ describe("ContentDataProvider", async function () { expect(await model.getFileFolderPath(item2)).to.equal( "/grandparent/parent", ); - - // Since our second call is cached, we only expect two axios calls - // (one for parent, one for grandparent) - expect(axiosInstance.get.callCount).to.equal(2); }); });