Commit f3f15d28 authored by Mickaël Desfrênes's avatar Mickaël Desfrênes
Browse files

refactor

parent 8168a446
Loading
Loading
Loading
Loading
+2 −1
Original line number Diff line number Diff line
@@ -30,6 +30,7 @@ CHECKSUM_ARTIFACTS := \
	$(DEB_PACKAGE)
RELEASE_CHECKSUMS := $(addsuffix .checksum.txt,$(CHECKSUM_ARTIFACTS))
RELEASE_UPLOAD_FILES := $(RELEASE_ARTIFACTS) $(RELEASE_CHECKSUMS)
GO_SOURCES := $(filter-out %_test.go,$(wildcard *.go))

.PHONY: build test format installer-macos installer-windows installer-deb release-checksums release-upload clean help

@@ -41,7 +42,7 @@ build: ## Build cross-platform binaries into dist (linux/amd64, darwin/arm64, wi

test: ## Run Go tests (including main_test.go explicitly)
	go test -mod=vendor ./...
	go test -mod=vendor main.go i18n.go main_test.go
	go test -mod=vendor $(GO_SOURCES) main_test.go

format: ## Format Go sources
	gofmt -w *.go

archive.go

0 → 100644
+208 −0
Original line number Diff line number Diff line
package main

import (
	"archive/tar"
	"archive/zip"
	"compress/gzip"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
)

func fileExists(path string) bool {
	_, err := os.Stat(path)
	return err == nil
}

func dirExists(path string) bool {
	i, err := os.Stat(path)
	return err == nil && i.IsDir()
}

func touchFile(path string) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o644)
	if err != nil {
		return err
	}
	return f.Close()
}

func copyFile(src, dst string) error {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return err
	}
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	out, err := os.Create(dst)
	if err != nil {
		return err
	}
	return copyAndClose(in, out)
}

func copyDir(src, dst string) error {
	return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		rel, err := filepath.Rel(src, path)
		if err != nil {
			return err
		}
		target := filepath.Join(dst, rel)
		if d.IsDir() {
			return os.MkdirAll(target, 0o755)
		}
		return copyFile(path, target)
	})
}

func extractArchive(src, destination string) error {
	if strings.HasSuffix(strings.ToLower(src), ".zip") {
		return extractZip(src, destination)
	}
	if strings.HasSuffix(strings.ToLower(src), ".tar.gz") || strings.HasSuffix(strings.ToLower(src), ".tgz") {
		return extractTarGz(src, destination)
	}
	return fmt.Errorf(T("err.unsupported_archive"), src)
}

func archiveTarget(destination, entryName string) (string, bool) {
	cleanTarget := filepath.Clean(filepath.Join(destination, entryName))
	cleanDest := filepath.Clean(destination)
	return cleanTarget, cleanTarget == cleanDest || strings.HasPrefix(cleanTarget, cleanDest+string(os.PathSeparator))
}

func extractZip(src, destination string) error {
	r, err := zip.OpenReader(src)
	if err != nil {
		return err
	}
	defer r.Close()
	if err := os.MkdirAll(destination, 0o755); err != nil {
		return err
	}
	for _, f := range r.File {
		cleanTarget, ok := archiveTarget(destination, f.Name)
		if !ok {
			return fmt.Errorf(T("err.invalid_zip_entry"), f.Name)
		}
		if f.FileInfo().IsDir() {
			if err := os.MkdirAll(cleanTarget, effectivePerm(f.Mode(), 0o755)); err != nil {
				return err
			}
			continue
		}
		if err := os.MkdirAll(filepath.Dir(cleanTarget), 0o755); err != nil {
			return err
		}
		in, err := f.Open()
		if err != nil {
			return err
		}
		out, err := os.OpenFile(cleanTarget, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, effectivePerm(f.Mode(), 0o644))
		if err != nil {
			return errors.Join(err, in.Close())
		}
		if err := copyAndClose(in, out); err != nil {
			return errors.Join(err, in.Close())
		}
		if err := in.Close(); err != nil {
			return err
		}
	}
	return nil
}

