2024-06-28 18:28:42 +01:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
2024-06-28 23:03:06 +01:00
|
|
|
"bytes"
|
|
|
|
"embed"
|
2024-06-30 12:02:22 +01:00
|
|
|
"errors"
|
2024-06-28 18:28:42 +01:00
|
|
|
"fmt"
|
2024-06-28 23:03:06 +01:00
|
|
|
"html/template"
|
2024-06-30 12:02:22 +01:00
|
|
|
"io"
|
2024-06-28 18:28:42 +01:00
|
|
|
"log/slog"
|
|
|
|
"os"
|
|
|
|
"path/filepath"
|
|
|
|
"strings"
|
2024-06-30 11:09:40 +01:00
|
|
|
"time"
|
2024-06-28 18:28:42 +01:00
|
|
|
|
2024-06-29 21:19:25 +01:00
|
|
|
"github.com/alecthomas/kong"
|
2024-06-28 18:28:42 +01:00
|
|
|
"github.com/go-git/go-git/v5"
|
2024-06-29 23:25:22 +01:00
|
|
|
"github.com/go-git/go-git/v5/plumbing/object"
|
2024-06-28 18:28:42 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
type RepoDir struct {
|
|
|
|
Name string
|
|
|
|
Description string
|
|
|
|
Owner string
|
2024-06-30 11:09:40 +01:00
|
|
|
LastCommit time.Time
|
2024-06-28 18:28:42 +01:00
|
|
|
}
|
|
|
|
|
2024-06-30 10:08:51 +01:00
|
|
|
// ToDo: replace has* with the filename, as it can be bare or .md
|
2024-06-29 23:25:22 +01:00
|
|
|
type RepoInfo struct {
|
|
|
|
Name string
|
|
|
|
Description string
|
|
|
|
Url string
|
|
|
|
HasReadme bool
|
|
|
|
HasLicense bool
|
|
|
|
HasContributing bool
|
|
|
|
}
|
|
|
|
|
|
|
|
type CommitFile struct {
|
2024-06-30 00:05:12 +01:00
|
|
|
Mode string
|
|
|
|
Path string
|
|
|
|
Name string
|
|
|
|
Lines int
|
|
|
|
IsBinary bool
|
|
|
|
Size int64
|
|
|
|
}
|
|
|
|
|
2024-06-30 12:02:22 +01:00
|
|
|
type FileStats struct {
|
|
|
|
Name string
|
|
|
|
Mode string // ToDo: is the name correct here?
|
|
|
|
Adds int
|
|
|
|
Dels int
|
|
|
|
Total int
|
|
|
|
}
|
|
|
|
|
2024-06-30 11:09:40 +01:00
|
|
|
type CommitInfo struct {
|
|
|
|
Hash string
|
|
|
|
ParentHash string
|
|
|
|
Date time.Time
|
|
|
|
Msg string
|
|
|
|
AuthorName string
|
|
|
|
AuthorEmail string
|
|
|
|
Files int
|
|
|
|
Adds int
|
|
|
|
Dels int
|
2024-06-30 12:02:22 +01:00
|
|
|
FileStats []*FileStats
|
2024-06-29 23:25:22 +01:00
|
|
|
}
|
|
|
|
|
2024-06-28 23:03:06 +01:00
|
|
|
//go:embed templates
|
|
|
|
var embedTmpl embed.FS
|
2024-06-30 11:09:40 +01:00
|
|
|
var timeShortFormatStr = "2006-01-02 15:04"
|
|
|
|
var timeLongFormatStr = time.RFC1123
|
2024-06-30 10:08:51 +01:00
|
|
|
var funcMap = template.FuncMap{
|
|
|
|
"inc": func(i int) int {
|
|
|
|
return i + 1
|
|
|
|
},
|
|
|
|
"menu": func(repoInfo *RepoInfo, relpath string) template.HTML {
|
|
|
|
menu := "<a href=\"" + relpath + "log.html\">Log</a>"
|
|
|
|
menu += " | <a href=\"" + relpath + "files.html\">Files</a>"
|
|
|
|
menu += " | <a href=\"" + relpath + "refs.html\">Refs</a>"
|
|
|
|
|
|
|
|
if repoInfo.HasReadme {
|
|
|
|
menu += "| <a href=\"" + relpath + "file/README.html\">README</a>"
|
|
|
|
}
|
|
|
|
|
|
|
|
if repoInfo.HasLicense {
|
|
|
|
menu += "| <a href=\"" + relpath + "file/LICENSE.html\">LICENSE</a>"
|
|
|
|
}
|
|
|
|
|
|
|
|
if repoInfo.HasContributing {
|
|
|
|
menu += "| <a href=\"" + relpath + "file/CONTRIBUTING.html\">CONTRIBUTING</a>"
|
|
|
|
}
|
|
|
|
|
|
|
|
return template.HTML(menu)
|
|
|
|
},
|
2024-06-30 11:09:40 +01:00
|
|
|
"firstLine": func(msg string) string {
|
|
|
|
return strings.Split(msg, "\n")[0]
|
|
|
|
},
|
|
|
|
"timeShortFormat": func(t time.Time) string {
|
|
|
|
return t.Format(timeShortFormatStr)
|
|
|
|
},
|
|
|
|
"timeLongFormat": func(t time.Time) string {
|
|
|
|
return t.Format(timeLongFormatStr)
|
|
|
|
},
|
2024-06-30 12:02:22 +01:00
|
|
|
"repeatStr": func(s string, n int) string {
|
|
|
|
return strings.Repeat(s, n)
|
|
|
|
},
|
2024-06-30 10:08:51 +01:00
|
|
|
}
|
2024-06-29 23:25:22 +01:00
|
|
|
|
|
|
|
func executeTemplate(name string, data any) ([]byte, error) {
|
2024-06-28 23:03:06 +01:00
|
|
|
path := filepath.Join("templates", fmt.Sprintf("%s.html.tmpl", name))
|
|
|
|
b, err := embedTmpl.ReadFile(path)
|
|
|
|
if err != nil {
|
2024-06-29 23:25:22 +01:00
|
|
|
return nil, fmt.Errorf("cannot read embedded file %q: %w", path, err)
|
2024-06-28 23:03:06 +01:00
|
|
|
}
|
|
|
|
|
2024-06-29 23:25:22 +01:00
|
|
|
tmpl, err := template.New("").Funcs(funcMap).Parse(string(b))
|
2024-06-28 23:03:06 +01:00
|
|
|
if err != nil {
|
2024-06-29 23:25:22 +01:00
|
|
|
return nil, fmt.Errorf("cannot parse template %q: %w", path, err)
|
2024-06-28 23:03:06 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
var strb bytes.Buffer
|
|
|
|
if err := tmpl.Execute(&strb, data); err != nil {
|
2024-06-29 23:25:22 +01:00
|
|
|
return nil, fmt.Errorf("cannot execute template %q: %w", path, err)
|
2024-06-28 23:03:06 +01:00
|
|
|
}
|
|
|
|
|
2024-06-29 23:25:22 +01:00
|
|
|
return strb.Bytes(), nil
|
2024-06-28 18:28:42 +01:00
|
|
|
}
|
|
|
|
|
2024-06-30 10:08:51 +01:00
|
|
|
func executeTemplateToFile(tmplName string, data any, filename string) error {
|
|
|
|
contents, err := executeTemplate(tmplName, data)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot execute %q template: %w", tmplName, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := os.WriteFile(filename, contents, 0755); err != nil {
|
|
|
|
return fmt.Errorf("cannot write %q contents to %q: %w", tmplName, filename, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2024-06-28 18:28:42 +01:00
|
|
|
func readFile(path string) (string, error) {
|
|
|
|
if fi, err := os.Stat(path); err == nil && !fi.IsDir() {
|
|
|
|
b, err := os.ReadFile(path)
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("cannot read contents of file %q: %w", path, err)
|
|
|
|
}
|
|
|
|
return strings.TrimSpace(string(b)), nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return "", nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func readRepoFile(dirname, name string) (contents string, err error) {
|
|
|
|
contents, err = readFile(filepath.Join(dirname, ".git", name))
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if contents == "" {
|
|
|
|
contents, err = readFile(filepath.Join(dirname, name))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-06-30 10:08:51 +01:00
|
|
|
func getRelpath(name string) string {
|
|
|
|
return strings.Repeat("../", strings.Count(name, "/"))
|
|
|
|
}
|
|
|
|
|
2024-06-28 18:28:42 +01:00
|
|
|
func generateIndex(args []string) error {
|
2024-06-29 23:25:22 +01:00
|
|
|
slog.Debug("Generating index", "args", args)
|
|
|
|
|
2024-06-28 18:28:42 +01:00
|
|
|
repoDirs := []*RepoDir{}
|
|
|
|
for _, dirname := range args {
|
|
|
|
slog.Debug("Processing directory", "dirname", dirname)
|
|
|
|
reponame := strings.TrimSuffix(filepath.Base(dirname), ".git")
|
|
|
|
repoDir := &RepoDir{Name: reponame}
|
|
|
|
|
2024-06-29 23:25:22 +01:00
|
|
|
// read description
|
2024-06-28 18:28:42 +01:00
|
|
|
description, err := readRepoFile(dirname, "description")
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot read description for repository %q: %w", dirname, err)
|
|
|
|
}
|
|
|
|
repoDir.Description = description
|
|
|
|
|
2024-06-29 23:25:22 +01:00
|
|
|
// read owner
|
2024-06-28 18:28:42 +01:00
|
|
|
owner, err := readRepoFile(dirname, "owner")
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot read owner for repository %q: %w", dirname, err)
|
|
|
|
}
|
|
|
|
repoDir.Owner = owner
|
|
|
|
|
|
|
|
// get last commit date
|
|
|
|
repo, err := git.PlainOpen(dirname)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot open repository %q: %w", dirname, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
head, err := repo.Head()
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot get repository head for %q: %w", dirname, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
c, err := repo.CommitObject(head.Hash())
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot get commit %q for repository %q: %w", head.Hash(), dirname, err)
|
|
|
|
}
|
2024-06-30 11:09:40 +01:00
|
|
|
repoDir.LastCommit = c.Author.When
|
2024-06-28 18:28:42 +01:00
|
|
|
|
|
|
|
repoDirs = append(repoDirs, repoDir)
|
|
|
|
}
|
|
|
|
|
2024-06-29 23:25:22 +01:00
|
|
|
data := map[string]any{"repoDirs": repoDirs}
|
2024-06-30 10:08:51 +01:00
|
|
|
if err := executeTemplateToFile("index", data, "index.html"); err != nil {
|
|
|
|
return fmt.Errorf("cannot execute template %q to file %q: %w", "index", "index.html", err)
|
2024-06-28 18:28:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2024-06-30 12:02:22 +01:00
|
|
|
func generateRepo(path string, logLimit int) error {
|
|
|
|
slog.Debug("Generating repository", "path", path, "logLimit", logLimit)
|
2024-06-29 23:25:22 +01:00
|
|
|
|
|
|
|
repoInfo := &RepoInfo{Name: strings.TrimSuffix(filepath.Base(path), ".git")}
|
|
|
|
|
|
|
|
// create and clean destination directory
|
|
|
|
fi, err := os.Stat(repoInfo.Name)
|
|
|
|
if err == nil && !fi.IsDir() {
|
|
|
|
return fmt.Errorf("path %q is not a directory", repoInfo.Name)
|
|
|
|
}
|
|
|
|
if err != nil && !os.IsNotExist(err) {
|
|
|
|
return fmt.Errorf("cannot stat %q: %w", repoInfo.Name, err)
|
|
|
|
}
|
|
|
|
if err == nil && fi.IsDir() {
|
|
|
|
if err := os.RemoveAll(repoInfo.Name); err != nil {
|
|
|
|
return fmt.Errorf("cannot delete directory for %q: %w", repoInfo.Name, err)
|
|
|
|
}
|
|
|
|
}
|
2024-06-30 11:09:40 +01:00
|
|
|
if err := os.MkdirAll(filepath.Join(repoInfo.Name, "commit"), 0755); err != nil {
|
2024-06-29 23:25:22 +01:00
|
|
|
return fmt.Errorf("cannot create directory for %q: %w", repoInfo.Name, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// read description
|
|
|
|
description, err := readRepoFile(path, "description")
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot read description for repository %q: %w", path, err)
|
|
|
|
}
|
|
|
|
repoInfo.Description = description
|
|
|
|
|
|
|
|
// read url
|
|
|
|
url, err := readRepoFile(path, "url")
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot read url for repository %q: %w", path, err)
|
|
|
|
}
|
|
|
|
repoInfo.Url = url
|
|
|
|
|
|
|
|
// open repository
|
|
|
|
repo, err := git.PlainOpen(path)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot open repository %q: %w", path, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
head, err := repo.Head()
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot get repo head: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
c, err := repo.CommitObject(head.Hash())
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot get commit for hash %s: %w", head.Hash(), err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// ToDo: populate hasReadme, hasLicense and hasContributing
|
|
|
|
|
|
|
|
tree, err := c.Tree()
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot get tree for commit %s: %w", c, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
slog.Debug("Processing repository tree", "head", head.Hash())
|
|
|
|
files := []*CommitFile{}
|
|
|
|
iErr := tree.Files().ForEach(func(o *object.File) error {
|
|
|
|
slog.Debug("Processing tree file", "name", o.Name)
|
|
|
|
|
|
|
|
dirpath := filepath.Join(repoInfo.Name, "file", filepath.Dir(o.Name))
|
|
|
|
filename := filepath.Base(o.Name)
|
|
|
|
slog.Debug("Creating directory", "dirpath", dirpath)
|
|
|
|
if err := os.MkdirAll(dirpath, 0755); err != nil {
|
|
|
|
return fmt.Errorf("cannot create directory %q: %w", dirpath, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
data := map[string]any{
|
2024-06-30 10:08:51 +01:00
|
|
|
"relpath": getRelpath(dirpath),
|
2024-06-29 23:25:22 +01:00
|
|
|
"repoInfo": repoInfo,
|
|
|
|
"filename": filename,
|
|
|
|
"filesize": o.Size,
|
|
|
|
}
|
|
|
|
|
|
|
|
isBinary, err := o.IsBinary()
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot get binary state for file %q: %w", o.Name, err)
|
|
|
|
}
|
|
|
|
data["isBinary"] = isBinary
|
|
|
|
|
2024-06-30 00:05:12 +01:00
|
|
|
var lines []string
|
2024-06-29 23:25:22 +01:00
|
|
|
if !isBinary {
|
2024-06-30 00:05:12 +01:00
|
|
|
var err error
|
|
|
|
lines, err = o.Lines()
|
2024-06-29 23:25:22 +01:00
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot get lines for %q: %w", o.Name, err)
|
|
|
|
}
|
|
|
|
data["filelines"] = lines
|
|
|
|
}
|
|
|
|
|
|
|
|
dstpath := filepath.Join(dirpath, fmt.Sprintf("%s.html", filename))
|
2024-06-30 10:08:51 +01:00
|
|
|
if err := executeTemplateToFile("file", data, dstpath); err != nil {
|
|
|
|
return fmt.Errorf("cannot execute template %q to file %q: %w", "file", dstpath, err)
|
2024-06-29 23:25:22 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
file := &CommitFile{
|
2024-06-30 00:05:12 +01:00
|
|
|
Mode: o.Mode.String(), // ToDo: correctly calculate the mode string
|
|
|
|
Path: filepath.Join("file", fmt.Sprintf("%s.html", o.Name)),
|
|
|
|
Name: o.Name,
|
|
|
|
Lines: len(lines),
|
|
|
|
IsBinary: isBinary,
|
|
|
|
Size: o.Size,
|
2024-06-29 23:25:22 +01:00
|
|
|
}
|
|
|
|
files = append(files, file)
|
|
|
|
|
|
|
|
return nil
|
|
|
|
})
|
|
|
|
if iErr != nil {
|
|
|
|
return fmt.Errorf("error while processing tree: %w", iErr)
|
|
|
|
}
|
|
|
|
|
2024-06-30 00:05:12 +01:00
|
|
|
// generate the files index file
|
2024-06-30 10:08:51 +01:00
|
|
|
filesData := map[string]any{"repoInfo": repoInfo, "files": files, "relpath": ""}
|
|
|
|
filesDstpath := filepath.Join(repoInfo.Name, "files.html")
|
|
|
|
if err := executeTemplateToFile("files", filesData, filesDstpath); err != nil {
|
|
|
|
return fmt.Errorf("cannot execute template %q to file %q: %w", "file", filesDstpath, err)
|
2024-06-29 23:25:22 +01:00
|
|
|
}
|
|
|
|
|
2024-06-30 00:05:12 +01:00
|
|
|
// generate the log file
|
|
|
|
cIter, err := repo.Log(&git.LogOptions{All: true})
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot get git log for repository: %w", err)
|
|
|
|
}
|
|
|
|
|
2024-06-30 12:02:22 +01:00
|
|
|
i := 0
|
|
|
|
remaining := 0
|
2024-06-30 11:09:40 +01:00
|
|
|
commits := []*CommitInfo{}
|
2024-06-30 12:02:22 +01:00
|
|
|
for {
|
|
|
|
i++
|
|
|
|
c, err := cIter.Next()
|
|
|
|
if errors.Is(err, io.EOF) {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot get next commit while processing log: %w", err)
|
|
|
|
}
|
|
|
|
|
2024-06-30 11:09:40 +01:00
|
|
|
slog.Debug("Processing commit", "hash", c.Hash)
|
2024-06-30 00:05:12 +01:00
|
|
|
|
2024-07-14 11:02:31 +01:00
|
|
|
if logLimit != 0 && i > logLimit {
|
2024-06-30 12:02:22 +01:00
|
|
|
remaining++
|
|
|
|
slog.Debug("Limit reached while processing log", "iteration", i, "remaining", remaining)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2024-06-30 00:05:12 +01:00
|
|
|
// ToDo: is this the best way to get the number of files?
|
|
|
|
fstats, err := c.Stats()
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot get commit stats %s: %w", c.Hash, err)
|
|
|
|
}
|
|
|
|
|
2024-06-30 12:02:22 +01:00
|
|
|
fileStats := make([]*FileStats, len(fstats))
|
|
|
|
for i, fs := range fstats {
|
|
|
|
fileStats[i] = &FileStats{
|
|
|
|
Name: fs.Name,
|
|
|
|
Mode: "",
|
|
|
|
Adds: fs.Addition,
|
|
|
|
Dels: fs.Deletion,
|
|
|
|
Total: fs.Addition + fs.Deletion,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-06-30 11:09:40 +01:00
|
|
|
commit := &CommitInfo{
|
|
|
|
Hash: c.Hash.String(),
|
|
|
|
Date: c.Author.When, // ToDo: author when vs commiter when?
|
|
|
|
Msg: c.Message,
|
|
|
|
AuthorName: c.Author.Name,
|
|
|
|
AuthorEmail: c.Author.Email,
|
2024-06-30 12:02:22 +01:00
|
|
|
Files: len(fstats), // ToDo: call len on FileStats
|
|
|
|
FileStats: fileStats,
|
2024-06-30 11:09:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
if parent, _ := c.Parent(0); parent != nil {
|
|
|
|
commit.ParentHash = parent.Hash.String()
|
2024-06-30 00:05:12 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// ToDo: are there not global commit stats?
|
|
|
|
for _, fstat := range fstats {
|
2024-06-30 11:09:40 +01:00
|
|
|
commit.Adds += fstat.Addition
|
|
|
|
commit.Dels += fstat.Deletion
|
2024-06-30 00:05:12 +01:00
|
|
|
}
|
|
|
|
|
2024-06-30 11:09:40 +01:00
|
|
|
commits = append(commits, commit)
|
|
|
|
|
|
|
|
// generate commit file
|
|
|
|
data := map[string]any{"repoInfo": repoInfo, "commit": commit, "relpath": "../"}
|
|
|
|
dstpath := filepath.Join(repoInfo.Name, "commit", fmt.Sprintf("%s.html", commit.Hash))
|
|
|
|
if err := executeTemplateToFile("commit", data, dstpath); err != nil {
|
|
|
|
return fmt.Errorf("cannot execute template %q to file %q: %w", "commit", dstpath, err)
|
|
|
|
}
|
2024-06-30 00:05:12 +01:00
|
|
|
}
|
|
|
|
|
2024-06-30 12:02:22 +01:00
|
|
|
logData := map[string]any{
|
|
|
|
"repoInfo": repoInfo,
|
|
|
|
"commits": commits,
|
|
|
|
"relpath": "",
|
|
|
|
"remaining": remaining,
|
|
|
|
}
|
2024-06-30 10:08:51 +01:00
|
|
|
logDstpath := filepath.Join(repoInfo.Name, "log.html")
|
|
|
|
if err := executeTemplateToFile("log", logData, logDstpath); err != nil {
|
|
|
|
return fmt.Errorf("cannot execute template %q to file %q: %w", "file", logDstpath, err)
|
2024-06-30 00:05:12 +01:00
|
|
|
}
|
|
|
|
|
2024-06-28 18:28:42 +01:00
|
|
|
return nil
|
|
|
|
}
|
2024-06-29 23:25:22 +01:00
|
|
|
|
|
|
|
func main() {
|
|
|
|
ctx := kong.Parse(&cli)
|
|
|
|
if cli.Debug {
|
|
|
|
slog.SetLogLoggerLevel(slog.LevelDebug)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := ctx.Run(); err != nil {
|
|
|
|
fmt.Fprintf(os.Stderr, "error: %s\n", err)
|
|
|
|
os.Exit(1)
|
|
|
|
}
|
|
|
|
}
|