diff --git a/README.md b/README.md index 9dbf754..0b45bdf 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Bundled VS Code extension and standalone tools for playable ad workflows. - **MRAID Checker** - Check HTML files against MRAID requirements and best practices. - **Mintegral Checker** - Validate Mintegral playable ZIPs against PlayTurbo requirements. - **Playworks Converter** - Convert Playworks HTML into per-network variants. Beta. +- **Initialize Project** - Download team template files (AGENTS.md, LOCALIZATION.md, .gitignore) from a shared Google Drive manifest. # Changelog @@ -117,3 +118,16 @@ Added Added - Added Localization dropdown in Device Simulator. ``` + +**v0.2.4** +``` +Added + - Added Initialize Project tool to the VS Code extension and standalone app. + - Downloads team template files (AGENTS.md, LOCALIZATION.md, .gitignore) from a shared manifest. + - Manifest is a JSON file hosted on Google Drive or any direct URL, listing files with name, URL, and description. + - Manifest URL is configurable per-user; defaults to the shared team Google Drive manifest. + - Google Drive share links are automatically converted to direct download URLs. + - Files panel shows a checklist loaded from the manifest with per-file Open button and Refresh/Configure controls. + - Destination folder is selectable via folder picker, defaulting to workspace root in the extension. + - Standalone stores the manifest URL in config.json and exposes an inline URL editor via the settings (⚙) button. +``` diff --git a/dist/hpl-toolbox-0.2.3.exe b/dist/hpl-toolbox-0.2.4.exe similarity index 69% rename from dist/hpl-toolbox-0.2.3.exe rename to dist/hpl-toolbox-0.2.4.exe index 5bc6c6d..9dd8a6c 100644 Binary files a/dist/hpl-toolbox-0.2.3.exe and b/dist/hpl-toolbox-0.2.4.exe differ diff --git a/dist/hpl-toolbox-0.2.3.vsix b/dist/hpl-toolbox-0.2.4.vsix similarity index 99% rename from dist/hpl-toolbox-0.2.3.vsix rename to dist/hpl-toolbox-0.2.4.vsix index de3d088..9a2a57a 100644 Binary files a/dist/hpl-toolbox-0.2.3.vsix and b/dist/hpl-toolbox-0.2.4.vsix differ diff --git a/package.json b/package.json index d732331..8d2e8c1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "hpl-toolbox", "displayName": "HPL Toolbox", "description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.", - "version": "0.2.3", + "version": "0.2.4", "publisher": "hesukastro", "license": "UNLICENSED", "repository": { @@ -55,6 +55,10 @@ { "command": "hplToolbox.checkForUpdates", "title": "HPL Toolbox: Check for Updates" + }, + { + "command": "hplToolbox.openProjectInit", + "title": "HPL Toolbox: Initialize Project" } ], "viewsContainers": { @@ -131,6 +135,16 @@ "description": "Port for the temporary LAN HTTP server. 0 (default) = OS-assigned ephemeral port." } } + }, + { + "title": "HPL Toolbox — Initialize Project", + "properties": { + "hplToolbox.projectInit.manifestUrl": { + "type": "string", + "default": "https://drive.google.com/file/d/1lRmHIy_0nXEy_LTwn0NzIqoJ4s9_JmxO/view?usp=drive_link", + "description": "URL to a JSON manifest file listing the files to download. The manifest must be an array of { \"filename\": string, \"url\": string } objects. Can be a raw GitHub/Gitea file URL or any direct JSON URL." + } + } } ] }, diff --git a/src/extension.ts b/src/extension.ts index cff5b84..a168d1b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,6 +9,7 @@ import { openPlayworksConverter } from './tools/playworksConverter'; import { openMintegralChecker } from './tools/mintegralChecker'; import { openChangelog } from './changelogView'; import { openDeviceSimulator } from './tools/deviceSimulator'; +import { openProjectInit } from './tools/projectInit'; import { checkForUpdates } from './updateChecker'; export function activate(context: vscode.ExtensionContext) { @@ -21,6 +22,7 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)), vscode.commands.registerCommand('hplToolbox.openMintegralChecker', () => openMintegralChecker(context)), vscode.commands.registerCommand('hplToolbox.openDeviceSimulator', () => openDeviceSimulator(context)), + vscode.commands.registerCommand('hplToolbox.openProjectInit', () => openProjectInit(context)), vscode.commands.registerCommand('hplToolbox.openChangelog', () => openChangelog(context)), vscode.commands.registerCommand('hplToolbox.checkForUpdates', () => checkForUpdates(context)), vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context)) diff --git a/src/launcherView.ts b/src/launcherView.ts index 589ed2f..d0318c2 100644 --- a/src/launcherView.ts +++ b/src/launcherView.ts @@ -14,6 +14,12 @@ interface ToolDefinition { } const FALLBACK_TOOLS: ToolDefinition[] = [ + { + id: 'project-init', + command: 'hplToolbox.openProjectInit', + title: 'Initialize Project', + description: 'Download AGENTS.md and .gitignore templates from Google Drive', + }, { command: 'hplToolbox.openPlecUpload', title: 'PLEC Upload', diff --git a/src/tools/projectInit.ts b/src/tools/projectInit.ts new file mode 100644 index 0000000..abf3c86 --- /dev/null +++ b/src/tools/projectInit.ts @@ -0,0 +1,266 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import { singletonPanel, getToolWebviewStyles, handleClipboardAndOpen } from './shared'; + +interface FileEntry { filename: string; url: string; description?: string; } + +const store: { panel: vscode.WebviewPanel | null } = { panel: null }; + +function gdriveToDirectUrl(url: string): string { + const fileMatch = url.match(/\/file\/d\/([a-zA-Z0-9_-]+)/); + if (fileMatch) { return `https://drive.google.com/uc?export=download&id=${fileMatch[1]}`; } + const idMatch = url.match(/[?&]id=([a-zA-Z0-9_-]+)/); + if (idMatch) { return `https://drive.google.com/uc?export=download&id=${idMatch[1]}`; } + return url; +} + +async function fetchManifest(url: string): Promise { + const res = await fetch(gdriveToDirectUrl(url), { headers: { 'User-Agent': 'Mozilla/5.0' } }); + if (!res.ok) { throw new Error(`HTTP ${res.status} ${res.statusText}`); } + const json = await res.json() as unknown; + if (!Array.isArray(json)) { throw new Error('Manifest must be a JSON array of { filename, url } objects.'); } + return (json as FileEntry[]).filter(e => e.filename && e.url); +} + +async function downloadFile(url: string, destPath: string): Promise { + const directUrl = gdriveToDirectUrl(url); + const res = await fetch(directUrl, { headers: { 'User-Agent': 'Mozilla/5.0' }, redirect: 'follow' }); + if (!res.ok) { throw new Error(`HTTP ${res.status} ${res.statusText}`); } + const contentType = res.headers.get('content-type') ?? ''; + const text = await res.text(); + const ext = path.extname(destPath).toLowerCase(); + if (contentType.includes('text/html') && ext !== '.html' && ext !== '.htm') { + throw new Error( + 'Got an HTML page instead of a file — the Google Drive link may require sign-in or a download confirmation. Make sure the file is set to "Anyone with the link can view".' + ); + } + await fs.promises.writeFile(destPath, text, 'utf8'); +} + +function getHtml(): string { + return ` + + + + + + +
+
+