func extractTarGz(src, destination string) error {
	f, err := os.Open(src)
	if err != nil {
		return err
	}
	defer f.Close()
	gz, err := gzip.NewReader(f)
	if err != nil {
		return err
	}
	defer gz.Close()
	tr := tar.NewReader(gz)
	if err := os.MkdirAll(destination, 0o755); err != nil {
		return err
	}
	for {
		hdr, err := tr.Next()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			return err
		}
		cleanTarget, ok := archiveTarget(destination, hdr.Name)
		if !ok {
			return fmt.Errorf(T("err.invalid_tar_entry"), hdr.Name)
		}
		switch hdr.Typeflag {
		case tar.TypeDir:
			if err := os.MkdirAll(cleanTarget, effectivePerm(hdr.FileInfo().Mode(), 0o755)); err != nil {
				return err
			}
		case tar.TypeReg, tar.TypeRegA:
			if err := os.MkdirAll(filepath.Dir(cleanTarget), 0o755); err != nil {
				return err
			}
			out, err := os.OpenFile(cleanTarget, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, effectivePerm(hdr.FileInfo().Mode(), 0o644))
			if err != nil {
				return err
			}
			if err := copyAndClose(tr, out); err != nil {
				return err
			}
		case tar.TypeSymlink:
			if err := os.MkdirAll(filepath.Dir(cleanTarget), 0o755); err != nil {
				return err
			}
			_ = os.Remove(cleanTarget)
			if err := os.Symlink(hdr.Linkname, cleanTarget); err != nil {
				return err
			}
		case tar.TypeLink:
			cleanLinkTarget, ok := archiveTarget(destination, hdr.Linkname)
			if !ok {
				return fmt.Errorf(T("err.invalid_tar_entry"), hdr.Linkname)
			}
			if err := os.MkdirAll(filepath.Dir(cleanTarget), 0o755); err != nil {
				return err
			}
			_ = os.Remove(cleanTarget)
			if err := os.Link(cleanLinkTarget, cleanTarget); err != nil {
				return err
			}
		}
	}
	return nil
}

func effectivePerm(mode os.FileMode, fallback os.FileMode) os.FileMode {
	perm := mode.Perm()
	if perm == 0 {
		return fallback
	}
	return perm
}

func copyAndClose(in io.Reader, out *os.File) error {
	if _, err := io.Copy(out, in); err != nil {
		return errors.Join(err, out.Close())
	}
	return out.Close()
}

cli.go

0 → 100644
+296 −0
Original line number Diff line number Diff line
package main

import (
	"fmt"

	"github.com/spf13/cobra"
)

func newRootCmd() *cobra.Command {
	lang := currentLocale
	cmd := &cobra.Command{
		Use:           "climax",
		Short:         T("app.short"),
		Long:          T("help.main.long"),
		SilenceUsage:  true,
		SilenceErrors: true,
		PersistentPreRun: func(cmd *cobra.Command, args []string) {
			setLocale(lang)
		},
	}
	cmd.CompletionOptions.DisableDefaultCmd = true
	cmd.PersistentFlags().StringVar(&lang, "lang", lang, T("flag.lang"))

	cmd.AddCommand(newNewCmd())
	cmd.AddCommand(newDemoCmd())
	cmd.AddCommand(newStartCmd())
	cmd.AddCommand(newStopCmd())
	cmd.AddCommand(newInfoCmd())
	cmd.AddCommand(newFreezeCmd())
	cmd.AddCommand(newBundlesListCmd())
	cmd.AddCommand(newBundlesAddCmd())
	cmd.AddCommand(newBundlesRemoveCmd())
	cmd.AddCommand(newFeedCmd())
	cmd.AddCommand(newSyncCmd())
	cmd.AddCommand(newBasexCmd())
	cmd.AddCommand(newTemplatesListCmd())
	cmd.AddCommand(newStaticListCmd())
	cmd.AddCommand(newProjectsCmd())
	cmd.AddCommand(newCacheClearCmd())
	cmd.AddCommand(newVersionCmd())
	cmd.SetHelpFunc(renderHelp)
	cmd.SetUsageFunc(func(c *cobra.Command) error {
		renderHelp(c, []string{})
		return nil
	})

	return cmd
}

