373 lines
11 KiB
Go
373 lines
11 KiB
Go
package sync
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
"image/jpeg"
|
|
"math/rand"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/cenkalti/dominantcolor"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/yuin/goldmark"
|
|
"go.uber.org/multierr"
|
|
"golang.org/x/image/draw"
|
|
|
|
"srs.epita.fr/fic-server/libfic"
|
|
)
|
|
|
|
const StandaloneExercicesDirectory = "exercices"
|
|
|
|
// GetThemes returns all theme directories in the base directory.
|
|
func GetThemes(i Importer) (themes []string, err error) {
|
|
if dirs, err := i.ListDir("/"); err != nil {
|
|
return nil, err
|
|
} else {
|
|
for _, dir := range dirs {
|
|
if !strings.HasPrefix(dir, ".") && !strings.HasPrefix(dir, "_") && dir != StandaloneExercicesDirectory {
|
|
if _, err := i.ListDir(dir); err == nil {
|
|
themes = append(themes, dir)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return themes, nil
|
|
}
|
|
|
|
// 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")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dstFile.Close()
|
|
|
|
if err = jpeg.Encode(dstFile, dst, &jpeg.Options{Quality: 100}); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
dstFile, err := os.Create(strings.TrimSuffix(importedPath, ".jpg") + ".thumb.jpg")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dstFile.Close()
|
|
|
|
if err = jpeg.Encode(dstFile, src, &jpeg.Options{Quality: 100}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
// getAuthors parses the AUTHORS file.
|
|
func getAuthors(i Importer, tname string) ([]string, error) {
|
|
if authors, err := GetFileContent(i, path.Join(tname, "AUTHORS.txt")); err != nil {
|
|
return nil, err
|
|
} else {
|
|
var ret []string
|
|
re := regexp.MustCompile("^([^<]+)(?: +<(.*)>)?$")
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
if i.Exists(path.Join(tdir, "overview.txt")) {
|
|
intro, err = GetFileContent(i, path.Join(tdir, "overview.txt"))
|
|
} 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())
|
|
}
|
|
}
|
|
|
|
if i.Exists(path.Join(tdir, "heading.jpg")) {
|
|
th.Image = path.Join(tdir, "heading.jpg")
|
|
} 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")))
|
|
}
|
|
|
|
if i.Exists(path.Join(tdir, "partner.jpg")) {
|
|
th.PartnerImage = path.Join(tdir, "partner.jpg")
|
|
} else if i.Exists(path.Join(tdir, "partner.png")) {
|
|
th.PartnerImage = path.Join(tdir, "partner.png")
|
|
}
|
|
|
|
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)))
|
|
} 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)))
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// 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))
|
|
} 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 {
|
|
continue
|
|
}
|
|
|
|
exceptions[tdir] = excepts
|
|
|
|
if len(btheme.Image) > 0 {
|
|
if _, err := i.importFile(btheme.Image,
|
|
func(filePath string, origin string) (interface{}, error) {
|
|
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)))
|
|
}
|
|
}
|
|
|
|
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)))
|
|
}
|
|
}
|
|
|
|
var theme *fic.Theme
|
|
if theme, err = fic.GetThemeByPath(btheme.Path); err != nil {
|
|
if _, err := fic.CreateTheme(btheme); err != nil {
|
|
errs = multierr.Append(errs, NewThemeError(btheme, fmt.Errorf("an error occurs during add: %w", err)))
|
|
continue
|
|
}
|
|
}
|
|
|
|
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)))
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func LoadThemeExceptions(i Importer, theme *fic.Theme) (*CheckExceptions, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// ApiListRemoteThemes is an accessor letting foreign packages to access remote themes list.
|
|
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)
|
|
}
|
|
|
|
// ApiListRemoteTheme is an accessor letting foreign packages to access remote main theme attributes.
|
|
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 {
|
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Errorf("%q", errs)})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, r)
|
|
}
|