Initialize Project

+

Fetches the shared file list from your team manifest and downloads selected files into the chosen folder.

+
+ +
+
+

Files

+
+ + + +
+
+
+
+
+
+
+

Download folder

+
+ + +
+
+
+ + +
+
+
+
+
+ + +`; +} + +export function openProjectInit(context: vscode.ExtensionContext) { + const { panel, isNew } = singletonPanel(store, 'hplToolbox.projectInit', 'Initialize Project'); + if (!isNew) { return; } + + panel.webview.html = getHtml(); + + async function sendManifest() { + const manifestUrl = vscode.workspace.getConfiguration('hplToolbox.projectInit').get('manifestUrl', ''); + if (!manifestUrl) { + panel.webview.postMessage({ + type: 'manifestError', + text: 'No manifest URL configured. Click ⚙ to set it in Settings.', + }); + return; + } + try { + const files = await fetchManifest(manifestUrl); + panel.webview.postMessage({ type: 'manifest', files, manifestUrl }); + } catch (err: any) { + panel.webview.postMessage({ type: 'manifestError', text: `Failed to load manifest: ${err.message}` }); + } + } + + panel.webview.onDidReceiveMessage(async (msg) => { + if (handleClipboardAndOpen(msg)) { return; } + + if (msg.type === 'fetchManifest') { + await sendManifest(); + } + + if (msg.type === 'openSettings') { + vscode.commands.executeCommand('workbench.action.openSettings', 'hplToolbox.projectInit.manifestUrl'); + } + + if (msg.type === 'pickFolder') { + const picked = await vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + openLabel: 'Select Destination Folder', + defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri, + }); + if (picked?.[0]) { + panel.webview.postMessage({ type: 'folder', path: picked[0].fsPath }); + } + } + + if (msg.type === 'init') { + const files: FileEntry[] = msg.files; + const dest: string = msg.destFolder || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + if (!dest) { + panel.webview.postMessage({ type: 'result', ok: false, text: 'No destination folder selected and no workspace is open.' }); + return; + } + const errors: string[] = []; + for (const f of files) { + try { + await downloadFile(f.url, path.join(dest, f.filename)); + } catch (err: any) { + errors.push(`${f.filename}: ${err.message}`); + } + } + if (errors.length) { + panel.webview.postMessage({ type: 'result', ok: false, text: `${files.length - errors.length}/${files.length} downloaded.\n\nErrors:\n${errors.join('\n')}` }); + } else { + panel.webview.postMessage({ type: 'result', ok: true, text: `Done! ${files.length} file(s) downloaded to: ${dest}` }); + } + } + }, undefined, context.subscriptions); +} diff --git a/standalone/hpltoolbox.exe b/standalone/hpltoolbox.exe index 7b17879..b1abe45 100644 Binary files a/standalone/hpltoolbox.exe and b/standalone/hpltoolbox.exe differ diff --git a/standalone/layout.go b/standalone/layout.go index 2b75012..cf7fc26 100644 --- a/standalone/layout.go +++ b/standalone/layout.go @@ -476,6 +476,7 @@ type navItem struct { var navItems = []navItem{ {Path: "/", Label: "Home"}, + {Path: "/project-init", Label: "Initialize Project", Description: "Download AGENTS.md and .gitignore templates from Google Drive"}, {Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"}, {Path: "/applovin", Label: "AppLovin Playable Preview", Description: "Upload to p.applov.in (QR preview)"}, {Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"}, @@ -521,6 +522,8 @@ func betaToolCount() int { func navInitials(label string) string { switch label { + case "Initialize Project": + return "IP" case "Base64 Scanner": return "B64" case "AppLovin Playable Preview": diff --git a/standalone/main.go b/standalone/main.go index 6aa1192..10b08c4 100644 --- a/standalone/main.go +++ b/standalone/main.go @@ -107,6 +107,7 @@ func buildMux() *http.ServeMux { } HomePage(w, r) }) + mux.HandleFunc("GET /project-init", ProjectInitPage) mux.HandleFunc("GET /plec", PlecPage) mux.HandleFunc("GET /applovin", ApplovinPage) mux.HandleFunc("GET /base64", Base64Page) @@ -118,6 +119,12 @@ func buildMux() *http.ServeMux { mux.HandleFunc("GET /changelog", ChangelogPage) mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript) + // Initialize Project + mux.HandleFunc("GET /api/project-init/manifest", ProjectInitManifestEndpoint) + mux.HandleFunc("POST /api/project-init/pickFolder", ProjectInitPickFolderEndpoint) + mux.HandleFunc("POST /api/project-init/setManifestUrl", ProjectInitSetManifestURLEndpoint) + mux.HandleFunc("POST /api/project-init/download", ProjectInitDownloadEndpoint) + // Shared API mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint) mux.HandleFunc("POST /api/open", OpenEndpoint) diff --git a/standalone/platform.go b/standalone/platform.go index efdfb86..bad6bd4 100644 --- a/standalone/platform.go +++ b/standalone/platform.go @@ -29,11 +29,12 @@ type SendToMobileConfig struct { } type AppConfig struct { - Plec PlecConfig `json:"plec"` - Applovin ApplovinConfig `json:"applovin"` - SendToMobile SendToMobileConfig `json:"sendToMobile"` - BetaToolsEnabled bool `json:"betaToolsEnabled"` - LastPickDir string `json:"lastPickDir,omitempty"` + Plec PlecConfig `json:"plec"` + Applovin ApplovinConfig `json:"applovin"` + SendToMobile SendToMobileConfig `json:"sendToMobile"` + BetaToolsEnabled bool `json:"betaToolsEnabled"` + LastPickDir string `json:"lastPickDir,omitempty"` + ProjectInitManifestURL string `json:"projectInitManifestUrl,omitempty"` } var configMu sync.Mutex diff --git a/standalone/project_init.go b/standalone/project_init.go new file mode 100644 index 0000000..ac2096c --- /dev/null +++ b/standalone/project_init.go @@ -0,0 +1,354 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" +) + +const defaultManifestURL = "https://drive.google.com/file/d/1lRmHIy_0nXEy_LTwn0NzIqoJ4s9_JmxO/view?usp=drive_link" + +type ManifestEntry struct { + Filename string `json:"filename"` + URL string `json:"url"` + Description string `json:"description,omitempty"` +} + +var reGDriveFileID = regexp.MustCompile(`/file/d/([a-zA-Z0-9_-]+)`) +var reGDriveIDParam = regexp.MustCompile(`[?&]id=([a-zA-Z0-9_-]+)`) + +func gdriveToDirectURL(rawURL string) string { + if m := reGDriveFileID.FindStringSubmatch(rawURL); m != nil { + return "https://drive.google.com/uc?export=download&id=" + m[1] + } + if m := reGDriveIDParam.FindStringSubmatch(rawURL); m != nil { + return "https://drive.google.com/uc?export=download&id=" + m[1] + } + return rawURL +} + +func fetchManifest(manifestURL string) ([]ManifestEntry, error) { + req, err := http.NewRequest("GET", gdriveToDirectURL(manifestURL), nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "Mozilla/5.0") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status) + } + var entries []ManifestEntry + if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil { + return nil, fmt.Errorf("failed to parse manifest JSON: %w", err) + } + filtered := entries[:0] + for _, e := range entries { + if e.Filename != "" && e.URL != "" { + filtered = append(filtered, e) + } + } + return filtered, nil +} + +func downloadManifestFile(rawURL, destPath string) error { + req, err := http.NewRequest("GET", gdriveToDirectURL(rawURL), nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "Mozilla/5.0") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + ct := resp.Header.Get("Content-Type") + ext := strings.ToLower(filepath.Ext(destPath)) + if strings.Contains(ct, "text/html") && ext != ".html" && ext != ".htm" { + return fmt.Errorf("got an HTML page instead of a file — make sure the Google Drive file is set to \"Anyone with the link can view\"") + } + return os.WriteFile(destPath, body, 0644) +} + +func getManifestURL() string { + cfg := LoadConfig() + if cfg.ProjectInitManifestURL != "" { + return cfg.ProjectInitManifestURL + } + return defaultManifestURL +} + +func ProjectInitPage(w http.ResponseWriter, r *http.Request) { + manifestURL := getManifestURL() + body := ` +
+

