feat: 🎸 Hämta lektionsschema (#110)

* feat: 🎸 add timetables from skola24

* refactor: 💡 Clean up sso authorization

* feat: 🎸 Reads timetable

* feat: 🎸 Veckoschema

Veckoschema kan laddas från Skola24

BREAKING CHANGE: 🧨 Child -> EtjanstChild

* feat: 🎸 Test data for skola24Children and timetable

* docs: ✏️ Updated instructions

Co-authored-by: Johan Öbrink <johan.obrink@gmail.com>
This commit is contained in:
Kajetan Kazimierczak 2021-04-09 10:35:00 +02:00 committed by GitHub
parent 3c33c75956
commit c2884497bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 911 additions and 51 deletions

View File

@ -16,69 +16,68 @@ the concrete implementation of fetch and cookie handler must be injected.
#### react-native
```javascript
import init from "@skolplattformen/embedded-api";
import CookieManager from "@react-native-community/cookies";
import init from '@skolplattformen/embedded-api'
import CookieManager from '@react-native-community/cookies'
const api = init(fetch, () => CookieManager.clearAll());
const api = init(fetch, () => CookieManager.clearAll())
```
#### node
```javascript
import init from "@skolplattformen/embedded-api";
import nodeFetch from "node-fetch";
import fetchCookie from "fetch-cookie/node-fetch";
import { CookieJar } from "tough-cookie";
import init from '@skolplattformen/embedded-api'
import nodeFetch from 'node-fetch'
import fetchCookie from 'fetch-cookie/node-fetch'
import { CookieJar } from 'tough-cookie'
const cookieJar = new CookieJar();
const fetch = fetchCookie(nodeFetch, cookieJar);
const cookieJar = new CookieJar()
const fetch = fetchCookie(nodeFetch, cookieJar)
const api = init(fetch, () => cookieJar.removeAllCookies());
const api = init(fetch, cookieJar)
```
### Login / logout
```javascript
api.on("login", async () => {
api.on('login', async () => {
// do stuff
console.log(api.isLoggedIn) // true
console.log(api.getSessionCookie) // session cookie if you want to save it
await api.logout()
});
})
api.on('logout', () => {
// handle logout
console.log(api.isLoggedIn) // false
}
const loginStatus = await api.login("YYYYMMDDXXXX");
const loginStatus = await api.login('YYYYMMDDXXXX')
window.open(
`https://app.bankid.com/?autostarttoken=${loginStatus.token}&redirect=null`
);
)
loginStatus.on("PENDING", () => console.log("BankID app not yet opened"));
loginStatus.on("USER_SIGN", () => console.log("BankID app is open"));
loginStatus.on("ERROR", () => console.log("Something went wrong"));
loginStatus.on("OK", () =>
console.log("BankID sign successful. Session will be established.")
);
loginStatus.on('PENDING', () => console.log('BankID app not yet opened'))
loginStatus.on('USER_SIGN', () => console.log('BankID app is open'))
loginStatus.on('ERROR', () => console.log('Something went wrong'))
loginStatus.on('OK', () =>
console.log('BankID sign successful. Session will be established.')
)
```
### Loading data
```javascript
// Get current user
const user = await api.getUser();
const user = await api.getUser()
// List children
const children = await api.getChildren();
// List children from Etjanster
const children = await api.getChildren()
// Get calendar
const calendar = await api.getCalendar(children[0]);
// Get school calendar
const calendar = await api.getCalendar(children[0])
// Get classmates
const classmates = await api.getClassmates(children[0]);
// Get classmates - disabled for reasons
// const classmates = await api.getClassmates(children[0])
// Get schedule
// Get student's personal schedule
import { DateTime } from 'luxon'
const from = DateTime.local()
@ -88,11 +87,22 @@ const schedule = await api.getSchedule(children[0], from, to)
// Get news
const news = await api.getNews(children[0])
// Get news details
const newsDetails = await api.getNewsDetails(children[0], news[0])
// Get menu
const menu = await api.getMenu(children[0])
// Get notifications
const notifications = await api.getNotifications(children[0])
// Get list of children from Skola24 (because of course it's different *DERP*)
const skola24Children = await getSkola24Children()
// Get timetable
const weekNumber = 15
const year = 2021
const timetable = await api.getTimeTable(skola24Children[0], weekNumber, year)
```
### Setting session cookie
@ -100,9 +110,9 @@ const notifications = await api.getNotifications(children[0])
It is possible to resurrect a logged in session by manually setting the session cookie.
```javascript
const sessionCookie = "some value";
const sessionCookie = 'some value'
api.setSessionCookie(sessionCookie); // will trigger `on('login')` event and set `.isLoggedIn = true`
api.setSessionCookie(sessionCookie) // will trigger `on('login')` event and set `.isLoggedIn = true`
```
### Fake user
@ -111,7 +121,7 @@ Login with personal number `12121212121212`, `201212121212` or `1212121212` and
api will be put into fake mode.
Static data will be returned and no calls to backend will be made.
The `LoginStatusChecker` returned by the login method will have `.token` set to "fake".
The `LoginStatusChecker` returned by the login method will have `.token` set to 'fake'.
## Try it out

View File

@ -165,7 +165,7 @@ describe('api', () => {
status = await api.login('1212121212')
expect(status.token).toEqual('fake')
})
it.skip('delivers fake data', async (done) => {
it('delivers fake data', async (done) => {
api.on('login', async () => {
const user = await api.getUser()
expect(user).toEqual({
@ -181,6 +181,12 @@ describe('api', () => {
const calendar2 = await api.getCalendar(children[1])
expect(calendar2).toHaveLength(18)
const skola24Children = await api.getSkola24Children()
expect(skola24Children).toHaveLength(1)
const timetable = await api.getTimetable(skola24Children[0], 2021, 15)
expect(timetable).toHaveLength(32)
done()
})
await api.login('121212121212')

View File

@ -2,11 +2,11 @@ import { DateTime } from 'luxon'
import { EventEmitter } from 'events'
import { decode } from 'he'
import * as html from 'node-html-parser'
import { URLSearchParams } from 'url'
import { checkStatus, LoginStatusChecker } from './loginStatus'
import {
AuthTicket,
CalendarItem,
Child,
Classmate,
CookieManager,
Fetch,
@ -16,15 +16,35 @@ import {
RequestInit,
ScheduleItem,
User,
Skola24Child,
EtjanstChild,
SSOSystem,
} from './types'
import * as routes from './routes'
import * as parse from './parse'
import * as parse from './parse/index'
import wrap, { Fetcher, FetcherOptions } from './fetcher'
import * as fake from './fakeData'
const fakeResponse = <T>(data: T): Promise<T> =>
new Promise((res) => setTimeout(() => res(data), 200 + Math.random() * 800))
const s24Init = {
headers: {
accept: 'application/json, text/javascript, */*; q=0.01',
referer: 'https://fns.stockholm.se/ng/timetable/timetable-viewer/fns.stockholm.se/',
'accept-language': 'en-US,en;q=0.9,sv;q=0.8',
'cache-control': 'no-cache',
'content-type': 'application/json',
pragma: 'no-cache',
host: 'fns.stockholm.se',
'x-scope': '8a22163c-8662-4535-9050-bc5e1923df48',
},
}
interface SSOSystems {
[name: string]: boolean | undefined
}
export class Api extends EventEmitter {
private fetch: Fetcher
@ -40,6 +60,8 @@ export class Api extends EventEmitter {
public childControllerUrl?: string
private authorizedSystems: SSOSystems = {}
constructor(
fetch: Fetch,
cookieManager: CookieManager,
@ -281,7 +303,7 @@ export class Api extends EventEmitter {
return parse.user(data)
}
public async getChildren(): Promise<Child[]> {
public async getChildren(): Promise<EtjanstChild[]> {
if (this.isFake) return fakeResponse(fake.children())
const cdnUrl = await this.retrieveCdnUrl()
@ -309,7 +331,7 @@ export class Api extends EventEmitter {
return parse.children(data)
}
public async getCalendar(child: Child): Promise<CalendarItem[]> {
public async getCalendar(child: EtjanstChild): Promise<CalendarItem[]> {
if (this.isFake) return fakeResponse(fake.calendar(child))
const url = routes.calendar(child.id)
@ -319,7 +341,7 @@ export class Api extends EventEmitter {
return parse.calendar(data)
}
public async getClassmates(child: Child): Promise<Classmate[]> {
public async getClassmates(child: EtjanstChild): Promise<Classmate[]> {
if (this.isFake) return fakeResponse(fake.classmates(child))
const url = routes.classmates(child.sdsId)
@ -330,7 +352,7 @@ export class Api extends EventEmitter {
}
public async getSchedule(
child: Child,
child: EtjanstChild,
from: DateTime,
to: DateTime
): Promise<ScheduleItem[]> {
@ -343,7 +365,7 @@ export class Api extends EventEmitter {
return parse.schedule(data)
}
public async getNews(child: Child): Promise<NewsItem[]> {
public async getNews(child: EtjanstChild): Promise<NewsItem[]> {
if (this.isFake) return fakeResponse(fake.news(child))
const url = routes.news(child.id)
@ -353,7 +375,7 @@ export class Api extends EventEmitter {
return parse.news(data)
}
public async getNewsDetails(child: Child, item: NewsItem): Promise<any> {
public async getNewsDetails(child: EtjanstChild, item: NewsItem): Promise<any> {
if (this.isFake) {
return fakeResponse(fake.news(child).find((ni) => ni.id === item.id))
}
@ -364,7 +386,7 @@ export class Api extends EventEmitter {
return parse.newsItemDetails(data)
}
public async getMenu(child: Child): Promise<MenuItem[]> {
public async getMenu(child: EtjanstChild): Promise<MenuItem[]> {
if (this.isFake) return fakeResponse(fake.menu(child))
const menuService = await this.getMenuChoice(child)
@ -383,7 +405,7 @@ export class Api extends EventEmitter {
return parse.menuList(data)
}
private async getMenuChoice(child: Child): Promise<string> {
private async getMenuChoice(child: EtjanstChild): Promise<string> {
const url = routes.menuChoice(child.id)
const session = this.getRequestInit()
const response = await this.fetch('menu-choice', url, session)
@ -392,7 +414,7 @@ export class Api extends EventEmitter {
return etjanstResponse
}
public async getNotifications(child: Child): Promise<Notification[]> {
public async getNotifications(child: EtjanstChild): Promise<Notification[]> {
if (this.isFake) return fakeResponse(fake.notifications(child))
const url = routes.notifications(child.sdsId)
@ -402,10 +424,138 @@ export class Api extends EventEmitter {
return parse.notifications(data)
}
private async readSAMLRequest(targetSystem: string): Promise<string> {
const url = routes.ssoRequestUrl(targetSystem)
const session = this.getRequestInit({
redirect: 'follow',
})
const response = await this.fetch('samlRequest', url, session)
const text = await response.text()
const samlRequest = /name="SAMLRequest" value="(?<saml>\S+)">/gm.exec(text || '')?.groups?.saml
if (!samlRequest) {
throw new Error('Could not parse SAML Request')
} else {
return samlRequest
}
}
private async submitSAMLRequest(samlRequest: string): Promise<string> {
const body = new URLSearchParams({ SAMLRequest: samlRequest }).toString()
const url = routes.ssoResponseUrl
const session = this.getRequestInit({
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
redirect: 'follow',
method: 'POST',
body,
})
const response = await this.fetch('samlResponse', url, session)
const text = await response.text()
const samlResponse = /name="SAMLResponse" value="(?<saml>\S+)">/gm.exec(text)?.groups?.saml
if (!samlResponse) {
throw new Error('Could not parse SAML Response')
} else {
return samlResponse
}
}
private async ssoAuthorize(targetSystem: SSOSystem): Promise<string> {
if (this.authorizedSystems[targetSystem]) {
return ''
}
const samlRequest = await this.readSAMLRequest(targetSystem)
const samlResponse = await this.submitSAMLRequest(samlRequest)
const body = new URLSearchParams({ SAMLResponse: samlResponse }).toString()
const url = routes.samlResponseUrl
const session = this.getRequestInit({
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
redirect: 'follow',
method: 'POST',
body,
})
const response = await this.fetch('samlAuthorize', url, session)
const text = await response.text()
this.authorizedSystems[targetSystem] = true
return text
}
public async getSkola24Children(): Promise<Skola24Child[]>{
if (this.isFake) return fakeResponse(fake.skola24Children())
await this.ssoAuthorize('TimetableViewer')
const body = { getPersonalTimetablesRequest: {
hostName: 'fns.stockholm.se'
}}
const session = this.getRequestInit({
...s24Init,
body: JSON.stringify(body),
method: 'POST',
})
const url = routes.timetables
const response = await this.fetch('s24children', url, session)
const {
data: {
getPersonalTimetablesResponse: {
childrenTimetables
}
}
} = await response.json()
return childrenTimetables as Skola24Child[]
}
private async getRenderKey(): Promise<string> {
const url = routes.renderKey
const session = this.getRequestInit(s24Init)
const response = await this.fetch('renderKey', url, session)
const { data: { key } } = await response.json()
return key as string
}
public async getTimetable(child: Skola24Child, week: number, year: number): Promise<any> {
if (this.isFake) return fakeResponse(fake.timetable(child))
const url = routes.timetable
const renderKey = await this.getRenderKey()
const params = {
blackAndWhite: false,
customerKey: '',
endDate: null,
height: 1063,
host: 'fns.stockholm.se',
periodText: '',
privateFreeTextMode: null,
privateSelectionMode: true,
renderKey,
scheduleDay: 0,
selection: child.personGuid,
selectionType: 5,
showHeader: false,
startDate: null,
unitGuid: child.unitGuid,
week,
width: 1227,
year,
}
const session = this.getRequestInit({
...s24Init,
method: 'POST',
body: JSON.stringify(params),
})
const response = await this.fetch(`timetable_${child.personGuid}_${year}_${week}`, url, session)
const json = await response.json()
return parse.timetable(json, year, week)
}
public async logout() {
this.isFake = false
this.personalNumber = undefined
this.isLoggedIn = false
this.authorizedSystems = {}
this.emit('logout')
await this.clearSession()
}

View File

@ -3,10 +3,13 @@ import {
CalendarItem,
Child,
Classmate,
EtjanstChild,
MenuItem,
NewsItem,
Notification,
ScheduleItem,
Skola24Child,
TimetableEntry,
User,
} from './types'
@ -1090,7 +1093,7 @@ export const user = (): User => ({
lastName: 'Namnsson',
})
export const children = (): Child[] => [
export const children = (): EtjanstChild[] => [
{
name: 'Shanel Nilsson (elev)',
id: '39b59e-bf4b9f-f68ac25321-977218-bf0',
@ -1106,8 +1109,19 @@ export const children = (): Child[] => [
schoolId: '8e6b13b-3116-e66c39b-a4c3fa5-a1d72d9',
},
]
export const skola24Children = (): Skola24Child[] => [
{
firstName: 'Shanel',
lastName: 'Jonsson Nilsson',
personGuid: 'abc123',
schoolGuid: 'def456',
schoolID: 'ghi789',
timetableID: 'jkl012',
unitGuid: 'mno345'
},
]
export const classmates = (child: Child): Classmate[] =>
export const classmates = (child: EtjanstChild): Classmate[] =>
data[child.id].classmates
export const news = (child: Child): NewsItem[] => data[child.id].news
@ -1122,3 +1136,425 @@ export const menu = (child: Child): MenuItem[] => data[child.id].menu
export const notifications = (child: Child): Notification[] =>
data[child.id].notifications
export const timetable = (child: Skola24Child): TimetableEntry[] => {
if (!child.personGuid || !child.unitGuid) return []
return [
{
id: 'N2FjMDc1NjYtZmM2Yy0wZDQyLTY3M2YtZWI5NGNiZDA3ZGU4',
blockName: '',
code: 'Lunch',
dayOfWeek: 1,
location: 'Ö5',
teacher: '',
timeEnd: '12:05:00',
timeStart: '11:40:00',
dateStart: '2021-04-12T11:40:00.000+02:00',
dateEnd: '2021-04-12T12:05:00.000+02:00',
name: '',
},
{
id: 'ZTQ1NWE0N2EtNzAwOS0wZTAzLTQ1ZDYtNTA1NWI4Y2JhNDYw',
blockName: '',
code: 'BL',
dayOfWeek: 1,
location: '221',
teacher: 'KUr',
timeEnd: '11:35:00',
timeStart: '09:40:00',
dateStart: '2021-04-12T09:40:00.000+02:00',
dateEnd: '2021-04-12T11:35:00.000+02:00',
name: '',
},
{
id: 'YjAxODRmY2QtNTJjZS0wMDJlLTYxOGItYmFlNTVlNDgzZmVk',
blockName: '',
code: 'NO',
dayOfWeek: 1,
location: '307',
teacher: 'TBo',
timeEnd: '13:30:00',
timeStart: '12:30:00',
dateStart: '2021-04-12T12:30:00.000+02:00',
dateEnd: '2021-04-12T13:30:00.000+02:00',
name: '',
},
{
id: 'MWRiZGI1NzgtYWIzNy0wYzMwLTVkMmEtMWFjNWRkMTRmOTdh',
blockName: '',
code: 'IDH',
dayOfWeek: 1,
location: '215',
teacher: 'HAl',
timeEnd: '15:45:00',
timeStart: '14:40:00',
dateStart: '2021-04-12T14:40:00.000+02:00',
dateEnd: '2021-04-12T15:45:00.000+02:00',
name: '',
},
{
id: 'MmZkZTZiMzMtMjdjMS0wZGIzLTUzYWYtZTg0Zjc1NDRlNzQw',
blockName: '',
code: 'M2FR',
dayOfWeek: 1,
location: '304',
teacher: 'DNi',
timeEnd: '14:25:00',
timeStart: '13:40:00',
dateStart: '2021-04-12T13:40:00.000+02:00',
dateEnd: '2021-04-12T14:25:00.000+02:00',
name: '',
},
{
id: 'MzAxMzU3MWItZGM1Ny0wOGVhLTVkZjUtOGFkMGIyYTY2OTAx',
blockName: '',
code: 'SO',
dayOfWeek: 1,
location: '303',
teacher: 'HRr',
timeEnd: '09:25:00',
timeStart: '08:15:00',
dateStart: '2021-04-12T08:15:00.000+02:00',
dateEnd: '2021-04-12T09:25:00.000+02:00',
name: '',
},
{
id: 'NDY3MDY1MmYtOTIzYi0wZmQ0LTVlZGEtNGVhZDRkOTExNTgz',
blockName: '',
code: 'M2FR',
dayOfWeek: 2,
location: '302,Fjärr',
teacher: 'DNi',
timeEnd: '09:50:00',
timeStart: '09:05:00',
dateStart: '2021-04-13T09:05:00.000+02:00',
dateEnd: '2021-04-13T09:50:00.000+02:00',
name: '',
},
{
id: 'NmE4OTU1NmItYzM0ZS0wYTI1LTYzM2QtYzBiN2M4OTVmYTQ3',
blockName: '',
code: 'EN',
dayOfWeek: 2,
location: 'Fjärr',
teacher: 'TPe',
timeEnd: '13:15:00',
timeStart: '12:30:00',
dateStart: '2021-04-13T12:30:00.000+02:00',
dateEnd: '2021-04-13T13:15:00.000+02:00',
name: '',
},
{
id: 'NDAxODRjOTctMmE5ZC0wMzdjLTY2NDMtODhlODEzOTQ3YTJh',
blockName: '',
code: 'Lunch',
dayOfWeek: 2,
location: 'Fjärr',
teacher: '',
timeEnd: '12:05:00',
timeStart: '11:40:00',
dateStart: '2021-04-13T11:40:00.000+02:00',
dateEnd: '2021-04-13T12:05:00.000+02:00',
name: '',
},
{
id: 'ZTc4YTcyZTUtMDc0NS0wNDE0LTVjODctYjY0MzQ2MGM3MDll',
blockName: '',
code: 'MA',
dayOfWeek: 2,
location: 'Fjärr',
teacher: 'CBr',
timeEnd: '11:20:00',
timeStart: '10:00:00',
dateStart: '2021-04-13T10:00:00.000+02:00',
dateEnd: '2021-04-13T11:20:00.000+02:00',
name: '',
},
{
id: 'MjRkMWE4YTItYTk5ZC0wYTFmLTVhMDgtMThiMmNhZDc1ZDUz',
blockName: '',
code: 'MU',
dayOfWeek: 2,
location: 'Fjärr',
teacher: 'KBj',
timeEnd: '14:15:00',
timeStart: '13:30:00',
dateStart: '2021-04-13T13:30:00.000+02:00',
dateEnd: '2021-04-13T14:15:00.000+02:00',
name: '',
},
{
id: 'NTU4ZTc4ZTctNDQyMy0wMjVkLTRiYzktZGUwYmFmYzk2YTlj',
blockName: '',
code: 'EN',
dayOfWeek: 3,
location: '303',
teacher: 'TPe',
timeEnd: '09:55:00',
timeStart: '09:10:00',
dateStart: '2021-04-14T09:10:00.000+02:00',
dateEnd: '2021-04-14T09:55:00.000+02:00',
name: '',
},
{
id: 'NDUyNjIxODItYzFiOC0wOTFjLTYwODYtZDllZjZjN2QyYzA3',
blockName: '',
code: 'SV a)',
dayOfWeek: 3,
location: '303',
teacher: 'JCa',
timeEnd: '14:45:00',
timeStart: '14:00:00',
dateStart: '2021-04-14T14:00:00.000+02:00',
dateEnd: '2021-04-14T14:45:00.000+02:00',
name: '',
},
{
id: 'NDdkMGI0ZjItMjkxMC0wYWI1LTQ0YWMtNDY3NTdkZTE2Njg3',
blockName: '',
code: 'SO',
dayOfWeek: 3,
location: '303',
teacher: 'HRr',
timeEnd: '11:00:00',
timeStart: '10:05:00',
dateStart: '2021-04-14T10:05:00.000+02:00',
dateEnd: '2021-04-14T11:00:00.000+02:00',
name: '',
},
{
id: 'ZTI2ZDgyNWUtM2ZlOS0wZDVmLTY5NTctNGYzZThjMTMxOTdh',
blockName: '',
code: 'NO a)',
dayOfWeek: 3,
location: '307',
teacher: 'TBo',
timeEnd: '13:50:00',
timeStart: '12:50:00',
dateStart: '2021-04-14T12:50:00.000+02:00',
dateEnd: '2021-04-14T13:50:00.000+02:00',
name: '',
},
{
id: 'NzMxNjczNGMtMmZmZi0wM2YzLTU0ZjMtODdjOTAwYzIwNTUw',
blockName: '',
code: 'Lunch',
dayOfWeek: 3,
location: 'Ö5',
teacher: '',
timeEnd: '12:40:00',
timeStart: '12:15:00',
dateStart: '2021-04-14T12:15:00.000+02:00',
dateEnd: '2021-04-14T12:40:00.000+02:00',
name: '',
},
{
id: 'MWRkZjhlZTktNTBmMC0wZjNhLTQ1OTgtMWJkOWM3MjI2NWQ4',
blockName: '',
code: 'SV',
dayOfWeek: 3,
location: '303',
teacher: 'JCa',
timeEnd: '12:05:00',
timeStart: '11:20:00',
dateStart: '2021-04-14T11:20:00.000+02:00',
dateEnd: '2021-04-14T12:05:00.000+02:00',
name: '',
},
{
id: 'NzM2Mjc2ZTYtY2JlYy0wOTc1LTU1ZGYtNjMwZjhjZWVjNjgy',
blockName: '',
code: 'MA a)',
dayOfWeek: 3,
location: '307',
teacher: 'CBr',
timeEnd: '15:45:00',
timeStart: '15:00:00',
dateStart: '2021-04-14T15:00:00.000+02:00',
dateEnd: '2021-04-14T15:45:00.000+02:00',
name: '',
},
{
id: 'YWNlZmEzZjYtM2EwNC0wYWY3LTU1N2MtMDBlMTA4MDQzMzRl',
blockName: '',
code: 'MU',
dayOfWeek: 3,
location: '504',
teacher: 'KBj',
timeEnd: '09:00:00',
timeStart: '08:15:00',
dateStart: '2021-04-14T08:15:00.000+02:00',
dateEnd: '2021-04-14T09:00:00.000+02:00',
name: '',
},
{
id: 'NDc4MThmMDYtYmYxYi0wZDBkLTdhNmItZGVjMjY3OWY3MmYz',
blockName: '',
code: 'IDH',
dayOfWeek: 4,
location: 'Fjärr',
teacher: 'AKö,CSv,HAl',
timeEnd: '15:45:00',
timeStart: '14:35:00',
dateStart: '2021-04-15T14:35:00.000+02:00',
dateEnd: '2021-04-15T15:45:00.000+02:00',
name: '',
},
{
id: 'ZjQyZjNkOWItYWMzZi0wYWRhLTQ3YzItNTZiNTJkOTRmY2Iy',
blockName: '',
code: 'M2FR',
dayOfWeek: 4,
location: 'Fjärr',
teacher: 'DNi',
timeEnd: '11:55:00',
timeStart: '11:10:00',
dateStart: '2021-04-15T11:10:00.000+02:00',
dateEnd: '2021-04-15T11:55:00.000+02:00',
name: '',
},
{
id: 'YzQ2NWZlOWMtYzM3ZC0wYzBlLTQzNTQtODMyYmU3ODcxMDQ3',
blockName: '',
code: 'MTID Arbetslagsråd 7C',
dayOfWeek: 4,
location: 'Fjärr',
teacher: 'JCa,CBr',
timeEnd: '10:00:00',
timeStart: '09:15:00',
dateStart: '2021-04-15T09:15:00.000+02:00',
dateEnd: '2021-04-15T10:00:00.000+02:00',
name: '',
},
{
id: 'YzMwMGY0YzAtNjhjNi0wYzY0LTU1MjctODg2MWQ4ZTRmZTI2',
blockName: '',
code: 'MU',
dayOfWeek: 4,
location: 'Fjärr',
teacher: 'KBj',
timeEnd: '10:55:00',
timeStart: '10:10:00',
dateStart: '2021-04-15T10:10:00.000+02:00',
dateEnd: '2021-04-15T10:55:00.000+02:00',
name: '',
},
{
id: 'ZDNlNTFhMGUtYWFlYy0wOGI0LTVlMGItOTc0MzFiZmIwODcx',
blockName: '',
code: 'Lunch',
dayOfWeek: 4,
location: 'Fjärr',
teacher: '',
timeEnd: '12:25:00',
timeStart: '12:00:00',
dateStart: '2021-04-15T12:00:00.000+02:00',
dateEnd: '2021-04-15T12:25:00.000+02:00',
name: '',
},
{
id: 'MDRiZWMyODMtNjEwZC0wZDYwLTRlOWItYTY1MjAwZTc0YTZm',
blockName: '',
code: 'SO',
dayOfWeek: 4,
location: 'Fjärr',
teacher: 'HRr',
timeEnd: '13:10:00',
timeStart: '12:35:00',
dateStart: '2021-04-15T12:35:00.000+02:00',
dateEnd: '2021-04-15T13:10:00.000+02:00',
name: '',
},
{
id: 'YTA0ZTA2NTktYTU5MS0wMTFmLTVlYWYtNWM1MTgxNDJlMDcy',
blockName: '',
code: 'EN a)',
dayOfWeek: 4,
location: 'Fjärr',
teacher: 'TPe',
timeEnd: '14:20:00',
timeStart: '13:35:00',
dateStart: '2021-04-15T13:35:00.000+02:00',
dateEnd: '2021-04-15T14:20:00.000+02:00',
name: '',
},
{
id: 'OGJhN2MxYTYtMDQ4NS0wNWNhLTUwZWEtZDQ5YzQyMzFhYzc5',
blockName: '',
code: 'Lunch',
dayOfWeek: 5,
location: 'Ö5',
teacher: '',
timeEnd: '12:05:00',
timeStart: '11:40:00',
dateStart: '2021-04-16T11:40:00.000+02:00',
dateEnd: '2021-04-16T12:05:00.000+02:00',
name: '',
},
{
id: 'ZmUwMGEwM2QtNTExMy0wODliLTY1ZGEtODM0YmRjNjc1NDIw',
blockName: '',
code: 'MA a)',
dayOfWeek: 5,
location: '303',
teacher: 'CBr',
timeEnd: '14:00:00',
timeStart: '13:15:00',
dateStart: '2021-04-16T13:15:00.000+02:00',
dateEnd: '2021-04-16T14:00:00.000+02:00',
name: '',
},
{
id: 'Y2IwYjYzZDEtODAxYi0wMTNjLTRjNDMtMDFlODgzMmY4MWEy',
blockName: '',
code: 'MU a)',
dayOfWeek: 5,
location: '510',
teacher: 'KBj',
timeEnd: '13:05:00',
timeStart: '12:20:00',
dateStart: '2021-04-16T12:20:00.000+02:00',
dateEnd: '2021-04-16T13:05:00.000+02:00',
name: '',
},
{
id: 'N2JkMGFiOTYtMjI5OC0wMjZiLTc3OGEtN2JkN2Q4MDZkNTEy',
blockName: '',
code: 'SL tmtx)',
dayOfWeek: 5,
location: '860',
teacher: 'EAl',
timeEnd: '15:10:00',
timeStart: '14:10:00',
dateStart: '2021-04-16T14:10:00.000+02:00',
dateEnd: '2021-04-16T15:10:00.000+02:00',
name: '',
},
{
id: 'NzkxMjE3MDctMWExNS0wN2RmLTQwMzQtNTEyZTczZjQyZTUw',
blockName: '',
code: 'SV',
dayOfWeek: 5,
location: '303',
teacher: 'JCa',
timeEnd: '10:35:00',
timeStart: '09:20:00',
dateStart: '2021-04-16T09:20:00.000+02:00',
dateEnd: '2021-04-16T10:35:00.000+02:00',
name: '',
},
{
id: 'ZTU1ZDQxNzQtN2Q3Yy0wMDMxLTY2ZmYtZmIyNGM5MjM3ZTRj',
blockName: '',
code: 'MA',
dayOfWeek: 5,
location: '303',
teacher: 'CBr',
timeEnd: '11:35:00',
timeStart: '10:40:00',
dateStart: '2021-04-16T10:40:00.000+02:00',
dateEnd: '2021-04-16T11:35:00.000+02:00',
name: '',
}
]
}

View File

@ -0,0 +1,148 @@
import { timetable, timetableEntry, TimetableResponse } from '../'
let response: TimetableResponse
describe('Timetable', () => {
beforeEach(() => {
response = {
error: null,
data: {
textList: [
{
x: 11,
y: 64,
fColor: '#000000',
fontsize: 14,
text: '8:30',
bold: false,
italic: false,
id: 9,
parentId: 6,
type: 'ClockAxisBox'
},
{
x: 11,
y: 125,
fColor: '#000000',
fontsize: 14,
text: '9:00',
bold: false,
italic: false,
id: 12,
parentId: 6,
type: 'ClockAxisBox'
},
],
boxList: [
{
x: 0,
y: 950,
width: 1226,
height: 112,
bColor: '#FFFFFF',
fColor: '#FFFFFF',
id: 0,
parentId: null,
type: 'Footer',
lessonGuids: null
},
{
x: 56,
y: 0,
width: 223,
height: 34,
bColor: '#FFFFFF',
fColor: '#000000',
id: 1,
parentId: null,
type: 'HeadingDay',
lessonGuids: null
},
],
lineList: [
{
p1x: 51,
p1y: 34,
p2x: 56,
p2y: 34,
color: '#000000',
id: 7,
parentId: 6,
type: 'ClockAxisGradiation'
},
{
p1x: 0,
p1y: 64,
p2x: 56,
p2y: 64,
color: '#000000',
id: 8,
parentId: 6,
type: 'ClockAxisGradiation'
},
],
lessonInfo: [
{
guidId: 'N2FjMDc1NjYtZmM2Yy0wZDQyLTY3M2YtZWI5NGNiZDA3ZGU4',
texts: [
'Lunch',
'',
'Ö5'
],
timeStart: '11:40:00',
timeEnd: '12:05:00',
dayOfWeekNumber: 1,
blockName: ''
},
{
guidId: 'ZTQ1NWE0N2EtNzAwOS0wZTAzLTQ1ZDYtNTA1NWI4Y2JhNDYw',
texts: [
'BL',
'KUr',
'221'
],
timeStart: '09:40:00',
timeEnd: '11:35:00',
dayOfWeekNumber: 1,
blockName: 'block'
},
]
},
exception: null,
validation: [],
}
})
describe('timetableEntry', () => {
it('parses basic timeTableEntry data correctly', () => {
const entry = timetableEntry(response.data.lessonInfo[1], 2021, 15)
expect(entry.id).toEqual('ZTQ1NWE0N2EtNzAwOS0wZTAzLTQ1ZDYtNTA1NWI4Y2JhNDYw')
expect(entry.code).toEqual('BL')
expect(entry.teacher).toEqual('KUr')
expect(entry.location).toEqual('221')
expect(entry.timeStart).toEqual('09:40:00')
expect(entry.timeEnd).toEqual('11:35:00')
expect(entry.dayOfWeek).toEqual(1)
expect(entry.blockName).toEqual('block')
})
it('parses dates correctly', () => {
const entry = timetableEntry(response.data.lessonInfo[1], 2021, 15)
expect(entry.dateStart).toEqual('2021-04-12T09:40:00.000+02:00')
expect(entry.dateEnd).toEqual('2021-04-12T11:35:00.000+02:00')
})
})
describe('timetable', () => {
it('throws error', () => {
response.error = 'b0rk'
expect(() => timetable(response, 2021, 15)).toThrow('b0rk')
})
it('parses lessonInfo', () => {
const table = timetable(response, 2021, 15)
expect(table).toHaveLength(2)
expect(table[0].id).toEqual('N2FjMDc1NjYtZmM2Yy0wZDQyLTY3M2YtZWI5NGNiZDA3ZGU4')
expect(table[1].id).toEqual('ZTQ1NWE0N2EtNzAwOS0wZTAzLTQ1ZDYtNTA1NWI4Y2JhNDYw')
})
})
})

View File

@ -6,4 +6,5 @@ export * from './menu'
export * from './news'
export * from './notifications'
export * from './schedule'
export * from './timetable'
export * from './user'

62
lib/parse/timetable.ts Normal file
View File

@ -0,0 +1,62 @@
import { DateTime } from 'luxon'
import { TimetableEntry } from '../types'
const calculateDate = (year: number, weekNumber: number, weekday: number, time: string): string => {
const [hours, minutes, seconds] = time.split(':')
return DateTime.local()
.set({
year,
weekNumber,
weekday,
hour: parseInt(hours, 10),
minute: parseInt(minutes, 10),
second: parseInt(seconds, 10),
millisecond: 0,
}).toISO()
}
interface TimetableResponseEntry {
guidId: string
texts: string[]
timeStart: string
timeEnd: string
dayOfWeekNumber: number
blockName: string
}
export interface TimetableResponse {
error: string | null
data: {
textList: any[]
boxList: any[]
lineList: any[]
lessonInfo: TimetableResponseEntry[]
}
exception: any
validation: any[]
}
interface EntryParser {
(args: TimetableResponseEntry, year: number, week: number): TimetableEntry
}
export const timetableEntry: EntryParser = ({
guidId, texts: [code, teacher, location], timeStart, timeEnd, dayOfWeekNumber, blockName,
}, year, week) => ({
id: guidId,
blockName,
code,
dayOfWeek: dayOfWeekNumber,
location,
teacher,
timeEnd,
timeStart,
dateStart: calculateDate(year, week, dayOfWeekNumber, timeStart),
dateEnd: calculateDate(year, week, dayOfWeekNumber, timeEnd),
name: ''
})
export const timetable = (response: TimetableResponse, year: number, week: number) => {
if (response.error) {
throw new Error(response.error)
}
return response.data.lessonInfo.map((entry) => timetableEntry(entry, year, week))
}

View File

@ -62,3 +62,14 @@ export const childcontrollerScript = `https://etjanst.stockholm.se/vardnadshavar
export const createItemConfig =
'https://raw.githubusercontent.com/kolplattformen/embedded-api/main/config.json'
// Skola24
export const ssoRequestUrl = (targetSystem: string) =>
`https://fnsservicesso1.stockholm.se/sso-ng/saml-2.0/authenticate?customer=https://login001.stockholm.se&targetsystem=${targetSystem}`
export const ssoResponseUrl = 'https://login001.stockholm.se/affwebservices/public/saml2sso'
export const samlResponseUrl = 'https://fnsservicesso1.stockholm.se/sso-ng/saml-2.0/response'
export const timetables = 'https://fns.stockholm.se/ng/api/services/skola24/get/personal/timetables'
export const renderKey = 'https://fns.stockholm.se/ng/api/get/timetable/render/key'
export const timetable = 'https://fns.stockholm.se/ng/api/render/timetable'

