355 lines
12 KiB
Go
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()">↻</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),
|
|
})
|
|
}
|
|
}
|