Initialize Project

+

Fetches the shared file list from your team manifest and downloads selected files into the chosen folder.

+
+ +
+
+

Files

+
+ + + +
+
+
+ +
+
+
+
+

Download folder

+
+ + +
+
+
+ + +
+
+
+
+ + +` + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(Page("/project-init", "Initialize Project", body))) +} + +func htmlEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, `"`, """) + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +func ProjectInitManifestEndpoint(w http.ResponseWriter, r *http.Request) { + manifestURL := getManifestURL() + entries, err := fetchManifest(manifestURL) + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, map[string]any{"files": entries, "manifestUrl": manifestURL}) +} + +func ProjectInitPickFolderEndpoint(w http.ResponseWriter, r *http.Request) { + cfg := LoadConfig() + folder := PickFolder("Select Destination Folder", cfg.LastPickDir) + if folder == "" { + writeJSON(w, map[string]any{"path": ""}) + return + } + cfg.LastPickDir = folder + _ = SaveConfig(cfg) + writeJSON(w, map[string]any{"path": folder}) +} + +func ProjectInitSetManifestURLEndpoint(w http.ResponseWriter, r *http.Request) { + var req struct { + URL string `json:"url"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) + return + } + cfg := LoadConfig() + cfg.ProjectInitManifestURL = strings.TrimSpace(req.URL) + if err := SaveConfig(cfg); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) + return + } + writeJSON(w, map[string]any{"ok": true}) +} + +func ProjectInitDownloadEndpoint(w http.ResponseWriter, r *http.Request) { + var req struct { + Files []ManifestEntry `json:"files"` + DestFolder string `json:"destFolder"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, map[string]any{"ok": false, "message": "Invalid request: " + err.Error()}) + return + } + if req.DestFolder == "" { + writeJSON(w, map[string]any{"ok": false, "message": "No destination folder specified."}) + return + } + var errs []string + for _, f := range req.Files { + if err := downloadManifestFile(f.URL, filepath.Join(req.DestFolder, f.Filename)); err != nil { + errs = append(errs, f.Filename+": "+err.Error()) + } + } + total := len(req.Files) + if len(errs) > 0 { + writeJSON(w, map[string]any{ + "ok": false, + "message": fmt.Sprintf("%d/%d downloaded.\n\nErrors:\n%s", total-len(errs), total, strings.Join(errs, "\n")), + }) + } else { + writeJSON(w, map[string]any{ + "ok": true, + "message": fmt.Sprintf("Done! %d file%s downloaded to: %s", total, pluralS(total), req.DestFolder), + }) + } +} diff --git a/tools.json b/tools.json index e161fca..185bbf4 100644 --- a/tools.json +++ b/tools.json @@ -1,4 +1,12 @@ [ + { + "id": "project-init", + "title": "Initialize Project", + "description": "Download AGENTS.md and .gitignore templates from Google Drive", + "command": "hplToolbox.openProjectInit", + "path": "/project-init", + "beta": false + }, { "id": "plec", "title": "PLEC Upload", @@ -62,5 +70,5 @@ "command": "hplToolbox.openDeviceSimulator", "path": "/device-simulator", "beta": false - } + }, ]