import QtQuick 2.15 import QtQuick.Layouts 1.15 import QtQuick.Controls 2.15 import org.kde.plasma.plasmoid 2.0 import org.kde.plasma.core 2.0 as PlasmaCore import org.kde.plasma.components 2.0 as PlasmaComponents import org.kde.plasma.components 3.0 as PlasmaComponents3 import org.kde.plasma.extras 2.0 as PlasmaExtras Item { width: PlasmaCore.Units.gridUnit * 20 height: PlasmaCore.Units.gridUnit * 40 clip: true readonly property string canvasUrl: plasmoid.configuration.canvasUrl readonly property string apiEndpoint: `${canvasUrl.replace(/\/$/, "")}/api/v1` readonly property string oauth2Token: plasmoid.configuration.oauth2Token readonly property string authHeader: `Bearer ${oauth2Token}` function callApi(path, perPage, callback) { let xhr = new XMLHttpRequest() let apiUrl = `${apiEndpoint}${path}` if (perPage >= 1) { // add pagination parameter apiUrl += `${path.includes("?") ? "&" : "?"}per_page=${perPage}` } xhr.open("GET", apiUrl) xhr.setRequestHeader("Authorization", authHeader) xhr.onload = () => { if (xhr.status == 200) { try { let json = JSON.parse(xhr.responseText) if (callback) { callback(json) } } catch (e) { if (e instanceof SyntaxError) { console.error(`Cannot parse response for ${path} as JSON:\n${xhr.responseText}`) } else { throw e } } } else { console.error(`XHR failed when retrieving ${path} (status ${xhr.status}):\n${xhr.responseText}`) } } xhr.send() } function syncCanvas() { const courses = plasmoid.configuration.courses.split("\n").map( // each line in the "courses" config consists of // a numeric course id, a space, and a course code line => { const spaceIndex = line.indexOf(" ") return {id: line.slice(0, spaceIndex), code: line.slice(spaceIndex + 1)} } ) let courseIndices = {} // reverse look-up table to sort by courses for (let i = 0; i < courses.length; i++) { courseIndices[courses[i].id] = i } const showSubmittedAssignments = plasmoid.configuration.showSubmittedAssignments // we need user id to check submission status callApi("/users/self", 0, user => { syncCourses(courses, courseIndices, showSubmittedAssignments, user.id) }) } // fetch asynchronously, but display in this order: // important -> normal -> finished // when an activity is both important and finished, important takes priority function syncCourses(courses, courseIndices, showSubmittedAssignments, userId) { announcementsModel.clear() assignmentsModel.clear() let announcementIndices = { important: 0, // actually constant, kept for symmetry normal: 0, finished: 0, } let announcementLock = false let assignmentIndices = { important: 0, normal: 0, finished: 0, } let assignmentLock = false for (let course of courses) { const courseIdx = courseIndices[course.id] callApi(`/announcements?context_codes[]=course_${course.id}`, 50, announcements => { announcements.forEach(announcement => { const info = { type: "announcement", activityId: announcement.id, courseId: course.id, course: course.code, title: announcement.title, url: announcement.html_url, important: plasmoid.configuration.importantAnnouncements.includes(announcement.id.toString()), finished: plasmoid.configuration.finishedAnnouncements.includes(announcement.id.toString()), } // figure out where we insert it into list let idx = 0; let endIdx = 0; // actually past the end if (info.important) { idx = announcementIndices.important endIdx = announcementIndices.normal announcementIndices.normal++ announcementIndices.finished++ } else if (!info.finished) { idx = announcementIndices.normal endIdx = announcementIndices.finished announcementIndices.finished++ } else { idx = announcementIndices.normal endIdx = announcementsModel.count } for (; idx < endIdx; idx++) { const annc = announcementsModel.get(idx) if (courseIndices[course.id] < courseIndices[annc.courseId]) { // we are just past the end of this course // insert this announcement here break } } announcementsModel.insert(idx, info) }) }) callApi(`/courses/${course.id}/assignments`, 50, assignments => { assignments.forEach(assignment => { callApi(`/courses/${course.id}/assignments/${assignment.id}/submissions/${userId}`, 0, submission => { const info = { type: "assignment", activityId: assignment.id, courseId: course.id, course: course.code, title: assignment.name, dueAt: assignment.due_at || "", // if null, use empty string to suppress errors submitted: submission.workflow_state != "unsubmitted", // "graded" counts as submitted url: assignment.html_url, important: plasmoid.configuration.importantAssignments.includes(assignment.id.toString()), finished: plasmoid.configuration.finishedAssignments.includes(assignment.id.toString()), } let idx = 0; let endIdx = 0; if (info.important) { idx = assignmentIndices.important endIdx = assignmentIndices.normal assignmentIndices.normal++ assignmentIndices.finished++ } else if (!info.finished) { idx = assignmentIndices.normal endIdx = assignmentIndices.finished assignmentIndices.finished++ } else { idx = assignmentIndices.normal endIdx = assignmentsModel.count } for (; idx < endIdx; idx++) { const annc = assignmentsModel.get(idx) if (courseIndices[course.id] < courseIndices[annc.courseId]) { break } } assignmentsModel.insert(idx, info) }) }) }) } } // sync on initialization Component.onCompleted: syncCanvas() // update every refreshInterval minutes Timer { interval: plasmoid.configuration.refreshInterval * 60 * 1000 running: true repeat: true onTriggered: syncCanvas() } // top level layout ColumnLayout { id: main anchors.fill: parent PlasmaExtras.Heading { level: 1 text: "Kanvas" } PlasmaExtras.Heading { level: 2 text: "Announcements" } ListModel { id: announcementsModel /* Uncomment when debugging layout ListElement { type: "announcement" course: "CS101" title: "Code quality" url: "https://xkcd.com/1513" important: true finished: false activityId: 0 } */ } ScrollView { implicitHeight: PlasmaCore.Units.gridUnit * 20 Layout.margins: PlasmaCore.Units.smallSpacing Layout.fillWidth: true Layout.fillHeight: true ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ListView { id: announcementsListView Layout.fillWidth: true spacing: PlasmaCore.Units.smallSpacing delegate: ActivityView {} model: announcementsModel } } PlasmaExtras.Heading { level: 2 text: "Assignments" } ListModel { id: assignmentsModel /* Uncomment when debugging layout ListElement { type: "assignment" course: "EE201" title: "Circuit diagram" dueAt: "2022-04-10T15:59:59Z" submitted: true url: "https://xkcd.com/730" important: true finished: true activityId: 1 } */ } ScrollView { implicitHeight: PlasmaCore.Units.gridUnit * 20 Layout.margins: PlasmaCore.Units.smallSpacing Layout.fillWidth: true Layout.fillHeight: true ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ListView { id: assignmentsListView Layout.fillWidth: true spacing: PlasmaCore.Units.gridUnit delegate: ActivityView {} model: assignmentsModel } } PlasmaComponents3.Button { icon.name: "view-refresh" text: i18n("Refresh") onClicked: syncCanvas() } } }