diff --git a/kernel/api/bazaar.go b/kernel/api/bazaar.go index 630c2f7d3..62087f010 100644 --- a/kernel/api/bazaar.go +++ b/kernel/api/bazaar.go @@ -41,6 +41,71 @@ func getBazaarPackageREAME(c *gin.Context) { } } +func getBazaarPlugin(c *gin.Context) { + ret := gulu.Ret.NewResult() + defer c.JSON(http.StatusOK, ret) + + ret.Data = map[string]interface{}{ + "packages": model.BazaarPlugins(), + } +} + +func getInstalledPlugin(c *gin.Context) { + ret := gulu.Ret.NewResult() + defer c.JSON(http.StatusOK, ret) + + ret.Data = map[string]interface{}{ + "packages": model.InstalledPlugins(), + } +} + +func installBazaarPlugin(c *gin.Context) { + ret := gulu.Ret.NewResult() + defer c.JSON(http.StatusOK, ret) + + arg, ok := util.JsonArg(c, ret) + if !ok { + return + } + + repoURL := arg["repoURL"].(string) + repoHash := arg["repoHash"].(string) + packageName := arg["packageName"].(string) + err := model.InstallBazaarPlugin(repoURL, repoHash, packageName) + if nil != err { + ret.Code = 1 + ret.Msg = err.Error() + return + } + + util.PushMsg(model.Conf.Language(69), 3000) + ret.Data = map[string]interface{}{ + "packages": model.BazaarPlugins(), + } +} + +func uninstallBazaarPlugin(c *gin.Context) { + ret := gulu.Ret.NewResult() + defer c.JSON(http.StatusOK, ret) + + arg, ok := util.JsonArg(c, ret) + if !ok { + return + } + + packageName := arg["packageName"].(string) + err := model.UninstallBazaarPlugin(packageName) + if nil != err { + ret.Code = -1 + ret.Msg = err.Error() + return + } + + ret.Data = map[string]interface{}{ + "packages": model.BazaarPlugins(), + } +} + func getBazaarWidget(c *gin.Context) { ret := gulu.Ret.NewResult() defer c.JSON(http.StatusOK, ret) diff --git a/kernel/api/router.go b/kernel/api/router.go index 3871daa51..11f69abb7 100644 --- a/kernel/api/router.go +++ b/kernel/api/router.go @@ -275,6 +275,10 @@ func ServeAPI(ginServer *gin.Engine) { ginServer.Handle("POST", "/api/graph/getGraph", model.CheckAuth, getGraph) ginServer.Handle("POST", "/api/graph/getLocalGraph", model.CheckAuth, getLocalGraph) + ginServer.Handle("POST", "/api/bazaar/getBazaarPlugin", model.CheckAuth, getBazaarPlugin) + ginServer.Handle("POST", "/api/bazaar/getInstalledPlugin", model.CheckAuth, getInstalledPlugin) + ginServer.Handle("POST", "/api/bazaar/installBazaarPlugin", model.CheckAuth, model.CheckReadonly, installBazaarPlugin) + ginServer.Handle("POST", "/api/bazaar/uninstallBazaarPlugin", model.CheckAuth, model.CheckReadonly, uninstallBazaarPlugin) ginServer.Handle("POST", "/api/bazaar/getBazaarWidget", model.CheckAuth, getBazaarWidget) ginServer.Handle("POST", "/api/bazaar/getInstalledWidget", model.CheckAuth, getInstalledWidget) ginServer.Handle("POST", "/api/bazaar/installBazaarWidget", model.CheckAuth, model.CheckReadonly, installBazaarWidget) diff --git a/kernel/bazaar/package.go b/kernel/bazaar/package.go index 4b214ba3a..bf8ab61e2 100644 --- a/kernel/bazaar/package.go +++ b/kernel/bazaar/package.go @@ -66,6 +66,28 @@ type Package struct { Downloads int `json:"downloads"` } +func PluginJSON(pluginDirName string) (ret map[string]interface{}, err error) { + p := filepath.Join(util.DataDir, "plugins", pluginDirName, "plugin.json") + if !gulu.File.IsExist(p) { + err = os.ErrNotExist + return + } + data, err := os.ReadFile(p) + if nil != err { + logging.LogErrorf("read plugin.json [%s] failed: %s", p, err) + return + } + if err = gulu.JSON.UnmarshalJSON(data, &ret); nil != err { + logging.LogErrorf("parse plugin.json [%s] failed: %s", p, err) + return + } + if 4 > len(ret) { + logging.LogWarnf("invalid plugin.json [%s]", p) + return nil, errors.New("invalid plugin.json") + } + return +} + func WidgetJSON(widgetDirName string) (ret map[string]interface{}, err error) { p := filepath.Join(util.DataDir, "widgets", widgetDirName, "widget.json") if !gulu.File.IsExist(p) { @@ -216,6 +238,26 @@ func isOutdatedIcon(icon *Icon, bazaarIcons []*Icon) bool { return false } +func isOutdatedPlugin(plugin *Plugin, bazaarPlugins []*Plugin) bool { + if !strings.HasPrefix(plugin.URL, "https://github.com/") { + return false + } + + repo := strings.TrimPrefix(plugin.URL, "https://github.com/") + parts := strings.Split(repo, "/") + if 2 != len(parts) || "" == strings.TrimSpace(parts[1]) { + return false + } + + for _, pkg := range bazaarPlugins { + if plugin.URL == pkg.URL && plugin.Name == pkg.Name && plugin.Author == pkg.Author && plugin.Version < pkg.Version { + plugin.RepoHash = pkg.RepoHash + return true + } + } + return false +} + func isOutdatedWidget(widget *Widget, bazaarWidgets []*Widget) bool { if !strings.HasPrefix(widget.URL, "https://github.com/") { return false diff --git a/kernel/bazaar/plugin.go b/kernel/bazaar/plugin.go new file mode 100644 index 000000000..6a2f665c4 --- /dev/null +++ b/kernel/bazaar/plugin.go @@ -0,0 +1,169 @@ +// SiYuan - Build Your Eternal Digital Garden +// Copyright (c) 2020-present, b3log.org +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package bazaar + +import ( + "errors" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/dustin/go-humanize" + ants "github.com/panjf2000/ants/v2" + "github.com/siyuan-note/httpclient" + "github.com/siyuan-note/logging" + "github.com/siyuan-note/siyuan/kernel/util" +) + +type Plugin struct { + Package +} + +func Plugins() (plugins []*Plugin) { + plugins = []*Plugin{} + + pkgIndex, err := getPkgIndex("plugins") + if nil != err { + return + } + bazaarIndex := getBazaarIndex() + + repos := pkgIndex["repos"].([]interface{}) + waitGroup := &sync.WaitGroup{} + lock := &sync.Mutex{} + p, _ := ants.NewPoolWithFunc(8, func(arg interface{}) { + defer waitGroup.Done() + + repo := arg.(map[string]interface{}) + repoURL := repo["url"].(string) + + plugin := &Plugin{} + innerU := util.BazaarOSSServer + "/package/" + repoURL + "/plugin.json" + innerResp, innerErr := httpclient.NewBrowserRequest().SetSuccessResult(plugin).Get(innerU) + if nil != innerErr { + logging.LogErrorf("get bazaar package [%s] failed: %s", repoURL, innerErr) + return + } + if 200 != innerResp.StatusCode { + logging.LogErrorf("get bazaar package [%s] failed: %d", innerU, innerResp.StatusCode) + return + } + plugin.URL = strings.TrimSuffix(plugin.URL, "/") + + repoURLHash := strings.Split(repoURL, "@") + plugin.RepoURL = "https://github.com/" + repoURLHash[0] + plugin.RepoHash = repoURLHash[1] + plugin.PreviewURL = util.BazaarOSSServer + "/package/" + repoURL + "/preview.png?imageslim" + plugin.PreviewURLThumb = util.BazaarOSSServer + "/package/" + repoURL + "/preview.png?imageView2/2/w/436/h/232" + plugin.Updated = repo["updated"].(string) + plugin.Stars = int(repo["stars"].(float64)) + plugin.OpenIssues = int(repo["openIssues"].(float64)) + plugin.Size = int64(repo["size"].(float64)) + plugin.HSize = humanize.Bytes(uint64(plugin.Size)) + plugin.HUpdated = formatUpdated(plugin.Updated) + pkg := bazaarIndex[strings.Split(repoURL, "@")[0]] + if nil != pkg { + plugin.Downloads = pkg.Downloads + } + lock.Lock() + plugins = append(plugins, plugin) + lock.Unlock() + }) + for _, repo := range repos { + waitGroup.Add(1) + p.Invoke(repo) + } + waitGroup.Wait() + p.Release() + + sort.Slice(plugins, func(i, j int) bool { return plugins[i].Updated > plugins[j].Updated }) + return +} + +func InstalledPlugins() (ret []*Plugin) { + ret = []*Plugin{} + pluginDirs, err := os.ReadDir(filepath.Join(util.DataDir, "plugins")) + if nil != err { + logging.LogWarnf("read plugins folder failed: %s", err) + return + } + + bazaarPlugins := Plugins() + + for _, pluginDir := range pluginDirs { + if !pluginDir.IsDir() { + continue + } + dirName := pluginDir.Name() + + pluginConf, parseErr := PluginJSON(dirName) + if nil != parseErr || nil == pluginConf { + continue + } + + installPath := filepath.Join(util.DataDir, "plugins", dirName) + + plugin := &Plugin{} + plugin.Installed = true + plugin.Name = pluginConf["name"].(string) + plugin.Author = pluginConf["author"].(string) + plugin.URL = pluginConf["url"].(string) + plugin.URL = strings.TrimSuffix(plugin.URL, "/") + plugin.Version = pluginConf["version"].(string) + plugin.RepoURL = plugin.URL + plugin.PreviewURL = "/plugins/" + dirName + "/preview.png" + plugin.PreviewURLThumb = "/plugins/" + dirName + "/preview.png" + info, statErr := os.Stat(filepath.Join(installPath, "README.md")) + if nil != statErr { + logging.LogWarnf("stat install theme README.md failed: %s", statErr) + continue + } + plugin.HInstallDate = info.ModTime().Format("2006-01-02") + installSize, _ := util.SizeOfDirectory(installPath) + plugin.InstallSize = installSize + plugin.HInstallSize = humanize.Bytes(uint64(installSize)) + readme, readErr := os.ReadFile(filepath.Join(installPath, "README.md")) + if nil != readErr { + logging.LogWarnf("read install plugin README.md failed: %s", readErr) + continue + } + plugin.README, _ = renderREADME(plugin.URL, readme) + plugin.Outdated = isOutdatedPlugin(plugin, bazaarPlugins) + ret = append(ret, plugin) + } + return +} + +func InstallPlugin(repoURL, repoHash, installPath string, systemID string) error { + repoURLHash := repoURL + "@" + repoHash + data, err := downloadPackage(repoURLHash, true, systemID) + if nil != err { + return err + } + return installPackage(data, installPath) +} + +func UninstallPlugin(installPath string) error { + if err := os.RemoveAll(installPath); nil != err { + logging.LogErrorf("remove plugin [%s] failed: %s", installPath, err) + return errors.New("remove community plugin failed") + } + //logging.Logger.Infof("uninstalled plugin [%s]", installPath) + return nil +} diff --git a/kernel/model/bazzar.go b/kernel/model/bazzar.go index 51175f414..a3130e274 100644 --- a/kernel/model/bazzar.go +++ b/kernel/model/bazzar.go @@ -32,6 +32,46 @@ func GetPackageREADME(repoURL, repoHash string) (ret string) { return } +func BazaarPlugins() (widgets []*bazaar.Widget) { + widgets = bazaar.Widgets() + for _, widget := range widgets { + widget.Installed = gulu.File.IsDir(filepath.Join(util.DataDir, "widgets", widget.Name)) + if widget.Installed { + if widget.Installed { + if widgetConf, err := widgetJSON(widget.Name); nil == err && nil != widget { + if widget.Version != widgetConf["version"].(string) { + widget.Outdated = true + } + } + } + } + } + return +} + +func InstalledPlugins() (plugins []*bazaar.Plugin) { + plugins = bazaar.InstalledPlugins() + return +} + +func InstallBazaarPlugin(repoURL, repoHash, pluginName string) error { + installPath := filepath.Join(util.DataDir, "plugins", pluginName) + err := bazaar.InstallPlugin(repoURL, repoHash, installPath, Conf.System.ID) + if nil != err { + return errors.New(fmt.Sprintf(Conf.Language(46), pluginName)) + } + return nil +} + +func UninstallBazaarPlugin(pluginName string) error { + installPath := filepath.Join(util.DataDir, "plugins", pluginName) + err := bazaar.UninstallPlugin(installPath) + if nil != err { + return errors.New(fmt.Sprintf(Conf.Language(47), err.Error())) + } + return nil +} + func BazaarWidgets() (widgets []*bazaar.Widget) { widgets = bazaar.Widgets() for _, widget := range widgets {