diff --git a/app/(onboarding)/_layout.tsx b/app/(onboarding)/_layout.tsx
index 8867eb2bc..b10796ccf 100644
--- a/app/(onboarding)/_layout.tsx
+++ b/app/(onboarding)/_layout.tsx
@@ -6,140 +6,145 @@ import { Stack } from '@/utils/native/AnimatedNavigator';
import { screenOptions } from "@/utils/theme/ScreenOptions";
export default function OnboardingLayout() {
- const newScreenOptions = React.useMemo(() => ({
- ...screenOptions,
- headerShown: false,
- headerBackVisible: true,
- headerTitle: '',
- gestureEnabled: false,
- headerTransparent: true,
- headerTintColor: "#FFFFFF",
- headerBackButtonDisplayMode: "minimal",
- headerBackButtonMenuEnabled: false
- }), []);
+ const newScreenOptions = React.useMemo(() => ({
+ ...screenOptions,
+ headerShown: false,
+ headerBackVisible: true,
+ headerTitle: '',
+ gestureEnabled: false,
+ headerTransparent: true,
+ headerTintColor: "#FFFFFF",
+ headerBackButtonDisplayMode: "minimal",
+ headerBackButtonMenuEnabled: false
+ }), []);
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
}
\ No newline at end of file
diff --git a/app/(onboarding)/utils/constants.tsx b/app/(onboarding)/utils/constants.tsx
index a7c58faad..316a6d286 100644
--- a/app/(onboarding)/utils/constants.tsx
+++ b/app/(onboarding)/utils/constants.tsx
@@ -58,6 +58,17 @@ export function GetSupportedServices(redirect: (path: { pathname: string, option
variant: 'service' as const,
color: 'light' as const,
},
+ {
+ name: "webuntis",
+ title: "Web Untis",
+ type: "main",
+ image: require("@/assets/images/service_webuntis.png"),
+ onPress: () => {
+ redirect({ pathname: './webuntis/credentials', options: { service: Services.WEBUNTIS } });
+ },
+ variant: 'service' as const,
+ color: 'light' as const,
+ },
{
name: "separator",
title: "separator",
diff --git a/app/(onboarding)/webuntis/credentials.tsx b/app/(onboarding)/webuntis/credentials.tsx
new file mode 100644
index 000000000..bb51c28e6
--- /dev/null
+++ b/app/(onboarding)/webuntis/credentials.tsx
@@ -0,0 +1,290 @@
+import { useTheme } from "@react-navigation/native";
+import { router } from "expo-router";
+import React, { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Keyboard,
+ KeyboardAvoidingView,
+ Platform,
+ Pressable,
+ View,
+} from "react-native";
+import Reanimated, {
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Credentials, WebUntisClient } from "webuntis-client";
+
+import OnboardingBackButton from "@/components/onboarding/OnboardingBackButton";
+import OnboardingInput from "@/components/onboarding/OnboardingInput";
+import { useAccountStore } from "@/stores/account";
+import { Account, Services } from "@/stores/account/types";
+import { useAlert } from "@/ui/components/AlertProvider";
+import AnimatedPressable from "@/ui/components/AnimatedPressable";
+import Button from "@/ui/components/Button";
+import Stack from "@/ui/components/Stack";
+import Typography from "@/ui/components/Typography";
+import uuid from "@/utils/uuid/uuid";
+
+const ANIMATION_DURATION = 170;
+export const PlatformPressable =
+ Platform.OS === "android" ? Pressable : AnimatedPressable;
+
+export default function WebUntisLoginWithCredentials() {
+ const insets = useSafeAreaInsets();
+ const theme = useTheme();
+
+ const alert = useAlert();
+ const { t } = useTranslation();
+
+ const [url, setUrl] = useState("");
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+
+ const [isLoggingIn, setIsLoggingIn] = useState(false);
+
+ const opacity = useSharedValue(1);
+ const scale = useSharedValue(1);
+
+ const keyboardListeners = useMemo(
+ () => ({
+ show: () => {
+ "worklet";
+ opacity.value = withTiming(0, { duration: ANIMATION_DURATION });
+ scale.value = withTiming(0.8, { duration: ANIMATION_DURATION });
+ },
+ hide: () => {
+ "worklet";
+ opacity.value = withTiming(1, { duration: ANIMATION_DURATION });
+ scale.value = withTiming(1, { duration: ANIMATION_DURATION });
+ },
+ }),
+ [opacity]
+ );
+
+ useEffect(() => {
+ const showSub = Keyboard.addListener(
+ "keyboardWillShow",
+ keyboardListeners.show
+ );
+ const hideSub = Keyboard.addListener(
+ "keyboardWillHide",
+ keyboardListeners.hide
+ );
+
+ return () => {
+ showSub.remove();
+ hideSub.remove();
+ };
+ }, [keyboardListeners]);
+
+ const handleLogin = async () => {
+ if (!url.trim() || !username.trim() || !password.trim()) {
+ alert.showAlert({
+ title: t("ERROR_AUTHENTICATION"),
+ description: t("ERROR_MISSING_FIELDS"),
+ icon: "TriangleAlert",
+ color: "#D60046",
+ withoutNavbar: true,
+ });
+ return;
+ }
+
+ setIsLoggingIn(true);
+ Keyboard.dismiss();
+
+ const school = url.trim().split(".")[0];
+ const identity = "PapillonApp";
+
+ const credentials = new Credentials(identity, school, username, password);
+ const client = new WebUntisClient(credentials);
+
+ const accountId = uuid();
+ const store = useAccountStore.getState();
+
+ try {
+ const session = await client.login();
+
+ if (!session) {
+ throw new Error("No session information returned from WebUntis");
+ }
+
+ await client.getAppData();
+
+ const displayName = client.getStudentDisplayName();
+ const schoolName = client.getTenantDisplayName();
+
+ const account: Account = {
+ id: accountId,
+ firstName: displayName,
+ lastName: "",
+ schoolName: schoolName,
+ services: [
+ {
+ id: accountId,
+ auth: {
+ accessToken: session.sessionId,
+ refreshToken: password,
+ additionals: {
+ school: school,
+ username: username,
+ url: url,
+ password: password,
+ },
+ },
+ serviceId: Services.WEBUNTIS,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+ ],
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+
+ store.addAccount(account);
+ store.setLastUsedAccount(accountId);
+
+ queueMicrotask(() => {
+ router.push({
+ pathname: "../end/color",
+ params: { accountId },
+ });
+ });
+ } catch (e) {
+ setIsLoggingIn(false);
+
+ alert.showAlert({
+ title: t("ERROR_AUTHENTICATION"),
+ description: t("ERROR_WEBUNTIS_LOGIN"),
+ icon: "TriangleAlert",
+ color: "#D60046",
+ technical: String(e),
+ withoutNavbar: true,
+ });
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {t("STEP")} 2
+
+
+ {t("STEP_OUTOF")} 3
+
+
+
+ {t("ONBOARDING_LOGIN_CREDENTIALS")} Web Untis
+
+
+
+
+
+
+
+ {
+ Keyboard.dismiss();
+
+ if (
+ !isLoggingIn &&
+ url.trim() &&
+ username.trim() &&
+ password.trim()
+ ) {
+ handleLogin();
+ }
+ },
+ returnKeyType: "done",
+ editable: !isLoggingIn,
+ }}
+ />
+
+
+
+
+
+ );
+}
diff --git a/app/(tabs)/index/hooks/useHomeHeaderData.ts b/app/(tabs)/index/hooks/useHomeHeaderData.ts
index 5e754364e..4ad5f81cf 100644
--- a/app/(tabs)/index/hooks/useHomeHeaderData.ts
+++ b/app/(tabs)/index/hooks/useHomeHeaderData.ts
@@ -1,12 +1,14 @@
-import { useState, useEffect, useMemo, useRef } from 'react';
+import { useEffect, useMemo, useRef,useState } from 'react';
+
import { getChatsFromCache } from '@/database/useChat';
import { AccountManager, getManager, subscribeManagerUpdate } from '@/services/shared';
import { Attendance } from '@/services/shared/attendance';
import { Chat } from '@/services/shared/chat';
import { Period } from '@/services/shared/grade';
-import { getCurrentPeriod } from '@/utils/grades/helper/period';
import { useAccountStore } from '@/stores/account';
import { Services } from '@/stores/account/types';
+import { getCurrentPeriod } from '@/utils/grades/helper/period';
+import { warn } from '@/utils/logger/logger';
export const useHomeHeaderData = () => {
const accounts = useAccountStore((state) => state.accounts);
@@ -32,11 +34,11 @@ export const useHomeHeaderData = () => {
const [chats, setChats] = useState([]);
const absencesCount = useMemo(() => {
- if (!attendances) return 0;
+ if (!attendances) {return 0;}
let count = 0;
attendances.forEach(att => {
- if(att && "absences" in att) {
- if (att.absences) count += att.absences.length;
+ if (att && "absences" in att && att.absences) {
+ count += att.absences.length;
}
});
return count;
@@ -53,9 +55,16 @@ export const useHomeHeaderData = () => {
const updateAttendance = async (manager: AccountManager) => {
const periods = await manager.getAttendancePeriods();
const currentPeriod = getCurrentPeriod(periods);
- const fetchedAttendances = await manager.getAttendanceForPeriod(currentPeriod.name);
attendancesPeriodsRef.current = periods;
+
+ if (!currentPeriod) {
+ warn("No current attendance period found, skipping fetch");
+ setAttendances([]);
+ return;
+ }
+
+ const fetchedAttendances = await manager.getAttendanceForPeriod(currentPeriod.name);
setAttendances(fetchedAttendances);
};
@@ -66,8 +75,10 @@ export const useHomeHeaderData = () => {
const unsubscribe = subscribeManagerUpdate((_) => {
const manager = getManager();
- updateAttendance(manager);
- updateDiscussions(manager);
+ if (manager) {
+ updateAttendance(manager);
+ updateDiscussions(manager);
+ }
});
return () => unsubscribe();
diff --git a/assets/images/service_webuntis.png b/assets/images/service_webuntis.png
new file mode 100644
index 000000000..3a98819a0
Binary files /dev/null and b/assets/images/service_webuntis.png differ
diff --git a/locales/fr.json b/locales/fr.json
index ec510df03..011088c63 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -51,6 +51,9 @@
"INPUT_PIN": "Code PIN",
"SEARCH_UNIV_PLACEHOLDER": "Rechercher une instance...",
"ONBOARDING_LOADING_LOGIN": "Connexion en cours...",
+ "ERROR_AUTHENTICATION": "Une erreur s'est produite",
+ "ERROR_MISSING_FIELDS": "Veuillez remplir tous les champs",
+ "ERROR_WEBUNTIS_LOGIN": "La connexion à ton compte Web Untis a échoué",
"WAITING": "En attente",
"IZLY_SMS_SEND": "Tu viens de recevoir un lien pour te connecter, clique dessus et suis les étapes.",
@@ -63,6 +66,7 @@
"INPUT_PASSWORD": "Mot de passe",
"INPUT_PASSWORD_CODE": "Code d'accès",
"INPUT_ETABID": "Identifiant de ton établissement",
+ "INPUT_BASE_URL": "Lien de ton établissement",
"LOGIN_BTN": "Se connecter",
"CONFIRM_BTN": "Confirmer",
"CANCEL_BTN": "Annuler",
diff --git a/package-lock.json b/package-lock.json
index 0847e5425..6271c4986 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -90,17 +90,18 @@
"react-native-linear-gradient": "^2.8.3",
"react-native-mmkv": "^3.3.3",
"react-native-qrcode-svg": "^6.3.15",
- "react-native-reanimated": "~4.1.1",
+ "react-native-reanimated": "^4.2.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screen-transitions": "^2.0.3",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "^0.21.2",
"react-native-webview": "13.15.0",
- "react-native-worklets": "0.5.1",
+ "react-native-worklets": "^0.7.2",
"skolengojs": "^1.1.10",
"turboself-api": "^2.1.9",
"typescript-eslint": "^8.49.0",
+ "webuntis-client": "^1.1.1",
"zustand": "^5.0.9"
},
"devDependencies": {
@@ -7335,7 +7336,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.3.0.tgz",
"integrity": "sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==",
- "dev": true,
"license": "MIT",
"peerDependencies": {
"@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0",
@@ -13933,25 +13933,24 @@
}
},
"node_modules/react-native-reanimated": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",
- "integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==",
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz",
+ "integrity": "sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg==",
"license": "MIT",
"dependencies": {
- "react-native-is-edge-to-edge": "^1.2.1",
- "semver": "7.7.2"
+ "react-native-is-edge-to-edge": "1.2.1",
+ "semver": "7.7.3"
},
"peerDependencies": {
- "@babel/core": "^7.0.0-0",
"react": "*",
"react-native": "*",
- "react-native-worklets": ">=0.5.0"
+ "react-native-worklets": ">=0.7.0"
}
},
"node_modules/react-native-reanimated/node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -14066,33 +14065,33 @@
}
},
"node_modules/react-native-worklets": {
- "version": "0.5.1",
- "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz",
- "integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==",
- "license": "MIT",
- "dependencies": {
- "@babel/plugin-transform-arrow-functions": "^7.0.0-0",
- "@babel/plugin-transform-class-properties": "^7.0.0-0",
- "@babel/plugin-transform-classes": "^7.0.0-0",
- "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0",
- "@babel/plugin-transform-optional-chaining": "^7.0.0-0",
- "@babel/plugin-transform-shorthand-properties": "^7.0.0-0",
- "@babel/plugin-transform-template-literals": "^7.0.0-0",
- "@babel/plugin-transform-unicode-regex": "^7.0.0-0",
- "@babel/preset-typescript": "^7.16.7",
- "convert-source-map": "^2.0.0",
- "semver": "7.7.2"
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.2.tgz",
+ "integrity": "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/plugin-transform-arrow-functions": "7.27.1",
+ "@babel/plugin-transform-class-properties": "7.27.1",
+ "@babel/plugin-transform-classes": "7.28.4",
+ "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1",
+ "@babel/plugin-transform-optional-chaining": "7.27.1",
+ "@babel/plugin-transform-shorthand-properties": "7.27.1",
+ "@babel/plugin-transform-template-literals": "7.27.1",
+ "@babel/plugin-transform-unicode-regex": "7.27.1",
+ "@babel/preset-typescript": "7.27.1",
+ "convert-source-map": "2.0.0",
+ "semver": "7.7.3"
},
"peerDependencies": {
- "@babel/core": "^7.0.0-0",
+ "@babel/core": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-worklets/node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -16361,6 +16360,42 @@
"node": ">=12"
}
},
+ "node_modules/webuntis-client": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/webuntis-client/-/webuntis-client-1.1.1.tgz",
+ "integrity": "sha512-UcgGMmAivGyjsUp2Fp1YgJLBtSXVyeWa1IhMZ941qgc7PrZmz+wKZxD/HTUybh+HmPSk99h3errXKuRO3uBCww==",
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^5.6.2",
+ "dotenv": "^17.2.3",
+ "eslint-plugin-simple-import-sort": "^12.1.1",
+ "eslint-plugin-unused-imports": "^4.3.0"
+ }
+ },
+ "node_modules/webuntis-client/node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/webuntis-client/node_modules/dotenv": {
+ "version": "17.2.3",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
+ "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/whatwg-encoding": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
diff --git a/package.json b/package.json
index 3bf776418..6a60acb0b 100644
--- a/package.json
+++ b/package.json
@@ -96,17 +96,18 @@
"react-native-linear-gradient": "^2.8.3",
"react-native-mmkv": "^3.3.3",
"react-native-qrcode-svg": "^6.3.15",
- "react-native-reanimated": "~4.1.1",
+ "react-native-reanimated": "^4.2.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screen-transitions": "^2.0.3",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "^0.21.2",
"react-native-webview": "13.15.0",
- "react-native-worklets": "0.5.1",
+ "react-native-worklets": "^0.7.2",
"skolengojs": "^1.1.10",
"turboself-api": "^2.1.9",
"typescript-eslint": "^8.49.0",
+ "webuntis-client": "^1.1.1",
"zustand": "^5.0.9"
},
"devDependencies": {
diff --git a/services/shared/index.ts b/services/shared/index.ts
index c664ed370..116f3820d 100644
--- a/services/shared/index.ts
+++ b/services/shared/index.ts
@@ -102,7 +102,7 @@ export class AccountManager {
);
}
} catch (e) {
- throw new AuthenticationError(String(e), service)
+ throw new AuthenticationError(String(e), service);
}
}
@@ -316,7 +316,10 @@ export class AccountManager {
);
}
- async getWeeklyTimetable(weekNumber: number, date: Date): Promise {
+ async getWeeklyTimetable(
+ weekNumber: number,
+ date: Date
+ ): Promise {
return await this.fetchData(
Capabilities.TIMETABLE,
async client =>
@@ -325,7 +328,8 @@ export class AccountManager {
: [],
{
multiple: true,
- fallback: async () => getCoursesFromCache([weekNumber], date.getFullYear()),
+ fallback: async () =>
+ getCoursesFromCache([weekNumber], date.getFullYear()),
saveToCache: async (data: CourseDay[]) => {
addCourseDayToDatabase(data);
},
@@ -647,6 +651,12 @@ export class AccountManager {
return new module.Lannion(service.id);
}
+ if (service.serviceId === Services.WEBUNTIS) {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const module = require("@/services/webuntis/index");
+ return new module.WebUntis(service.id);
+ }
+
error(
"We're not able to find a plugin for service: " +
service.serviceId +
diff --git a/services/shared/types.ts b/services/shared/types.ts
index 974a2aff4..0e685a59d 100644
--- a/services/shared/types.ts
+++ b/services/shared/types.ts
@@ -6,6 +6,7 @@ import { SessionHandle } from "pawnote";
import { Client as ArdClient } from "pawrd";
import { Skolengo as SkolengoSession } from "skolengojs";
import { Client as TurboselfClient } from "turboself-api";
+import { WebUntisClient } from "webuntis-client";
import { Appscho } from "@/services/appscho";
import { Lannion } from "@/services/lannion";
@@ -34,6 +35,7 @@ import { Izly } from "../izly";
import { Multi } from "../multi";
import { Skolengo } from "../skolengo";
import { TurboSelf } from "../turboself";
+import { WebUntis } from "../webuntis";
import { Balance } from "./balance";
import { Kid } from "./kid";
@@ -58,11 +60,24 @@ export interface SchoolServicePlugin {
| TurboselfClient
| User
| LannionClient
+ | WebUntisClient
| undefined;
refreshAccount: (
credentials: Auth
- ) => Promise;
+ ) => Promise<
+ | Pronote
+ | Skolengo
+ | EcoleDirecte
+ | Multi
+ | TurboSelf
+ | ARD
+ | Izly
+ | Alise
+ | Appscho
+ | Lannion
+ | WebUntis
+ >;
getKids?: () => Kid[];
getCanteenKind?: () => CanteenKind;
getHomeworks?: (weekNumber: number) => Promise;
diff --git a/services/webuntis/homework.ts b/services/webuntis/homework.ts
new file mode 100644
index 000000000..11f39fbb3
--- /dev/null
+++ b/services/webuntis/homework.ts
@@ -0,0 +1,31 @@
+import { WebUntisClient } from "webuntis-client";
+
+import { getDateRangeOfWeek } from "@/database/useHomework";
+import { Homework } from "@/services/shared/homework";
+
+export async function fetchWebUntisHomeworks(
+ session: WebUntisClient,
+ accountId: string,
+ weekNumberRaw: number
+): Promise {
+ const result: Homework[] = [];
+
+ const { start, end } = getDateRangeOfWeek(weekNumberRaw);
+ const homeworks = await session.getHomeworks(start, end);
+
+ for (const homework of homeworks) {
+ result.push({
+ id: homework.id,
+ subject: homework.subject,
+ content: homework.text,
+ dueDate: homework.dueDate,
+ isDone: homework.completed,
+ attachments: [],
+ evaluation: false,
+ custom: false,
+ createdByAccount: accountId,
+ });
+ }
+
+ return result;
+}
diff --git a/services/webuntis/index.ts b/services/webuntis/index.ts
new file mode 100644
index 000000000..62ae706b2
--- /dev/null
+++ b/services/webuntis/index.ts
@@ -0,0 +1,62 @@
+import { WebUntisClient } from "webuntis-client";
+
+import { Auth, Services } from "@/stores/account/types";
+import { error } from "@/utils/logger/logger";
+
+import { Homework } from "../shared/homework";
+import { CourseDay } from "../shared/timetable";
+import { Capabilities, SchoolServicePlugin } from "../shared/types";
+import { fetchWebUntisHomeworks } from "./homework";
+import { refreshWebUntisAccount } from "./refresh";
+import { fetchWebUntisWeekTimetable } from "./timetable";
+
+export class WebUntis implements SchoolServicePlugin {
+ displayName = "WebUntis";
+ service = Services.WEBUNTIS;
+ capabilities: Capabilities[] = [Capabilities.REFRESH];
+ session: WebUntisClient | undefined = undefined;
+ authData: Auth = {};
+
+ constructor(public accountId: string) {}
+
+ async refreshAccount(credentials: Auth): Promise {
+ const refresh = await refreshWebUntisAccount(this.accountId, credentials);
+
+ this.authData = refresh.auth;
+ this.session = refresh.session;
+
+ this.capabilities.push(Capabilities.TIMETABLE);
+ this.capabilities.push(Capabilities.HOMEWORK);
+
+ return this;
+ }
+
+ async getWeeklyTimetable(
+ weekNumber: number,
+ date: Date
+ ): Promise {
+ if (this.session) {
+ return await fetchWebUntisWeekTimetable(
+ this.session,
+ this.accountId,
+ weekNumber
+ );
+ }
+
+ error("Session is not valid", "WebUntis.getWeeklyTimetable");
+ return [];
+ }
+
+ async getHomeworks(weekNumber: number): Promise {
+ if (this.session) {
+ return await fetchWebUntisHomeworks(
+ this.session,
+ this.accountId,
+ weekNumber
+ );
+ }
+
+ error("Session is not valid", "WebUntis.getHomeworks");
+ return [];
+ }
+}
diff --git a/services/webuntis/refresh.ts b/services/webuntis/refresh.ts
new file mode 100644
index 000000000..e6d331d00
--- /dev/null
+++ b/services/webuntis/refresh.ts
@@ -0,0 +1,49 @@
+import { Credentials, WebUntisClient } from "webuntis-client";
+
+import { useAccountStore } from "@/stores/account";
+import { Auth } from "@/stores/account/types";
+
+/**
+ * Refreshes the WebUntis account credentials using the provided authentication data.
+ * @param accountId
+ * @param authCredentials
+ * @returns {Promise} A promise that resolves to the updated authentication data.
+ */
+export async function refreshWebUntisAccount(
+ accountId: string,
+ authCredentials: Auth
+): Promise<{ auth: Auth; session: WebUntisClient }> {
+ const school = String(authCredentials.additionals?.["school"] || "");
+ const username = String(authCredentials.additionals?.["username"] || "");
+ const password = String(
+ authCredentials.additionals?.["password"] ||
+ authCredentials.refreshToken ||
+ ""
+ );
+ const url = String(authCredentials.additionals?.["url"] || "");
+
+ const credentials = new Credentials(
+ "PapillonApp",
+ school,
+ username,
+ password
+ );
+ const client = new WebUntisClient(credentials);
+
+ const session = await client.login();
+
+ const auth: Auth = {
+ accessToken: session.sessionId || "",
+ refreshToken: password,
+ additionals: {
+ school: school,
+ username: username,
+ url: url,
+ password: password,
+ },
+ };
+
+ useAccountStore.getState().updateServiceAuthData(accountId, auth);
+
+ return { auth: auth, session: client };
+}
diff --git a/services/webuntis/timetable.ts b/services/webuntis/timetable.ts
new file mode 100644
index 000000000..6d4827275
--- /dev/null
+++ b/services/webuntis/timetable.ts
@@ -0,0 +1,75 @@
+import { WebUntisClient } from "webuntis-client";
+import { TimetableDay } from "webuntis-client/dist/types/timetable/day";
+
+import { getDateRangeOfWeek } from "@/database/useHomework";
+import {
+ Course,
+ CourseDay,
+ CourseStatus,
+ CourseType,
+} from "@/services/shared/timetable";
+
+export async function fetchWebUntisWeekTimetable(
+ session: WebUntisClient,
+ accountId: string,
+ weekNumber: number
+): Promise {
+ const { start, end } = getDateRangeOfWeek(weekNumber);
+ const timetable = await session.getOwnTimetable(start, end);
+
+ const mappedCourses = mapCourses(accountId, timetable);
+ const dayMap: Record = {};
+
+ for (const course of mappedCourses) {
+ const dayKey = course.from.toISOString().split("T")[0];
+ dayMap[dayKey] = dayMap[dayKey] || [];
+ dayMap[dayKey].push(course);
+ }
+
+ for (const day in dayMap) {
+ dayMap[day].sort((a, b) => a.from.getTime() - b.from.getTime());
+ }
+
+ return Object.entries(dayMap).map(([day, courses]) => ({
+ date: new Date(day),
+ courses,
+ }));
+}
+
+const mapCourses = (accountId: string, days: TimetableDay[]): Course[] => {
+ const courseList: Course[] = [];
+
+ for (const day of days) {
+ for (const c of day.gridEntries) {
+ if (!c.subject || !c.room || !c.teacher) {
+ continue;
+ }
+
+ const subject = c.subject!.displayName;
+ const room = c.room!.displayName;
+ const teacher = c.teacher!.longName;
+
+ const course: Course = {
+ id:
+ subject + room + teacher + c.from.toISOString() + c.to.toISOString(),
+ type: CourseType.LESSON,
+ subject: subject,
+ room: room,
+ teacher: teacher,
+ createdByAccount: accountId,
+ from: c.from,
+ to: c.to,
+ };
+
+ if (c.type === "EXAM") {
+ course.status = CourseStatus.EVALUATED;
+ } else if (c.status === "CHANGED") {
+ course.status = CourseStatus.EDITED;
+ }
+
+ courseList.push(course);
+ }
+ }
+
+ return courseList;
+};
diff --git a/stores/account/types.ts b/stores/account/types.ts
index fa79ea34f..fc9a831a3 100644
--- a/stores/account/types.ts
+++ b/stores/account/types.ts
@@ -92,6 +92,7 @@ export enum Services {
PRONOTE,
SKOLENGO,
ECOLEDIRECTE,
+ WEBUNTIS,
TURBOSELF,
ARD,
IZLY,
diff --git a/utils/grades/helper/period.ts b/utils/grades/helper/period.ts
index 33817c624..6b308aca1 100644
--- a/utils/grades/helper/period.ts
+++ b/utils/grades/helper/period.ts
@@ -1,7 +1,7 @@
import { Period } from "@/services/shared/grade";
import { error, warn } from "@/utils/logger/logger";
-export function getCurrentPeriod(periods: Period[]): Period {
+export function getCurrentPeriod(periods: Period[]): Period | null {
const now = new Date().getTime();
const excludedNames = ["Bac blanc", "Brevet blanc", "Hors période", "Année", "Contrôle en cours de formation", "EPREUVES PONCTUELLES 1ERE SERIE", "EPREUVES PONCTUELLES 2EME SERIE", "MI-SEMESTRE 1", "MI-SEMESTRE 2"];
periods = periods
@@ -20,4 +20,5 @@ export function getCurrentPeriod(periods: Period[]): Period {
}
error("Unable to find the current period and unable to fallback...");
+ return null;
}
\ No newline at end of file