v0.2.4
This commit is contained in:
14
README.md
14
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.
|
||||
```
|
||||
|
||||
Binary file not shown.
Binary file not shown.
16
package.json
16
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
|
||||
266
src/tools/projectInit.ts
Normal file
266
src/tools/projectInit.ts
Normal file
@@ -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<FileEntry[]> {
|
||||
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<void> {
|
||||
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 `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
${getToolWebviewStyles()}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Initialize Project</h2>
|
||||
<p class="tool-description">Fetches the shared file list from your team manifest and downloads selected files into the chosen folder.</p>
|
||||
</header>
|
||||
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Files</h3>
|
||||
<div style="display:flex;gap:var(--tool-gap-xs);align-items:center;">
|
||||
<span id="manifestSource" class="muted" style="font-size:11px;margin-right:var(--tool-gap-xs);"></span>
|
||||
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Refresh manifest" onclick="refresh()">↻</button>
|
||||
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Configure manifest URL" onclick="openManifestSettings()">⚙</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div id="fileList"></div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<p class="control-label">Download folder</p>
|
||||
<div class="field-row">
|
||||
<input type="text" id="destFolder" value="" placeholder="(workspace root)" readonly />
|
||||
<button class="secondary" onclick="pickFolder()">Browse…</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="initBtn" onclick="initProject()" disabled>Initialize Project</button>
|
||||
<button id="selectAllBtn" class="secondary" onclick="toggleSelectAll()" style="display:none;">Deselect all</button>
|
||||
</div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const savedState = vscode.getState() || {};
|
||||
document.getElementById('destFolder').value = savedState.destFolder || '';
|
||||
|
||||
let files = [];
|
||||
let allSelected = true;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
setStatus('', '');
|
||||
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Loading manifest…</p>';
|
||||
document.getElementById('initBtn').disabled = true;
|
||||
document.getElementById('selectAllBtn').style.display = 'none';
|
||||
vscode.postMessage({ type: 'fetchManifest' });
|
||||
}
|
||||
|
||||
function openManifestSettings() { vscode.postMessage({ type: 'openSettings' }); }
|
||||
function pickFolder() { vscode.postMessage({ type: 'pickFolder' }); }
|
||||
|
||||
function initProject() {
|
||||
const checked = files.filter((_, i) => document.getElementById('cb_' + i)?.checked);
|
||||
if (!checked.length) { setStatus('No files selected.', 'err'); return; }
|
||||
const destFolder = document.getElementById('destFolder').value.trim();
|
||||
const el = document.getElementById('status');
|
||||
el.textContent = 'Downloading ' + checked.length + ' file(s)…';
|
||||
el.className = 'status-panel is-busy';
|
||||
vscode.postMessage({ type: 'init', files: checked, destFolder });
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
allSelected = !allSelected;
|
||||
files.forEach((_, i) => { const cb = document.getElementById('cb_' + i); if (cb) cb.checked = allSelected; });
|
||||
document.getElementById('selectAllBtn').textContent = allSelected ? 'Deselect all' : 'Select all';
|
||||
}
|
||||
|
||||
function renderFiles(list, manifestUrl) {
|
||||
files = list;
|
||||
allSelected = true;
|
||||
document.getElementById('manifestSource').textContent =
|
||||
manifestUrl ? '(' + manifestUrl.replace(/^https?:\\/\\//, '').split('/')[0] + ')' : '';
|
||||
|
||||
if (!list.length) {
|
||||
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Manifest loaded but contains no files.</p>';
|
||||
document.getElementById('initBtn').disabled = true;
|
||||
document.getElementById('selectAllBtn').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = list.map((f, i) =>
|
||||
'<tr>' +
|
||||
'<td style="width:20px;text-align:center;"><input type="checkbox" id="cb_' + i + '" checked /></td>' +
|
||||
'<td class="mono wrap" style="width:20%;">' + escapeHtml(f.filename) + '</td>' +
|
||||
'<td class="muted wrap" style="width:70%;">' + escapeHtml(f.description || '') + '</td>' +
|
||||
'<td style="width:10%;text-align:right;"><button style="min-height:22px;padding:2px 8px;font-size:11px;" data-url="' + escapeHtml(f.url) + '" onclick="openUrl(this)">Open</button></td>' +
|
||||
'</tr>'
|
||||
).join('');
|
||||
|
||||
document.getElementById('fileList').innerHTML =
|
||||
'<div class="results-panel" style="margin-top:0;overflow-x:hidden;">' +
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr>' +
|
||||
'<th style="width:20px;"></th>' +
|
||||
'<th style="width:20%;">Filename</th>' +
|
||||
'<th style="width:70%;">Description</th>' +
|
||||
'<th style="width:10%;"></th>' +
|
||||
'</tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
'</div>';
|
||||
|
||||
document.getElementById('initBtn').disabled = false;
|
||||
document.getElementById('selectAllBtn').style.display = '';
|
||||
document.getElementById('selectAllBtn').textContent = 'Deselect all';
|
||||
}
|
||||
|
||||
function openUrl(btn) {
|
||||
vscode.postMessage({ type: 'open', text: btn.dataset.url });
|
||||
}
|
||||
|
||||
window.addEventListener('message', e => {
|
||||
const msg = e.data;
|
||||
if (msg.type === 'manifest') { renderFiles(msg.files, msg.manifestUrl); setStatus('', ''); }
|
||||
if (msg.type === 'manifestError') {
|
||||
document.getElementById('fileList').innerHTML = '<p class="err" style="margin:0;">' + escapeHtml(msg.text) + '</p>';
|
||||
document.getElementById('initBtn').disabled = true;
|
||||
document.getElementById('selectAllBtn').style.display = 'none';
|
||||
}
|
||||
if (msg.type === 'folder') {
|
||||
document.getElementById('destFolder').value = msg.path;
|
||||
const s = vscode.getState() || {};
|
||||
s.destFolder = msg.path;
|
||||
vscode.setState(s);
|
||||
}
|
||||
if (msg.type === 'result') { setStatus(msg.text, msg.ok ? 'ok' : 'err'); }
|
||||
});
|
||||
|
||||
function setStatus(text, cls) {
|
||||
const el = document.getElementById('status');
|
||||
el.textContent = text;
|
||||
el.className = 'status-panel' + (cls ? ' ' + cls : '');
|
||||
}
|
||||
|
||||
refresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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<string>('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);
|
||||
}
|
||||
Binary file not shown.
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
354
standalone/project_init.go
Normal file
354
standalone/project_init.go
Normal file
@@ -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 := `
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Initialize Project</h2>
|
||||
<p class="tool-description">Fetches the shared file list from your team manifest and downloads selected files into the chosen folder.</p>
|
||||
</header>
|
||||
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<h3 class="panel-title">Files</h3>
|
||||
<div style="display:flex;gap:4px;align-items:center;">
|
||||
<span id="manifestSource" class="muted" style="font-size:11px;margin-right:4px;"></span>
|
||||
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Refresh manifest" onclick="refresh()">↻</button>
|
||||
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Configure manifest URL" onclick="toggleSettings()">⚙</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div id="settingsGroup" class="control-group" style="display:none;">
|
||||
<p class="control-label">Manifest URL</p>
|
||||
<div class="field-row">
|
||||
<input type="text" id="manifestUrlInput" value="` + htmlEscape(manifestURL) + `" style="flex:1;" placeholder="https://…" />
|
||||
<button onclick="saveManifestUrl()">Save & Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div id="fileList"></div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<p class="control-label">Download folder</p>
|
||||
<div class="field-row">
|
||||
<input type="text" id="destFolder" value="" placeholder="(no folder selected)" readonly style="flex:1;" />
|
||||
<button class="secondary" onclick="pickFolder()">Browse…</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="initBtn" onclick="initProject()" disabled>Initialize Project</button>
|
||||
<button id="selectAllBtn" class="secondary" onclick="toggleSelectAll()" style="display:none;">Deselect all</button>
|
||||
</div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
let files = [];
|
||||
let allSelected = true;
|
||||
let settingsVisible = false;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function toggleSettings() {
|
||||
settingsVisible = !settingsVisible;
|
||||
document.getElementById('settingsGroup').style.display = settingsVisible ? '' : 'none';
|
||||
}
|
||||
|
||||
async function saveManifestUrl() {
|
||||
const url = document.getElementById('manifestUrlInput').value.trim();
|
||||
await fetch('/api/project-init/setManifestUrl', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
settingsVisible = false;
|
||||
document.getElementById('settingsGroup').style.display = 'none';
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
setStatus('', '');
|
||||
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Loading manifest…</p>';
|
||||
document.getElementById('initBtn').disabled = true;
|
||||
document.getElementById('selectAllBtn').style.display = 'none';
|
||||
try {
|
||||
const r = await fetch('/api/project-init/manifest');
|
||||
const j = await r.json();
|
||||
if (j.error) { renderError(j.error); return; }
|
||||
renderFiles(j.files || [], j.manifestUrl || '');
|
||||
} catch (e) {
|
||||
renderError('Failed to load manifest: ' + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
document.getElementById('fileList').innerHTML = '<p class="err" style="margin:0;">' + escapeHtml(msg) + '</p>';
|
||||
document.getElementById('initBtn').disabled = true;
|
||||
document.getElementById('selectAllBtn').style.display = 'none';
|
||||
document.getElementById('manifestSource').textContent = '';
|
||||
}
|
||||
|
||||
function renderFiles(list, manifestUrl) {
|
||||
files = list;
|
||||
allSelected = true;
|
||||
document.getElementById('manifestSource').textContent =
|
||||
manifestUrl ? '(' + manifestUrl.replace(/^https?:\/\//, '').split('/')[0] + ')' : '';
|
||||
|
||||
if (!list.length) {
|
||||
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Manifest loaded but contains no files.</p>';
|
||||
document.getElementById('initBtn').disabled = true;
|
||||
document.getElementById('selectAllBtn').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = list.map((f, i) =>
|
||||
'<tr>' +
|
||||
'<td style="width:20px;text-align:center;"><input type="checkbox" id="cb_' + i + '" checked /></td>' +
|
||||
'<td class="mono wrap" style="width:20%;">' + escapeHtml(f.filename) + '</td>' +
|
||||
'<td class="muted wrap" style="width:70%;">' + escapeHtml(f.description || '') + '</td>' +
|
||||
'<td style="width:10%;text-align:right;"><button style="min-height:22px;padding:2px 8px;font-size:11px;" onclick="openUrl(' + JSON.stringify(f.url) + ')">Open</button></td>' +
|
||||
'</tr>'
|
||||
).join('');
|
||||
|
||||
document.getElementById('fileList').innerHTML =
|
||||
'<div class="results-panel" style="margin-top:0;overflow-x:hidden;">' +
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr><th style="width:20px;"></th><th style="width:20%;">Filename</th><th style="width:70%;">Description</th><th style="width:10%;"></th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
'</div>';
|
||||
|
||||
document.getElementById('initBtn').disabled = false;
|
||||
document.getElementById('selectAllBtn').style.display = '';
|
||||
document.getElementById('selectAllBtn').textContent = 'Deselect all';
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
allSelected = !allSelected;
|
||||
files.forEach((_, i) => { const cb = document.getElementById('cb_' + i); if (cb) cb.checked = allSelected; });
|
||||
document.getElementById('selectAllBtn').textContent = allSelected ? 'Deselect all' : 'Select all';
|
||||
}
|
||||
|
||||
async function pickFolder() {
|
||||
const r = await fetch('/api/project-init/pickFolder', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) document.getElementById('destFolder').value = j.path;
|
||||
}
|
||||
|
||||
async function openUrl(url) {
|
||||
await fetch('/api/open', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
}
|
||||
|
||||
async function initProject() {
|
||||
const checked = files.filter((_, i) => document.getElementById('cb_' + i)?.checked);
|
||||
if (!checked.length) { setStatus('No files selected.', 'err'); return; }
|
||||
const destFolder = document.getElementById('destFolder').value.trim();
|
||||
if (!destFolder) { setStatus('Please select a destination folder first.', 'err'); return; }
|
||||
const el = document.getElementById('status');
|
||||
el.textContent = 'Downloading ' + checked.length + ' file(s)…';
|
||||
el.className = 'status-panel is-busy';
|
||||
try {
|
||||
const r = await fetch('/api/project-init/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ files: checked, destFolder }),
|
||||
});
|
||||
const j = await r.json();
|
||||
setStatus(j.message, j.ok ? 'ok' : 'err');
|
||||
} catch (e) {
|
||||
setStatus('Error: ' + (e.message || e), 'err');
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(text, cls) {
|
||||
const el = document.getElementById('status');
|
||||
el.textContent = text;
|
||||
el.className = 'status-panel' + (cls ? ' ' + cls : '');
|
||||
}
|
||||
|
||||
refresh();
|
||||
</script>
|
||||
`
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
10
tools.json
10
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
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user