server/admin/sync/exercices.go

429 lines
13 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package sync
import (
"bytes"
"fmt"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"github.com/BurntSushi/toml"
"github.com/gin-gonic/gin"
"github.com/yuin/goldmark"
"srs.epita.fr/fic-server/libfic"
)
// Set AllowWIPExercice if WIP exercices are accepted.
var AllowWIPExercice bool = false
func fixnbsp(s string) string {
return strings.Replace(strings.Replace(strings.Replace(s, " ?", " ?", -1), " !", " !", -1), " :", " :", -1)
}
// GetExercices returns all exercice directories existing in a given theme, considering the given Importer.
func GetExercices(i Importer, theme *fic.Theme) ([]string, error) {
var exercices []string
if len(theme.Path) == 0 {
return []string{}, nil
} else if dirs, err := i.listDir(theme.Path); err != nil {
return []string{}, err
} else {
for _, dir := range dirs {
if _, err := i.listDir(path.Join(theme.Path, dir)); err == nil {
if dir[0] != '.' && strings.Contains(dir, "-") {
exercices = append(exercices, dir)
}
}
}
}
return exercices, nil
}
func buildDependancyMap(i Importer, theme *fic.Theme) (dmap map[int64]*fic.Exercice, err error) {
var exercices []string
if exercices, err = GetExercices(i, theme); err != nil {
return
} else {
dmap = map[int64]*fic.Exercice{}
for _, edir := range exercices {
var eid int
var ename string
eid, ename, err = parseExerciceDirname(edir)
if err != nil {
err = nil
continue
}
var e *fic.Exercice
e, err = theme.GetExerciceByTitle(ename)
if err != nil {
return
}
dmap[int64(eid)] = e
}
return
}
}
func parseExerciceDirname(edir string) (eid int, ename string, err error) {
edir_splt := strings.SplitN(edir, "-", 2)
if len(edir_splt) != 2 {
err = fmt.Errorf("%q is not a valid exercice directory: missing id prefix", edir)
return
}
eid, err = strconv.Atoi(edir_splt[0])
if err != nil {
err = fmt.Errorf("%q: invalid exercice identifier: %s", edir, err)
return
}
ename = edir_splt[1]
return
}
// BuildExercice creates an Exercice from a given importer.
func BuildExercice(i Importer, theme *fic.Theme, epath string, dmap *map[int64]*fic.Exercice, exceptions *CheckExceptions) (e *fic.Exercice, p ExerciceParams, eid int, edir string, errs []error) {
e = &fic.Exercice{}
e.Path = epath
edir = path.Base(epath)
var err error
eid, e.Title, err = parseExerciceDirname(edir)
if err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("unable to parse exercice directory: %w", err), theme))
return nil, p, eid, edir, errs
}
// Limit exceptions to this exercice
exceptions = exceptions.GetExerciceExceptions(e)
//log.Printf("Kept repochecker exceptions for this exercice: %v", exceptions)
e.Language = theme.Language
// Overwrite language if language.txt exists
if language, err := GetFileContent(i, path.Join(epath, "language.txt")); err == nil {
language = strings.TrimSpace(language)
if strings.Contains(language, "\n") {
errs = append(errs, NewExerciceError(e, fmt.Errorf("language.txt: Language can't contain new lines"), theme))
} else {
e.Language = language
}
}
// Overwrite title if title.txt exists
if myTitle, err := GetFileContent(i, path.Join(epath, "title.txt")); err == nil {
myTitle = strings.TrimSpace(myTitle)
if strings.Contains(myTitle, "\n") {
errs = append(errs, NewExerciceError(e, fmt.Errorf("title.txt: Title can't contain new lines"), theme))
} else {
e.Title = myTitle
}
}
// Character reserved for WIP exercices
if len(e.Title) > 0 && e.Title[0] == '%' {
errs = append(errs, NewExerciceError(e, fmt.Errorf("title can't contain start by '%%'"), theme))
}
e.URLId = fic.ToURLid(e.Title)
e.Title = fixnbsp(e.Title)
// Texts to format using Markdown
if i.exists(path.Join(epath, "overview.txt")) {
e.Overview, err = GetFileContent(i, path.Join(epath, "overview.txt"))
} else if i.exists(path.Join(epath, "overview.md")) {
e.Overview, err = GetFileContent(i, path.Join(epath, "overview.md"))
} else {
err = fmt.Errorf("Unable to find overview.txt nor overview.md")
}
if err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("overview.txt: %s", err), theme))
} else {
e.Overview = fixnbsp(e.Overview)
// Call checks hooks
for _, h := range hooks.mdTextHooks {
for _, err := range h(e.Overview, e.Language, exceptions.GetFileExceptions("overview.md", "overview.txt")) {
errs = append(errs, NewExerciceError(e, fmt.Errorf("overview.md: %w", err)))
}
}
var buf bytes.Buffer
err := goldmark.Convert([]byte(strings.Split(e.Overview, "\n")[0]), &buf)
if err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("overview.md: an error occurs during markdown formating of the headline: %w", err), theme))
} else {
e.Headline = string(buf.Bytes())
}
if e.Overview, err = ProcessMarkdown(i, e.Overview, epath); err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("overview.md: an error occurs during markdown formating: %w", err), theme))
}
}
if i.exists(path.Join(epath, "statement.txt")) {
e.Statement, err = GetFileContent(i, path.Join(epath, "statement.txt"))
} else if i.exists(path.Join(epath, "statement.md")) {
e.Statement, err = GetFileContent(i, path.Join(epath, "statement.md"))
} else {
err = fmt.Errorf("Unable to find statement.txt nor statement.md")
}
if err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("statement.md: %w", err), theme))
} else {
// Call checks hooks
for _, h := range hooks.mdTextHooks {
for _, err := range h(e.Statement, e.Language, exceptions.GetFileExceptions("statement.md", "statement.txt")) {
errs = append(errs, NewExerciceError(e, fmt.Errorf("statement.md: %w", err)))
}
}
if e.Statement, err = ProcessMarkdown(i, fixnbsp(e.Statement), epath); err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("statement.md: an error occurs during markdown formating: %w", err), theme))
}
}
if i.exists(path.Join(epath, "finished.txt")) {
e.Finished, err = GetFileContent(i, path.Join(epath, "finished.txt"))
} else if i.exists(path.Join(epath, "finished.md")) {
e.Finished, err = GetFileContent(i, path.Join(epath, "finished.md"))
}
if err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("finished.md: %w", err), theme))
} else if len(e.Finished) > 0 {
// Call checks hooks
for _, h := range hooks.mdTextHooks {
for _, err := range h(e.Finished, e.Language, exceptions.GetFileExceptions("finished.md", "finished.txt")) {
errs = append(errs, NewExerciceError(e, fmt.Errorf("finished.md: %w", err)))
}
}
if e.Finished, err = ProcessMarkdown(i, e.Finished, epath); err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("finished.md: an error occurs during markdown formating: %w", err), theme))
}
}
// Parse challenge.txt
var md toml.MetaData
p, md, err = parseExerciceParams(i, epath)
if err != nil {
errs = append(errs, NewChallengeTxtError(e, 0, err, theme))
return
}
// Alert about unknown keys in challenge.txt
if len(md.Undecoded()) > 0 {
for _, k := range md.Undecoded() {
errs = append(errs, NewChallengeTxtError(e, 0, fmt.Errorf("unknown key %q found, check https://srs.nemunai.re/fic/files/challenge/", k), theme))
}
}
e.WIP = p.WIP
if p.WIP && !AllowWIPExercice {
errs = append(errs, NewExerciceError(e, fmt.Errorf("exercice declared Work In Progress in challenge.txt"), theme))
}
if p.Gain == 0 {
errs = append(errs, NewChallengeTxtError(e, 0, fmt.Errorf("Undefined gain for challenge"), theme))
} else {
e.Gain = p.Gain
}
// Handle dependency
if len(p.Dependencies) > 0 {
if len(p.Dependencies[0].Theme) > 0 && p.Dependencies[0].Theme != theme.Name {
errs = append(errs, NewExerciceError(e, fmt.Errorf("unable to treat dependency to another theme (%q): not implemented.", p.Dependencies[0].Theme), theme))
} else {
if dmap == nil {
if dmap2, err := buildDependancyMap(i, theme); err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("unable to build dependency map: %w", err), theme))
} else {
dmap = &dmap2
}
}
if dmap != nil {
for edk, ed := range *dmap {
if edk == p.Dependencies[0].Id {
e.Depend = &ed.Id
break
}
}
if e.Depend == nil {
dmap_keys := []string{}
for k, _ := range *dmap {
dmap_keys = append(dmap_keys, fmt.Sprintf("%d", k))
}
errs = append(errs, NewExerciceError(e, fmt.Errorf("Unable to find required exercice dependancy %d (available at time of processing: %s)", p.Dependencies[0].Id, strings.Join(dmap_keys, ",")), theme))
}
}
}
}
// Handle resolutions
resolutionFound := false
e.VideoURI = path.Join(epath, "resolution.mp4")
if !i.exists(e.VideoURI) {
e.VideoURI = ""
} else if size, err := GetFileSize(i, e.VideoURI); err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("resolution.mp4: %w", err), theme))
e.VideoURI = ""
} else if size == 0 {
errs = append(errs, NewExerciceError(e, fmt.Errorf("resolution.mp4: The file is empty!"), theme))
e.VideoURI = ""
} else {
e.VideoURI = strings.Replace(url.PathEscape(path.Join("$RFILES$", e.VideoURI)), "%2F", "/", -1)
resolutionFound = true
}
writeup := path.Join(epath, "resolution.md")
if !i.exists(writeup) {
writeup = path.Join(epath, "resolution.txt")
}
if i.exists(writeup) {
if size, err := GetFileSize(i, writeup); err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("resolution.md: %w", err), theme))
} else if size == 0 {
errs = append(errs, NewExerciceError(e, fmt.Errorf("resolution.md: The file is empty!"), theme))
} else if e.Resolution, err = GetFileContent(i, writeup); err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("resolution.md: %w", err), theme))
} else {
// Call checks hooks
for _, h := range hooks.mdTextHooks {
for _, err := range h(e.Resolution, e.Language, exceptions.GetFileExceptions("resolution.md"), p.GetRawFlags()...) {
errs = append(errs, NewExerciceError(e, fmt.Errorf("resolution.md: %w", err)))
}
}
if e.Resolution, err = ProcessMarkdown(i, e.Resolution, epath); err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("resolution.md: error during markdown processing: %w", err), theme))
} else {
resolutionFound = true
}
}
}
if !resolutionFound {
errs = append(errs, NewExerciceError(e, ErrResolutionNotFound, theme))
}
// Call checks hooks
for _, h := range hooks.exerciceHooks {
for _, err := range h(e, exceptions) {
errs = append(errs, NewExerciceError(e, err))
}
}
return
}
// SyncExercice imports new or updates existing given exercice.
func SyncExercice(i Importer, theme *fic.Theme, epath string, dmap *map[int64]*fic.Exercice, exceptions *CheckExceptions) (e *fic.Exercice, eid int, errs []error) {
var err error
var p ExerciceParams
var berrors []error
e, p, eid, _, berrors = BuildExercice(i, theme, epath, dmap, exceptions)
for _, e := range berrors {
errs = append(errs, e)
}
if e != nil {
// Create or update the exercice
err = theme.SaveNamedExercice(e)
if err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("error on exercice save: %w", err), theme))
return
}
// Import eercice tags
if _, err := e.WipeTags(); err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("unable to wipe tags: %w", err), theme))
}
for _, tag := range p.Tags {
if _, err := e.AddTag(tag); err != nil {
errs = append(errs, NewExerciceError(e, fmt.Errorf("unable to add tag: %w", err), theme))
return
}
}
}
return
}
// SyncExercices imports new or updates existing exercices, in a given theme.
func SyncExercices(i Importer, theme *fic.Theme, exceptions *CheckExceptions) (errs []error) {
if exercices, err := GetExercices(i, theme); err != nil {
errs = append(errs, err)
} else {
emap := map[string]int{}
dmap, _ := buildDependancyMap(i, theme)
for _, edir := range exercices {
e, eid, cur_errs := SyncExercice(i, theme, path.Join(theme.Path, edir), &dmap, exceptions)
if e != nil {
emap[e.Title] = eid
dmap[int64(eid)] = e
errs = append(errs, cur_errs...)
}
}
// Remove old exercices
if exercices, err := theme.GetExercices(); err == nil {
for _, ex := range exercices {
if _, ok := emap[ex.Title]; !ok {
ex.DeleteCascade()
}
}
}
}
return
}
// ApiListRemoteExercices is an accessor letting foreign packages to access remote exercices list.
func ApiListRemoteExercices(c *gin.Context) {
theme, _, errs := BuildTheme(GlobalImporter, c.Params.ByName("thid"))
if theme != nil {
exercices, err := GetExercices(GlobalImporter, theme)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, exercices)
} else {
c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs))
return
}
}
// ApiListRemoteExercice is an accessor letting foreign packages to access remote exercice attributes.
func ApiGetRemoteExercice(c *gin.Context) {
theme, exceptions, errs := BuildTheme(GlobalImporter, c.Params.ByName("thid"))
if theme != nil {
exercice, _, _, _, errs := BuildExercice(GlobalImporter, theme, path.Join(theme.Path, c.Params.ByName("exid")), nil, exceptions)
if exercice != nil {
c.JSON(http.StatusOK, exercice)
return
} else {
c.JSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Errorf("%q", errs)})
return
}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Errorf("%q", errs)})
return
}
}