server/admin/sync/themes.go

373 lines
11 KiB
Go
Raw Normal View History

2017-12-08 20:01:42 +00:00
package sync
import (
"bytes"
2017-12-08 20:01:42 +00:00
"fmt"
2020-01-16 13:57:13 +00:00
"image"
"image/jpeg"
"math/rand"
2022-05-16 09:38:46 +00:00
"net/http"
2020-01-16 13:57:13 +00:00
"os"
2017-12-08 20:01:42 +00:00
"path"
"regexp"
2017-12-08 20:01:42 +00:00
"strings"
2018-12-09 18:34:01 +00:00
"unicode"
2017-12-08 20:01:42 +00:00
"github.com/cenkalti/dominantcolor"
2022-05-16 09:38:46 +00:00
"github.com/gin-gonic/gin"
"github.com/yuin/goldmark"
"go.uber.org/multierr"
2020-01-16 13:57:13 +00:00
"golang.org/x/image/draw"
2021-05-13 21:47:18 +00:00
"srs.epita.fr/fic-server/libfic"
2017-12-08 20:01:42 +00:00
)
const StandaloneExercicesDirectory = "exercices"
// GetThemes returns all theme directories in the base directory.
func GetThemes(i Importer) (themes []string, err error) {
2023-11-25 16:13:31 +00:00
if dirs, err := i.ListDir("/"); err != nil {
2017-12-08 20:01:42 +00:00
return nil, err
} else {
for _, dir := range dirs {
if !strings.HasPrefix(dir, ".") && !strings.HasPrefix(dir, "_") && dir != StandaloneExercicesDirectory {
2023-11-25 16:13:31 +00:00
if _, err := i.ListDir(dir); err == nil {
2021-07-30 09:32:23 +00:00
themes = append(themes, dir)
}
2017-12-08 20:01:42 +00:00
}
}
}
return themes, nil
}
2020-01-16 13:57:13 +00:00
// resizePicture makes the given image just fill the given rectangle.
func resizePicture(importedPath string, rect image.Rectangle) error {
if fl, err := os.Open(importedPath); err != nil {
return err
} else {
if src, _, err := image.Decode(fl); err != nil {
fl.Close()
return err
} else if src.Bounds().Max.X > rect.Max.X && src.Bounds().Max.Y > rect.Max.Y {
fl.Close()
mWidth := rect.Max.Y * src.Bounds().Max.X / src.Bounds().Max.Y
mHeight := rect.Max.X * src.Bounds().Max.Y / src.Bounds().Max.X
if mWidth > rect.Max.X {
rect.Max.X = mWidth
} else {
rect.Max.Y = mHeight
}
dst := image.NewRGBA(rect)
draw.CatmullRom.Scale(dst, rect, src, src.Bounds(), draw.Over, nil)
dstFile, err := os.Create(strings.TrimSuffix(importedPath, ".jpg") + ".thumb.jpg")
2020-01-16 13:57:13 +00:00
if err != nil {
return err
}
defer dstFile.Close()
2022-07-08 21:11:11 +00:00
if err = jpeg.Encode(dstFile, dst, &jpeg.Options{Quality: 100}); err != nil {
2020-01-16 13:57:13 +00:00
return err
}
} else {
dstFile, err := os.Create(strings.TrimSuffix(importedPath, ".jpg") + ".thumb.jpg")
if err != nil {
return err
}
defer dstFile.Close()
2022-07-08 21:11:11 +00:00
if err = jpeg.Encode(dstFile, src, &jpeg.Options{Quality: 100}); err != nil {
return err
}
2020-01-16 13:57:13 +00:00
}
}
return nil
}
type SubImager interface {
SubImage(r image.Rectangle) image.Image
}
// getBackgroundColor retrieves the most dominant color in the bottom of the image.
func getBackgroundColor(importedPath string) (uint32, error) {
fl, err := os.Open(strings.TrimSuffix(importedPath, ".jpg") + ".thumb.jpg")
if err != nil {
return 0, err
}
src, _, err := image.Decode(fl)
if err != nil {
fl.Close()
return 0, err
}
bounds := src.Bounds()
// Test if the right and left corner have the same color
bottomLeft := src.(SubImager).SubImage(image.Rect(0, bounds.Dy()-10, 40, bounds.Dy()))
bottomRight := src.(SubImager).SubImage(image.Rect(bounds.Dx()-40, bounds.Dy()-10, bounds.Dx(), bounds.Dy()))
colorLeft := dominantcolor.Find(bottomLeft)
colorRight := dominantcolor.Find(bottomRight)
if uint32(colorLeft.R>>5)<<16+uint32(colorLeft.G>>5)<<8+uint32(colorLeft.B>>5) == uint32(colorRight.R>>5)<<16+uint32(colorRight.G>>5)<<8+uint32(colorRight.B>>5) {
return uint32(colorLeft.R)<<16 + uint32(colorLeft.G)<<8 + uint32(colorLeft.B), nil
}
// Only keep the darkest color of the bottom of the image
bottomFull := src.(SubImager).SubImage(image.Rect(0, bounds.Dy()-5, bounds.Dx(), bounds.Dy()))
colors := dominantcolor.FindN(bottomFull, 4)
color := colors[0]
for _, c := range colors {
if uint32(color.R<<2)+uint32(color.G<<2)+uint32(color.B<<2) > uint32(c.R<<2)+uint32(c.G<<2)+uint32(c.B<<2) {
color = c
}
}
return uint32(color.R)<<16 + uint32(color.G)<<8 + uint32(color.B), nil
}
2018-05-11 23:08:37 +00:00
// getAuthors parses the AUTHORS file.
2017-12-08 20:01:42 +00:00
func getAuthors(i Importer, tname string) ([]string, error) {
if authors, err := GetFileContent(i, path.Join(tname, "AUTHORS.txt")); err != nil {
2017-12-08 20:01:42 +00:00
return nil, err
} else {
var ret []string
re := regexp.MustCompile("^([^<]+)(?: +<(.*)>)?$")
2018-12-09 18:34:01 +00:00
for _, a := range strings.Split(authors, "\n") {
a = strings.TrimFunc(a, unicode.IsSpace)
grp := re.FindStringSubmatch(a)
if len(grp) < 2 || grp[2] == "" {
ret = append(ret, a)
} else {
ret = append(ret, fmt.Sprintf("<a href=\"%s\">%s</a>", grp[2], grp[1]))
}
}
return ret, nil
2017-12-08 20:01:42 +00:00
}
}
// BuildTheme creates a Theme from a given importer.
func BuildTheme(i Importer, tdir string) (th *fic.Theme, exceptions *CheckExceptions, errs error) {
th = &fic.Theme{}
th.Path = tdir
// Get exceptions
exceptions = LoadThemeException(i, th)
// Overwrite language
if language, err := GetFileContent(i, path.Join(tdir, "language.txt")); err == nil {
language = strings.TrimSpace(language)
if strings.Contains(language, "\n") {
errs = multierr.Append(errs, NewThemeError(th, fmt.Errorf("language.txt: Language can't contain new lines")))
} else {
th.Language = language
}
}
// Extract theme's label
if tname, err := GetFileContent(i, path.Join(tdir, "title.txt")); err == nil {
th.Name = fixnbsp(tname)
} else if f := strings.Index(tdir, "-"); f >= 0 {
th.Name = fixnbsp(tdir[f+1:])
} else {
th.Name = fixnbsp(tdir)
}
th.URLId = fic.ToURLid(th.Name)
if authors, err := getAuthors(i, tdir); err != nil {
errs = multierr.Append(errs, NewThemeError(th, fmt.Errorf("unable to get AUTHORS.txt: %w", err)))
return nil, nil, errs
} else {
// Format authors
th.Authors = strings.Join(authors, ", ")
}
var err error
if i.Exists(path.Join(tdir, "headline.txt")) {
th.Headline, err = GetFileContent(i, path.Join(tdir, "headline.txt"))
} else if i.Exists(path.Join(tdir, "headline.md")) {
th.Headline, err = GetFileContent(i, path.Join(tdir, "headline.md"))
}
if err != nil {
errs = multierr.Append(errs, NewThemeError(th, fmt.Errorf("unable to get theme's headline: %w", err)))
}
if th.Headline != "" {
// Call checks hooks
for _, h := range hooks.mdTextHooks {
for _, err := range multierr.Errors(h(th.Headline, th.Language, exceptions.GetFileExceptions("headline.md", "headline.txt"))) {
errs = multierr.Append(errs, NewThemeError(th, fmt.Errorf("headline.md: %w", err)))
}
}
}
var intro string
2023-05-03 08:54:02 +00:00
if i.Exists(path.Join(tdir, "overview.txt")) {
intro, err = GetFileContent(i, path.Join(tdir, "overview.txt"))
2023-05-03 08:54:02 +00:00
} else if i.Exists(path.Join(tdir, "overview.md")) {
intro, err = GetFileContent(i, path.Join(tdir, "overview.md"))
} else {
err = fmt.Errorf("unable to find overview.txt nor overview.md")
}
if err != nil {
errs = multierr.Append(errs, NewThemeError(th, fmt.Errorf("unable to get theme's overview: %w", err)))
} else {
// Call checks hooks
for _, h := range hooks.mdTextHooks {
for _, err := range multierr.Errors(h(intro, th.Language, exceptions.GetFileExceptions("overview.md", "overview.txt"))) {
errs = multierr.Append(errs, NewThemeError(th, fmt.Errorf("overview.md: %w", err)))
}
}
// Split headline from intro
if th.Headline == "" {
ovrvw := strings.Split(fixnbsp(intro), "\n")
th.Headline = ovrvw[0]
if len(ovrvw) > 1 {
intro = strings.Join(ovrvw[1:], "\n")
}
}
// Format overview (markdown)
th.Intro, err = ProcessMarkdown(i, intro, tdir)
if err != nil {
errs = multierr.Append(errs, NewThemeError(th, fmt.Errorf("overview.txt: an error occurs during markdown formating: %w", err)))
}
var buf bytes.Buffer
err := goldmark.Convert([]byte(th.Headline), &buf)
if err != nil {
errs = multierr.Append(errs, NewThemeError(th, fmt.Errorf("overview.txt: an error occurs during markdown formating of the headline: %w", err)))
} else {
th.Headline = string(buf.Bytes())
}
}
2023-05-03 08:54:02 +00:00
if i.Exists(path.Join(tdir, "heading.jpg")) {
th.Image = path.Join(tdir, "heading.jpg")
2023-05-03 08:54:02 +00:00
} else if i.Exists(path.Join(tdir, "heading.png")) {
th.Image = path.Join(tdir, "heading.png")
} else {
errs = multierr.Append(errs, NewThemeError(th, fmt.Errorf("heading.jpg: No such file")))
}
2023-05-03 08:54:02 +00:00
if i.Exists(path.Join(tdir, "partner.jpg")) {
2021-09-01 08:15:26 +00:00
th.PartnerImage = path.Join(tdir, "partner.jpg")
2023-05-03 08:54:02 +00:00
} else if i.Exists(path.Join(tdir, "partner.png")) {
2021-09-01 08:15:26 +00:00
th.PartnerImage = path.Join(tdir, "partner.png")
}
2023-05-03 08:54:02 +00:00
if i.Exists(path.Join(tdir, "partner.txt")) {
if txt, err := GetFileContent(i, path.Join(tdir, "partner.txt")); err != nil {
errs = multierr.Append(errs, NewThemeError(th, fmt.Errorf("unable to get partner's text: %w", err)))
2021-09-01 08:15:26 +00:00
} else {
th.PartnerText, err = ProcessMarkdown(i, txt, tdir)
if err != nil {
errs = multierr.Append(errs, NewThemeError(th, fmt.Errorf("partner.txt: an error occurs during markdown formating: %w", err)))
2021-09-01 08:15:26 +00:00
}
}
}
return
}
2018-05-11 23:08:37 +00:00
// SyncThemes imports new or updates existing themes.
func SyncThemes(i Importer) (exceptions map[string]*CheckExceptions, errs error) {
if themes, err := GetThemes(i); err != nil {
errs = multierr.Append(errs, fmt.Errorf("Unable to list themes: %w", err))
2017-12-08 20:01:42 +00:00
} else {
rand.Shuffle(len(themes), func(i, j int) {
themes[i], themes[j] = themes[j], themes[i]
})
exceptions = map[string]*CheckExceptions{}
for _, tdir := range themes {
btheme, excepts, berrs := BuildTheme(i, tdir)
errs = multierr.Append(errs, berrs)
if btheme == nil {
2017-12-08 20:01:42 +00:00
continue
2017-12-21 21:17:41 +00:00
}
2017-12-08 20:01:42 +00:00
exceptions[tdir] = excepts
if len(btheme.Image) > 0 {
if _, err := i.importFile(btheme.Image,
func(filePath string, origin string) (interface{}, error) {
2020-01-16 13:57:13 +00:00
if err := resizePicture(filePath, image.Rect(0, 0, 500, 300)); err != nil {
return nil, err
}
btheme.Image = strings.TrimPrefix(filePath, fic.FilesDir)
btheme.BackgroundColor, _ = getBackgroundColor(filePath)
return nil, nil
}); err != nil {
errs = multierr.Append(errs, NewThemeError(btheme, fmt.Errorf("unable to import heading image: %w", err)))
2020-04-15 05:39:38 +00:00
}
}
2021-09-01 08:15:26 +00:00
if len(btheme.PartnerImage) > 0 {
if _, err := i.importFile(btheme.PartnerImage,
func(filePath string, origin string) (interface{}, error) {
btheme.PartnerImage = strings.TrimPrefix(filePath, fic.FilesDir)
return nil, nil
}); err != nil {
errs = multierr.Append(errs, NewThemeError(btheme, fmt.Errorf("unable to import partner image: %w", err)))
2021-09-01 08:15:26 +00:00
}
}
2021-11-22 14:35:07 +00:00
var theme *fic.Theme
if theme, err = fic.GetThemeByPath(btheme.Path); err != nil {
2021-11-22 14:35:07 +00:00
if _, err := fic.CreateTheme(btheme); err != nil {
errs = multierr.Append(errs, NewThemeError(btheme, fmt.Errorf("an error occurs during add: %w", err)))
continue
}
2018-11-25 02:35:54 +00:00
}
2021-11-22 14:35:07 +00:00
if !fic.CmpTheme(theme, btheme) {
btheme.Id = theme.Id
if _, err := btheme.Update(); err != nil {
errs = multierr.Append(errs, NewThemeError(btheme, fmt.Errorf("an error occurs during update: %w", err)))
2017-12-21 21:17:41 +00:00
continue
2017-12-08 20:01:42 +00:00
}
}
}
}
return
2017-12-08 20:01:42 +00:00
}
func LoadThemeExceptions(i Importer, theme *fic.Theme) (*CheckExceptions, error) {
return nil, nil
}
2018-05-11 23:08:37 +00:00
// ApiListRemoteThemes is an accessor letting foreign packages to access remote themes list.
2022-05-16 09:38:46 +00:00
func ApiListRemoteThemes(c *gin.Context) {
themes, err := GetThemes(GlobalImporter)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, themes)
2017-12-08 20:01:42 +00:00
}
2018-05-11 23:08:37 +00:00
// ApiListRemoteTheme is an accessor letting foreign packages to access remote main theme attributes.
2022-05-16 09:38:46 +00:00
func ApiGetRemoteTheme(c *gin.Context) {
if c.Params.ByName("thid") == "_" {
c.Status(http.StatusNoContent)
return
}
r, _, errs := BuildTheme(GlobalImporter, c.Params.ByName("thid"))
if r == nil {
2022-05-16 09:38:46 +00:00
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Errorf("%q", errs)})
return
}
2022-05-16 09:38:46 +00:00
c.JSON(http.StatusOK, r)
2017-12-08 20:01:42 +00:00
}