Skip to content

Commit 4bc2213

Browse files
pelikhanjwunderl
andauthored
Download all projects from a discourse tag (microsoft#6570)
* moving discourse to pxtlib * add cli to download topics from tag * expose more types * download and regen markdown * fix check * error checking * don't download script twice * simplify discourse interface * handle cli * handle ill formatted post * cli type * cli tweaks * logging * apply replacement * fix regex * cleanup * fix build * junk file * store forum images locally * pul thumb for makecode * drop screenshots on disk * download from forum image * don't sort * add "gulp cli" support * don't rewrite existing scripts * extract rx * Update cli/cli.ts Co-Authored-By: Joey Wunderlich <jwunderl@users.noreply.github.com> * revert fix * undo changes * fix build * clean out id * clean out id Co-authored-by: Joey Wunderlich <jwunderl@users.noreply.github.com>
1 parent d4941d1 commit 4bc2213

File tree

6 files changed

+225
-51
lines changed

6 files changed

+225
-51
lines changed

cli/cli.ts

+144-12
Original file line numberDiff line numberDiff line change
@@ -3130,6 +3130,116 @@ export function exportCppAsync(parsed: commandParser.ParsedCommand) {
31303130
})
31313131
}
31323132

3133+
export function downloadDiscourseTagAsync(parsed: commandParser.ParsedCommand): Promise<void> {
3134+
const rx = /```codecard((.|\s)*)```/;
3135+
const tag = parsed.args[0] as string;
3136+
if (!tag)
3137+
U.userError("Missing tag")
3138+
const out = parsed.flags["out"] as string || "temp";
3139+
const outmd = parsed.flags["md"] as string;
3140+
const discourseRoot = pxt.appTarget.appTheme
3141+
&& pxt.appTarget.appTheme.socialOptions
3142+
&& pxt.appTarget.appTheme.socialOptions.discourse;
3143+
if (!discourseRoot)
3144+
U.userError("Target not configured for discourse");
3145+
if (outmd && !fs.existsSync(outmd))
3146+
U.userError(`${outmd} file not found`)
3147+
let md: string = outmd && fs.readFileSync(outmd, { encoding: "utf8" });
3148+
3149+
nodeutil.mkdirP(out);
3150+
let n = 0;
3151+
let cards: pxt.CodeCard[] = [];
3152+
// parse existing cards
3153+
if (md) {
3154+
md.replace(rx, (m, c) => {
3155+
cards = JSON.parse(c);
3156+
return "";
3157+
})
3158+
}
3159+
return pxt.discourse.topicsByTag(discourseRoot, tag)
3160+
.then(topics => Promise.mapSeries(topics, topic => {
3161+
pxt.log(` ${topic.title}`)
3162+
return pxt.discourse.extractSharedIdFromPostUrl(topic.url)
3163+
.then(id => {
3164+
if (!id) {
3165+
pxt.log(` --> unknown project id`)
3166+
return Promise.resolve();
3167+
}
3168+
n++;
3169+
return extractAsyncInternal(id, out, false)
3170+
.then(() => {
3171+
// does the current card have an image?
3172+
let card = cards.filter(c => c.url == topic.url)[0];
3173+
if (card && card.imageUrl) {
3174+
pxt.log(`${card.name} already in markdown`)
3175+
return Promise.resolve(); // already handled
3176+
}
3177+
// new card? add to list
3178+
if (!card) {
3179+
card = topic;
3180+
card.description = "";
3181+
cards.push(card);
3182+
}
3183+
3184+
const pfn = `./docs/static/discourse/${id}.`;
3185+
if (md && !["png", "jpg", "gif"].some(ext => nodeutil.fileExistsSync(pfn + ext))) {
3186+
return downloadImageAsync(id, topic, `https://makecode.com/api/${id}/thumb`)
3187+
.catch(e => {
3188+
// no image
3189+
pxt.debug(`no thumb ${e}`);
3190+
// use image from forum
3191+
if (topic.imageUrl)
3192+
return downloadImageAsync(id, topic, topic.imageUrl);
3193+
else
3194+
throw e; // bail out
3195+
})
3196+
}
3197+
return Promise.resolve();
3198+
}).catch(e => {
3199+
pxt.log(`error: project ${id} could not be loaded or no image`);
3200+
});
3201+
})
3202+
}))
3203+
.then(() => {
3204+
if (md) {
3205+
// inject updated cards
3206+
cards.forEach(card => delete (card as any).id);
3207+
md = md.replace(rx, (m, c) => {
3208+
return `\`\`\`codecard
3209+
${JSON.stringify(cards, null, 4)}
3210+
\`\`\``;
3211+
})
3212+
fs.writeFileSync(outmd, md, { encoding: "utf8" });
3213+
}
3214+
pxt.log(`downloaded ${n} programs from tag ${tag}`)
3215+
})
3216+
3217+
function downloadImageAsync(id: string, topic: pxt.CodeCard, url: string): Promise<void> {
3218+
return pxt.Util.requestAsync({
3219+
url: `https://makecode.com/api/${id}/thumb`,
3220+
method: "GET",
3221+
responseArrayBuffer: true,
3222+
headers: {
3223+
"accept": "image/*"
3224+
}
3225+
}).then(resp => {
3226+
if (resp.buffer) {
3227+
const m = /image\/(png|jpeg|gif)/.exec(resp.headers["content-type"] as string);
3228+
if (!m) {
3229+
pxt.log(`unknown image type: ${resp.headers["content-type"]}`);
3230+
} else {
3231+
let ext = m[1];
3232+
if (ext == "jpeg") ext = "jpg";
3233+
const ifn = `/static/discourse/${id}.${ext}`;
3234+
const localifn = "./docs" + ifn;
3235+
topic.imageUrl = ifn;
3236+
nodeutil.writeFileSync(localifn, new Buffer(resp.buffer as ArrayBuffer));
3237+
}
3238+
}
3239+
});
3240+
}
3241+
}
3242+
31333243
export function formatAsync(parsed: commandParser.ParsedCommand) {
31343244
let inPlace = !!parsed.flags["i"];
31353245
let testMode = !!parsed.flags["t"];
@@ -4682,7 +4792,8 @@ export function extractAsync(parsed: commandParser.ParsedCommand): Promise<void>
46824792
const vscode = !!parsed.flags["code"];
46834793
const out = parsed.flags["code"] || '.';
46844794
const filename = parsed.args[0];
4685-
return extractAsyncInternal(filename, out as string, vscode);
4795+
return extractAsyncInternal(filename, out as string, vscode)
4796+
.then(() => { });
46864797
}
46874798

