2023-06-15 04:59:55 +02:00
|
|
|
package winget
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"os"
|
|
|
|
"path/filepath"
|
|
|
|
"regexp"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/caarlos0/log"
|
|
|
|
"github.com/goreleaser/goreleaser/internal/artifact"
|
|
|
|
"github.com/goreleaser/goreleaser/internal/client"
|
|
|
|
"github.com/goreleaser/goreleaser/internal/commitauthor"
|
|
|
|
"github.com/goreleaser/goreleaser/internal/pipe"
|
|
|
|
"github.com/goreleaser/goreleaser/internal/tmpl"
|
|
|
|
"github.com/goreleaser/goreleaser/pkg/config"
|
|
|
|
"github.com/goreleaser/goreleaser/pkg/context"
|
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
errNoRepoName = pipe.Skip("winget.repository.name name is required")
|
|
|
|
errNoPublisher = pipe.Skip("winget.publisher is required")
|
|
|
|
errNoLicense = pipe.Skip("winget.license is required")
|
|
|
|
errNoShortDescription = pipe.Skip("winget.short_description is required")
|
|
|
|
errInvalidPackageIdentifier = pipe.Skip("winget.package_identifier is invalid")
|
|
|
|
errSkipUpload = pipe.Skip("winget.skip_upload is set")
|
|
|
|
errSkipUploadAuto = pipe.Skip("winget.skip_upload is set to 'auto', and current version is a pre-release")
|
|
|
|
errMultipleArchives = pipe.Skip("found multiple archives for the same platform, please consider filtering by id")
|
2023-09-20 16:52:36 +02:00
|
|
|
|
|
|
|
// copied from winget src
|
|
|
|
packageIdentifierValid = regexp.MustCompile("^[^\\.\\s\\\\/:\\*\\?\"<>\\|\\x01-\\x1f]{1,32}(\\.[^\\.\\s\\\\/:\\*\\?\"<>\\|\\x01-\\x1f]{1,32}){1,7}$")
|
2023-06-15 04:59:55 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
type errNoArchivesFound struct {
|
|
|
|
goamd64 string
|
|
|
|
ids []string
|
|
|
|
}
|
|
|
|
|
|
|
|
func (e errNoArchivesFound) Error() string {
|
|
|
|
return fmt.Sprintf("no zip archives found matching goos=[windows] goarch=[amd64 386] goamd64=%s ids=%v", e.goamd64, e.ids)
|
|
|
|
}
|
|
|
|
|
|
|
|
const wingetConfigExtra = "WingetConfig"
|
|
|
|
|
|
|
|
type Pipe struct{}
|
|
|
|
|
2023-06-20 14:33:59 +02:00
|
|
|
func (Pipe) String() string { return "winget" }
|
|
|
|
func (Pipe) ContinueOnError() bool { return true }
|
2023-06-15 04:59:55 +02:00
|
|
|
func (p Pipe) Skip(ctx *context.Context) bool {
|
|
|
|
return len(ctx.Config.Winget) == 0
|
|
|
|
}
|
|
|
|
|
|
|
|
func (Pipe) Default(ctx *context.Context) error {
|
|
|
|
for i := range ctx.Config.Winget {
|
|
|
|
winget := &ctx.Config.Winget[i]
|
|
|
|
|
|
|
|
winget.CommitAuthor = commitauthor.Default(winget.CommitAuthor)
|
|
|
|
|
|
|
|
if winget.CommitMessageTemplate == "" {
|
2023-06-26 19:09:18 +02:00
|
|
|
winget.CommitMessageTemplate = "New version: {{ .PackageIdentifier }} {{ .Version }}"
|
2023-06-15 04:59:55 +02:00
|
|
|
}
|
|
|
|
if winget.Name == "" {
|
|
|
|
winget.Name = ctx.Config.ProjectName
|
|
|
|
}
|
|
|
|
if winget.Goamd64 == "" {
|
|
|
|
winget.Goamd64 = "v1"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p Pipe) Run(ctx *context.Context) error {
|
feat: allow goreleaser to run in gerrit, soft-serve and others (#4271)
Currently, GoReleaser will assume you're running against github, gitea
or gitlab.
You could set `release.disable: true`, but it would still set and try to
use some defaults that could break things up.
Now, if you disable the release, goreleaser will not set these defaults.
It'll also hard error in some cases in which it would happily produce
invalid resources before, namely, if `release.disable` is set, and, for
example, `brews.url_template` is empty (in which case it would try to
use the one from the release, usually github).
closes #4208
2023-09-04 16:23:38 +02:00
|
|
|
cli, err := client.NewReleaseClient(ctx)
|
2023-06-15 04:59:55 +02:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return p.runAll(ctx, cli)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Publish .
|
|
|
|
func (p Pipe) Publish(ctx *context.Context) error {
|
|
|
|
cli, err := client.New(ctx)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return p.publishAll(ctx, cli)
|
|
|
|
}
|
|
|
|
|
feat: allow goreleaser to run in gerrit, soft-serve and others (#4271)
Currently, GoReleaser will assume you're running against github, gitea
or gitlab.
You could set `release.disable: true`, but it would still set and try to
use some defaults that could break things up.
Now, if you disable the release, goreleaser will not set these defaults.
It'll also hard error in some cases in which it would happily produce
invalid resources before, namely, if `release.disable` is set, and, for
example, `brews.url_template` is empty (in which case it would try to
use the one from the release, usually github).
closes #4208
2023-09-04 16:23:38 +02:00
|
|
|
func (p Pipe) runAll(ctx *context.Context, cli client.ReleaseURLTemplater) error {
|
2023-06-15 04:59:55 +02:00
|
|
|
for _, winget := range ctx.Config.Winget {
|
|
|
|
err := p.doRun(ctx, winget, cli)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
feat: allow goreleaser to run in gerrit, soft-serve and others (#4271)
Currently, GoReleaser will assume you're running against github, gitea
or gitlab.
You could set `release.disable: true`, but it would still set and try to
use some defaults that could break things up.
Now, if you disable the release, goreleaser will not set these defaults.
It'll also hard error in some cases in which it would happily produce
invalid resources before, namely, if `release.disable` is set, and, for
example, `brews.url_template` is empty (in which case it would try to
use the one from the release, usually github).
closes #4208
2023-09-04 16:23:38 +02:00
|
|
|
func (p Pipe) doRun(ctx *context.Context, winget config.Winget, cl client.ReleaseURLTemplater) error {
|
2023-06-15 04:59:55 +02:00
|
|
|
if winget.Repository.Name == "" {
|
|
|
|
return errNoRepoName
|
|
|
|
}
|
|
|
|
|
2023-06-25 06:46:54 +02:00
|
|
|
tp := tmpl.New(ctx)
|
|
|
|
|
2023-06-30 19:46:53 +02:00
|
|
|
err := tp.ApplyAll(
|
|
|
|
&winget.Publisher,
|
|
|
|
&winget.Name,
|
|
|
|
&winget.Author,
|
|
|
|
&winget.PublisherURL,
|
2023-07-06 04:33:18 +02:00
|
|
|
&winget.PublisherSupportURL,
|
2023-06-30 19:46:53 +02:00
|
|
|
&winget.Homepage,
|
|
|
|
&winget.SkipUpload,
|
|
|
|
&winget.Description,
|
|
|
|
&winget.ShortDescription,
|
|
|
|
&winget.ReleaseNotesURL,
|
|
|
|
&winget.Path,
|
2023-07-06 04:33:18 +02:00
|
|
|
&winget.Copyright,
|
|
|
|
&winget.CopyrightURL,
|
|
|
|
&winget.License,
|
|
|
|
&winget.LicenseURL,
|
2023-06-30 19:46:53 +02:00
|
|
|
)
|
2023-06-15 04:59:55 +02:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2023-06-30 19:46:53 +02:00
|
|
|
|
2023-06-29 18:59:33 +02:00
|
|
|
if winget.Publisher == "" {
|
2023-06-15 04:59:55 +02:00
|
|
|
return errNoPublisher
|
|
|
|
}
|
|
|
|
|
|
|
|
if winget.License == "" {
|
|
|
|
return errNoLicense
|
|
|
|
}
|
|
|
|
|
2023-06-29 18:59:33 +02:00
|
|
|
winget.Repository, err = client.TemplateRef(tp.Apply, winget.Repository)
|
2023-06-15 04:59:55 +02:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if winget.ShortDescription == "" {
|
|
|
|
return errNoShortDescription
|
|
|
|
}
|
|
|
|
|
2023-06-29 18:59:33 +02:00
|
|
|
winget.ReleaseNotes, err = tp.WithExtraFields(tmpl.Fields{
|
2023-06-25 06:46:54 +02:00
|
|
|
"Changelog": ctx.ReleaseNotes,
|
|
|
|
}).Apply(winget.ReleaseNotes)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2023-06-15 04:59:55 +02:00
|
|
|
if winget.URLTemplate == "" {
|
2023-06-29 18:59:33 +02:00
|
|
|
winget.URLTemplate, err = cl.ReleaseURLTemplate(ctx)
|
2023-06-15 04:59:55 +02:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-06-29 18:59:33 +02:00
|
|
|
if winget.Path == "" {
|
|
|
|
winget.Path = filepath.Join("manifests", strings.ToLower(string(winget.Publisher[0])), winget.Publisher, winget.Name, ctx.Version)
|
2023-06-15 04:59:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
filters := []artifact.Filter{
|
|
|
|
artifact.ByGoos("windows"),
|
|
|
|
artifact.ByFormats("zip"),
|
|
|
|
artifact.ByType(artifact.UploadableArchive),
|
|
|
|
artifact.Or(
|
|
|
|
artifact.ByGoarch("386"),
|
2023-06-15 05:34:12 +02:00
|
|
|
artifact.ByGoarch("arm64"),
|
2023-06-15 04:59:55 +02:00
|
|
|
artifact.And(
|
|
|
|
artifact.ByGoamd64(winget.Goamd64),
|
|
|
|
artifact.ByGoarch("amd64"),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
}
|
|
|
|
if len(winget.IDs) > 0 {
|
|
|
|
filters = append(filters, artifact.ByIDs(winget.IDs...))
|
|
|
|
}
|
|
|
|
archives := ctx.Artifacts.Filter(artifact.And(filters...)).List()
|
|
|
|
if len(archives) == 0 {
|
|
|
|
return errNoArchivesFound{
|
|
|
|
goamd64: winget.Goamd64,
|
|
|
|
ids: winget.IDs,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if winget.PackageIdentifier == "" {
|
2023-06-29 18:59:33 +02:00
|
|
|
winget.PackageIdentifier = winget.Publisher + "." + winget.Name
|
2023-06-15 04:59:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if !packageIdentifierValid.MatchString(winget.PackageIdentifier) {
|
|
|
|
return fmt.Errorf("%w: %s", errInvalidPackageIdentifier, winget.PackageIdentifier)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := createYAML(ctx, winget, Version{
|
|
|
|
PackageIdentifier: winget.PackageIdentifier,
|
|
|
|
PackageVersion: ctx.Version,
|
|
|
|
DefaultLocale: defaultLocale,
|
|
|
|
ManifestType: "version",
|
|
|
|
ManifestVersion: manifestVersion,
|
|
|
|
}, artifact.WingetVersion); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2023-09-17 19:22:39 +02:00
|
|
|
var deps []PackageDependency
|
|
|
|
for _, dep := range winget.Dependencies {
|
|
|
|
if err := tp.ApplyAll(&dep.MinimumVersion, &dep.PackageIdentifier); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
deps = append(deps, PackageDependency{
|
|
|
|
PackageIdentifier: dep.PackageIdentifier,
|
|
|
|
MinimumVersion: dep.MinimumVersion,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2023-06-15 04:59:55 +02:00
|
|
|
installer := Installer{
|
|
|
|
PackageIdentifier: winget.PackageIdentifier,
|
|
|
|
PackageVersion: ctx.Version,
|
|
|
|
InstallerLocale: defaultLocale,
|
|
|
|
InstallerType: "zip",
|
|
|
|
Commands: []string{},
|
|
|
|
ReleaseDate: ctx.Date.Format(time.DateOnly),
|
|
|
|
Installers: []InstallerItem{},
|
|
|
|
ManifestType: "installer",
|
|
|
|
ManifestVersion: manifestVersion,
|
2023-09-17 19:22:39 +02:00
|
|
|
Dependencies: Dependencies{
|
|
|
|
PackageDependencies: deps,
|
|
|
|
},
|
2023-06-15 04:59:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
var amd64Count, i386count int
|
|
|
|
for _, archive := range archives {
|
|
|
|
sha256, err := archive.Checksum("sha256")
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
var files []InstallerItemFile
|
|
|
|
folder := artifact.ExtraOr(*archive, artifact.ExtraWrappedIn, ".")
|
|
|
|
for _, bin := range artifact.ExtraOr(*archive, artifact.ExtraBinaries, []string{}) {
|
|
|
|
files = append(files, InstallerItemFile{
|
2023-07-18 21:43:36 +02:00
|
|
|
RelativeFilePath: strings.ReplaceAll(filepath.Join(folder, bin), "/", "\\"),
|
|
|
|
PortableCommandAlias: strings.TrimSuffix(filepath.Base(bin), ".exe"),
|
2023-06-15 04:59:55 +02:00
|
|
|
})
|
|
|
|
}
|
|
|
|
url, err := tmpl.New(ctx).WithArtifact(archive).Apply(winget.URLTemplate)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
installer.Installers = append(installer.Installers, InstallerItem{
|
|
|
|
Architecture: fromGoArch[archive.Goarch],
|
|
|
|
NestedInstallerType: "portable",
|
|
|
|
NestedInstallerFiles: files,
|
|
|
|
InstallerURL: url,
|
|
|
|
InstallerSha256: sha256,
|
|
|
|
UpgradeBehavior: "uninstallPrevious",
|
|
|
|
})
|
|
|
|
switch archive.Goarch {
|
|
|
|
case "386":
|
|
|
|
i386count++
|
|
|
|
case "amd64":
|
|
|
|
amd64Count++
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if i386count > 1 || amd64Count > 1 {
|
|
|
|
return errMultipleArchives
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := createYAML(ctx, winget, installer, artifact.WingetInstaller); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return createYAML(ctx, winget, Locale{
|
2023-07-06 04:33:18 +02:00
|
|
|
PackageIdentifier: winget.PackageIdentifier,
|
|
|
|
PackageVersion: ctx.Version,
|
|
|
|
PackageLocale: defaultLocale,
|
|
|
|
Publisher: winget.Publisher,
|
|
|
|
PublisherURL: winget.PublisherURL,
|
|
|
|
PublisherSupportURL: winget.PublisherSupportURL,
|
|
|
|
Author: winget.Author,
|
|
|
|
PackageName: winget.Name,
|
|
|
|
PackageURL: winget.Homepage,
|
|
|
|
License: winget.License,
|
|
|
|
LicenseURL: winget.LicenseURL,
|
|
|
|
Copyright: winget.Copyright,
|
|
|
|
CopyrightURL: winget.CopyrightURL,
|
|
|
|
ShortDescription: winget.ShortDescription,
|
|
|
|
Description: winget.Description,
|
|
|
|
Moniker: winget.Name,
|
|
|
|
Tags: winget.Tags,
|
|
|
|
ReleaseNotes: winget.ReleaseNotes,
|
|
|
|
ReleaseNotesURL: winget.ReleaseNotesURL,
|
|
|
|
ManifestType: "defaultLocale",
|
|
|
|
ManifestVersion: manifestVersion,
|
2023-06-15 04:59:55 +02:00
|
|
|
}, artifact.WingetDefaultLocale)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p Pipe) publishAll(ctx *context.Context, cli client.Client) error {
|
|
|
|
skips := pipe.SkipMemento{}
|
|
|
|
for _, files := range ctx.Artifacts.Filter(artifact.Or(
|
|
|
|
artifact.ByType(artifact.WingetInstaller),
|
|
|
|
artifact.ByType(artifact.WingetVersion),
|
|
|
|
artifact.ByType(artifact.WingetDefaultLocale),
|
|
|
|
)).GroupByID() {
|
|
|
|
err := doPublish(ctx, cli, files)
|
|
|
|
if err != nil && pipe.IsSkip(err) {
|
|
|
|
skips.Remember(err)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return skips.Evaluate()
|
|
|
|
}
|
|
|
|
|
|
|
|
func doPublish(ctx *context.Context, cl client.Client, wingets []*artifact.Artifact) error {
|
|
|
|
winget, err := artifact.Extra[config.Winget](*wingets[0], wingetConfigExtra)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if strings.TrimSpace(winget.SkipUpload) == "true" {
|
|
|
|
return errSkipUpload
|
|
|
|
}
|
|
|
|
|
|
|
|
if strings.TrimSpace(winget.SkipUpload) == "auto" && ctx.Semver.Prerelease != "" {
|
|
|
|
return errSkipUploadAuto
|
|
|
|
}
|
|
|
|
|
2023-06-26 19:09:18 +02:00
|
|
|
msg, err := tmpl.New(ctx).WithExtraFields(tmpl.Fields{
|
|
|
|
"PackageIdentifier": winget.PackageIdentifier,
|
|
|
|
}).Apply(winget.CommitMessageTemplate)
|
2023-06-15 04:59:55 +02:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
author, err := commitauthor.Get(ctx, winget.CommitAuthor)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
repo := client.RepoFromRef(winget.Repository)
|
|
|
|
|
|
|
|
var files []client.RepoFile
|
|
|
|
for _, pkg := range wingets {
|
|
|
|
content, err := os.ReadFile(pkg.Path)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
files = append(files, client.RepoFile{
|
2023-07-20 03:04:45 +02:00
|
|
|
Content: content,
|
|
|
|
Path: filepath.Join(winget.Path, pkg.Name),
|
|
|
|
Identifier: repoFileID(pkg.Type),
|
2023-06-15 04:59:55 +02:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
if winget.Repository.Git.URL != "" {
|
|
|
|
return client.NewGitUploadClient(repo.Branch).
|
|
|
|
CreateFiles(ctx, author, repo, msg, files)
|
|
|
|
}
|
|
|
|
|
|
|
|
cl, err = client.NewIfToken(ctx, cl, winget.Repository.Token)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, file := range files {
|
2023-07-20 03:04:45 +02:00
|
|
|
if err := cl.CreateFile(
|
|
|
|
ctx,
|
|
|
|
author,
|
|
|
|
repo,
|
|
|
|
file.Content,
|
|
|
|
file.Path,
|
|
|
|
msg+": add "+file.Identifier,
|
|
|
|
); err != nil {
|
2023-06-15 04:59:55 +02:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if !winget.Repository.PullRequest.Enabled {
|
2023-09-24 21:18:07 +02:00
|
|
|
log.Debug("wingets.pull_request disabled")
|
2023-06-15 04:59:55 +02:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
log.Info("winget.pull_request enabled, creating a PR")
|
|
|
|
pcl, ok := cl.(client.PullRequestOpener)
|
|
|
|
if !ok {
|
|
|
|
return fmt.Errorf("client does not support pull requests")
|
|
|
|
}
|
|
|
|
|
|
|
|
return pcl.OpenPullRequest(ctx, client.Repo{
|
|
|
|
Name: winget.Repository.PullRequest.Base.Name,
|
|
|
|
Owner: winget.Repository.PullRequest.Base.Owner,
|
|
|
|
Branch: winget.Repository.PullRequest.Base.Branch,
|
2023-06-25 07:16:37 +02:00
|
|
|
}, repo, msg, winget.Repository.PullRequest.Draft)
|
2023-06-15 04:59:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
func langserverLineFor(tp artifact.Type) string {
|
|
|
|
switch tp {
|
|
|
|
case artifact.WingetInstaller:
|
|
|
|
return installerLangServer
|
|
|
|
case artifact.WingetDefaultLocale:
|
|
|
|
return defaultLocaleLangServer
|
|
|
|
default:
|
|
|
|
return versionLangServer
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func extFor(tp artifact.Type) string {
|
|
|
|
switch tp {
|
|
|
|
case artifact.WingetVersion:
|
|
|
|
return ".yaml"
|
|
|
|
case artifact.WingetInstaller:
|
|
|
|
return ".installer.yaml"
|
|
|
|
case artifact.WingetDefaultLocale:
|
2023-06-25 06:27:08 +02:00
|
|
|
return ".locale." + defaultLocale + ".yaml"
|
2023-06-15 04:59:55 +02:00
|
|
|
default:
|
|
|
|
// should never happen
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
}
|
2023-07-20 03:04:45 +02:00
|
|
|
|
|
|
|
func repoFileID(tp artifact.Type) string {
|
|
|
|
switch tp {
|
|
|
|
case artifact.WingetVersion:
|
|
|
|
return "version"
|
|
|
|
case artifact.WingetInstaller:
|
|
|
|
return "installer"
|
|
|
|
case artifact.WingetDefaultLocale:
|
|
|
|
return "locale"
|
|
|
|
default:
|
|
|
|
// should never happen
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
}
|