From 443276bc667aff0a4b06cda8ac13305ede1ad169 Mon Sep 17 00:00:00 2001 From: devld Date: Sun, 9 Aug 2026 10:37:26 +0800 Subject: [PATCH] feat(script): add versioned script drive updates Parse version metadata from script headers, fetch remote script content during repository refresh, and expose update actions in the admin UI. Reload the root drive after installation so active script-drive instances use updated code without uninstalling first. Keep legacy scripts without versions compatible and document the metadata format. Co-Authored-By: codex --- docs/script-drive-template.js | 1 + docs/site/extensions/script-drives.md | 10 ++ docs/site/zh-CN/extensions/script-drives.md | 12 +- drive/script/script_utils.go | 117 +++++++++++--------- drive/script/script_utils_test.go | 114 +++++++++++++++++++ script-drives/AGENTS.md | 3 + script-drives/dropbox.js | 1 + script-drives/qiniu.js | 1 + server/api_admin.go | 2 +- server/api_admin_drives.go | 10 +- web/src/i18n/lang/en-US.json | 1 + web/src/i18n/lang/ko-KR.json | 1 + web/src/i18n/lang/zh-CN.json | 1 + web/src/types/model/admin.ts | 4 + web/src/views/Admin/ExtraDrives/index.vue | 42 ++++++- 15 files changed, 264 insertions(+), 56 deletions(-) create mode 100644 drive/script/script_utils_test.go diff --git a/docs/script-drive-template.js b/docs/script-drive-template.js index 7d9e4899..0e76ae7b 100644 --- a/docs/script-drive-template.js +++ b/docs/script-drive-template.js @@ -1,4 +1,5 @@ // My Drive +// @version 1.0.0 // > Here and below is the drive's description // > It supports `markdown` // > It will be shown above the configuration form diff --git a/docs/site/extensions/script-drives.md b/docs/site/extensions/script-drives.md index 668b2634..67753df3 100644 --- a/docs/site/extensions/script-drives.md +++ b/docs/site/extensions/script-drives.md @@ -41,6 +41,16 @@ A custom repository returns an array in the style of the GitHub Contents API, wi ] ``` +The server script can declare a version in its leading comments: + +```js +// Example Cloud +// @version 1.0.0 +// Example Cloud REST API adapter. +``` + +When the repository is refreshed, go-drive downloads each server script to read its display name, description, and version. The management page uses this version to offer an update without uninstalling the script Drive. If the uploader changes, also bump the server script version. + After installation, create the corresponding type on the Drive management page and reload the Drives. ## Development entry points diff --git a/docs/site/zh-CN/extensions/script-drives.md b/docs/site/zh-CN/extensions/script-drives.md index c8bac8b6..22e6c28f 100644 --- a/docs/site/zh-CN/extensions/script-drives.md +++ b/docs/site/zh-CN/extensions/script-drives.md @@ -3,7 +3,7 @@ title: 脚本 Drive 开发与安装 description: 安装第三方脚本 Drive,或使用 JavaScript 开发 go-drive 存储适配器和浏览器直传集成。 lang: zh-CN translation_key: script-drives -source_hash: 22245c0b56314dd215cb21cc5940e240d8b8297e041d50f846899d9237165854 +source_hash: 3a69abcd8810baca58e4dcd5d03bc38f5c411909f4bb68e74ce15ce824d59e99 --- # 脚本 Drive 开发与安装 @@ -42,6 +42,16 @@ drive-repository-url: https://example.com/my-drives.json ] ``` +服务器端脚本可以在开头注释中声明版本号: + +```js +// Example Cloud +// @version 1.0.0 +// Example Cloud REST API adapter. +``` + +刷新仓库时,go-drive 会下载每个服务器端脚本,解析显示名、说明和版本号。管理页会根据版本号提供更新按钮,无需先卸载脚本 Drive。如果上传器发生变化,也需要同步提升服务器端脚本版本号。 + 安装后在 Drive 管理页创建对应类型并重新加载。 ## 开发入口 diff --git a/drive/script/script_utils.go b/drive/script/script_utils.go index a066bb6a..aba2c0be 100644 --- a/drive/script/script_utils.go +++ b/drive/script/script_utils.go @@ -23,6 +23,9 @@ type driveRepositoryListResp struct { type AvailableDriveScript struct { Name string `json:"name"` + DisplayName string `json:"displayName,omitempty"` + Description string `json:"description,omitempty"` + Version string `json:"version,omitempty"` DriveURL string `json:"driveUrl"` DriveUploaderURL string `json:"driveUploaderUrl,omitempty"` } @@ -55,9 +58,20 @@ func ListAvailableScriptsFromRepository(ctx context.Context, repoURL string) ([] continue } name := strings.TrimSuffix(item.Name, ".js") + content, e := downloadScriptContent(ctx, item.DownloadURL) + if e != nil { + return nil, e + } + meta, e := parseDriveScriptMeta(content, item.Name) + if e != nil { + return nil, e + } resultItem := AvailableDriveScript{ - Name: name, - DriveURL: item.DownloadURL, + Name: name, + DisplayName: meta.DisplayName, + Description: meta.Description, + Version: meta.Version, + DriveURL: item.DownloadURL, } if uploaderItem, ok := itemsMap[name+"-uploader.js"]; ok { resultItem.DriveUploaderURL = uploaderItem.DownloadURL @@ -71,6 +85,7 @@ type DriveScript struct { // Name is the script name without `.js`` suffix Name string `json:"name"` DisplayName string `json:"displayName"` + Version string `json:"version,omitempty"` Description string `json:"description"` } @@ -145,21 +160,31 @@ func InstallDriveScript(ctx context.Context, config common.Config, s AvailableDr return err.NewBadRequestError("invalid installation request") } - drivesDir, e := config.GetDir(config.DrivesDir, true) + driveContent, e := downloadScriptContent(ctx, s.DriveURL) if e != nil { return e } - e = downloadFile(ctx, s.DriveURL, filepath.Join(drivesDir, s.Name+".js")) + var uploaderContent []byte + if s.DriveUploaderURL != "" { + uploaderContent, e = downloadScriptContent(ctx, s.DriveUploaderURL) + if e != nil { + return e + } + } + + drivesDir, e := config.GetDir(config.DrivesDir, true) if e != nil { return e } + if e = os.WriteFile(filepath.Join(drivesDir, s.Name+".js"), driveContent, 0644); e != nil { + return e + } if s.DriveUploaderURL != "" { driveUploadersDir, e := config.GetDir(config.DriveUploadersDir, true) if e != nil { return e } - e = downloadFile(ctx, s.DriveUploaderURL, filepath.Join(driveUploadersDir, s.Name+".js")) - if e != nil { + if e = os.WriteFile(filepath.Join(driveUploadersDir, s.Name+".js"), uploaderContent, 0644); e != nil { return e } } @@ -192,23 +217,20 @@ func UninstallDriveScript(config common.Config, name string) error { return nil } -func downloadFile(ctx context.Context, url string, name string) error { +func downloadScriptContent(ctx context.Context, url string) ([]byte, error) { req, e := http.NewRequestWithContext(ctx, "GET", url, nil) if e != nil { - return e + return nil, e } resp, e := http.DefaultClient.Do(req) if e != nil { - return e + return nil, e } defer func() { _ = resp.Body.Close() }() - f, e := os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) - if e != nil { - return e + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, err.NewRemoteApiError(resp.StatusCode, "failed to download script") } - defer func() { _ = f.Close() }() - _, e = io.Copy(f, resp.Body) - return e + return io.ReadAll(resp.Body) } func ListDriveScripts(config common.Config) ([]DriveScript, error) { @@ -234,51 +256,46 @@ func ListDriveScripts(config common.Config) ([]DriveScript, error) { func readDriveScriptMeta(file string, config common.Config) (DriveScript, error) { scriptsPath, _ := config.GetDir(config.DrivesDir, false) - scriptFile, e := os.Open(filepath.Join(scriptsPath, file)) + content, e := os.ReadFile(filepath.Join(scriptsPath, file)) if e != nil { return DriveScript{}, e } - defer func() { - _ = scriptFile.Close() - }() - r := bufio.NewReader(scriptFile) - name := readMetaValue(r, true, file) - description := readMetaValue(r, false, "") - return DriveScript{ - Name: strings.TrimSuffix(file, ".js"), - DisplayName: name, - Description: description, - }, nil + return parseDriveScriptMeta(content, file) } -var metaPrefixRegexp = regexp.MustCompile(`^\s*//\s*`) +var ( + metaPrefixRegexp = regexp.MustCompile(`^\s*//\s?`) + versionMetaRegexp = regexp.MustCompile(`(?i)^(?:@version|version)\s*:?\s*(\S+)\s*$`) +) -func readMetaValue(r *bufio.Reader, oneLine bool, def string) string { - sb := strings.Builder{} - for { - line, e := r.ReadBytes('\n') - if e != nil { +func parseDriveScriptMeta(content []byte, file string) (DriveScript, error) { + scanner := bufio.NewScanner(bytes.NewReader(content)) + lines := make([]string, 0, 8) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "//") { break } - if !bytes.HasPrefix(line, []byte("//")) { - break - } - - temp := strings.TrimSpace(string(metaPrefixRegexp.ReplaceAll(line, []byte{}))) - sb.WriteString(temp) + lines = append(lines, strings.TrimSpace(metaPrefixRegexp.ReplaceAllString(line, ""))) + } + if e := scanner.Err(); e != nil { + return DriveScript{}, e + } - if oneLine { - break - } + name := strings.TrimSuffix(filepath.Base(file), ".js") + meta := DriveScript{Name: name, DisplayName: name} + if len(lines) > 0 && lines[0] != "" { + meta.DisplayName = lines[0] + } - if len(bytes.TrimSpace(line)) == 0 { - break + description := make([]string, 0, len(lines)) + for _, line := range lines[1:] { + if match := versionMetaRegexp.FindStringSubmatch(line); match != nil { + meta.Version = match[1] + continue } - - sb.WriteRune('\n') - } - if sb.Len() == 0 { - return def + description = append(description, line) } - return sb.String() + meta.Description = strings.TrimSpace(strings.Join(description, "\n")) + return meta, nil } diff --git a/drive/script/script_utils_test.go b/drive/script/script_utils_test.go new file mode 100644 index 00000000..66fcd728 --- /dev/null +++ b/drive/script/script_utils_test.go @@ -0,0 +1,114 @@ +package script + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "testing" + + "go-drive/common" +) + +func TestParseDriveScriptMeta(t *testing.T) { + meta, e := parseDriveScriptMeta([]byte("// Example Cloud\n// @version 1.2.3\n// Example description.\n//\n// More details.\n\nfunction example() {}\n"), "example.js") + if e != nil { + t.Fatalf("parseDriveScriptMeta() error = %v", e) + } + + want := DriveScript{ + Name: "example", + DisplayName: "Example Cloud", + Version: "1.2.3", + Description: "Example description.\n\nMore details.", + } + if !reflect.DeepEqual(meta, want) { + t.Fatalf("parseDriveScriptMeta() = %#v, want %#v", meta, want) + } +} + +func TestParseDriveScriptMetaKeepsLegacyScriptsCompatible(t *testing.T) { + meta, e := parseDriveScriptMeta([]byte("// Legacy Drive\n// Legacy description\n\nfunction legacy() {}\n"), "legacy.js") + if e != nil { + t.Fatalf("parseDriveScriptMeta() error = %v", e) + } + + if meta.Name != "legacy" || meta.DisplayName != "Legacy Drive" || + meta.Version != "" || meta.Description != "Legacy description" { + t.Fatalf("unexpected legacy metadata: %#v", meta) + } +} + +func TestListAvailableScriptsFromRepositoryReadsScriptMetadata(t *testing.T) { + mux := http.NewServeMux() + var server *httptest.Server + mux.HandleFunc("/repo", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode([]driveRepositoryListResp{ + {Name: "example.js", DownloadURL: server.URL + "/example.js"}, + {Name: "example-uploader.js", DownloadURL: server.URL + "/example-uploader.js"}, + }) + }) + mux.HandleFunc("/example.js", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("// Example Cloud\n// @version 2.0.0\n// Remote description\n\nfunction example() {}\n")) + }) + mux.HandleFunc("/example-uploader.js", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("function uploader() {}\n")) + }) + server = httptest.NewServer(mux) + defer server.Close() + + items, e := ListAvailableScriptsFromRepository(context.Background(), server.URL+"/repo") + if e != nil { + t.Fatalf("ListAvailableScriptsFromRepository() error = %v", e) + } + + want := []AvailableDriveScript{{ + Name: "example", + DisplayName: "Example Cloud", + Description: "Remote description", + Version: "2.0.0", + }} + if len(items) != 1 { + t.Fatalf("got %d available scripts, want 1", len(items)) + } + if items[0].Name != want[0].Name || items[0].DisplayName != want[0].DisplayName || + items[0].Description != want[0].Description || items[0].Version != want[0].Version { + t.Fatalf("metadata = %#v, want %#v", items[0], want[0]) + } +} + +func TestListDriveScriptsReadsVersion(t *testing.T) { + dataDir := t.TempDir() + config := common.Config{DataDir: dataDir, DrivesDir: "script-drives"} + drivesDir := filepath.Join(dataDir, config.DrivesDir) + if e := os.MkdirAll(drivesDir, 0755); e != nil { + t.Fatal(e) + } + if e := os.WriteFile(filepath.Join(drivesDir, "example.js"), []byte("// Example\n// Version: 3.0.0\n// Description\n\nfunction example() {}\n"), 0644); e != nil { + t.Fatal(e) + } + + items, e := ListDriveScripts(config) + if e != nil { + t.Fatalf("ListDriveScripts() error = %v", e) + } + if len(items) != 1 || items[0].Version != "3.0.0" { + t.Fatalf("installed metadata = %#v", items) + } +} + +func TestDownloadScriptContentRejectsNonSuccessStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + if _, e := downloadScriptContent(context.Background(), server.URL); e == nil { + t.Fatal("downloadScriptContent() error = nil, want error") + } +} diff --git a/script-drives/AGENTS.md b/script-drives/AGENTS.md index 997f474e..91f0d55a 100644 --- a/script-drives/AGENTS.md +++ b/script-drives/AGENTS.md @@ -70,6 +70,7 @@ Use a stable, short, lowercase identifier for ``. Files with the same base ```js // Example Cloud +// @version 1.0.0 // Example Cloud REST API adapter. // // Create an API token with file read/write permissions. @@ -78,6 +79,7 @@ Use a stable, short, lowercase identifier for ``. Files with the same base ``` - The first line is the display name shown in the UI. +- An optional `// @version 1.0.0` line declares the script version. The main server script version is the version of the extension; bump it when the uploader changes. - Following `//` lines, up to the empty comment line, are the Markdown description. - The `reference` directive provides editor completion only; it does not change runtime behavior. - After saving a script, create or reload the Drive from the administration UI. @@ -395,6 +397,7 @@ It demonstrates the interface contract and does not represent a real service: ```js // Example REST Drive +// @version 1.0.0 // Example of a complete HTTP API based adapter. // // Enter the API endpoint and a token with file read/write permissions. diff --git a/script-drives/dropbox.js b/script-drives/dropbox.js index 8de0756d..6133dea8 100644 --- a/script-drives/dropbox.js +++ b/script-drives/dropbox.js @@ -1,4 +1,5 @@ // Dropbox +// @version 1.0.0 // Dropbox drive /// diff --git a/script-drives/qiniu.js b/script-drives/qiniu.js index fd0343e5..e8dfad33 100644 --- a/script-drives/qiniu.js +++ b/script-drives/qiniu.js @@ -1,4 +1,5 @@ // Qiniu +// @version 1.0.0 // Qiniu Kodo /// diff --git a/server/api_admin.go b/server/api_admin.go index b9e4a057..41e90c76 100644 --- a/server/api_admin.go +++ b/server/api_admin.go @@ -122,7 +122,7 @@ func InitAdminRoutes( // region script drives scriptDriveRoutesGroup := r.Group("/drive-scripts") - sdr := &scriptDrivesRoute{config: config} + sdr := &scriptDrivesRoute{config: config, rootDrive: rootDrive} // get available drives from repository scriptDriveRoutesGroup.GET("/available", sdr.getAvailableDrives) // get installed drives diff --git a/server/api_admin_drives.go b/server/api_admin_drives.go index e93ecbe9..d85211b2 100644 --- a/server/api_admin_drives.go +++ b/server/api_admin_drives.go @@ -139,8 +139,9 @@ func (dr *drivesRoute) reloadDrives(c *gin.Context) { } type scriptDrivesRoute struct { - config common.Config - repoLock sync.Mutex + config common.Config + rootDrive *drive.RootDrive + repoLock sync.Mutex } func (sdr *scriptDrivesRoute) _loadAvailableDriveScripts(ctx context.Context, forceLoad bool) ([]script.AvailableDriveScript, error) { @@ -214,6 +215,11 @@ func (sdr *scriptDrivesRoute) installDrive(c *gin.Context) { _ = c.Error(e) return } + if sdr.rootDrive != nil { + if e := sdr.rootDrive.ReloadDrive(c.Request.Context(), false); e != nil { + _ = c.Error(e) + } + } } func (sdr *scriptDrivesRoute) uninstallDrive(c *gin.Context) { diff --git a/web/src/i18n/lang/en-US.json b/web/src/i18n/lang/en-US.json index 776967b3..65c784e1 100644 --- a/web/src/i18n/lang/en-US.json +++ b/web/src/i18n/lang/en-US.json @@ -159,6 +159,7 @@ "scripts": "Scripts", "ops": "Operations", "install": "Install", + "update": "Update", "uninstall": "Remove", "edit": "Edit", "uninstall_confirm": "Confirm deletion?", diff --git a/web/src/i18n/lang/ko-KR.json b/web/src/i18n/lang/ko-KR.json index d409d97d..644d07d2 100644 --- a/web/src/i18n/lang/ko-KR.json +++ b/web/src/i18n/lang/ko-KR.json @@ -159,6 +159,7 @@ "scripts": "스크립트", "ops": "작업", "install": "설치", + "update": "업데이트", "uninstall": "제거", "edit": "편집", "uninstall_confirm": "삭제하시겠습니까?", diff --git a/web/src/i18n/lang/zh-CN.json b/web/src/i18n/lang/zh-CN.json index 4d861a0d..82e1fe86 100644 --- a/web/src/i18n/lang/zh-CN.json +++ b/web/src/i18n/lang/zh-CN.json @@ -159,6 +159,7 @@ "scripts": "脚本", "ops": "操作", "install": "安装", + "update": "更新", "uninstall": "删除", "edit": "编辑", "uninstall_confirm": "确认删除?", diff --git a/web/src/types/model/admin.ts b/web/src/types/model/admin.ts index 6c1bcd3d..49b97ccc 100644 --- a/web/src/types/model/admin.ts +++ b/web/src/types/model/admin.ts @@ -130,12 +130,16 @@ export interface JobExecution { export interface AvailableDriveScript { name: string driveUrl: string + displayName?: string + description?: string + version?: string driveUploaderUrl?: string } export interface InstalledDriveScript { name: string displayName: string + version?: string description?: string } diff --git a/web/src/views/Admin/ExtraDrives/index.vue b/web/src/views/Admin/ExtraDrives/index.vue index 6b67ac3b..6d7c300d 100644 --- a/web/src/views/Admin/ExtraDrives/index.vue +++ b/web/src/views/Admin/ExtraDrives/index.vue @@ -30,6 +30,9 @@ @click="showScriptDetail(item)" >{{ formatName(item) }} +
+ {{ versionText(item) }} +
+ { const result: DriveScript[] = [] installed.forEach((e) => { + const available = availableMap[e.name] result.push({ name: e.name, displayName: e.displayName, description: e.description, + version: e.version, installed: true, - script: availableMap[e.name], + script: available, }) }) @@ -181,6 +195,9 @@ const loadData = async (force?: boolean) => { if (installedMap[e.name]) return result.push({ name: e.name, + displayName: e.displayName, + description: e.description, + version: e.version, installed: false, script: e, }) @@ -198,7 +215,7 @@ const doInstall = async (item: DriveScript) => { item.loading = true try { await installDriveScript(item.script!.name) - loadData() + await loadData() } catch (e: any) { alert(e.message) } finally { @@ -242,6 +259,22 @@ const formatName = (item: DriveScript) => { return item.name } +const hasUpdate = (item: DriveScript) => { + return ( + item.installed && + !!item.script?.version && + item.script.version !== item.version + ) +} + +const versionText = (item: DriveScript) => { + if (hasUpdate(item)) { + return `v${item.version || '?'} → v${item.script!.version}` + } + const version = item.version || item.script?.version + return version ? `v${version}` : '' +} + const editDrive = (item: DriveScript) => { edit.name = item.name edit.showing = true @@ -273,6 +306,11 @@ loadData() } } + .script-drive-version { + color: var(--color-text-secondary); + font-size: 0.85em; + } + .script-drive-url { max-width: 40vw; white-space: nowrap;