46884799
function isScriptId(id: string) {
@@ -4722,13 +4833,13 @@ function fetchTextAsync(filename: string): Promise<Buffer> {
47224833
return readFileAsync(filename)
47234834
}
47244835

4725-
function extractAsyncInternal(filename: string, out: string, vscode: boolean): Promise<void> {
4836+
function extractAsyncInternal(filename: string, out: string, vscode: boolean): Promise<string[]> {
47264837
if (filename && nodeutil.existsDirSync(filename)) {
47274838
pxt.log(`extracting folder ${filename}`);
47284839
return Promise.all(fs.readdirSync(filename)
47294840
.filter(f => /\.(hex|uf2)/.test(f))
47304841
.map(f => extractAsyncInternal(path.join(filename, f), out, vscode)))
4731-
.then(() => { });
4842+
.then(() => [filename]);
47324843
}
47334844

47344845
return fetchTextAsync(filename)
@@ -4738,6 +4849,7 @@ function extractAsyncInternal(filename: string, out: string, vscode: boolean): P
47384849
pxt.debug('launching code...')
47394850
dirs.forEach(dir => openVsCode(dir));
47404851
}
4852+
return dirs;
47414853
})
47424854
}
47434855

@@ -4777,31 +4889,31 @@ function extractBufferAsync(buf: Buffer, outDir: string): Promise<string[]> {
47774889
.then(() => {
47784890
let str = buf.toString("utf8")
47794891
if (str[0] == ":") {
4780-
console.log("Detected .hex file.")
4892+
pxt.debug("Detected .hex file.")
47814893
return unpackHexAsync(buf)
47824894
} else if (str[0] == "U") {
4783-
console.log("Detected .uf2 file.")
4895+
pxt.debug("Detected .uf2 file.")
47844896
return unpackHexAsync(buf)
47854897
} else if (str[0] == "{") { // JSON
4786-
console.log("Detected .json file.")
4898+
pxt.debug("Detected .json file.")
47874899
return JSON.parse(str)
47884900
} else if (buf[0] == 0x5d) { // JSZ
4789-
console.log("Detected .jsz/.pxt file.")
4901+
pxt.debug("Detected .jsz/.pxt file.")
47904902
return pxt.lzmaDecompressAsync(buf as any)
47914903
.then(str => JSON.parse(str))
47924904
} else
47934905
return Promise.resolve(null)
47944906
})
47954907
.then(json => {
47964908
if (!json) {
4797-
console.log("Couldn't extract.")
4909+
pxt.log("Couldn't extract.")
47984910
return undefined;
47994911
}
48004912
if (json.meta && json.source) {
48014913
json = typeof json.source == "string" ? JSON.parse(json.source) : json.source
48024914
}
48034915
if (Array.isArray(json.scripts)) {
4804-
console.log("Legacy TD workspace.")
4916+
pxt.debug("Legacy TD workspace.")
48054917
json.projects = json.scripts.map((scr: any) => ({
48064918
name: scr.header.name,
48074919
files: oneFile(scr.source, scr.header.editor)
@@ -4810,8 +4922,8 @@ function extractBufferAsync(buf: Buffer, outDir: string): Promise<string[]> {
48104922
}
48114923

48124924
if (json[pxt.CONFIG_NAME]) {
4813-
console.log("Raw JSON files.")
4814-
let cfg: pxt.PackageConfig = JSON.parse(json[pxt.CONFIG_NAME])
4925+
pxt.debug("Raw JSON files.")
4926+
let cfg: pxt.PackageConfig = pxt.Package.parseAndValidConfig(json[pxt.CONFIG_NAME])
48154927
let files = json
48164928
json = {
48174929
projects: [{
@@ -4823,7 +4935,7 @@ function extractBufferAsync(buf: Buffer, outDir: string): Promise<string[]> {
48234935

48244936
let prjs: SavedProject[] = json.projects
48254937
if (!prjs) {
4826-
console.log("No projects found.")
4938+
pxt.log("No projects found.")
48274939
return undefined;
48284940
}
48294941
const dirs = writeProjects(prjs, outDir)
@@ -6148,6 +6260,26 @@ ${pxt.crowdin.KEY_VARIABLE} - crowdin key
61486260
argString: "<target-directory>"
61496261
}, exportCppAsync);
61506262

6263+
p.defineCommand({
6264+
name: "downloaddiscoursetag",
6265+
aliases: ["ddt"],
6266+
help: "Download program for a discourse tag",
6267+
advanced: true,
6268+
argString: "<tag>",
6269+
flags: {
6270+
out: {
6271+
description: "output folder, default is temp",
6272+
argument: "out",
6273+
type: "string"
6274+
},
6275+
md: {
6276+
description: "path of the markdown file to generate",
6277+
argument: "out",
6278+
type: "string"
6279+
}
6280+
}
6281+
}, downloadDiscourseTagAsync)
6282+
61516283
function simpleCmd(name: string, help: string, callback: (c?: commandParser.ParsedCommand) => Promise<void>, argString?: string, onlineHelp?: boolean): void {
61526284
p.defineCommand({ name, help, onlineHelp, argString }, callback);
61536285
}

gulpfile.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -612,4 +612,4 @@ exports.karma = karma;
612612
exports.update = update;
613613
exports.uglify = runUglify;
614614
exports.watch = initWatch;
615-
exports.watchCli = initWatchCli;
615+
exports.watchCli = initWatchCli;

pxtlib/discourse.ts

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
namespace pxt.discourse {
2+
interface DiscoursePostResponse {
3+
featured_link?: string;
4+
post_stream?: DiscoursePostStream;
5+
}
6+
interface DiscoursePostStream {
7+
posts?: DiscoursePost[];
8+
}
9+
interface DiscoursePost {
10+
link_counts?: DiscourseLinkCount[];
11+
}
12+
interface DiscourseLinkCount {
13+
url?: string;
14+
}
15+
interface TagTopic {
16+
id: string;
17+
title: string;
18+
image_url: string;
19+
slug: string;
20+
views: number;
21+
like_count: number;
22+
posters: {
23+
user_id: string;
24+
}[];
25+
}
26+
interface TagUser {
27+
id: number;
28+
username: string;
29+
name: string;
30+
avatar_template: string;
31+
}
32+
interface TagsResponse {
33+
users: TagUser[];
34+
topic_list: {
35+
topics: TagTopic[];
36+
}
37+
}
38+
39+
export function extractSharedIdFromPostUrl(url: string): Promise<string> {
40+
// https://docs.discourse.org/#tag/Posts%2Fpaths%2F~1posts~1%7Bid%7D.json%2Fget
41+
return pxt.Util.httpGetJsonAsync(url + ".json")
42+
.then((json: DiscoursePostResponse) => {
43+
// extract from post_stream
44+
let projectId = json.post_stream
45+
&& json.post_stream.posts
46+
&& json.post_stream.posts[0]
47+
&& json.post_stream.posts[0].link_counts
48+
.map(link => pxt.Cloud.parseScriptId(link.url))
49+
.filter(id => !!id)[0];
50+
return projectId;
51+
});
52+
}
53+
54+
export function topicsByTag(apiUrl: string, tag: string): Promise<pxt.CodeCard[]> {
55+
apiUrl = apiUrl.replace(/\/$/, '');
56+
const q = `${apiUrl}/tags/${tag}.json`;
57+
return pxt.Util.httpGetJsonAsync(q)
58+
.then((json: TagsResponse) => {
59+
const users = pxt.Util.toDictionary(json.users, u => u.id.toString());
60+
return json.topic_list.topics.map(t => {
61+
return <pxt.CodeCard>{
62+
id: t.id,
63+
title: t.title,
64+
url: `${apiUrl}/t/${t.slug}/${t.id}`,
65+
imageUrl: t.image_url,
66+
author: users[t.posters[0].user_id].name,
67+
cardType: "forumUrl"
68+
}
69+
});
70+
});
71+
}
72+
}

pxtlib/initprj.ts

+7-8
Original file line numberDiff line numberDiff line change
@@ -230,14 +230,13 @@ jobs:
230230
const configMap = JSON.parse(files[pxt.CONFIG_NAME])
231231
if (options)
232232
Util.jsonMergeFrom(configMap, options);
233-
if (pxt.webConfig)
234-
Object.keys(pxt.webConfig)
235-
.forEach(k => configMap[k.toLowerCase()] = (<any>pxt.webConfig)[k]);
236-
configMap["platform"] = pxt.appTarget.platformid || pxt.appTarget.id
237-
configMap["target"] = pxt.appTarget.id
238-
configMap["docs"] = pxt.appTarget.appTheme.homeUrl || "./";
239-
configMap["homeurl"] = pxt.appTarget.appTheme.homeUrl || "???";
240-
233+
if (pxt.webConfig) { // CLI
234+
Object.keys(pxt.webConfig).forEach(k => configMap[k.toLowerCase()] = (<any>pxt.webConfig)[k]);
235+
configMap["platform"] = pxt.appTarget.platformid || pxt.appTarget.id
236+
configMap["target"] = pxt.appTarget.id
237+
configMap["docs"] = pxt.appTarget.appTheme.homeUrl || "./";
238+
configMap["homeurl"] = pxt.appTarget.appTheme.homeUrl || "???";
239+
}
241240
U.iterMap(files, (k, v) => {
242241
v = v.replace(/@([A-Z]+)@/g, (f, n) => configMap[n.toLowerCase()] || "")
243242
files[k] = v

webapp/src/discourse.ts

-28
This file was deleted.

webapp/src/projects.tsx

+1-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import * as core from "./core";
88
import * as cloud from "./cloud";
99
import * as cloudsync from "./cloudsync";
1010

11-
import * as discourse from "./discourse";
1211
import * as codecard from "./codecard"
1312
import * as carousel from "./carousel";
1413
import { showAboutDialogAsync } from "./dialogs";
@@ -830,7 +829,7 @@ export class ProjectsDetail extends data.Component<ProjectsDetailProps, Projects
830829

831830
handleOpenForumUrlInEditor() {
832831
const { url } = this.props;
833-
discourse.extractSharedIdFromPostUrl(url)
832+
pxt.discourse.extractSharedIdFromPostUrl(url)
834833
.then(projectId => {
835834
// if we have a projectid, load it
836835
if (projectId)

0 commit comments

Comments
 (0)