Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1296,6 +1296,12 @@
"category": "SPFx Toolkit",
"icon": "$(trash)"
},
{
"command": "spfx-toolkit.exportSiteAppCatalogs",
"title": "Export Site App Catalogs",
"category": "SPFx Toolkit",
"icon": "$(export)"
},
{
"command": "spfx-toolkit.moreTenantWideExtensionActions",
"title": "...",
Expand Down Expand Up @@ -1450,7 +1456,12 @@
{
"command": "spfx-toolkit.addSiteAppCatalog",
"when": "viewItem == sp-app-catalog-root",
"group": "inline"
"group": "inline@1"
},
{
"command": "spfx-toolkit.exportSiteAppCatalogs",
"when": "viewItem == sp-app-catalog-root",
"group": "inline@2"
},
{
"command": "spfx-toolkit.removeSiteAppCatalog",
Expand Down
1 change: 1 addition & 0 deletions src/constants/Commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export const Commands = {
addTenantAppCatalog: `${EXTENSION_NAME}.addTenantAppCatalog`,
addSiteAppCatalog: `${EXTENSION_NAME}.addSiteAppCatalog`,
removeSiteAppCatalog: `${EXTENSION_NAME}.removeSiteAppCatalog`,
exportSiteAppCatalogs: `${EXTENSION_NAME}.exportSiteAppCatalogs`,

// Set form customizer
setFormCustomizer: `${EXTENSION_NAME}.setFormCustomizer`,
Expand Down
5 changes: 5 additions & 0 deletions src/models/SiteAppCatalogExport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface SiteAppCatalogExport {
url: string;
apps: Record<string, unknown>[];
error?: string;
}
1 change: 1 addition & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './GenerateWorkflowCommandInput';
export * from './Sample';
export * from './ServeConfig';
export * from './SiteAppCatalog';
export * from './SiteAppCatalogExport';
export * from './solution-add-result';
export * from './SpfxAddComponentCommandInput';
export * from './SpfxDoctorOutput';
Expand Down
112 changes: 103 additions & 9 deletions src/services/actions/CliActions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { readFileSync, writeFileSync } from 'fs';
import { homedir } from 'os';
import { Folders } from '../check/Folders';
import { commands, Progress, ProgressLocation, Uri, window, workspace, WorkspaceFolder } from 'vscode';
import { Commands, SpfxCompatibilityMatrix, WebViewType, WebviewCommand, WorkflowType } from '../../constants';
import { AppCatalogApp, GenerateWorkflowCommandInput, SiteAppCatalog, SolutionAddResult, SpfxDoctorOutput, Subscription } from '../../models';
import { AppCatalogApp, GenerateWorkflowCommandInput, SiteAppCatalog, SiteAppCatalogExport, SolutionAddResult, SpfxDoctorOutput, Subscription } from '../../models';
import { Extension } from '../dataType/Extension';
import { CliExecuter } from '../executeWrappers/CliCommandExecuter';
import { Notifications } from '../dataType/Notifications';
Expand Down Expand Up @@ -59,6 +60,9 @@ export class CliActions {
subscriptions.push(
commands.registerCommand(Commands.removeSiteAppCatalog, CliActions.removeSiteAppCatalog)
);
subscriptions.push(
commands.registerCommand(Commands.exportSiteAppCatalogs, CliActions.exportSiteAppCatalogs)
);
}

/**
Expand Down Expand Up @@ -175,15 +179,8 @@ export class CliActions {
*/
public static async getAppCatalogApps(appCatalogUrl?: string): Promise<AppCatalogApp[] | undefined> {
try {
const commandOptions: any = appCatalogUrl && appCatalogUrl.trim() !== '' ? {
appCatalogScope: 'sitecollection',
appCatalogUrl: appCatalogUrl
} : {};

const response = (await CliExecuter.execute('spo app list', 'json', commandOptions));
const apps = response?.stdout || '[]';
const appsJson = await CliActions.getAppCatalogAppsRaw(appCatalogUrl);

const appsJson: any[] = JSON.parse(apps);
const appList = appsJson.map(({ ID, Title, Deployed, IsEnabled }) => {
return {
ID,
Expand All @@ -200,6 +197,23 @@ export class CliActions {
}
}

/**
* Retrieves the unmapped 'spo app list' output for the tenant or site app catalog.
*
* @param appCatalogUrl The URL of the tenant or site app catalog.
* @returns A promise that resolves to the app objects as returned by the CLI, with all their properties.
*/
public static async getAppCatalogAppsRaw(appCatalogUrl?: string): Promise<any[]> {
const commandOptions: any = appCatalogUrl && appCatalogUrl.trim() !== '' ? {
appCatalogScope: 'sitecollection',
appCatalogUrl: appCatalogUrl
} : {};

const response = (await CliExecuter.execute('spo app list', 'json', commandOptions));

return JSON.parse(response?.stdout || '[]');
}

/**
* Retrieves the tenant-wide extensions from the specified tenant app catalog URL.
* @param tenantAppCatalogUrl The URL of the tenant app catalog.
Expand Down Expand Up @@ -671,6 +685,86 @@ export class CliActions {
}
}

/**
* Exports an inventory of all site collection app catalogs and the apps they contain.
*/
public static async exportSiteAppCatalogs() {
try {
const appCatalogUrls = await CliActions.appCatalogUrlsGet();
// the first entry is the tenant app catalog, which is out of scope for this export
const siteAppCatalogUrls = appCatalogUrls?.slice(1) ?? [];

if (siteAppCatalogUrls.length === 0) {
Notifications.warning('No site app catalogs found to export.');
return;
}

const siteAppCatalogs = await window.withProgress({
location: ProgressLocation.Notification,
title: `Collecting the site app catalog details... Check [output window](command:${Commands.showOutputChannel}) to follow the progress.`,
cancellable: false
}, async () => {
const catalogs: SiteAppCatalogExport[] = [];

for (const siteAppCatalogUrl of siteAppCatalogUrls) {
try {
catalogs.push({
url: siteAppCatalogUrl,
apps: await CliActions.getAppCatalogAppsRaw(siteAppCatalogUrl)
});
} catch (e: any) {
catalogs.push({
url: siteAppCatalogUrl,
apps: [],
error: e?.error?.message || e?.message || 'Failed to retrieve the apps of this site app catalog.'
});
}
}

return catalogs;
});

const timestamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
const defaultPath = join(workspace.workspaceFolders?.[0]?.uri.fsPath || homedir(), `site-app-catalogs-${timestamp}.json`);

const targetUri = await window.showSaveDialog({
defaultUri: Uri.file(defaultPath),
filters: { JSON: ['json'] },
saveLabel: 'Export'
});

if (!targetUri) {
return;
}

writeFileSync(targetUri.fsPath, CliActions.buildSiteAppCatalogsExport(siteAppCatalogs), 'utf8');

const openFile = 'Open file';
Notifications.info(`Exported ${siteAppCatalogs.length} site app catalog(s) to '${basename(targetUri.fsPath)}'.`, openFile).then((selectedOption) => {
if (selectedOption === openFile) {
commands.executeCommand('vscode.open', targetUri);
}
});
} catch (e: any) {
const message = e?.error?.message || e?.message || 'An unexpected error occurred during the export.';
Notifications.error(message);
}
}

/**
* Builds the content of the site app catalogs export file.
*
* @param siteAppCatalogs The site app catalogs to include in the export.
* @returns The serialized export content.
*/
private static buildSiteAppCatalogsExport(siteAppCatalogs: SiteAppCatalogExport[]): string {
return JSON.stringify({
generatedOn: new Date().toISOString(),
tenantUrl: EnvironmentInformation.tenantUrl,
siteAppCatalogs
}, null, 2);
}

/**
* Upgrades the project by generating the upgrade steps and displaying them in a Markdown preview.
* @private
Expand Down