View File

@ -66,7 +66,7 @@ export interface CalendarItem {
* @export
* @interface Child
*/
export interface Child {
export interface EtjanstChild {
id: string
/**
* <p>Special ID used to access certain subsystems</p>
@ -84,6 +84,8 @@ export interface Child {
schoolId?: string
}
export interface Child extends EtjanstChild, Skola24Child {}
/**
* @export
* @interface Classmate
@ -181,3 +183,29 @@ export interface User {
email?: string | null
notificationId?: string
}
export interface Skola24Child {
schoolGuid?: string
unitGuid?: string
schoolID?: string
timetableID?: string
personGuid?: string
firstName?: string
lastName?: string
}
export type SSOSystem = 'TimetableViewer'
export interface TimetableEntry {
id: string
code: string
name: string
teacher: string
location: string
timeStart: string
timeEnd: string
dayOfWeek: number
blockName: string
dateStart: string
dateEnd: string
}

16
run.js
View File

@ -22,6 +22,7 @@ const fetchCookie = require('fetch-cookie/node-fetch')
const { writeFile } = require('fs/promises')
const path = require('path')
const fs = require('fs')
const { inspect } = require('util')
const init = require('./dist').default
@ -118,23 +119,30 @@ async function run() {
console.log('news')
const news = await api.getNews(children[0])
*/
/*console.log('news details')
/* console.log('news details')
const newsItems = await Promise.all(
news.map((newsItem) =>
api.getNewsDetails(children[0], newsItem)
.catch((err) => { console.error(newsItem.id, err) })
)
)
console.log(newsItems)*/
console.log(newsItems) */
/*console.log('menu')
/* console.log('menu')
const menu = await api.getMenu(children[0])
console.log(menu)*/
console.log(menu) */
// console.log('notifications')
// const notifications = await api.getNotifications(children[0])
// console.log(notifications)
const skola24children = await api.getSkola24Children()
console.log(skola24children)
console.log('timetable')
const timetable = await api.getTimetable(skola24children[0], 15, 2021)
console.log(inspect(timetable, false, 1000, true))
await api.logout()
})