Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,41 @@ async function setupAndCreateWidget() {
}

async function presentWidget() {
const widget = await setupAndCreateWidget()
switch (PREVIEW_WIDGET_SIZE) {
case 'small':
widget.presentSmall()
break
case 'medium':
widget.presentMedium()
break
case 'large':
widget.presentLarge()
break
}
// Ask the user which widget size to preview
const alert = new Alert();
alert.title = "Widget Preview Size";
alert.message = "Select which widget size you want to preview:";
alert.addAction("📱 Small");
alert.addAction("🖥️ Medium");
alert.addAction("🧩 Large");
alert.addCancelAction("Cancel");

const response = await alert.presentAlert();
if (response === -1) {
console.log("Preview cancelled — no widget shown.");
return;
}

const sizeMap = ["small", "medium", "large"];
const chosenSize = sizeMap[response];

config.widgetFamily = chosenSize;
console.log(`📱 Previewing as ${chosenSize} widget`);

const widget = await setupAndCreateWidget();

// Present the widget in the chosen size
switch (chosenSize) {
case "small":
await widget.presentSmall();
break;
case "medium":
await widget.presentMedium();
break;
case "large":
await widget.presentLarge();
break;
}
}

function showDocumentation() {
Expand Down
13 changes: 11 additions & 2 deletions src/api/cacheOrFetch.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFromCache, writeToCache } from '@/api/cache'
import { fetchAbsencesFor, fetchExamsFor, fetchGradesFor, fetchLessonsFor, fetchSchoolYears } from '@/api/fetch'
import { fetchAbsencesFor, fetchExamsFor, fetchGradesFor, fetchLessonsFor, fetchSchoolYears, fetchHomeworksFor } from '@/api/fetch'
import { CURRENT_DATETIME, NOTIFIABLE_TOPICS } from '@/constants'
import { compareCachedAbsences, compareCachedExams, compareCachedGrades, compareCachedLessons } from '@/features/notify'
import { compareCachedAbsences, compareCachedExams, compareCachedGrades, compareCachedLessons, compareCachedHomeworks } from '@/features/notify'
import { Settings } from '@/settings/settings'
import { sortKeysByDate } from '@/utils/helper'

Expand Down Expand Up @@ -119,6 +119,15 @@ export async function getAbsencesFor(user: FullUser, from: Date, to: Date, widge
)
}

export async function getHomeworksFor(user: FullUser, from: Date, to: Date, widgetConfig: Settings) {
return getCachedOrFetch(
'homeworks',
widgetConfig.cache.homeworks,
widgetConfig, () => fetchHomeworksFor(user, from, to),
compareCachedHomeworks,
)
}

export async function getSchoolYears(user: FullUser, widgetConfig: Settings) {
return getCachedOrFetch('school_years', widgetConfig.cache.schoolYears, widgetConfig, () => fetchSchoolYears(user))
}
Expand Down
12 changes: 12 additions & 0 deletions src/api/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
transformExams,
transformGrades,
transformSchoolYears,
transformHomeworks
} from './transform'
import { transformLessons } from './transformLessons'
import { getModuleFileManager, writeConfig } from '@/utils/scriptable/fileSystem'
Expand Down Expand Up @@ -222,6 +223,17 @@ export async function fetchAbsencesFor(user: FullUser, from: Date, to: Date) {
return fetchArrayLikeData<Absence, TransformedAbsence>(user, urlAbsences, 'absences', transformAbsences)
}

export async function fetchHomeworksFor(user, from, to) {
const urlHomeworks = `https://${user.server}.webuntis.com/WebUntis/api/homeworks/lessons?startDate=${formatDateForUntis(from)}&endDate=${formatDateForUntis(to)}`
const request = prepareRequest(urlHomeworks, user)
const json = await request.loadJSON()
if (!json || !json.data) {
console.warn('⚠️ Could not fetch homeworks!')
return []
}
return transformHomeworks(json.data)
}