func newNewCmd() *cobra.Command {
	interactive := false
	cmd := &cobra.Command{
		Use:   "new [directory]",
		Short: T("cmd.new.short"),
		RunE: func(cmd *cobra.Command, args []string) error {
			directory := cwd()
			if len(args) > 0 {
				directory = args[0]
			}
			return runNew(directory, interactive)
		},
	}
	cmd.Flags().BoolVar(&interactive, "interactive", false, T("flag.new.interactive"))
	return cmd
}

func newDemoCmd() *cobra.Command {
	directory := cwd()
	cmd := &cobra.Command{
		Use:   "demo",
		Short: T("cmd.demo.short"),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runDemo(directory)
		},
	}
	cmd.Flags().StringVar(&directory, "directory", directory, T("flag.common.directory"))
	return cmd
}

func newStartCmd() *cobra.Command {
	httpHost := webHost
	httpPort := webPort
	basexPort := baseXPort
	httpStopPort := stopPort
	service := false
	directory := cwd()
	cmd := &cobra.Command{
		Use:   "start",
		Short: T("cmd.start.short"),
		RunE: func(cmd *cobra.Command, args []string) error {
			_, err := startHTTPServer(httpHost, httpPort, basexPort, httpStopPort, service, directory, true)
			return err
		},
	}
	cmd.Flags().StringVar(&httpHost, "http-host", httpHost, T("flag.start.http_host"))
	cmd.Flags().IntVar(&httpPort, "http-port", httpPort, T("flag.start.http_port"))
	cmd.Flags().IntVar(&basexPort, "basex-port", basexPort, T("flag.start.basex_port"))
	cmd.Flags().IntVar(&httpStopPort, "http-stop-port", httpStopPort, T("flag.start.http_stop_port"))
	cmd.Flags().BoolVar(&service, "service", false, T("flag.start.service"))
	cmd.Flags().StringVar(&directory, "directory", directory, T("flag.common.directory"))
	return cmd
}

func newStopCmd() *cobra.Command {
	httpStopPort := stopPort
	directory := cwd()
	cmd := &cobra.Command{
		Use:   "stop",
		Short: T("cmd.stop.short"),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runStop(httpStopPort, directory)
		},
	}
	cmd.Flags().IntVar(&httpStopPort, "http-stop-port", httpStopPort, T("flag.start.http_stop_port"))
	cmd.Flags().StringVar(&directory, "directory", directory, T("flag.common.directory"))
	return cmd
}

func newInfoCmd() *cobra.Command {
	directory := cwd()
	jsonOut := false
	cmd := &cobra.Command{
		Use:   "info",
		Short: T("cmd.info.short"),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runInfo(directory, jsonOut)
		},
	}
	cmd.Flags().StringVar(&directory, "directory", directory, T("flag.common.directory"))
	cmd.Flags().BoolVar(&jsonOut, "json-out", false, T("flag.info.json_out"))
	return cmd
}

func newFreezeCmd() *cobra.Command {
	output := ""
	directory := cwd()
	debug := false
	cmd := &cobra.Command{
		Use:   "freeze",
		Short: T("cmd.freeze.short"),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runFreeze(output, directory, debug)
		},
	}
	cmd.Flags().StringVar(&output, "output", output, T("flag.freeze.output"))
	cmd.Flags().StringVar(&directory, "directory", directory, T("flag.common.directory"))
	cmd.Flags().BoolVar(&debug, "debug", false, T("flag.freeze.debug"))
	return cmd
}

func newBundlesListCmd() *cobra.Command {
	directory := cwd()
	cmd := &cobra.Command{
		Use:   "bundles-list",
		Short: T("cmd.bundles_list.short"),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runBundlesList(directory)
		},
	}
	cmd.Flags().StringVar(&directory, "directory", directory, T("flag.common.directory"))
	return cmd
}

