cosmos-explorer/src/GitHub/GitHubContentProvider.ts
2021-07-15 08:53:07 -05:00

432 lines
16 KiB
TypeScript

import { makeNotebookRecord, Notebook, stringifyNotebook, toJS } from "@nteract/commutable";
import { FileType, IContent, IContentProvider, IEmptyContent, IGetParams, ServerConfig } from "@nteract/core";
import { from, Observable, of } from "rxjs";
import { AjaxResponse } from "rxjs/ajax";
import { HttpStatusCodes } from "../Common/Constants";
import { getErrorMessage } from "../Common/ErrorHandlingUtils";
import * as Logger from "../Common/Logger";
import * as UrlUtility from "../Common/UrlUtility";
import { NotebookUtil } from "../Explorer/Notebook/NotebookUtil";
import * as Base64Utils from "../Utils/Base64Utils";
import * as GitHubUtils from "../Utils/GitHubUtils";
import { GitHubClient, IGitHubFile, IGitHubResponse } from "./GitHubClient";
export interface GitHubContentProviderParams {
gitHubClient: GitHubClient;
promptForCommitMsg: (title: string, primaryButtonLabel: string) => Promise<string>;
}
class GitHubContentProviderError extends Error {
constructor(error: string, public errno: number = GitHubContentProvider.SelfErrorCode) {
super(error);
}
}
// Provides 'contents' API for GitHub
// http://jupyter-api.surge.sh/#!/contents
export class GitHubContentProvider implements IContentProvider {
public static readonly SelfErrorCode = 555;
constructor(private params: GitHubContentProviderParams) {}
public remove(_: ServerConfig, uri: string): Observable<AjaxResponse> {
return from(
this.getContent(uri).then(async (content: IGitHubResponse<IGitHubFile | IGitHubFile[]>) => {
try {
const commitMsg = await this.validateContentAndGetCommitMsg(content, "Delete", "Delete");
const response = await this.params.gitHubClient.deleteFileAsync(content.data as IGitHubFile, commitMsg);
if (response.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to delete", response.status);
}
return this.createSuccessAjaxResponse(HttpStatusCodes.NoContent, undefined);
} catch (error) {
Logger.logError(getErrorMessage(error), "GitHubContentProvider/remove", error.errno);
return this.createErrorAjaxResponse(error);
}
})
);
}
public get(_: ServerConfig, uri: string, params: Partial<IGetParams>): Observable<AjaxResponse> {
return from(
this.getContent(uri).then(async (content: IGitHubResponse<IGitHubFile | IGitHubFile[]>) => {
try {
if (content.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to get content", content.status);
}
if (!Array.isArray(content.data) && !content.data.content && params.content !== 0) {
const file = content.data;
file.content = (
await this.params.gitHubClient.getBlobAsync(file.repo.owner, file.repo.name, file.sha)
).data;
}
return this.createSuccessAjaxResponse(HttpStatusCodes.OK, this.createContentModel(uri, content.data, params));
} catch (error) {
Logger.logError(getErrorMessage(error), "GitHubContentProvider/get", error.errno);
return this.createErrorAjaxResponse(error);
}
})
);
}
public update<FT extends FileType>(
_: ServerConfig,
uri: string,
model: Partial<IContent<FT>>
): Observable<AjaxResponse> {
return from(
this.getContent(uri).then(async (content: IGitHubResponse<IGitHubFile | IGitHubFile[]>) => {
try {
const gitHubFile = content.data as IGitHubFile;
const commitMsg = await this.validateContentAndGetCommitMsg(content, "Rename", "Rename");
const newUri = model.path;
const newPath = GitHubUtils.fromContentUri(newUri).path;
const response = await this.params.gitHubClient.renameFileAsync(
gitHubFile.repo.owner,
gitHubFile.repo.name,
gitHubFile.branch.name,
commitMsg,
gitHubFile.path,
newPath
);
if (response.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to rename", response.status);
}
gitHubFile.commit = response.data;
gitHubFile.path = newPath;
gitHubFile.name = NotebookUtil.getName(gitHubFile.path);
return this.createSuccessAjaxResponse(
HttpStatusCodes.OK,
this.createContentModel(newUri, gitHubFile, { content: 0 })
);
} catch (error) {
Logger.logError(getErrorMessage(error), "GitHubContentProvider/update", error.errno);
return this.createErrorAjaxResponse(error);
}
})
);
}
public create<FT extends FileType>(
_: ServerConfig,
uri: string,
model: Partial<IContent<FT>> & { type: FT }
): Observable<AjaxResponse> {
return from(
this.params.promptForCommitMsg("Create New Notebook", "Create").then(async (commitMsg: string) => {
try {
if (!commitMsg) {
throw new GitHubContentProviderError("Couldn't get a commit message");
}
if (model.type !== "notebook") {
throw new GitHubContentProviderError("Unsupported content type");
}
const contentInfo = GitHubUtils.fromContentUri(uri);
if (!contentInfo) {
throw new GitHubContentProviderError(`Failed to parse ${uri}`);
}
const content = Base64Utils.utf8ToB64(stringifyNotebook(toJS(makeNotebookRecord())));
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: false,
};
const name = `Untitled-${new Date().toLocaleString("default", options)}.ipynb`;
let path = name;
if (contentInfo.path) {
path = UrlUtility.createUri(contentInfo.path, name);
}
const response = await this.params.gitHubClient.createOrUpdateFileAsync(
contentInfo.owner,
contentInfo.repo,
contentInfo.branch,
path,
commitMsg,
content
);
if (response.status !== HttpStatusCodes.Created) {
throw new GitHubContentProviderError("Failed to create", response.status);
}
const newUri = GitHubUtils.toContentUri(contentInfo.owner, contentInfo.repo, contentInfo.branch, path);
const newGitHubFile: IGitHubFile = {
type: "blob",
name: NotebookUtil.getName(newUri),
path,
repo: {
owner: contentInfo.owner,
name: contentInfo.repo,
private: undefined,
},
branch: {
name: contentInfo.branch,
},
commit: response.data,
};
return this.createSuccessAjaxResponse(
HttpStatusCodes.Created,
this.createContentModel(newUri, newGitHubFile, { content: 0 })
);
} catch (error) {
Logger.logError(getErrorMessage(error), "GitHubContentProvider/create", error.errno);
return this.createErrorAjaxResponse(error);
}
})
);
}
public save<FT extends FileType>(
_: ServerConfig,
uri: string,
model: Partial<IContent<FT>>
): Observable<AjaxResponse> {
return from(
this.getContent(uri).then(async (content: IGitHubResponse<IGitHubFile | IGitHubFile[]>) => {
try {
let commitMsg: string;
if (content.status === HttpStatusCodes.NotFound) {
// We'll create a new file since it doesn't exist
commitMsg = await this.params.promptForCommitMsg("Save", "Save");
if (!commitMsg) {
throw new GitHubContentProviderError("Couldn't get a commit message");
}
} else {
commitMsg = await this.validateContentAndGetCommitMsg(content, "Save", "Save");
}
let updatedContent: string;
if (model.type === "notebook") {
updatedContent = Base64Utils.utf8ToB64(stringifyNotebook(model.content as Notebook));
} else if (model.type === "file") {
updatedContent = model.content as string;
if (model.format !== "base64") {
updatedContent = Base64Utils.utf8ToB64(updatedContent);
}
} else {
throw new GitHubContentProviderError("Unsupported content type");
}
const contentInfo = GitHubUtils.fromContentUri(uri);
let gitHubFile: IGitHubFile;
if (content.data) {
gitHubFile = content.data as IGitHubFile;
}
const response = await this.params.gitHubClient.createOrUpdateFileAsync(
contentInfo.owner,
contentInfo.repo,
contentInfo.branch,
contentInfo.path,
commitMsg,
updatedContent,
gitHubFile?.sha
);
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.Created) {
throw new GitHubContentProviderError("Failed to create or update", response.status);
}
if (gitHubFile) {
gitHubFile.commit = response.data;
} else {
const contentResponse = await this.params.gitHubClient.getContentsAsync(
contentInfo.owner,
contentInfo.repo,
contentInfo.branch,
contentInfo.path
);
if (contentResponse.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to get content", response.status);
}
gitHubFile = contentResponse.data as IGitHubFile;
}
return this.createSuccessAjaxResponse(
HttpStatusCodes.OK,
this.createContentModel(uri, gitHubFile, { content: 0 })
);
} catch (error) {
Logger.logError(getErrorMessage(error), "GitHubContentProvider/update", error.errno);
return this.createErrorAjaxResponse(error);
}
})
);
}
public listCheckpoints(): Observable<AjaxResponse> {
const error = new GitHubContentProviderError("Not implemented");
Logger.logError(error.message, "GitHubContentProvider/listCheckpoints", error.errno);
return of(this.createErrorAjaxResponse(error));
}
public createCheckpoint(): Observable<AjaxResponse> {
const error = new GitHubContentProviderError("Not implemented");
Logger.logError(error.message, "GitHubContentProvider/createCheckpoint", error.errno);
return of(this.createErrorAjaxResponse(error));
}
public deleteCheckpoint(): Observable<AjaxResponse> {
const error = new GitHubContentProviderError("Not implemented");
Logger.logError(error.message, "GitHubContentProvider/deleteCheckpoint", error.errno);
return of(this.createErrorAjaxResponse(error));
}
public restoreFromCheckpoint(): Observable<AjaxResponse> {
const error = new GitHubContentProviderError("Not implemented");
Logger.logError(error.message, "GitHubContentProvider/restoreFromCheckpoint", error.errno);
return of(this.createErrorAjaxResponse(error));
}
private async validateContentAndGetCommitMsg(
content: IGitHubResponse<IGitHubFile | IGitHubFile[]>,
promptTitle: string,
promptPrimaryButtonLabel: string
): Promise<string> {
if (content.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to get content", content.status);
}
if (Array.isArray(content.data)) {
throw new GitHubContentProviderError("Operation not supported for collections");
}
const commitMsg = await this.params.promptForCommitMsg(promptTitle, promptPrimaryButtonLabel);
if (!commitMsg) {
throw new GitHubContentProviderError("Couldn't get a commit message");
}
return commitMsg;
}
private async getContent(uri: string): Promise<IGitHubResponse<IGitHubFile | IGitHubFile[]>> {
const contentInfo = GitHubUtils.fromContentUri(uri);
if (contentInfo) {
const { owner, repo, branch, path } = contentInfo;
return this.params.gitHubClient.getContentsAsync(owner, repo, branch, path);
}
return Promise.resolve({ status: GitHubContentProvider.SelfErrorCode, data: undefined });
}
private createContentModel(
uri: string,
content: IGitHubFile | IGitHubFile[],
params: Partial<IGetParams>
): IContent<FileType> {
if (Array.isArray(content)) {
return this.createDirectoryModel(uri, content);
}
if (content.type === "tree") {
return this.createDirectoryModel(uri, undefined);
}
if (NotebookUtil.isNotebookFile(uri)) {
return this.createNotebookModel(content, params);
}
return this.createFileModel(content, params);
}
private createDirectoryModel(uri: string, gitHubFiles: IGitHubFile[] | undefined): IContent<"directory"> {
return {
name: NotebookUtil.getName(uri),
path: uri,
type: "directory",
writable: true, // TODO: tamitta: we don't know this info here
created: "", // TODO: tamitta: we don't know this info here
last_modified: "", // TODO: tamitta: we don't know this info here
mimetype: undefined,
content: gitHubFiles?.map(
(file: IGitHubFile) =>
this.createContentModel(
GitHubUtils.toContentUri(file.repo.owner, file.repo.name, file.branch.name, file.path),
file,
{
content: 0,
}
) as IEmptyContent<FileType>
),
format: "json",
};
}
private createNotebookModel(gitHubFile: IGitHubFile, params: Partial<IGetParams>): IContent<"notebook"> {
const content: Notebook = gitHubFile.content && params.content !== 0 ? JSON.parse(gitHubFile.content) : undefined;
return {
name: gitHubFile.name,
path: GitHubUtils.toContentUri(
gitHubFile.repo.owner,
gitHubFile.repo.name,
gitHubFile.branch.name,
gitHubFile.path
),
type: "notebook",
writable: true, // TODO: tamitta: we don't know this info here
created: "", // TODO: tamitta: we don't know this info here
last_modified: gitHubFile.commit.commitDate,
mimetype: content ? "application/x-ipynb+json" : undefined,
content,
format: content ? "json" : undefined,
};
}
private createFileModel(gitHubFile: IGitHubFile, params: Partial<IGetParams>): IContent<"file"> {
const content: string = gitHubFile.content && params.content !== 0 ? gitHubFile.content : undefined;
return {
name: gitHubFile.name,
path: GitHubUtils.toContentUri(
gitHubFile.repo.owner,
gitHubFile.repo.name,
gitHubFile.branch.name,
gitHubFile.path
),
type: "file",
writable: true, // TODO: tamitta: we don't know this info here
created: "", // TODO: tamitta: we don't know this info here
last_modified: gitHubFile.commit.commitDate,
mimetype: content ? "text/plain" : undefined,
content,
format: content ? "text" : undefined,
};
}
private createSuccessAjaxResponse(status: number, content: IContent<FileType>): AjaxResponse {
return {
originalEvent: new Event("no-op"),
xhr: new XMLHttpRequest(),
request: {},
status,
response: content ? content : undefined,
responseText: content ? JSON.stringify(content) : undefined,
responseType: "json",
};
}
private createErrorAjaxResponse(error: GitHubContentProviderError): AjaxResponse {
return {
originalEvent: new Event("no-op"),
xhr: new XMLHttpRequest(),
request: {},
status: error.errno,
response: error,
responseText: getErrorMessage(error),
responseType: "json",
};
}
}