Files
vsix-hpl-toolbox/standalone/project_init.go
2026-06-22 11:45:36 +08:00

355 lines
12 KiB
Go

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()">&#8635;</button>
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Configure manifest URL" onclick="toggleSettings()">&#9881;</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 &amp; 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&#8230;</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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&#8230;</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, "&", "&amp;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
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),
})
}
}