export async function fetchClassRolesFor(user: FullUser, from: Date, to: Date) {
const urlClassRoles = `https://${
user.server
Expand Down
15 changes: 14 additions & 1 deletion src/api/fetchManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import {
TransformedExam,
TransformedGrade,
TransformedLesson,
TransformedHomework,
} from '@/types/transformed'
import { getDateInXSeconds } from '@/utils/helper'
import { getRefreshDateForLessons } from '@/utils/refreshDate'
import { checkNewRefreshDate, proposeRefreshIn } from '@/widget'
import { getAbsencesFor, getExamsFor, getGradesFor, getSchoolYears, getTimetable } from './cacheOrFetch'
import { getAbsencesFor, getExamsFor, getGradesFor, getSchoolYears, getTimetable, getHomeworksFor } from './cacheOrFetch'
import { fetchClassRolesFor } from './fetch'

export interface FetchedData {
Expand All @@ -21,6 +22,7 @@ export interface FetchedData {
exams?: TransformedExam[]
grades?: TransformedGrade[]
absences?: TransformedAbsence[]
homeworks?: TransformedHomework[]
classRoles?: TransformedClassRole[]
refreshDate?: Date
}
Expand All @@ -31,6 +33,7 @@ enum FetchableItems {
GRADES,
ABSENCES,
ROLES,
HOMEWORKS,
}

const VIEW_FETCH_MAP = new Map<View, FetchableItems>([
Expand All @@ -39,6 +42,7 @@ const VIEW_FETCH_MAP = new Map<View, FetchableItems>([
[View.EXAMS, FetchableItems.EXAMS],
[View.GRADES, FetchableItems.GRADES],
[View.ABSENCES, FetchableItems.ABSENCES],
[View.HOMEWORKS, FetchableItems.HOMEWORKS]
])

/**
Expand Down Expand Up @@ -101,6 +105,15 @@ export async function fetchDataForViews(viewNames: View[], user: FullUser, widge
fetchPromises.push(promise)
}

if (itemsToFetch.has(FetchableItems.HOMEWORKS)) {
const homeworksTo = getDateInXSeconds(widgetConfig.views.homeworks.scope);
const promise = getHomeworksFor(user, CURRENT_DATETIME, homeworksTo, widgetConfig).then((homeworks) => {
fetchedData.homeworks = homeworks;
});
proposeRefreshIn(widgetConfig.cache.homeworks, fetchedData);
fetchPromises.push(promise);
}

if (itemsToFetch.has(FetchableItems.ROLES)) {
const promise = fetchClassRolesFor(user, CURRENT_DATETIME, CURRENT_DATETIME).then((roles) => {
fetchedData.classRoles = roles
Expand Down
70 changes: 68 additions & 2 deletions src/api/transform.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Absence, ClassRole, Exam, Grade, SchoolYear } from '@/types/api'
import { Absence, ClassRole, Exam, Grade, SchoolYear , HomeworkApiData} from '@/types/api'
import {
TransformedAbsence,
TransformedClassRole,
TransformedExam,
TransformedGrade,
TransformedSchoolYear,
TransformedHomework,
} from '@/types/transformed'

function parseDateNumber(date: number) {
Expand Down Expand Up @@ -94,15 +95,80 @@ export function transformAbsences(absences: Absence[]) {
from: combineDateAndTime(absence.startDate, absence.startTime),
to: combineDateAndTime(absence.endDate, absence.endTime),
createdBy: absence.createdUser,
reason: absence.reason ?? "Unknown",
text: absence.text ?? "",
reasonId: absence.reasonId,
isExcused: absence.isExcused,
excusedBy: absence.excuse.username,
excuseStatus: absence.excuseStatus ?? absence.excuse?.excuseStatus ?? "open",
excusedBy: absence.excuse?.username ?? "",
}
transformedAbsences.push(transformedAbsence)
}
return transformedAbsences
}

export function transformHomeworks(apiData: HomeworkApiData): TransformedHomework[] {
const transformedHomeworks: TransformedHomework[] = []

const records = apiData.records ?? []
const homeworks = apiData.homeworks ?? []
const teachers = apiData.teachers ?? []
const lessons = apiData.lessons ?? []

const hwMap = new Map(homeworks.map(hw => [hw.id, hw]))

// 1️⃣ Verknüpfte Homeworks (mit Record)
for (const record of records) {
const hw = hwMap.get(record.homeworkId)
if (!hw) continue

const teacher = teachers.find(t => t.id === record.teacherId)
const lesson = lessons.find(l => l.id === hw.lessonId)

transformedHomeworks.push({
id: hw.id,
lessonId: hw.lessonId,
subject: lesson?.subject ?? "",
teacher: teacher?.name ?? "",
text: hw.text ?? "",
remark: hw.remark ?? "",
completed: !!hw.completed,
date: hw.date ? parseDateNumber(hw.date) : undefined,
dueDate: hw.dueDate ? parseDateNumber(hw.dueDate) : undefined,
attachments: hw.attachments ?? [],
})
}

// 2️⃣ Homeworks ohne Record
for (const hw of homeworks) {
if (!transformedHomeworks.find(t => t.id === hw.id)) {
const lesson = lessons.find(l => l.id === hw.lessonId)

transformedHomeworks.push({
id: hw.id,
lessonId: hw.lessonId,
subject: lesson?.subject ?? "",
teacher: "",
text: hw.text ?? "",
remark: hw.remark ?? "",
completed: !!hw.completed,
date: hw.date ? parseDateNumber(hw.date) : undefined,
dueDate: hw.dueDate ? parseDateNumber(hw.dueDate) : undefined,
attachments: hw.attachments ?? [],
})
}
}

// 3️⃣ Sortieren
transformedHomeworks.sort((a, b) => {
const aTime = a.dueDate ? a.dueDate.getTime() : (a.date ? a.date.getTime() : 0)
const bTime = b.dueDate ? b.dueDate.getTime() : (b.date ? b.date.getTime() : 0)
return aTime - bTime
})

return transformedHomeworks
}

export function transformClassRoles(classRoles: ClassRole[]) {
const transformedClassRoles: TransformedClassRole[] = []
for (const classRole of classRoles) {
Expand Down
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ export const CONFIG_FILE_NAME = 'untis-config.json'
export const CUSTOM_CONFIG_KEYS = ['subjects']
export const GITHUB_USER = 'kaiser-jan'
export const GITHUB_REPO = 'scriptable-untis'
export const GITHUB_SCRIPT_NAME = 'UntisWidget.js'
export const GITHUB_SCRIPT_NAME = 'UntisWidget.js'
52 changes: 51 additions & 1 deletion src/features/notify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { LOCALE, NO_VALUE_PLACEHOLDERS } from '@/constants'
import { applySubjectConfig, getSubjectConfigFor } from '@/settings/subjectConfig'
import { Settings } from '@/settings/settings'
import { LessonState } from '@/types/api'
import { TransformedAbsence, TransformedExam, TransformedGrade, TransformedLessonWeek } from '@/types/transformed'
import { TransformedAbsence, TransformedExam, TransformedGrade, TransformedLessonWeek, TransformedHomework } from '@/types/transformed'
import { asNumericTime, scheduleNotification } from '@/utils/helper'
import { getSubjectTitle } from '@/utils/lessonHelper'
import { cleanupExpiredHomeworks } from '@/utils/scriptable/componentHelper'

/**
* Compares the fetched lessons with the cached lessons and sends notifications for most changes.
Expand Down Expand Up @@ -223,3 +224,52 @@ export function compareCachedAbsences(
}
}
}

export function compareCachedHomeworks(
homeworks: TransformedHomework[],
cachedHomeworks: TransformedHomework[],
widgetConfig: Settings
) {
const offset = widgetConfig?.views?.homeworks?.dueNotificationOffset ?? 86400 // default 1 day
const now = new Date()
// Cleanup expired items
const activeHomeworks = cleanupExpiredHomeworks(homeworks)
// Detect removed homeworks
const removed = cachedHomeworks.filter(
old => !activeHomeworks.some(hw => hw.id === old.id)
)
for (const r of removed) {
scheduleNotification(
`🗑️ Homework removed`,
`${r.subject || 'Unknown'} — ${r.text || 'deleted by teacher'}`,
'event'
)
console.log(`Removed homework: ${r.text || r.subject}`)
}
// Detect new or updated homeworks
for (const hw of activeHomeworks) {
const cached = cachedHomeworks.find(c => c.id === hw.id)
if (!cached) {
// New homework
scheduleNotification(
`📝 New homework: ${hw.subject || 'Unknown'}`,
hw.text ?? 'Check the widget for details.'
);
continue
}
// Due date changed → re-schedule notification
if (hw.dueDate && (!cached.dueDate || hw.dueDate.getTime() !== new Date(cached.dueDate).getTime())) {
const notifyDate = new Date(hw.dueDate.getTime() - offset * 1000)
if (notifyDate > now) {
scheduleNotification(
`⏰ Upcoming: ${hw.subject}`,
hw.text ?? '',
'event',
notifyDate
)
}
}
}
// Return cleaned list to be cached again
return activeHomeworks
}
38 changes: 33 additions & 5 deletions src/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export enum View {
EXAMS = 'exams',
GRADES = 'grades',
ABSENCES = 'absences',
HOMEWORKS = 'homeworks',
}

function parseLayoutString(layoutString: string) {
Expand Down Expand Up @@ -43,10 +44,37 @@ function adaptLayoutForSize(layout: View[][]) {
}
}

// keep the last raw layout string (so createWidget can show better error messages)
let LAST_LAYOUT_STRING = ""
export function getLayout() {
const layoutString = args.widgetParameter ?? defaultLayout
console.log(`Parsing layout string "${layoutString}..."`)
const layout = adaptLayoutForSize(parseLayoutString(layoutString))
console.log(`Got parsed layout: ${layout}`)
return layout
let layoutStringRaw = args.widgetParameter?.trim() ?? ""
LAST_LAYOUT_STRING = layoutStringRaw // store raw param for createWidget
let layoutString = layoutStringRaw
console.log(`Parsing layout string "${layoutString}"...`)

// Handle "all" or empty parameter
if (layoutString === "" || layoutString.toLowerCase() === "all") {
layoutString = defaultLayout
// If it was truly empty, clear LAST_LAYOUT_STRING so we don't treat it as "unknown"
if (layoutStringRaw === "") LAST_LAYOUT_STRING = ""
}

// Allow comma-separated values (e.g. "homeworks,lessons")
layoutString = layoutString
.split(",")
.map(v => v.trim().toLowerCase())
.filter(v => v.length > 0)
.join(",")

const layout = adaptLayoutForSize(parseLayoutString(layoutString))
console.log(`Got parsed layout: ${layout}`)
return layout
}

export function getLastLayoutString(): string {
return LAST_LAYOUT_STRING
}

export function setLastLayoutString(value: string) {
LAST_LAYOUT_STRING = value
}
Loading