From 310cf8dde93dbadeba83a7bb546f7e7c8779f147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Herman?= Date: Tue, 20 Feb 2024 00:49:52 +0100 Subject: [PATCH] feat: export report card --- cmd/report-card.go | 140 ++++++++++++++++++++++++++++++++ gaps/report-card.go | 42 ++++++++++ go.mod | 8 +- go.sum | 10 ++- parser/grades.go | 22 ++--- parser/report-card.go | 184 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 387 insertions(+), 19 deletions(-) create mode 100644 cmd/report-card.go create mode 100644 gaps/report-card.go create mode 100644 parser/report-card.go diff --git a/cmd/report-card.go b/cmd/report-card.go new file mode 100644 index 0000000..a5fec54 --- /dev/null +++ b/cmd/report-card.go @@ -0,0 +1,140 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "lutonite.dev/gaps-cli/gaps" + "lutonite.dev/gaps-cli/parser" + "lutonite.dev/gaps-cli/util" + "os" + "strconv" +) + +type ReportCardCmdOpts struct { + format string +} + +var ( + reportCardOpts = &ReportCardCmdOpts{} + reportCardCmd = &cobra.Command{ + Use: "report-card", + Short: "Allows to consult your report card", + RunE: func(cmd *cobra.Command, args []string) error { + cfg := buildTokenClientConfiguration() + + action := gaps.NewReportCardAction(cfg) + reports, err := action.FetchReportCard() + util.CheckErr(err) + + if len(reports) == 0 { + log.Error("No reports found for the given parameters") + return nil + } + + if reportCardOpts.format == "json" { + return json.NewEncoder(os.Stdout).Encode(reports) + } + + reportCardOpts.PrintReportCardTable(reports) + return nil + }, + } +) + +func init() { + reportCardCmd.Flags().StringVarP(&reportCardOpts.format, "format", "o", "table", "Output format (table, json)") + + rootCmd.AddCommand(reportCardCmd) +} + +func (g *ReportCardCmdOpts) PrintReportCardTable(moduleReports []*parser.ModuleReport) { + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.Style().Options.SeparateRows = true + t.SetColumnConfigs([]table.ColumnConfig{ + {Number: 1, AutoMerge: true}, + {Number: 2, AutoMerge: true, Align: text.AlignCenter, AlignHeader: text.AlignCenter}, + {Number: 3, AutoMerge: true}, + {Number: 4, Align: text.AlignCenter, AlignHeader: text.AlignCenter}, + {Number: 5, Align: text.AlignCenter, AlignHeader: text.AlignCenter, AlignFooter: text.AlignRight}, + {Number: 6, Align: text.AlignCenter, AlignHeader: text.AlignCenter, AlignFooter: text.AlignCenter}, + }) + + t.AppendHeader(table.Row{"Module", "Credits", "Class", "Category", "Weight", "Grade"}) + + for _, module := range moduleReports { + moduleDesc := fmt.Sprintf("%s (%s)", module.Name, module.Identifier) + if module.Year > 0 { + moduleDesc += fmt.Sprintf(" - %d-%d", module.Year, module.Year+1) + } + + for _, group := range module.Classes { + groupDesc := fmt.Sprintf("%s (%s)", group.Name, group.Identifier) + for _, grade := range group.Grades { + t.AppendRow(table.Row{ + moduleDesc, + module.Credits, + groupDesc, + grade.Name, + fmt.Sprintf("%d%%", grade.Weight), + grade.Grade, + }) + } + + if group.Mean != "" { + t.AppendRow(table.Row{ + moduleDesc, + module.Credits, + "", + "", + "", + fmt.Sprintf("%s (W: %d)", group.Mean, group.Weight), + }, table.RowConfig{AutoMerge: true}) + } + } + + situation := text.Colors{text.FgGreen}.Sprint(module.Situation) + t.AppendRow(table.Row{ + moduleDesc, + module.Credits, + situation, + situation, + situation, + text.Colors{text.Bold, text.FgBlue}.Sprint(module.GlobalGrade), + }, table.RowConfig{AutoMerge: true}) + + t.AppendSeparator() + } + + t.AppendFooter(table.Row{ + "", + "", + "", + "", + "WEIGHTED GPA", + fmt.Sprintf("%.2f", computeGpa(moduleReports)), + }, table.RowConfig{AutoMerge: true}) + + t.Render() +} + +func computeGpa(grades []*parser.ModuleReport) float64 { + var totalCredits uint + var totalPoints float64 + + for _, module := range grades { + if module.Situation != "RĂ©ussite" { + continue + } + + totalCredits += module.Credits + gradeNumeric, _ := strconv.ParseFloat(module.GlobalGrade, 64) + totalPoints += float64(module.Credits) * gradeNumeric + } + + return totalPoints / float64(totalCredits) +} diff --git a/gaps/report-card.go b/gaps/report-card.go new file mode 100644 index 0000000..d50c31f --- /dev/null +++ b/gaps/report-card.go @@ -0,0 +1,42 @@ +package gaps + +import ( + "fmt" + "golang.org/x/net/html/charset" + "lutonite.dev/gaps-cli/parser" +) + +type ReportCardAction struct { + cfg *TokenClientConfiguration +} + +func NewReportCardAction(config *TokenClientConfiguration) *ReportCardAction { + return &ReportCardAction{ + cfg: config, + } +} + +func (a *ReportCardAction) FetchReportCard() ([]*parser.ModuleReport, error) { + req, err := a.cfg.buildRequest("GET", fmt.Sprintf("/consultation/notes/bulletin.php?id=%d", a.cfg.studentId)) + if err != nil { + return nil, err + } + + res, err := a.cfg.doForm(req, nil) + if err != nil { + return nil, err + } + + defer res.Body.Close() + utfBody, err := charset.NewReader(res.Body, "iso-8859-1") + if err != nil { + return nil, err + } + + pres, err := parser.FromResponseBody(utfBody) + if err != nil { + return nil, err + } + + return pres.ReportCard() +} diff --git a/go.mod b/go.mod index dd77ef3..2335f65 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,8 @@ require ( github.com/spf13/cobra v1.6.1 github.com/spf13/viper v1.15.0 github.com/zalando/go-keyring v0.2.3 - golang.org/x/term v0.5.0 + golang.org/x/net v0.7.0 + golang.org/x/term v0.16.0 ) require ( @@ -22,7 +23,7 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/magiconair/properties v1.8.7 // indirect - github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.0.6 // indirect github.com/rivo/uniseg v0.2.0 // indirect @@ -31,8 +32,7 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.2 // indirect - golang.org/x/net v0.7.0 // indirect - golang.org/x/sys v0.8.0 // indirect + golang.org/x/sys v0.16.0 // indirect golang.org/x/text v0.7.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 6b5fe30..8dde485 100644 --- a/go.sum +++ b/go.sum @@ -148,8 +148,9 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= @@ -346,12 +347,13 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/parser/grades.go b/parser/grades.go index 8f25331..d919e8e 100644 --- a/parser/grades.go +++ b/parser/grades.go @@ -41,10 +41,10 @@ type gradesParser struct { } const ( - classHeader gradeRowType = iota - groupHeader - gradeRow - unknownRow + gradesClassHeader gradeRowType = iota + gradesGroupHeader + gradesGradeRow + gradesUnknownRow = -1 ) func (s *Parser) Grades() ([]*ClassGrades, error) { @@ -64,7 +64,7 @@ func (p gradesParser) parse() ([]*ClassGrades, error) { var globalErr error p.doc.Find("table.displayArray tbody tr").Each(func(i int, s *goquery.Selection) { switch p.getRowType(s) { - case classHeader: + case gradesClassHeader: class, err := p.parseClassHeader(s) if err != nil { globalErr = err @@ -72,7 +72,7 @@ func (p gradesParser) parse() ([]*ClassGrades, error) { } classes = append(classes, class) - case groupHeader: + case gradesGroupHeader: group, err := p.parseGroupHeader(s) if err != nil { globalErr = err @@ -82,7 +82,7 @@ func (p gradesParser) parse() ([]*ClassGrades, error) { classOff := len(classes) - 1 classes[classOff].GradeGroups = append(classes[classOff].GradeGroups, group) - case gradeRow: + case gradesGradeRow: grade, err := p.parseGradeRow(s) if err != nil { globalErr = err @@ -111,13 +111,13 @@ func (p gradesParser) parse() ([]*ClassGrades, error) { func (p gradesParser) getRowType(row *goquery.Selection) gradeRowType { if row.Has("td.bigheader").Length() > 0 { - return classHeader + return gradesClassHeader } else if row.Has("td[rowspan]").Length() > 0 { - return groupHeader + return gradesGroupHeader } else if row.Find("td").Size() == 5 { - return gradeRow + return gradesGradeRow } else { - return unknownRow + return gradesUnknownRow } } diff --git a/parser/report-card.go b/parser/report-card.go new file mode 100644 index 0000000..a86000a --- /dev/null +++ b/parser/report-card.go @@ -0,0 +1,184 @@ +package parser + +import ( + "errors" + "github.com/PuerkitoBio/goquery" + "strconv" + "strings" +) + +type ModuleReport struct { + Identifier string `json:"id"` + Name string `json:"name"` + Year uint `json:"year"` + PassingGrade string `json:"passingGrade"` + GlobalGrade string `json:"grade"` + Credits uint `json:"credits"` + Situation string `json:"situation"` + Classes []*ModuleClass `json:"classes"` +} + +type ModuleClass struct { + Identifier string `json:"id"` + Name string `json:"name"` + Grades []*ClassGrade `json:"grades"` + Mean string `json:"mean"` + Weight uint `json:"weight"` +} + +type ClassGrade struct { + Name string `json:"name"` + Weight uint `json:"weight"` + Grade string `json:"grade"` +} + +type reportCardRowType int +type reportCardParser struct { + Parser + + doc *goquery.Document +} + +const ( + reportCardTableHeader reportCardRowType = iota + reportCardModuleRow + reportCardUnitRow + reportCardCreditsRow + reportCardUnknownRow = -1 +) + +var ( + UnknownReportCardStructure = errors.New("unknown report card structure") +) + +func (s *Parser) ReportCard() ([]*ModuleReport, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(s.src)) + if err != nil { + return nil, err + } + + return reportCardParser{ + Parser: *s, + doc: doc, + }.parse() +} + +func (p reportCardParser) parse() ([]*ModuleReport, error) { + var reports []*ModuleReport + var globalErr error + + p.doc.Find("table#record_table tr").Each(func(i int, s *goquery.Selection) { + if globalErr != nil { + return + } + + switch p.getRowType(s) { + case reportCardTableHeader: + if s.Children().Length() != 7 { + globalErr = UnknownReportCardStructure + return + } + case reportCardModuleRow: + module, err := p.parseModuleRow(s) + if err != nil { + globalErr = err + return + } + + reports = append(reports, module) + case reportCardUnitRow: + class, err := p.parseUnitRow(s) + if err != nil { + globalErr = err + return + } + + moduleOff := len(reports) - 1 + reports[moduleOff].Classes = append(reports[moduleOff].Classes, class) + } + }) + + return reports, globalErr +} + +func (p reportCardParser) getRowType(row *goquery.Selection) reportCardRowType { + if row.HasClass("bulletin_header_row") { + return reportCardTableHeader + } else if row.HasClass("bulletin_module_row") { + if row.HasClass("total-credits-row") { + return reportCardCreditsRow + } + return reportCardModuleRow + } else if row.HasClass("bulletin_unit_row") { + return reportCardUnitRow + } else { + return reportCardUnknownRow + } +} + +func (p reportCardParser) parseModuleRow(row *goquery.Selection) (*ModuleReport, error) { + id := row.Find("td.module-code").Text() + + nameText := row.Find("td").Eq(1).Text() + nameSplit := strings.SplitN(nameText, id, 2) + name := strings.TrimSpace(nameSplit[0][:len(nameSplit[0])-1]) + passingGrade := strings.TrimSpace(nameSplit[1][len(") [seuil : ") : len(nameSplit[1])-1]) + + situation := row.Find("td").Eq(2).Text() + + year, _ := strconv.ParseUint(strings.SplitN(row.Find("td").Eq(3).Text(), " - ", 2)[0], 10, 32) + + grade := row.Find("td").Eq(4).Text() + + credits, err := strconv.ParseUint(row.Find("td").Eq(6).Text(), 10, 32) + if err != nil { + return nil, err + } + + return &ModuleReport{ + Identifier: id, + Name: name, + Year: uint(year), + PassingGrade: passingGrade, + GlobalGrade: grade, + Credits: uint(credits), + Situation: situation, + }, nil +} + +func (p reportCardParser) parseUnitRow(row *goquery.Selection) (*ModuleClass, error) { + id := row.Find("td").Eq(0).Text() + + classContents := row.Find("td").Eq(1).Contents() + className := strings.TrimSpace(classContents.First().Text()) + + var grades []*ClassGrade + for i := 2; i < classContents.Length(); i += 2 { + if strings.TrimSpace(classContents.Eq(i).Text()) == "" { + break + } + + gradeText := strings.SplitN(classContents.Eq(i).Text(), "(", 2) + name := strings.TrimSpace(gradeText[0]) + weight, _ := strconv.ParseUint(strings.TrimSpace(gradeText[1][:strings.Index(gradeText[1], "%")]), 10, 32) + grade := strings.TrimSpace(classContents.Eq(i + 1).Text()) + + grades = append(grades, &ClassGrade{ + Name: name, + Weight: uint(weight), + Grade: grade, + }) + } + + mean := row.Find("td").Eq(4).Text() + + weight, _ := strconv.ParseUint(row.Find("td").Eq(5).Text(), 10, 32) + + return &ModuleClass{ + Identifier: id, + Name: className, + Grades: grades, + Mean: mean, + Weight: uint(weight), + }, nil +}