func newBundlesAddCmd() *cobra.Command {
	directory := cwd()
	cmd := &cobra.Command{
		Use:   "bundles-add <bundle-name>",
		Short: T("cmd.bundles_add.short"),
		Args:  cobra.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runBundlesAdd(args[0], directory)
		},
	}
	cmd.Flags().StringVar(&directory, "directory", directory, T("flag.common.directory"))
	return cmd
}

func newBundlesRemoveCmd() *cobra.Command {
	directory := cwd()
	cmd := &cobra.Command{
		Use:   "bundles-remove <bundle-name>",
		Short: T("cmd.bundles_remove.short"),
		Args:  cobra.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runBundlesRemove(args[0], directory)
		},
	}
	cmd.Flags().StringVar(&directory, "directory", directory, T("flag.common.directory"))
	return cmd
}

func newFeedCmd() *cobra.Command {
	directory := cwd()
	verbose := false
	yes := false
	cmd := &cobra.Command{
		Use:   "feed <feed-path>",
		Short: T("cmd.feed.short"),
		Args:  cobra.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runFeed(args[0], directory, verbose, yes)
		},
	}
	cmd.Flags().StringVar(&directory, "directory", directory, T("flag.common.directory"))
	cmd.Flags().BoolVar(&verbose, "verbose", false, T("flag.feed.verbose"))
	cmd.Flags().BoolVarP(&yes, "yes", "y", false, T("flag.feed.yes"))
	return cmd
}

func newSyncCmd() *cobra.Command {
	directory := cwd()
	cmd := &cobra.Command{
		Use:   "sync",
		Short: T("cmd.sync.short"),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runSync(directory)
		},
	}
	cmd.Flags().StringVar(&directory, "directory", directory, T("flag.common.directory"))
	return cmd
}

func newBasexCmd() *cobra.Command {
	directory := cwd()
	verbose := false
	cmd := &cobra.Command{
		Use:   "basex",
		Short: T("cmd.basex.short"),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runBasex(directory, verbose)
		},
	}
	cmd.Flags().StringVar(&directory, "directory", directory, T("flag.common.directory"))
	cmd.Flags().BoolVar(&verbose, "verbose", false, T("flag.basex.verbose"))
	return cmd
}

func newTemplatesListCmd() *cobra.Command {
	directory := cwd()
	cmd := &cobra.Command{
		Use:   "templates-list",
		Short: T("cmd.templates_list.short"),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runTemplatesList(directory)
		},
	}
	cmd.Flags().StringVar(&directory, "directory", directory, T("flag.common.directory"))
	return cmd
}

func newStaticListCmd() *cobra.Command {
	directory := cwd()
	cmd := &cobra.Command{
		Use:   "static-list",
		Short: T("cmd.static_list.short"),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runStaticList(directory)
		},
	}
	cmd.Flags().StringVar(&directory, "directory", directory, T("flag.common.directory"))
	return cmd
}

func newProjectsCmd() *cobra.Command {
	jsonOut := false
	cmd := &cobra.Command{
		Use:   "projects",
		Short: T("cmd.projects.short"),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runProjects(jsonOut)
		},
	}
	cmd.Flags().BoolVar(&jsonOut, "json-out", false, T("flag.projects.json_out"))
	return cmd
}

func newCacheClearCmd() *cobra.Command {
	return &cobra.Command{
		Use:   "cache-clear",
		Short: T("cmd.cache_clear.short"),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runCacheClear()
		},
	}
}

func newVersionCmd() *cobra.Command {
	return &cobra.Command{
		Use:   "version",
		Short: T("cmd.version.short"),
		RunE: func(cmd *cobra.Command, args []string) error {
			fmt.Println(version)
			return nil
		},
	}
}

commands.go

0 → 100644
+506 −0

File added.

Preview size limit exceeded, changes collapsed.

config.go

0 → 100644
+310 −0
Original line number Diff line number Diff line
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"
)

