diff --git a/go.mod b/go.mod index 4f7477c..836ccb5 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/bubbletea v0.24.2 github.com/charmbracelet/lipgloss v0.9.1 github.com/charmbracelet/log v0.3.1 + github.com/joho/godotenv v1.5.1 ) require ( @@ -16,7 +17,6 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.18 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -26,6 +26,7 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/sahilm/fuzzy v0.1.0 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/net v0.7.0 // indirect golang.org/x/sync v0.1.0 // indirect diff --git a/go.sum b/go.sum index 368139d..b380790 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= @@ -44,6 +46,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/main b/main index eeaa06e..eb0f856 100755 Binary files a/main and b/main differ diff --git a/main.go b/main.go index 3d7ab00..90907c4 100644 --- a/main.go +++ b/main.go @@ -79,12 +79,12 @@ func main() { firstLoggedIn := client.GetFirstLoggedIn(&httpClient, baseURL) logger.Info("UserData 🥸 :", "name", name, "email", email, "lastLoggedIn", lastLoggedIn, "firstLoggedIn", firstLoggedIn) - URL := "https://themis.housing.rug.nl/course/2023-2024/progfun/" rootNode := tree.BuildRootAssignmentNode("root", URL, logger) - rootNode, err = tree.PullAssignmentsFromThemisAndBuildTree(&httpClient, URL, rootNode, 0, logger) + rootNode, err = tree.PullAssignmentsFromThemisAndBuildTree(&httpClient, URL, rootNode, 1, logger) if err != nil { log.Fatal(err) return } + } diff --git a/models/model.go b/models/model.go new file mode 100644 index 0000000..08e20b2 --- /dev/null +++ b/models/model.go @@ -0,0 +1,11 @@ +package models + +import ( + "time" +) + +type AssignmentDate struct { + StartDate time.Time + DueDate time.Time + EndDate time.Time +} diff --git a/parser/parser.go b/parser/parser.go index dc78df2..8a75dc6 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -4,6 +4,8 @@ import ( "fmt" "net/http" "strings" + "themis-cli/models" + "time" // goquery "github.com/PuerkitoBio/goquery" @@ -28,10 +30,82 @@ func extractCourseData(doc *goquery.Document) []map[string]string { courses = append(courses, course) } }) - return courses } +func GetDatesFromAssignmentPage(client *http.Client, AssignmentPageURL string) (models.AssignmentDate, error) { + // Initialize an empty model + dates := models.AssignmentDate{} + + // Send an HTTP GET request to get the assignment page + resp, err := client.Get(AssignmentPageURL) + if err != nil { + return dates, fmt.Errorf("error fetching assignment page: %v", err) + } + defer resp.Body.Close() + + // Check HTTP response status + if resp.StatusCode != http.StatusOK { + return dates, fmt.Errorf("receiving non-OK response status %s", resp.Status) + } + + // Create a new goquery document from the HTTP response body + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return dates, fmt.Errorf("error reading document: %v", err) + } + + // Parse the start date .cfg-line .cfg-key .tip[data-title] + startDate, err := findDateInTooltip(doc, ".cfg-line:contains('Start:') .tip[data-title]") + if err != nil { + return dates, err + } + + // Parse the deadline date .cfg-line .cfg-key .tip[data-title] + dueDate, err := findDateInTooltip(doc, ".cfg-line:contains('Deadline:') .tip[data-title]") + if err != nil { + return dates, err + } + + // Parse the end date .cfg-line .cfg-key .tip[data-title] + endDate, err := findDateInTooltip(doc, ".cfg-line:contains('End:') .tip[data-title]") + if err != nil { + return dates, err + } + + dates = models.AssignmentDate{ + StartDate: startDate, + DueDate: dueDate, + EndDate: endDate, + } + + return dates, nil +} + +// findDateInTooltip finds the date within a tooltip element's data-title attribute +func findDateInTooltip(doc *goquery.Document, selector string) (time.Time, error) { + var parsedDate time.Time + timeLayout := "Mon Jan 02 2006 15:04:05 GMT-0700" + + tooltip := doc.Find(selector).First() + dateString, exists := tooltip.Attr("data-title") + if !exists { + return parsedDate, fmt.Errorf("no tooltip with date found") + } + + // Extract just the date part from the complex string + dateParts := strings.Split(dateString, " ") + dateString = strings.Join(dateParts[:6], " ") + + // Parse the dateString into Go's time.Time + parsedDate, err := time.Parse(timeLayout, dateString) + if err != nil { + return parsedDate, fmt.Errorf("error parsing date: %v", err) + } + + return parsedDate, nil +} + func GetAssignmentsOnPage(client *http.Client, URL string) ([]map[string]string, error) { var assignments []map[string]string @@ -46,6 +120,8 @@ func GetAssignmentsOnPage(client *http.Client, URL string) ([]map[string]string, if err != nil { return nil, fmt.Errorf("error parsing assignments page: %v", err) } + // TODO: read the dates from the inside the scanned assignement + // Find the assignments in the HTML doc.Find("div.subsec.round.shade.ass-children ul.round li").Each(func(i int, s *goquery.Selection) { assignment := make(map[string]string) @@ -53,6 +129,7 @@ func GetAssignmentsOnPage(client *http.Client, URL string) ([]map[string]string, assignmentName := anchor.Text() href, exists := anchor.Attr("href") + if exists { assignment["name"] = strings.TrimSpace(assignmentName) assignment["url"] = baseURL + href diff --git a/tree/tree.go b/tree/tree.go index af0620a..b6aa03b 100644 --- a/tree/tree.go +++ b/tree/tree.go @@ -8,6 +8,7 @@ import ( log "github.com/charmbracelet/log" ) + const ( baseURL = "https://themis.housing.rug.nl" ) @@ -20,6 +21,11 @@ type AssignmentNode struct { children []*AssignmentNode } +// Title returns the title of the node. +func (n *AssignmentNode) Title() string { return n.Name } +func (n *AssignmentNode) Description() string { return n.URL } +func (n *AssignmentNode) FilterValue() string { return n.Name } + // AppendChild appends a child node to the parent node. // It sets the parent of the child node and adds the child node to the parent's list of children. func (n *AssignmentNode) AppendChild(c *AssignmentNode, logger *log.Logger) { @@ -71,3 +77,5 @@ func PullAssignmentsFromThemisAndBuildTree(client *http.Client, URL string, root return rootNode, nil } + +// build diff --git a/ui/tui.go b/ui/tui.go deleted file mode 100644 index 1a68373..0000000 --- a/ui/tui.go +++ /dev/null @@ -1,279 +0,0 @@ -package ui - -//use charm CLI to create the TUI for this application - -// A simple example demonstrating the use of multiple text input components -// from the Bubbles component library. - -import ( - "fmt" - "strings" - cfg "themis-cli/config" - "time" - - "github.com/charmbracelet/bubbles/cursor" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -var ( - focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) - blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - cursorStyle = focusedStyle.Copy() - noStyle = lipgloss.NewStyle() - helpStyle = blurredStyle.Copy() - cursorModeHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) - - focusedButton = focusedStyle.Copy().Render("[ Submit ]") - blurredButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Submit")) - - // vars - spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) - dotStyle = helpStyle.Copy().UnsetMargins() - durationStyle = dotStyle.Copy() - appStyle = lipgloss.NewStyle().Margin(1, 2, 0, 2) -) - -type LoginModel struct { - focusIndex int - inputs []textinput.Model - cursorMode cursor.Mode -} - -type AssignmentLoaderModel struct { - assignments []ResultMsg - spinner spinner.Model - quitting bool -} - -type ResultMsg struct { - assignmentName string - assignmentURL string - duration time.Duration -} - -func (result ResultMsg) String() string { - return fmt.Sprintf("%s %s %s", result.assignmentName, - durationStyle.Render(result.assignmentURL), result.duration) -} - -func InitalizeResultMsg() ResultMsg { - return ResultMsg{ - assignmentName: "", - assignmentURL: "", - duration: 0, - } -} - -func (result ResultMsg) SetAssignmentName(name string) ResultMsg { - result.assignmentName = name - return result -} - -func (result ResultMsg) SetAssignmentURL(url string) ResultMsg { - result.assignmentURL = url - return result -} - -func (result ResultMsg) SetDuration(duration time.Duration) ResultMsg { - result.duration = duration - return result -} - -func NewAssignmentLoaderModel() AssignmentLoaderModel { - const numLastResults = 5 - s := spinner.New() - s.Style = spinnerStyle - return AssignmentLoaderModel{ - spinner: s, - assignments: make([]ResultMsg, numLastResults), - } -} - -func (m AssignmentLoaderModel) Init() tea.Cmd { - return m.spinner.Tick -} - -func (m LoginModel) Init() tea.Cmd { - return textinput.Blink -} - -func initialModel() LoginModel { - m := LoginModel{ - inputs: make([]textinput.Model, 2), - } - - var t textinput.Model - for i := range m.inputs { - t = textinput.New() - t.Cursor.Style = cursorStyle - t.CharLimit = 32 - - switch i { - case 0: - t.Placeholder = "S or P number" - t.Focus() - t.PromptStyle = focusedStyle - t.TextStyle = focusedStyle - case 1: - t.Placeholder = "Password" - t.EchoMode = textinput.EchoPassword - t.EchoCharacter = '•' - } - - m.inputs[i] = t - } - - return m -} - -func (m AssignmentLoaderModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - m.quitting = true - return m, tea.Quit - case ResultMsg: - m.assignments = append(m.assignments[1:], msg) - return m, nil - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - default: - return m, nil - } -} - -func (m LoginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "esc": - return m, tea.Quit - - // Change cursor mode - case "ctrl+r": - m.cursorMode++ - if m.cursorMode > cursor.CursorHide { - m.cursorMode = cursor.CursorBlink - } - cmds := make([]tea.Cmd, len(m.inputs)) - for i := range m.inputs { - cmds[i] = m.inputs[i].Cursor.SetMode(m.cursorMode) - } - return m, tea.Batch(cmds...) - - // Set focus to next input - case "tab", "shift+tab", "enter", "up", "down": - s := msg.String() - - // Did the user press enter while the submit button was focused? - // If so, exit. - if s == "enter" && m.focusIndex == len(m.inputs) { - cfg.SetUsernameInENV(m.inputs[0].Value()) - cfg.SetPasswordInENV(m.inputs[1].Value()) - return m, tea.Quit - } - - // Cycle indexes - if s == "up" || s == "shift+tab" { - m.focusIndex-- - } else { - m.focusIndex++ - } - - if m.focusIndex > len(m.inputs) { - m.focusIndex = 0 - } else if m.focusIndex < 0 { - m.focusIndex = len(m.inputs) - } - - cmds := make([]tea.Cmd, len(m.inputs)) - for i := 0; i <= len(m.inputs)-1; i++ { - if i == m.focusIndex { - // Set focused state - cmds[i] = m.inputs[i].Focus() - m.inputs[i].PromptStyle = focusedStyle - m.inputs[i].TextStyle = focusedStyle - continue - } - // Remove focused state - m.inputs[i].Blur() - m.inputs[i].PromptStyle = noStyle - m.inputs[i].TextStyle = noStyle - } - - return m, tea.Batch(cmds...) - } - } - // Handle character input and blinking - cmd := m.updateInputs(msg) - return m, cmd -} - -func (m *LoginModel) updateInputs(msg tea.Msg) tea.Cmd { - cmds := make([]tea.Cmd, len(m.inputs)) - - // Only text inputs with Focus() set will respond, so it's safe to simply - // update all of them here without any further logic. - for i := range m.inputs { - m.inputs[i], cmds[i] = m.inputs[i].Update(msg) - } - return tea.Batch(cmds...) -} - -func (m LoginModel) View() string { - var b strings.Builder - for i := range m.inputs { - b.WriteString(m.inputs[i].View()) - if i < len(m.inputs)-1 { - b.WriteRune('\n') - } - } - - button := &blurredButton - if m.focusIndex == len(m.inputs) { - button = &focusedButton - } - fmt.Fprintf(&b, "\n\n%s\n\n", *button) - - b.WriteString(helpStyle.Render("cursor mode is ")) - b.WriteString(cursorModeHelpStyle.Render(m.cursorMode.String())) - b.WriteString(helpStyle.Render(" (ctrl+r to change style)")) - return b.String() -} - -func (m AssignmentLoaderModel) View() string { - var s string - - if m.quitting { - s += "All Done!" - } else { - s += m.spinner.View() + " Pulling Assignments..." - } - - s += "\n\n" - - for _, res := range m.assignments { - s += res.String() + "\n" - } - - if !m.quitting { - s += helpStyle.Render("Press any key to exit") - } - - if m.quitting { - s += "\n" - } - - return appStyle.Render(s) -} - -func SetEnvVarsFromTUI() { - p := tea.NewProgram(initialModel()) - if _, err := p.Run(); err != nil { - fmt.Printf("could not start program: %s\n", err) - } -}