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("%s", 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) }