var (
	releasesOnce sync.Once
	releasesData []release
	releasesErr  error
)

func loadProjectConfig(pathToConfig string) (*projectConfig, error) {
	absPath, err := filepath.Abs(pathToConfig)
	if err != nil {
		return nil, err
	}

	var data []byte
	if fileExists(absPath) {
		data, err = os.ReadFile(absPath)
		if err != nil {
			return nil, err
		}
	} else {
		data = []byte(configInitTemplate)
	}

	var cfg configXML
	if err := xml.Unmarshal(data, &cfg); err != nil {
		return nil, err
	}
	if cfg.XMLName.Local != "configuration" && cfg.XMLName.Local != "" {
		return nil, fmt.Errorf(T("err.not_max_config"), absPath)
	}
	if cfg.XMLNS == "" {
		cfg.XMLNS = "http://certic.unicaen.fr/max/ns/1.0"
	}
	if cfg.Env == "" {
		cfg.Env = "dev"
	}
	if cfg.VocabularyBundle == "" {
		cfg.VocabularyBundle = "max-dumb-xml"
	}
	if cfg.Bundles.Bundle == nil {
		cfg.Bundles.Bundle = []bundleNode{}
	}
	if cfg.Languages.Language == nil {
		cfg.Languages.Language = []string{}
	}

	return &projectConfig{Path: absPath, Cfg: cfg}, nil
}

func (pc *projectConfig) write() error {
	pc.Cfg.XMLName = xml.Name{Local: "configuration"}
	out, err := xml.MarshalIndent(pc.Cfg, "", "    ")
	if err != nil {
		return err
	}
	content := append([]byte(xml.Header), out...)
	content = append(content, '\n')
	return os.WriteFile(pc.Path, content, 0o644)
}

func (pc *projectConfig) title() string {
	if strings.TrimSpace(pc.Cfg.Title) == "" {
		return T("msg.untitled")
	}
	return pc.Cfg.Title
}

func (pc *projectConfig) bundlesMap() map[string]bundleConfig {
	vals := map[string]bundleConfig{}
	for _, b := range pc.Cfg.Bundles.Bundle {
		vals[b.Name] = bundleConfig{Name: b.Name, URL: b.URL}
	}
	return vals
}

func sortedMapKeys[V any](m map[string]V) []string {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	return keys
}

func (pc *projectConfig) setBundlesMap(b map[string]bundleConfig) {
	bundles := make([]bundleNode, 0, len(b))
	for _, name := range sortedMapKeys(b) {
		item := b[name]
		bundles = append(bundles, bundleNode{Name: item.Name, URL: item.URL})
	}
	pc.Cfg.Bundles.Bundle = bundles
}

func (pc *projectConfig) maxVersion() map[string]string {
	return map[string]string{"name": pc.Cfg.Version.Name, "url": pc.Cfg.Version.URL}
}

func (pc *projectConfig) setMaxVersion(name, url string) {
	pc.Cfg.Version.Name = name
	pc.Cfg.Version.URL = url
}

func (pc *projectConfig) maxVersionFromMaxDir() string {
	expathPkg := filepath.Join(filepath.Dir(pc.Path), ".max", "resources", "expath-pkg.xml")
	if !fileExists(expathPkg) {
		return ""
	}
	content, err := os.ReadFile(expathPkg)
	if err != nil {
		return ""
	}
	decoder := xml.NewDecoder(bytes.NewReader(content))
	for {
		tok, err := decoder.Token()
		if err != nil {
			break
		}
		if start, ok := tok.(xml.StartElement); ok && start.Name.Local == "package" {
			for _, a := range start.Attr {
				if a.Name.Local == "version" {
					return a.Value
				}
			}
		}
	}
	return ""
}

