Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add show docs command (#480) #481

Merged
merged 3 commits into from
Oct 16, 2023
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
5 changes: 4 additions & 1 deletion .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
name: PR

on:
push: {}
pull_request: {}
workflow_dispatch: {}
push:
branches:
- main

jobs:
test:
Expand Down
4 changes: 1 addition & 3 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,19 @@
"version": "0.2.0",
"configurations": [
{
"type": "pwa-extensionHost",
"type": "extensionHost",
"request": "launch",
"name": "Launch Client",
"runtimeExecutable": "${execPath}",
"args": ["--disable-extensions", "--extensionDevelopmentPath=${workspaceFolder}"],
"sourceMaps": true,
"outFiles": ["${workspaceFolder}/client/out/**/*.js"],
"preLaunchTask": "npm watch 'vscode-plugin'"
},
{
"type": "node",
"request": "attach",
"name": "Attach to Server",
"address": "localhost",
"protocol": "inspector",
"port": 6009,
"sourceMaps": true,
"outFiles": ["${workspaceFolder}/server/out/**/*.js"]
Expand Down
2 changes: 2 additions & 0 deletions client/src/Commands/Activate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import * as Create from "./Create/Create";
import * as Errors from "./Errors/OpenLastest";
import * as Language from "./Language/Activate";
import * as ShowVanillaFile from "./Vanilla/ShowVanillaFile";
import * as ShowDocs from "./Docs/ShowDocs";

export function Activate(context: ExtensionContext): void {
Create.Activate(context);
Errors.Activate(context);
Language.Activate(context);
ShowVanillaFile.Activate(context);
ShowDocs.Activate(context);
}
258 changes: 258 additions & 0 deletions client/src/Commands/Docs/ShowDocs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { commands, ExtensionContext, FileType, ProgressLocation, Uri, window, workspace } from "vscode";
import { Commands } from "@blockception/shared";
import path from "path";
import { Console } from '../../Console/Console';

export function Activate(context: ExtensionContext): void {
async function ShowDocs(args: any) {
const base = context.storageUri || context.globalStorageUri;
const storage_path = path.join(base.fsPath, "docs");
const command = new ShowDocsCommand(storage_path);

const sidebar = await command.getSidebar();

if (sidebar.length === 0) {
return;
}
// Processed titles are made from the toc_title and the parent toc_title
const titles: string[] = sidebar.map((x) => x.processedTitle ?? x.toc_title);

return window.showQuickPick(titles).then((title) => {
if (!title) {
return;
}

// Find the item by title or processed title
const item = sidebar.find((x) => x.processedTitle === title || x.toc_title === title);
if (!item) {
Console.Error("Failed to find docs item", title);
return;
}

return command.process(item);
});
}

context.subscriptions.push(commands.registerCommand(Commands.ShowDocs, ShowDocs));
}

const day_diff_2 = 1000 * 60 * 60 * 24 * 2;
// URL with the list of all docs
const sidebar_url = "https://learn.microsoft.com/en-us/minecraft/creator/toc.json";
// URL to the docs, that will be prepended to the href
const html_url = "https://learn.microsoft.com/en-us/minecraft/creator/";

/**
* Represents a command to show documentation.
*/
class ShowDocsCommand {
private storage: string;
// Cached flattened sidebar
private sidebar: SidebarItem[];

/**
* Creates a new instance of the ShowDocsCommand class.
* @param storage The storage path.
*/
constructor(storage: string) {
this.storage = storage;
this.sidebar = [];
}

/**
* Gets the flattened sidebar.
* @returns A promise that resolves to the flattened sidebar. or an empty array if the sidebar could not be downloaded.
*/
async getSidebar(): Promise<SidebarItem[]> {
if (this.sidebar.length > 0) {
return this.sidebar;
}
const data = await fetch(sidebar_url);

if (!data.ok) {
window.showErrorMessage("Failed to download docs sidebar");
return this.sidebar;
}

const jsonData = (await data.json()) as Sidebar;

if (!Sidebar.is(jsonData)) {
window.showErrorMessage("Failed to parse docs sidebar");
return this.sidebar;
}

return (this.sidebar = this.flattenSidebar(jsonData.items));
}

/**
* Flattens the sidebar from a tree to a list omitting all elements without href.
* It also adds the processed title to each element.
* @param sidebar The sidebar to flatten
* @param prefix The prefix to add to the processed title
* @returns The flattened sidebar
*/
flattenSidebar(sidebar: SidebarItem[], prefix: string = ""): SidebarItem[] {
const result: SidebarItem[] = [];

for (let I = 0; I < sidebar.length; I++) {
const item = sidebar[I];
// Add the prefix to the title
// Remove the docs title from the path, because it is too long
item.processedTitle = `${prefix}/${item.toc_title}`.replace("/Minecraft: Bedrock Documentation/", "");
if (item.href) {
result.push(item);
}

if (item.children) {
// Iterative approach would be better, but recursion is easier
result.push(...this.flattenSidebar(item.children, item.processedTitle));
}
}

return result;
}

/**
* Gets the filepath for the specified sidebar item.
* @param item The sidebar item.
* @returns The filepath for the specified sidebar item.
*/
getFilepath(item: SidebarItem): string {
// At this point, elements without href are filtered out
return path.join(this.storage, item.href! + ".md");
}

/**
* Checks if the specified file can be read.
* @param filepath The filepath of the file to check.
* @returns A promise that resolves to true if the file can be read, false otherwise.
*/
async canRead(filepath: string): Promise<boolean> {
try {
const stat = await workspace.fs.stat(Uri.file(filepath));

if (stat.type !== FileType.File) return false;

//Check if the file is not older then 2 days
const now = new Date();
const file = new Date(stat.mtime);
const diff = now.getTime() - file.getTime();

return diff <= day_diff_2;
} catch (err) {
Console.Error(`Failed to read file ${filepath}`, )
}

return false;
}

/**
* Downloads the specified URI to the specified filepath.
* @param uri The URI to download.
* @param filepath The filepath to download to.
* @returns A promise that resolves when the download is complete.
*/
async download(uri: string, filepath: string): Promise<void> {
const progressOptions = {
location: ProgressLocation.Notification,
title: "Downloading docs",
cancellable: false,
};

return window.withProgress(progressOptions, async (progress) => {
const options: RequestInit = {
method: "GET",
};

progress.report({
message: "Downloading docs",
increment: 0,
});

try {
// Download the html page
const result = await fetch(uri, options);

if (!result.ok) {
window.showErrorMessage("Failed to download docs\n", `${uri}\n${filepath}\n`, result.statusText);
return;
}

const text = await result.text();

progress.report({ increment: 50 });
// Find the github link and change blob to raw. It's not the best solution, but it works
const matches = /(https:\/\/github.com\/MicrosoftDocs\/minecraft-creator\/blob\/main\/[^"]+)/g.exec(text);
if (!matches || matches.length === 0 || matches[0] === undefined) {
window.showErrorMessage("Failed to download docs\n", `${uri}\n${filepath}\nNo github link found`);
return;
}
const mdUrl = matches[0].replace(
"MicrosoftDocs/minecraft-creator/blob/",
"MicrosoftDocs/minecraft-creator/raw/"
);
// Download the markdown file
const mdResult = await fetch(mdUrl, options);

if (!mdResult.ok) {
window.showErrorMessage("Failed to download docs\n", `${uri}\n${filepath}\n`, mdResult.statusText);
return;
}

const mdText = await mdResult.text();

await workspace.fs.writeFile(Uri.file(filepath), Buffer.from(mdText, "utf8"));
Console.Info("Downloaded docs", filepath);
} catch (err) {
window.showErrorMessage("Failed to download docs\n", `${uri}\n${filepath}\n`, JSON.stringify(err));
}

progress.report({ increment: 50 });
});
}

/**
* Processes the specified sidebar item.
* @param item The sidebar item to process.
* @returns A promise that resolves when the processing is complete.
*/
async process(item: SidebarItem): Promise<void> {
const filepath = this.getFilepath(item);
const dir = path.dirname(filepath);

await workspace.fs.createDirectory(Uri.file(dir));

const canRead = await this.canRead(filepath);
if (!canRead) {
const html_uri = html_url + item.href!;
await this.download(html_uri, filepath);
}

try {
const uri = Uri.file(filepath);
// Open the markdown preview
await commands.executeCommand("markdown.showPreview", uri);
} catch (err) {
window.showErrorMessage("Failed to open docs", filepath);
}
}
}

// Interfaces for the sidebar. It's not complete, but it's enough for this use case
interface Sidebar {
items: SidebarItem[];
}

interface SidebarItem {
href?: string;
toc_title: string;
children?: SidebarItem[];
// This is added by the flattenSidebar function
processedTitle?: string;
}

namespace Sidebar {
export function is(item: any): item is Sidebar {
return item && item.items && Array.isArray(item.items);
}
}
3 changes: 3 additions & 0 deletions client/src/Commands/Docs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/* Auto generated */

export * from "./ShowDocs";
12 changes: 6 additions & 6 deletions client/src/Console/Console.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
export class Console {
/**Sends a error to the console log of the server*/
static Error(message: string): void {
console.error(message);
static Error(message: string, ...optionalParams: any[]): void {
console.error(message, ...optionalParams);
}

/**Sends a error to the console log of the server*/
static Info(message: string): void {
console.info(message);
static Info(message: string, ...optionalParams: any[]): void {
console.info(message, ...optionalParams);
}

/**Sends a error to the console log of the server*/
Expand All @@ -15,7 +15,7 @@ export class Console {
}

/**Sends a error to the console log of the server*/
static Warn(message: string): void {
console.warn(message);
static Warn(message: string, ...optionalParams: any[]): void {
console.warn(message, ...optionalParams);
}
}
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,11 @@
"title": "Show vanilla file",
"command": "bc.minecraft.vanilla.show",
"category": "Blockception"
},
{
"title": "Show docs",
"command": "bc.minecraft.docs.show",
"category": "Blockception"
}
],
"grammars": [
Expand Down
3 changes: 3 additions & 0 deletions shared/src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export namespace Commands {
/** */
export const ShowVanillaFile: string = "bc.minecraft.vanilla.show";

/** */
export const ShowDocs: string = "bc.minecraft.docs.show";

/** */
export namespace Files {
export const Append = "bc-files-append";
Expand Down