func (pc *projectConfig) availableBundles() (map[string]availableBundle, error) {
	active := pc.bundlesMap()
	out := map[string]availableBundle{}
	availablePath := filepath.Join(filepath.Dir(pc.Path), ".max", "resources", "available-bundles.xml")
	if !fileExists(availablePath) {
		return out, nil
	}
	data, err := os.ReadFile(availablePath)
	if err != nil {
		return nil, err
	}
	var parsed struct {
		Bundles []bundleNode `xml:"bundle"`
	}
	if err := xml.Unmarshal(data, &parsed); err != nil {
		return nil, err
	}
	for _, b := range parsed.Bundles {
		_, isActive := active[b.Name]
		out[b.Name] = availableBundle{
			Name:        b.Name,
			URL:         b.URL,
			Description: b.Description,
			Active:      isActive,
			Vocabulary:  b.VocabularyBundle == "true",
		}
	}
	return out, nil
}

func maxReleases() ([]release, error) {
	releasesOnce.Do(func() {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()

		req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://git.unicaen.fr/api/v4/projects/2429/releases", nil)
		if err == nil {
			req.Header.Set("PRIVATE-TOKEN", gitlabAPIReadToken)
			resp, errDo := http.DefaultClient.Do(req)
			if errDo == nil {
				defer resp.Body.Close()
				if resp.StatusCode >= 200 && resp.StatusCode < 300 {
					body, _ := io.ReadAll(resp.Body)
					if errDecode := json.Unmarshal(body, &releasesData); errDecode == nil {
						releasesData = filterSupportedMaxReleases(releasesData)
						return
					}
				}
			}
		}

		if err := json.Unmarshal(fallbackReleases, &releasesData); err != nil {
			releasesErr = err
			return
		}
		releasesData = filterSupportedMaxReleases(releasesData)
	})
	return releasesData, releasesErr
}

func maxRelease(name string) (*release, error) {
	releases, err := maxReleases()
	if err != nil {
		return nil, err
	}
	for _, r := range releases {
		if r.Name == name {
			for _, s := range r.Assets.Sources {
				if s.Format == "zip" {
					r.ZipURL = s.URL
					break
				}
			}
			return &r, nil
		}
	}
	return nil, nil
}

func latestMaxRelease() (*release, error) {
	releases, err := maxReleases()
	if err != nil {
		return nil, err
	}
	if len(releases) == 0 {
		return nil, errors.New(T("err.no_release_available"))
	}
	return maxRelease(releases[0].Name)
}

func filterSupportedMaxReleases(releases []release) []release {
	filtered := make([]release, 0, len(releases))
	for _, r := range releases {
		if isSupportedMaxVersion(r.Name) {
			filtered = append(filtered, r)
		}
	}
	return filtered
}

func isSupportedMaxVersion(raw string) bool {
	return compareVersion(raw, minSupportedMaxVersion) >= 0
}

func compareVersion(left, right string) int {
	lv, okLeft := parseVersion(left)
	rv, okRight := parseVersion(right)
	if !okLeft || !okRight {
		return -1
	}
	for i := 0; i < len(lv) || i < len(rv); i++ {
		leftPart := 0
		if i < len(lv) {
			leftPart = lv[i]
		}
		rightPart := 0
		if i < len(rv) {
			rightPart = rv[i]
		}
		switch {
		case leftPart < rightPart:
			return -1
		case leftPart > rightPart:
			return 1
		}
	}
	return 0
}

func parseVersion(raw string) ([]int, bool) {
	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		return nil, false
	}
	if strings.HasPrefix(trimmed, "v") || strings.HasPrefix(trimmed, "V") {
		trimmed = trimmed[1:]
	}
	main := strings.SplitN(trimmed, "-", 2)[0]
	main = strings.SplitN(main, "+", 2)[0]
	parts := strings.Split(main, ".")
	if len(parts) < 3 {
		return nil, false
	}
	out := make([]int, 0, len(parts))
	for _, part := range parts {
		if part == "" {
			return nil, false
		}
		value, err := strconv.Atoi(part)
		if err != nil {
			return nil, false
		}
		out = append(out, value)
	}
	return out, true
}

func displayVersion(raw string) string {
	if strings.TrimSpace(raw) == "" {
		return T("msg.unknown_title")
	}
	return raw
}
Loading