Merge pull request #501 from kolplattformen/feature/monorepo

This commit is contained in:
Jonathan Edenström 2021-10-03 12:10:25 +02:00 committed by GitHub
commit 7c3d867f6f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
131 changed files with 29250 additions and 0 deletions

View File

@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
}

View File

@ -0,0 +1,19 @@
module.exports = {
parser: '@typescript-eslint/parser', // Specifies the ESLint parser
parserOptions: {
ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
sourceType: 'module', // Allows for the use of imports
project: ['./tsconfig.eslint.json'],
},
extends: ['airbnb-typescript/base', 'prettier'],
plugins: ['prettier'],
ignorePatterns: ['*.test.ts'],
rules: {
// Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
// e.g. "@typescript-eslint/explicit-function-return-type": "off",
// '@typescript-eslint/indent': ['error', 2],
'@typescript-eslint/semi': [2, 'never'],
'max-len': ['error', { code: 120, ignoreUrls: true }],
'import/prefer-default-export': 0,
},
}

View File

@ -0,0 +1,31 @@
name: Release
on:
push:
branches:
- main
jobs:
release:
name: Release
runs-on: ubuntu-18.04
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v1
with:
node-version: 14
- name: Setup timezone
uses: zcong1993/setup-timezone@master
with:
timezone: Europe/Stockholm
- name: Install dependencies
run: yarn install --immutable --silent --non-interactive 2> >(grep -v warning 1>&2)
- name: Build
run: yarn build
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-release

View File

@ -0,0 +1,26 @@
# This workflow will do a clean install of node dependencies and run tests
name: Test
on:
pull_request:
branches: [main]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js and run tests
uses: actions/setup-node@v2.1.2
with:
node-version: 14.x
- name: Setup timezone
uses: zcong1993/setup-timezone@master
with:
timezone: Europe/Stockholm
- run: yarn install --immutable --silent --non-interactive 2> >(grep -v warning 1>&2)
- run: yarn lint
- run: yarn test

106
libs/api-skolplattformen/.gitignore vendored Normal file
View File

@ -0,0 +1,106 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
record

View File

@ -0,0 +1,10 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"jsxBracketSameLine": false
}

View File

@ -0,0 +1,3 @@
{
"branches": ["main"]
}

View File

@ -0,0 +1,21 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "pwa-node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/devrun.js",
"preLaunchTask": "tsc: build - tsconfig.json",
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
]
}
]
}

View File

@ -0,0 +1,18 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "typescript",
"tsconfig": "tsconfig.json",
"option": "watch",
"problemMatcher": [
"$tsc-watch"
],
"group": {
"kind": "build",
"isDefault": true
},
"label": "tsc: watch - tsconfig.json"
}
]
}

View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,132 @@
# embedded-api
Since the proxy was blocked (and also deemed a bad idea by some), this is a reboot of the API running in process in the app(s).
## Installing
`npm i -S @skolplattformen/embedded-api` or `yarn add @skolplattformen/embedded-api`
## Calling
### Import and init
Since fetch and cookies behave distinctly different in node, react-native and the browser,
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'
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'
const cookieJar = new CookieJar()
const fetch = fetchCookie(nodeFetch, cookieJar)
const api = init(fetch, cookieJar)
```
### Login / logout
```javascript
api.on('login', async () => {
// do stuff
console.log(api.isLoggedIn) // true
await api.logout()
})
api.on('logout', () => {
// handle logout
console.log(api.isLoggedIn) // false
}
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.')
)
```
### Loading data
```javascript
// Get current user
const user = await api.getUser()
// List children from Etjanster
const children = await api.getChildren()
// Get school calendar
const calendar = await api.getCalendar(children[0])
// Get classmates - disabled for reasons
// const classmates = await api.getClassmates(children[0])
// Get student's personal schedule
import { DateTime } from 'luxon'
const from = DateTime.local()
const to = DateTime.local().plus({ week: 1 })
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
It is possible to resurrect a logged in session by manually setting the session cookie.
```javascript
const sessionCookie = 'some value'
api.setSessionCookie(sessionCookie) // will trigger `on('login')` event and set `.isLoggedIn = true`
```
### Fake user
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'.
## Try it out
1. Clone and enter repo: `git clone git@github.com:kolplattformen/embedded-api.git && cd embedded-api`
2. Install dependencies: `yarn`
3. Build package: `yarn build`
4. Run example: `node run [your personal number]`
5. Sign in with mobile BankID

View File

@ -0,0 +1,17 @@
module.exports = function agentDecorator(fetch, agent) {
fetch = fetch || window.fetch
async function fetchWrapper(url, opts) {
opts = opts || {}
// Prepare request
opts.agent = agent
// Actual request
const res = await fetch(url, opts)
return res
}
return fetchWrapper
}

View File

@ -0,0 +1,25 @@
{
"headers": {
"accept": "text/plain",
"accept-language": "en-GB,en-SE;q=0.9,en;q=0.8,sv-SE;q=0.7,sv;q=0.6,en-US;q=0.5",
"access-control-allow-origin": "*",
"cache-control": "no-cache",
"content-type": "text/plain",
"pragma": "no-cache",
"sec-ch-ua": "\"Google Chrome\";v=\"89\", \"Chromium\";v=\"89\", \";Not A Brand\";v=\"99\"",
"sec-ch-ua-mobile": "?0",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"x-xsrf-token": "SfONpuvKXD1XHML3Kelvm3easB6Xn3xtbVPG52jdpc3Q7sRxJv7_6wfjo1qS3NOQWkfCvfPkJpJg0QIBmo358o7FdQY2aWvUOxA9MU2Fl0E1",
"y-xsrf-token11": "FyXUbtZUE2iT09J7FOLTpfZ_onjbj3WEIO6jOY9B1KaZzMrAs4WS03AuWbQhmKyCEX2inTPVDzyPc58tN2EM4L1vYD6aH_zhlc7gVo9jaPdLKQc4qnE6ue184cSamKE0",
"topology-key": "labor matter federal|",
"topology-short-key": "assumeoutside",
"topology-base64-iterations": "8"
},
"referrer": "https://etjanst.stockholm.se/",
"referrerPolicy": "strict-origin-when-cross-origin",
"body": "XVDf/EliJ/oZH9BRlRCMNds2jCRcTL8/isnpuj2wD6wH1lxX/cHY/AM6XJ8nweGne+FAPgcpj+blQ+dQvvmiJfK4t0u66tg8L60ysfDs/eBeoA794lvvtwRwJ946VUahZG89Al7UFkx5Ew1AGp4yuJ38drNDK4J5RAUGvzOWTmniZnSYs9P5UR2SWP39NcOoovwZsce7tRigdusI8sSDSUh+lVDkwfERfQqe3oG+FPiGQsfeFd2y/5f8chxU9VQz4oF7BLiP69xBPJr2KFkQc7MJaqEQy87loe0vwehRN/lOP858pPiVfc96M2jc0+yQEgnUBXPgQmFVC6CIHfQ0Mg==",
"method": "POST",
"mode": "cors"
}

View File

@ -0,0 +1,229 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
/* eslint-disable no-console */
/* eslint-disable import/no-extraneous-dependencies */
/**
* A more elaborated test file for local development
* - Support for proxy (i recommend Burp Suite https://portswigger.net/burp/communitydownload)
* - Saves sessionCoookie to a file and tries to use it again
*/
const { DateTime } = require('luxon')
const { inspect } = require('util')
const nodeFetch = require('node-fetch')
const { CookieJar } = require('tough-cookie')
const fetchCookie = require('fetch-cookie/node-fetch')
// eslint-disable-next-line import/no-unresolved
const { writeFile, readFile } = require('fs/promises')
const path = require('path')
const fs = require('fs')
const HttpProxyAgent = require('https-proxy-agent')
const agentWrapper = require('./agentFetchWrapper')
const init = require('./dist').default
const [, , personalNumber] = process.argv
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
const cookieJar = new CookieJar()
let bankIdUsed = false
async function run() {
const agent = new HttpProxyAgent('http://localhost:8080')
const agentEnabledFetch = agentWrapper(nodeFetch, agent)
const fetch = fetchCookie(agentEnabledFetch, cookieJar)
try {
const api = init(fetch, cookieJar, { record })
api.on('login', async () => {
console.log('Logged in')
if (bankIdUsed) {
const sessionCookie = getSessionCookieFromCookieJar()
ensureDirectoryExistence('./record')
await writeFile(
'./record/latestSessionCookie.txt',
JSON.stringify(sessionCookie)
)
console.log(
'Session cookie saved to file ./record/latesSessionCookie.txt'
)
}
console.log('user')
const user = await api.getUser()
console.log(user)
console.log('children')
const children = await api.getChildren()
console.log(children)
/*
console.log('calendar')
const calendar = await api.getCalendar(children[0])
console.log(calendar)
console.log('classmates')
const classmates = await api.getClassmates(children[0])
console.log(classmates)
*/
try {
console.log('schedule')
const schedule = await api.getSchedule(children[1], DateTime.local(), DateTime.local().plus({ week: 1 }))
console.log(schedule)
} catch (error) {
console.error(error)
}
let skola24children
try {
skola24children = await api.getSkola24Children()
console.log(skola24children)
} catch (error) {
console.error(error)
}
try {
console.log('timetable')
const timetable = await api.getTimetable(skola24children[0], 15, 2021, "sv")
console.log(inspect(timetable, false, 1000, true))
} catch (error) {
console.error(error)
}
/*
console.log('news')
const news = await api.getNews(children[0])
*/
/* 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('menu')
const menu = await api.getMenu(children[0])
console.log(menu) */
// console.log('notifications')
// const notifications = await api.getNotifications(children[0])
// console.log(notifications)
await api.logout()
})
api.on('logout', () => {
console.log('Logged out')
process.exit(0)
})
// Eventhandlers above must be setup before calling login
bankIdUsed = await Login(api)
} catch (err) {
console.error(err)
}
}
async function Login(api) {
let useBankId = true
try {
console.log('Attempt to use saved session cookie to login')
const rawContent = await readFile('./record/latestSessionCookie.txt')
const sessionCookie = JSON.parse(rawContent)
await api.setSessionCookie(`${sessionCookie.key}=${sessionCookie.value}`)
useBankId = false
console.log('Login with old cookie succeeded')
} catch (error) {
console.log('Could not login with old session cookie. Reverting to BankId')
// console.error(error)
}
if (useBankId) {
console.log('*** BankId login - open BankId app ***')
if (!personalNumber) {
console.error(
'You must pass in a valid personal number, eg `node run 197001011111`'
)
process.exit(1)
}
const status = await api.login(personalNumber)
status.on('PENDING', () => console.log('PENDING'))
status.on('USER_SIGN', () => console.log('USER_SIGN'))
status.on('ERROR', () => console.error('ERROR'))
status.on('OK', () => console.log('OK'))
status.on('CANCELLED', () => {
console.log('User cancelled login')
process.exit(0)
})
}
return useBankId
}
function ensureDirectoryExistence(filePath) {
const dirname = path.dirname(filePath)
if (fs.existsSync(dirname)) {
return
}
ensureDirectoryExistence(dirname)
fs.mkdirSync(dirname)
}
function getSessionCookieFromCookieJar() {
const cookies = cookieJar.getCookiesSync('https://etjanst.stockholm.se')
const sessionCookie = cookies.find((c) => c.key === 'SMSESSION')
return sessionCookie
}
const record = async (info, data) => {
const name = info.error ? `${info.name}_error` : info.name
const filename = `./record/${name}.json`
ensureDirectoryExistence(filename)
const content = {
url: info.url,
headers: info.headers,
status: info.status,
statusText: info.statusText,
}
if (data) {
switch (info.type) {
case 'json':
content.json = data
break
case 'text':
content.text = data
break
case 'blob': {
const buffer = await data.arrayBuffer()
content.blob = Buffer.from(buffer).toString('base64')
break
}
default:
throw new Error('Unknown data type')
}
} else if (info.error) {
const { message, stack } = info.error
content.error = {
message,
stack,
}
}
await writeFile(filename, JSON.stringify(content, null, 2))
}
// Hack to keep it running while wating for await
const timer = setTimeout(() => {}, 999999)
run()
.then(() => {
clearTimeout(timer)
console.log('...')
})
.catch((err) => {
clearTimeout(timer)
console.error(err)
})

View File

@ -0,0 +1,9 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
transform: {
'.(ts|tsx)': 'ts-jest',
},
testRegex: '(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$',
moduleFileExtensions: ['ts', 'tsx', 'js'],
}

BIN
libs/api-skolplattformen/lib/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,142 @@
/* eslint-disable class-methods-use-this */
/* eslint-disable no-param-reassign */
/* eslint-disable max-len */
/* eslint-disable no-plusplus */
export class URLSearchParams {
private dict: {[key: string]: string[]} = {}
constructor(search: string | string[] | any | URLSearchParams = '') {
if (search instanceof URLSearchParams) {
this.dict = this.parseToDict(search.toString())
} else {
this.dict = this.parseToDict(search)
}
}
/**
* Appends a specified key/value pair as a new search parameter
*/
public append(name: string, value: string): void {
this.appendTo(this.dict, name, value)
}
/**
* Deletes this give search parameter, and its associated value, from the list of all search parameters
*/
public delete(name: string): void {
delete this.dict[name]
}
/**
* Returns the first value associated to the given search parameter
*/
get(name: string): string | null {
return name in this.dict ? this.dict[name][0] : null
}
/**
* Returns all the values association with a given parameter
*/
getAll(name: string): string[] {
return name in this.dict ? this.dict[name].slice(0) : []
}
/**
* Test if the search parameter exists
*/
has(name: string): boolean {
return name in this.dict
}
/**
* Sets the value associated to a given search parameter to
* the given value. If there were several values, delete the others.
*/
set(name: string, value: string): void {
this.dict[name] = [value]
}
/**
* Returns a string containing a query string suitable for use in a URL
*/
toString(): string {
return Object.entries(this.dict)
.map(([key, value]) => `${key}=${this.encode(value)}`)
.join('&')
}
/**
*
*/
parseToDict(search: string | string[] | any): any {
const dict = {}
if (typeof search === 'object') {
// if 'search' is an array, treat it as a sequence
if (Array.isArray(search)) {
for (let i=0; i<search.length; i++) {
const item = search[i]
if (Array.isArray(item) && item.length === 2) {
this.appendTo(dict, item[0], item[1])
} else {
throw new TypeError("Failed to construct 'URLSearchParams': Sequence initalizer must only contain pair elements")
}
}
} else {
Object.entries(search).forEach(([key, value]) => this.appendTo(dict, key, value))
}
} else {
// remove 1st ?
if (search.indexOf('?') === 0) {
search = search.slice(1)
}
const pairs = search.split('&')
for (let j=0; j<pairs.length; j++) {
const value = pairs[j]
const index = value.indexOf('=')
if (index > -1) {
this.appendTo(dict, this.decode(value.slice(0, index)), this.decode(value.slice(index+1)))
} else if (value) {
this.appendTo(dict, this.decode(value), '')
}
}
}
return dict
}
appendTo(dict: any, name: string, value: string | Function | any): void {
// eslint-disable-next-line no-nested-ternary
const val = typeof value === 'string' ? value: (
value !== null && value !== undefined && typeof value.toString === 'function' ? value.toString() : JSON.stringify(value)
)
if (name in dict) {
dict[name].push(value)
} else {
dict[name] = [val]
}
}
decode(str: string): string {
return str
.replace(/[ +]/g, '%20')
.replace(/(%[a-f0-9]{2})+/ig, (match) => decodeURIComponent(match))
}
encode(str: string[]): string {
const replace: {[key: string]: string} = {
'!': '%21',
"'": '%27',
'(': '%28',
')': '%29',
'~': '%7E',
'%20': '+',
'%00': '\x00'
}
// eslint-disable-next-line no-useless-escape
return encodeURIComponent(str.join(',')).replace(/[!'\(\)~]|%20|%00/g, (match) => replace[match] || '')
}
}

View File

@ -0,0 +1,81 @@
import { CookieJar, Cookie as TCookie } from 'tough-cookie'
export interface Cookie {
name: string
value: string
path?: string
domain?: string
version?: string
expires?: string
secure?: boolean
httpOnly?: boolean
}
export interface Cookies {
[key: string]: Cookie
}
export interface CookieManagerStatic {
set(url: string, cookie: Cookie, useWebKit?: boolean): Promise<boolean>
setFromResponse(url: string, cookie: string): Promise<boolean>
get(url: string, useWebKit?: boolean): Promise<Cookies>
clearAll(useWebKit?: boolean): Promise<boolean>
}
const convertTtoC = (cookie: string | TCookie): Cookie => {
if (typeof cookie === 'string') {
return convertTtoC(TCookie.parse(cookie) as TCookie)
}
return {
name: cookie.key,
value: cookie.value,
domain: cookie.domain || undefined,
expires:
cookie.expires === 'Infinity' ? undefined : cookie.expires.toUTCString(),
httpOnly: cookie.httpOnly || undefined,
path: cookie.path || undefined,
secure: cookie.secure,
}
}
const convertCtoT = (cookie: Cookie): TCookie =>
new TCookie({
key: cookie.name,
value: cookie.value,
domain: cookie.domain,
expires: cookie.expires ? new Date(cookie.expires) : undefined,
httpOnly: cookie.httpOnly || false,
path: cookie.path,
secure: cookie.secure || false,
})
const convertCookies = (cookies: TCookie[]): Cookies =>
cookies.reduce(
(map, cookie) => ({
...map,
[cookie.key]: convertTtoC(cookie),
}),
{} as Cookies
)
const jar = new CookieJar()
const CookieManager: CookieManagerStatic = {
clearAll: async () => {
await jar.removeAllCookies()
return true
},
get: async (url) => {
const cookies = await jar.getCookies(url)
return convertCookies(cookies)
},
set: async (url, cookie) => {
await jar.setCookie(convertCtoT(cookie), url)
return true
},
setFromResponse: async (url, cookie) => {
await jar.setCookie(cookie, url)
return true
},
}
export default CookieManager

View File

@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`handles route calender 1`] = `"https://etjanst.stockholm.se/vardnadshavare/inloggad2/Calender/GetSchoolCalender?childId=123&rowLimit=50"`;
exports[`handles route children 1`] = `"https://etjanst.stockholm.se/vardnadshavare/inloggad2/GetChildren"`;
exports[`handles route classmates 1`] = `"https://etjanst.stockholm.se/vardnadshavare/inloggad2/contacts/GetStudentsByClass?studentId=123"`;
exports[`handles route image 1`] = `"https://etjanst.stockholm.se/vardnadshavare/inloggad2/NewsBanner?url=https://example.com/img.png"`;
exports[`handles route login with personal number 1`] = `"https://login003.stockholm.se/NECSadcmbid/authenticate/NECSadcmbid?TARGET=-SM-HTTPS%3a%2f%2flogin001%2estockholm%2ese%2fNECSadc%2fmbid%2fb64startpage%2ejsp%3fstartpage%3daHR0cHM6Ly9ldGphbnN0LnN0b2NraG9sbS5zZS92YXJkbmFkc2hhdmFyZS9pbmxvZ2dhZDIvaGVt&initialize=bankid&personalNumber=201701012393&_=1618404258782"`;
exports[`handles route login without personal number 1`] = `"https://login003.stockholm.se/NECSadcmbid/authenticate/NECSadcmbid?TARGET=-SM-HTTPS%3a%2f%2flogin001%2estockholm%2ese%2fNECSadc%2fmbid%2fb64startpage%2ejsp%3fstartpage%3daHR0cHM6Ly9ldGphbnN0LnN0b2NraG9sbS5zZS92YXJkbmFkc2hhdmFyZS9pbmxvZ2dhZDIvaGVt&initialize=bankid&_=1618404258782"`;
exports[`handles route menuChoice 1`] = `"https://etjanst.stockholm.se/vardnadshavare/inloggad2/Matsedel/GetMatsedelChoice?childId=123"`;
exports[`handles route menuList 1`] = `"https://etjanst.stockholm.se/vardnadshavare/inloggad2/Matsedel/GetMatsedelList?childId=123"`;
exports[`handles route menuRss 1`] = `"https://etjanst.stockholm.se/vardnadshavare/inloggad2/Matsedel/GetMatsedelRSS?childId=123"`;
exports[`handles route news 1`] = `"https://etjanst.stockholm.se/vardnadshavare/inloggad2/News/GetNewsArchive?bannerImageLimit=5000&childId=123"`;
exports[`handles route newsDetails 1`] = `"https://etjanst.stockholm.se/vardnadshavare/inloggad2/News/GetNewsArticle?newsItemId=321&childId=123"`;
exports[`handles route notifications 1`] = `"https://etjanst.stockholm.se/vardnadshavare/inloggad2/notifications/getnotifications?childId=123"`;
exports[`handles route schedule 1`] = `"https://etjanst.stockholm.se/vardnadshavare/inloggad2/Calender/GetSchema?childId=123&startDate=2021-01-01&endDate=2021-01-01"`;
exports[`handles route user 1`] = `"https://etjanst.stockholm.se/vardnadshavare/base/getuserdata"`;

View File

@ -0,0 +1,22 @@
import * as routes from '../routes'
Date.now = jest.fn(() => 1618404258782)
test.each([
['children', routes.children],
['calender', routes.calendar('123')],
['classmates', routes.classmates('123')],
['user', routes.user],
['news', routes.news('123')],
['newsDetails', routes.newsDetails('123', '321')],
['image', routes.image('https://example.com/img.png')],
['notifications', routes.notifications('123')],
['menuRss', routes.menuRss('123')],
['menuList', routes.menuList('123')],
['menuChoice', routes.menuChoice('123')],
['schedule', routes.schedule('123', '2021-01-01', '2021-01-01')],
['login with personal number', routes.login('201701012393')],
['login without personal number', routes.login()],
])('handles route %s', (_name, input) => {
expect(input).toMatchSnapshot()
})

View File

@ -0,0 +1,197 @@
import init from './'
import { Api } from './api'
import { Fetch, Headers, Response } from './types'
import CookieManager from '@react-native-cookies/cookies'
describe('api', () => {
let fetch: jest.Mocked<Fetch>
let response: jest.Mocked<Response>
let headers: jest.Mocked<Headers>
let api: Api
beforeEach(() => {
headers = { get: jest.fn() }
response = {
json: jest.fn(),
text: jest.fn(),
ok: true,
status: 200,
statusText: 'ok',
headers,
}
fetch = jest.fn().mockResolvedValue(response)
response.text.mockResolvedValue('<html></html>')
CookieManager.clearAll()
api = init(fetch, CookieManager)
})
describe('#login', () => {
it('exposes token', async () => {
const data = {
token: '9462cf77-bde9-4029-bb41-e599f3094613',
order: '5fe57e4c-9ad2-4b52-b794-48adef2f6663',
}
response.json.mockResolvedValue(data)
const personalNumber = 'my personal number'
const status = await api.login(personalNumber)
expect(status.token).toEqual(data.token)
status.cancel()
})
it('emits PENDING', async (done) => {
const data = {
token: '9462cf77-bde9-4029-bb41-e599f3094613',
order: '5fe57e4c-9ad2-4b52-b794-48adef2f6663',
}
response.json.mockResolvedValue(data)
response.text.mockResolvedValue('PENDING')
const personalNumber = 'my personal number'
const status = await api.login(personalNumber)
status.on('PENDING', async () => {
status.cancel()
done()
})
})
it('retries on PENDING', async (done) => {
const data = {
token: '9462cf77-bde9-4029-bb41-e599f3094613',
order: '5fe57e4c-9ad2-4b52-b794-48adef2f6663',
}
response.json.mockResolvedValue(data)
response.text.mockResolvedValueOnce('PENDING')
response.text.mockResolvedValueOnce('OK')
const personalNumber = 'my personal number'
const status = await api.login(personalNumber)
status.on('OK', () => {
expect(fetch).toHaveBeenCalledTimes(4)
done()
})
})
it('remembers used personal number', async () => {
const data = {
token: '9462cf77-bde9-4029-bb41-e599f3094613',
order: '5fe57e4c-9ad2-4b52-b794-48adef2f6663',
}
response.json.mockResolvedValue(data)
const personalNumber = 'my personal number'
await api.login(personalNumber)
expect(api.getPersonalNumber()).toEqual(personalNumber)
})
it('forgets used personal number if sign in is unsuccessful', async (done) => {
const data = {
token: '9462cf77-bde9-4029-bb41-e599f3094613',
order: '5fe57e4c-9ad2-4b52-b794-48adef2f6663',
}
response.json.mockResolvedValue(data)
response.text.mockResolvedValueOnce('ERROR')
const personalNumber = 'my personal number'
const status = await api.login(personalNumber)
status.on('ERROR', () => {
expect(api.getPersonalNumber()).toEqual(undefined)
done()
})
})
it('throws error on external api error', async () => {
expect.hasAssertions()
const data = ''
response.json.mockResolvedValue(data)
response.ok = false
response.status = 500
response.statusText = 'Internal Server Error'
const personalNumber = 'my personal number'
try {
await api.login(personalNumber)
} catch (error) {
expect(error.message).toEqual(expect.stringContaining('Server Error'))
}
})
})
describe('#logout', () => {
it('clears session', async () => {
await api.logout()
const session = await api.getSession('')
expect(session).toEqual({
headers: {
cookie: '',
},
})
})
it('emits logout event', async () => {
const listener = jest.fn()
api.on('logout', listener)
await api.logout()
expect(listener).toHaveBeenCalled()
})
it('sets .isLoggedIn', async () => {
api.isLoggedIn = true
await api.logout()
expect(api.isLoggedIn).toBe(false)
})
it('forgets personalNumber', async () => {
const data = {
token: '9462cf77-bde9-4029-bb41-e599f3094613',
order: '5fe57e4c-9ad2-4b52-b794-48adef2f6663',
}
response.json.mockResolvedValue(data)
const pnr = 'my personal number'
await api.login(pnr)
api.isLoggedIn = true
await api.logout()
expect(api.getPersonalNumber()).toEqual(undefined)
})
})
describe('fake', () => {
it('sets fake mode for the correct pnr:s', async () => {
let status
status = await api.login('121212121212')
expect(status.token).toEqual('fake')
status = await api.login('201212121212')
expect(status.token).toEqual('fake')
status = await api.login('1212121212')
expect(status.token).toEqual('fake')
})
it('delivers fake data', async (done) => {
api.on('login', async () => {
const user = await api.getUser()
expect(user).toEqual({
firstName: 'Namn',
lastName: 'Namnsson',
isAuthenticated: true,
personalNumber: "195001182046",
})
const children = await api.getChildren()
expect(children).toHaveLength(2)
const calendar1 = await api.getCalendar(children[0])
expect(calendar1).toHaveLength(20)
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, 'sv')
expect(timetable).toHaveLength(32)
done()
})
await api.login('121212121212')
})
})
})

View File

@ -0,0 +1,469 @@
import { DateTime } from 'luxon'
import { EventEmitter } from 'events'
import { decode } from 'he'
import * as html from 'node-html-parser'
import { Language } from '@skolplattformen/curriculum/dist/translations'
import { URLSearchParams } from './URLSearchParams'
import { checkStatus, LoginStatusChecker } from './loginStatus'
import {
AuthTicket,
CalendarItem,
Classmate,
CookieManager,
Fetch,
MenuItem,
NewsItem,
Notification,
RequestInit,
ScheduleItem,
User,
Skola24Child,
EtjanstChild,
SSOSystem,
TimetableEntry
} from './types'
import * as routes from './routes'
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
private personalNumber?: string
private headers: any
private cookieManager: CookieManager
public isLoggedIn: boolean = false
public isFake: boolean = false
private authorizedSystems: SSOSystems = {}
constructor(
fetch: Fetch,
cookieManager: CookieManager,
options?: FetcherOptions
) {
super()
this.fetch = wrap(fetch, options)
this.cookieManager = cookieManager
this.headers = {}
}
public getPersonalNumber(): string | undefined {
return this.personalNumber
}
private getRequestInit(options: RequestInit = {}): RequestInit {
return {
...options,
headers: {
...this.headers,
...options.headers,
},
}
}
public async getSession(
url: string,
options?: RequestInit
): Promise<RequestInit> {
const init = this.getRequestInit(options)
const cookie = await this.cookieManager.getCookieString(url)
return {
...init,
headers: {
...init.headers,
cookie,
},
}
}
private async clearSession(): Promise<void> {
this.headers = {}
await this.cookieManager.clearAll()
}
private addHeader(name: string, value: string): void {
this.headers[name] = value
}
public async login(personalNumber?: string): Promise<LoginStatusChecker> {
if (personalNumber !== undefined && personalNumber.endsWith('1212121212')) return this.fakeMode()
this.isFake = false
const ticketUrl = routes.login(personalNumber)
const ticketResponse = await this.fetch('auth-ticket', ticketUrl)
if (!ticketResponse.ok) {
throw new Error(
`Server Error [${ticketResponse.status}] [${ticketResponse.statusText}] [${ticketUrl}]`
)
}
const ticket: AuthTicket = await ticketResponse.json()
// login was initiated - store personal number
this.personalNumber = personalNumber
const status = checkStatus(this.fetch, ticket)
status.on('OK', async () => {
await this.retrieveSessionCookie()
await this.retrieveXsrfToken()
this.isLoggedIn = true
this.emit('login')
})
status.on('ERROR', () => {
this.personalNumber = undefined
})
return status
}
public async setSessionCookie(sessionCookie: string): Promise<void> {
// Manually set cookie in this call and let the cookieManager
// handle it from here
// If we put it into the cookieManager manually, we get duplicate cookies
const url = routes.loginCookie
await this.fetch('login-cookie', url, {
headers: {
cookie: sessionCookie,
},
redirect: 'manual', // Important! Turn off redirect following. We can get into a redirect loop without this.
})
const user = await this.getUser()
if (!user.isAuthenticated) {
throw new Error('Session cookie is expired')
}
await this.retrieveXsrfToken()
this.isLoggedIn = true
this.emit('login')
}
private async retrieveSessionCookie(): Promise<void> {
const url = routes.loginCookie
await this.fetch('login-cookie', url)
}
private async retrieveXsrfToken(): Promise<void> {
const url = routes.hemPage
const session = this.getRequestInit()
const response = await this.fetch('hemPage', url, session)
const text = await response.text()
const doc = html.parse(decode(text))
const xsrfToken =
doc
.querySelector('input[name="__RequestVerificationToken"]')
?.getAttribute('value') || ''
this.addHeader('x-xsrf-token', xsrfToken)
}
private async fakeMode(): Promise<LoginStatusChecker> {
this.isFake = true
setTimeout(() => {
this.isLoggedIn = true
this.emit('login')
}, 50)
const emitter: any = new EventEmitter()
emitter.token = 'fake'
return emitter
}
public async getUser(): Promise<User> {
if (this.isFake) return fakeResponse(fake.user())
const url = routes.user
const session = this.getRequestInit()
const response = await this.fetch('user', url, session)
const data = await response.json()
return parse.user(data)
}
public async getChildren(): Promise<EtjanstChild[]> {
if (this.isFake) return fakeResponse(fake.children())
const url = routes.children
const session = this.getRequestInit({
headers: {
Accept: 'application/json;odata=verbose',
Host: 'etjanst.stockholm.se',
Referer: 'https://etjanst.stockholm.se/vardnadshavare/inloggad2/hem',
},
})
const response = await this.fetch('children', url, session)
if (!response.ok) {
throw new Error(
`Server Error [${response.status}] [${response.statusText}] [${url}]`
)
}
const data = await response.json()
return parse.children(data)
}
public async getCalendar(child: EtjanstChild): Promise<CalendarItem[]> {
if (this.isFake) return fakeResponse(fake.calendar(child))
const url = routes.calendar(child.id)
const session = this.getRequestInit()
const response = await this.fetch('calendar', url, session)
const data = await response.json()
return parse.calendar(data)
}
public async getClassmates(child: EtjanstChild): Promise<Classmate[]> {
if (this.isFake) return fakeResponse(fake.classmates(child))
const url = routes.classmates(child.sdsId)
const session = this.getRequestInit()
const response = await this.fetch('classmates', url, session)
const data = await response.json()
return parse.classmates(data)
}
public async getSchedule(
child: EtjanstChild,
from: DateTime,
to: DateTime,
): Promise<ScheduleItem[]> {
if (this.isFake) return fakeResponse(fake.schedule(child))
const url = routes.schedule(child.id, from.toISODate(), to.toISODate())
const session = this.getRequestInit()
const response = await this.fetch('schedule', url, session)
const data = await response.json()
return parse.schedule(data)
}
public async getNews(child: EtjanstChild): Promise<NewsItem[]> {
if (this.isFake) return fakeResponse(fake.news(child))
const url = routes.news(child.id)
const session = this.getRequestInit()
const response = await this.fetch('news', url, session)
const data = await response.json()
return parse.news(data)
}
public async getNewsDetails(child: EtjanstChild, item: NewsItem): Promise<any> {
if (this.isFake) {
return fakeResponse(fake.news(child).find((ni) => ni.id === item.id))
}
const url = routes.newsDetails(child.id, item.id)
const session = this.getRequestInit()
const response = await this.fetch(`news_${item.id}`, url, session)
const data = await response.json()
return parse.newsItemDetails(data)
}
public async getMenu(child: EtjanstChild): Promise<MenuItem[]> {
if (this.isFake) return fakeResponse(fake.menu(child).map(parse.menuItem))
const menuService = await this.getMenuChoice(child)
if (menuService === 'rss') {
const url = routes.menuRss(child.id)
const session = this.getRequestInit()
const response = await this.fetch('menu-rss', url, session)
const data = await response.json()
return parse.menu(data)
}
const url = routes.menuList(child.id)
const session = this.getRequestInit()
const response = await this.fetch('menu-list', url, session)
const data = await response.json()
return parse.menuList(data)
}
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)
const data = await response.json()
const etjanstResponse = parse.etjanst(data)
return etjanstResponse
}
public async getNotifications(child: EtjanstChild): Promise<Notification[]> {
if (this.isFake) return fakeResponse(fake.notifications(child))
const url = routes.notifications(child.sdsId)
const session = this.getRequestInit()
const response = await this.fetch('notifications', url, session)
const data = await response.json()
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="(\S+)">/gm.exec(text || '')?.[1]
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="(\S+)">/gm.exec(text)?.[1]
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, lang: Language)
: Promise<TimetableEntry[]> {
if (this.isFake) return fakeResponse(fake.timetable(child))
if(!child.timetableID) {
return new Array<TimetableEntry>()
}
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, lang)
}
public async logout() {
this.isFake = false
this.personalNumber = undefined
this.isLoggedIn = false
this.authorizedSystems = {}
this.emit('logout')
await this.clearSession()
}
}

View File

@ -0,0 +1,331 @@
import {
deserialize,
serialize,
wrapToughCookie,
wrapReactNativeCookieManager,
} from './cookies'
import { Cookie, CookieManager } from './types'
import { CookieJar } from 'tough-cookie'
import RNCookieManager from '@react-native-cookies/cookies'
describe('CookieManager', () => {
describe('deserialize', () => {
it('deserializes cookies with only name and value', () => {
const cookieStr = 'foo=bar'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
it('deserializes cookies with Expires', () => {
const cookieStr = 'foo=bar; Expires=Tue, 09 Mar 2021 08:27:48 GMT'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
expires: 'Tue, 09 Mar 2021 08:27:48 GMT',
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
it('deserializes cookies with Domain', () => {
const cookieStr = 'foo=bar; Domain=.stockholm.se'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
domain: '.stockholm.se',
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
it('deserializes cookies with Path', () => {
const cookieStr = 'foo=bar; Path=/'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
path: '/',
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
it('deserializes cookies with Secure', () => {
const cookieStr = 'foo=bar; Secure'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
secure: true,
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
it('deserializes cookies with HttpOnly', () => {
const cookieStr = 'foo=bar; HttpOnly'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
httpOnly: true,
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
it('deserializes cookies with HTTPOnly', () => {
const cookieStr = 'foo=bar; HTTPOnly'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
httpOnly: true,
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
it('deserializes cookies with all properties', () => {
const cookieStr =
'foo=bar; Expires=Tue, 09 Mar 2021 08:27:48 GMT; Domain=.stockholm.se; Path=/; Secure; HTTPOnly'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
expires: 'Tue, 09 Mar 2021 08:27:48 GMT',
domain: '.stockholm.se',
path: '/',
secure: true,
httpOnly: true,
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
})
describe('serialize', () => {
it('serializes cookies with only name and value', () => {
const cookieStr = 'foo=bar'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
}
expect(serialize(cookie)).toEqual(cookieStr)
})
it('serializes cookies with Expires', () => {
const cookieStr = 'foo=bar; Expires=Tue, 09 Mar 2021 08:27:48 GMT'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
expires: 'Tue, 09 Mar 2021 08:27:48 GMT',
}
expect(serialize(cookie)).toEqual(cookieStr)
})
it('serializes cookies with Domain', () => {
const cookieStr = 'foo=bar; Domain=.stockholm.se'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
domain: '.stockholm.se',
}
expect(serialize(cookie)).toEqual(cookieStr)
})
it('serializes cookies with Path', () => {
const cookieStr = 'foo=bar; Path=/'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
path: '/',
}
expect(serialize(cookie)).toEqual(cookieStr)
})
it('serializes cookies with Secure', () => {
const cookieStr = 'foo=bar; Secure'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
secure: true,
}
expect(serialize(cookie)).toEqual(cookieStr)
})
it('serializes cookies with HttpOnly', () => {
const cookieStr = 'foo=bar; HttpOnly'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
httpOnly: true,
}
expect(serialize(cookie)).toEqual(cookieStr)
})
it('serializes cookies with all properties', () => {
const cookieStr =
'foo=bar; Expires=Tue, 09 Mar 2021 08:27:48 GMT; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
expires: 'Tue, 09 Mar 2021 08:27:48 GMT',
domain: '.stockholm.se',
path: '/',
secure: true,
httpOnly: true,
}
expect(serialize(cookie)).toEqual(cookieStr)
})
})
describe('wrap', () => {
describe('tough cookie', () => {
let jar: CookieJar
let manager: CookieManager
beforeEach(() => {
jar = new CookieJar()
manager = wrapToughCookie(jar)
})
it('handles getCookieString', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr =
'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
await jar.setCookie(cookieStr, url)
const storedCookies = await manager.getCookieString(
'https://foo.stockholm.se/bar/baz'
)
expect(storedCookies).toEqual('foo=bar')
})
it('handles getCookies', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr =
'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
domain: 'stockholm.se',
path: '/',
secure: true,
httpOnly: true,
}
await jar.setCookie(cookieStr, url)
const storedCookies = await manager.getCookies(
'https://foo.stockholm.se/bar/baz'
)
expect(storedCookies).toHaveLength(1)
expect(storedCookies[0]).toEqual(cookie)
})
it('handles setCookie', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
domain: 'stockholm.se',
path: '/',
secure: true,
httpOnly: true,
}
await manager.setCookie(cookie, url)
const cookies = await jar.getCookieString(url)
expect(cookies).toEqual('foo=bar')
})
it('handles setCookieString', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr =
'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
await manager.setCookieString(cookieStr, url)
const cookies = await jar.getCookieString(url)
expect(cookies).toEqual('foo=bar')
})
it('handles clearAll', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr =
'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
await manager.setCookieString(cookieStr, url)
await manager.clearAll()
const cookies = await jar.getCookieString(url)
expect(cookies).toEqual('')
})
})
describe('@react-native-cookies/cookies', () => {
let manager: CookieManager
beforeEach(async () => {
await RNCookieManager.clearAll()
manager = wrapReactNativeCookieManager(RNCookieManager)
})
it('handles getCookieString', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr =
'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
await RNCookieManager.setFromResponse(url, cookieStr)
const storedCookies = await manager.getCookieString(
'https://foo.stockholm.se/bar/baz'
)
expect(storedCookies).toEqual('foo=bar')
})
it('handles getCookies', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr =
'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
domain: 'stockholm.se',
path: '/',
secure: true,
httpOnly: true,
}
await RNCookieManager.setFromResponse(url, cookieStr)
const storedCookies = await manager.getCookies(
'https://foo.stockholm.se/bar/baz'
)
expect(storedCookies).toHaveLength(1)
expect(storedCookies[0]).toEqual(cookie)
})
it('handles setCookie', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
domain: 'stockholm.se',
path: '/',
secure: true,
httpOnly: true,
}
await manager.setCookie(cookie, url)
const cookies = await RNCookieManager.get(url)
expect(cookies).toHaveProperty('foo')
expect(cookies['foo'].value).toEqual('bar')
})
it('handles setCookieString', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr =
'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
await manager.setCookieString(cookieStr, url)
const cookies = await RNCookieManager.get(url)
expect(cookies).toHaveProperty('foo')
expect(cookies['foo'].value).toEqual('bar')
})
it('handles clearAll', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr =
'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
await manager.setCookieString(cookieStr, url)
await manager.clearAll()
const cookies = await RNCookieManager.get(url)
expect(cookies).toEqual({})
})
})
})
})

View File

@ -0,0 +1,118 @@
import { camelCase, pascalCase } from 'change-case'
import { Cookie, CookieManager } from './types'
interface IndexableCookie extends Cookie {
[key: string]: string | boolean | undefined
}
interface Serializer {
(cookie: Cookie): string
}
interface Deserializer {
(cookieString: string): Cookie
}
export const serialize: Serializer = (cookie) => {
const ic = <IndexableCookie>cookie
const tokens = [`${ic.name}=${ic.value}`]
const keyVals = ['expires', 'domain', 'path']
keyVals
.filter((key) => ic[key])
.forEach((key) => {
tokens.push(`${pascalCase(key)}=${ic[key]}`)
})
const bools = ['secure', 'httpOnly']
bools
.filter((key) => ic[key])
.forEach((key) => {
tokens.push(pascalCase(key))
})
return tokens.join('; ')
}
export const deserialize: Deserializer = (cookieString) => {
const [nameVal, ...others] = cookieString
.split(';')
.map((token) => token.trim())
const [name, value] = nameVal.split('=')
const cookie: Cookie = {
name,
value,
}
others
.map((keyVal) => keyVal.split('='))
.forEach(([key, val]) => {
const prop = camelCase(key)
// eslint-disable-next-line default-case
switch (prop) {
case 'expires':
case 'domain':
case 'path':
cookie[prop] = val
break
case 'secure':
case 'httpOnly':
cookie[prop] = true
break
}
})
return cookie
}
interface ToughCookie {
toString: () => string
}
export interface ToughCookieJar {
getCookieString: (url: string) => Promise<string>
getCookies: (url: string) => Promise<ToughCookie[]>
setCookie: (cookie: string, url: string) => Promise<any>
removeAllCookies: () => Promise<void>
}
export const wrapToughCookie = (jar: ToughCookieJar): CookieManager => ({
getCookieString: (url) => jar.getCookieString(url),
getCookies: async (url) => {
const cookies = await jar.getCookies(url)
return cookies.map((cookie) => deserialize(cookie.toString()))
},
setCookie: async (cookie, url) => {
await jar.setCookie(serialize(cookie), url)
},
setCookieString: async (cookieString, url) => {
await jar.setCookie(cookieString, url)
},
clearAll: () => jar.removeAllCookies(),
})
interface RNCookies {
[key: string]: Cookie
}
export interface RNCookieManager {
set(url: string, cookie: Cookie, useWebKit?: boolean): Promise<boolean>
setFromResponse(url: string, cookie: string): Promise<boolean>
get(url: string, useWebKit?: boolean): Promise<RNCookies>
clearAll(useWebKit?: boolean): Promise<boolean>
}
export const wrapReactNativeCookieManager = (
rnc: RNCookieManager
): CookieManager => ({
clearAll: () => rnc.clearAll().then(),
getCookieString: async (url) => {
const cookies = await rnc.get(url)
return Object.values(cookies)
.map((c) => `${c.name}=${c.value}`)
.join('; ')
},
getCookies: async (url) => {
const cookies = await rnc.get(url)
return Object.values(cookies)
},
setCookie: async (cookie, url) => {
await rnc.setFromResponse(url, serialize(cookie))
},
setCookieString: async (cookieString, url) => {
await rnc.setFromResponse(url, cookieString)
},
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,83 @@
import wrap, { CallInfo, Fetcher, Recorder } from './fetcher'
import { Fetch, Headers, Response } from './types'
const Blob = require('node-blob')
Blob.prototype.arrayBuffer = async function () {
return this.buffer.buffer
}
describe('fetcher', () => {
let fetch: jest.Mocked<Fetch>
let response: jest.Mocked<Response>
let headers: jest.Mocked<Headers>
let fetcher: Fetcher
beforeEach(() => {
headers = { get: jest.fn() }
response = {
ok: true,
status: 200,
statusText: 'ok',
json: jest.fn(),
text: jest.fn(),
headers,
}
fetch = jest.fn().mockResolvedValue(response)
fetcher = wrap(fetch)
})
it('calls fetch', async () => {
await fetcher('foo', '/')
expect(fetch).toHaveBeenCalledWith('/', expect.any(Object))
})
it('json returns the result', async () => {
const data = { foo: 'bar' }
response.json.mockResolvedValue(data)
const res = await fetcher('foo', '/')
const result = await res.json()
expect(result).toEqual(data)
})
it('text returns the result', async () => {
const data = 'Hello World!'
response.text.mockResolvedValue(data)
const res = await fetcher('foo', '/')
const result = await res.text()
expect(result).toEqual(data)
})
describe('record', () => {
let recorder: Recorder
let expectedInfo: CallInfo
beforeEach(() => {
recorder = jest.fn().mockResolvedValue(undefined)
fetcher = wrap(fetch, { record: recorder })
expectedInfo = {
name: 'foo',
type: '',
url: '/',
headers: expect.any(Object),
status: 200,
statusText: 'ok',
}
})
it('records with the correct parameters for json', async () => {
response.json.mockResolvedValue({})
await (await fetcher('foo', '/')).json()
expectedInfo.type = 'json'
const expectedData = {}
expect(recorder).toHaveBeenCalledWith(expectedInfo, expectedData)
})
it('records with the correct parameters for text', async () => {
response.text.mockResolvedValue('Hello')
await (await fetcher('foo', '/')).text()
expectedInfo.type = 'text'
const expectedData = 'Hello'
expect(recorder).toHaveBeenCalledWith(expectedInfo, expectedData)
})
})
})

View File

@ -0,0 +1,85 @@
import { Fetch, RequestInit, Response } from './types'
export interface CallInfo extends RequestInit {
name: string
type: string
url: string
status: number
statusText: string
error?: Error
}
export interface FetcherOptions {
record?: (
info: CallInfo,
data: string | Blob | ArrayBuffer | any
) => Promise<void>
}
export interface Fetcher {
(name: string, url: string, init?: RequestInit): Promise<Response>
}
export interface Recorder {
(info: CallInfo, data: string | Blob | ArrayBuffer | any): Promise<void>
}
const record = async (
name: string,
url: string,
init: RequestInit | undefined,
type: string,
options: FetcherOptions,
response: Response,
data: string | ArrayBuffer | Blob | any
): Promise<void> => {
if (!options.record) {
return
}
const info: CallInfo = {
...(init || {}),
name,
url,
type,
status: response.status,
statusText: response.statusText,
}
await options.record(info, data)
}
export default function wrap(
fetch: Fetch,
options: FetcherOptions = {}
): Fetcher {
return async (
name: string,
url: string,
init: RequestInit = { headers: {} }
): Promise<Response> => {
const config = {
...init,
headers: {
'User-Agent':
// eslint-disable-next-line max-len
'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36',
...init.headers,
},
}
const response = await fetch(url, config)
const wrapMethod = (res: Response, methodName: string): void => {
// @ts-ignore
const original = res[methodName].bind(res)
// @ts-ignore
res[methodName] = async (...args) => {
const result = await original(...args)
await record(name, url, config, methodName, options, response, result)
return result
}
}
wrapMethod(response, 'json')
wrapMethod(response, 'text')
return response
}
}

View File

@ -0,0 +1,27 @@
import { Api } from './api'
import { FetcherOptions } from './fetcher'
import { Fetch } from './types'
import {
RNCookieManager,
ToughCookieJar,
wrapReactNativeCookieManager,
wrapToughCookie,
} from './cookies'
export { Api, FetcherOptions }
export * from './types'
export { LoginStatusChecker } from './loginStatus'
const init = (
fetch: Fetch,
cookieManagerImpl: RNCookieManager | ToughCookieJar,
options?: FetcherOptions
): Api => {
// prettier-ignore
const cookieManager = ((cookieManagerImpl as RNCookieManager).get)
? wrapReactNativeCookieManager(cookieManagerImpl as RNCookieManager)
: wrapToughCookie(cookieManagerImpl as ToughCookieJar)
return new Api(fetch, cookieManager, options)
}
export default init

View File

@ -0,0 +1,63 @@
import { EventEmitter } from 'events'
import { Fetcher } from './fetcher'
import { loginStatus } from './routes'
import { AuthTicket } from './types'
/*
export enum LoginEvent {
OK = 'OK',
PENDING = 'PENDING',
ERROR = 'ERROR',
USER_SIGN = 'USER_SIGN',
}
*/
export interface LoginStatusChecker {
token: string
on: (
event: 'OK' | 'PENDING' | 'ERROR' | 'USER_SIGN' | 'CANCELLED',
listener: (...args: any[]) => void
) => LoginStatusChecker
cancel: () => Promise<void>
}
class Checker extends EventEmitter {
public token: string
private fetcher: Fetcher
private url: string
private cancelled: boolean = false
constructor(fetcher: Fetcher, ticket: AuthTicket) {
super()
this.fetcher = fetcher
this.url = loginStatus(ticket.order)
this.token = ticket.token
this.check()
}
async check(): Promise<void> {
const response = await this.fetcher('login-status', this.url)
const status = await response.text()
this.emit(status)
if (
!this.cancelled &&
status !== 'OK' &&
status !== 'ERROR!' &&
status !== 'CANCELLED'
) {
setTimeout(() => this.check(), 1000)
}
}
async cancel(): Promise<void> {
this.cancelled = true
}
}
export const checkStatus = (
fetch: Fetcher,
ticket: AuthTicket
): LoginStatusChecker => new Checker(fetch, ticket)

View File

@ -0,0 +1 @@
declare module 'h2m'

View File

@ -0,0 +1,74 @@
import { EtjanstResponse } from '../'
import { calendar } from '../calendar'
let response: EtjanstResponse
beforeEach(() => {
response = {
Success: true,
Error: null,
Data: [
{
Title: 'Jullov',
Id: 29,
Description: 'hello',
Location: null,
EventDate: '2020-12-21',
EventDateTime: '09:00',
LongEventDateTime: '2020-12-21 09:00',
EndDate: '2021-01-08',
EndDateTime: '10:00',
LongEndDateTime: '2021-01-08 10:00',
EventDateDayNumber: '21',
EventDateMonthName: 'dec',
EventDateMonthFullName: 'december',
FullDateDescription: '2020-12-21 09:00 - 2021-01-08 10:00',
IsSameDay: false,
AllDayEvent: false,
ListId: null,
Mentor: null,
},
{
Title: 'Utvecklingsdag, förskolorna är stängda',
Id: 5,
Description: null,
Location: null,
EventDate: '2021-05-28',
EventDateTime: '',
LongEventDateTime: '2021-05-28',
EndDate: '2021-05-28',
EndDateTime: '',
LongEndDateTime: '2021-05-28',
EventDateDayNumber: '28',
EventDateMonthName: 'maj',
EventDateMonthFullName: 'maj',
FullDateDescription: '2021-05-28 - 2021-05-28',
IsSameDay: true,
AllDayEvent: true,
ListId: null,
Mentor: null,
},
],
}
})
it('parses calendar correctly', () => {
const [firstEvent] = calendar(response)
expect(firstEvent).toEqual({
id: 29,
location: null,
title: 'Jullov',
description: 'hello',
startDate: '2020-12-21T08:00:00.000Z',
endDate: '2021-01-08T09:00:00.000Z',
allDay: false,
})
})
it('parses start and end date without time', () => {
const [, secondEvent] = calendar(response)
expect(secondEvent.startDate).toEqual('2021-05-27T22:00:00.000Z')
expect(secondEvent.endDate).toEqual('2021-05-27T22:00:00.000Z')
})

View File

@ -0,0 +1,43 @@
import { EtjanstResponse } from '../'
import { children } from '../children'
let response: EtjanstResponse
beforeEach(() => {
response = {
Success: true,
Error: null,
Data: [
{
Name: 'Some name',
Id: '42C3997E-D772-423F-9290-6FEEB3CB2DA7',
SDSId: '786E3393-F044-4660-9105-B444DEB289AA',
Status: 'GR',
UserType: 'Student',
SchoolId: 'DE2E1293-0F40-4B91-9D91-1E99355DC257',
SchoolName: null,
GroupId: null,
GroupName: null,
Classes:
'VHsidan_0495CABC-77DB-41D7-824B-8B4D63E50D15;Section_AD1BB3B2-C1EE-4DFE-8209-CB6D42CE23D7;Section_0E67D0BF-594C-4C1B-9291-E753926DCD40;VHsidan_1C94EC54-9798-401C-B973-2454246D95DA',
isSameSDSId: false,
ResultUnitId: null,
ResultUnitName: null,
UnitId: null,
UnitName: null,
},
],
}
})
it('parses children correctly', () => {
expect(children(response)).toEqual([
{
name: 'Some name',
id: '42C3997E-D772-423F-9290-6FEEB3CB2DA7',
sdsId: '786E3393-F044-4660-9105-B444DEB289AA',
schoolId: 'DE2E1293-0F40-4B91-9D91-1E99355DC257',
status: 'GR',
},
])
})

View File

@ -0,0 +1,72 @@
import { EtjanstResponse } from '../'
import { classmates } from '../classmates'
let response: EtjanstResponse
beforeEach(() => {
response = {
Success: true,
Error: null,
Data: [
{
ID: 0,
BATCH: null,
SIS_ID: '22F0CFC7-09C7-45DC-9388-AE9A9EA1356B',
USERNAME: null,
SCHOOL_SIS_ID: null,
EMAILADDRESS: null,
STATUS: null,
ERRORCODE: 0,
PRIMARY_SCHOOL_SIS_ID: null,
MENTOR_SIS_ID: null,
FIRSTNAME: 'Bo',
LASTNAME: 'Burström',
ACTIVE: false,
Guardians: [
{
SOCIALNUMBER: null,
DISPLAYNAME: null,
FIRSTNAME: 'Allan',
LASTNAME: 'Fridell',
ADDRESS: 'Hult södregård',
CITY: null,
POCODE: null,
TELHOME: null,
TELMOBILE: '0690-6346216',
EMAILHOME: 'allan.fridell@mailinater.com',
SECTION_NAME: null,
SECTION_ID: null,
TERM_STARTDATE: null,
TERM_ENDDATE: null,
GROUPTYPE: null,
STUDENT_FIRSTNAME: null,
STUDENT_LASTNAME: null,
STUDENT_ID: null,
},
],
ClassName: '7C',
ClassId: 'B2BF465B-581B-43AC-9CA7-F11BB0ED4646',
},
],
}
})
it('parses class mates correctly', () => {
expect(classmates(response)).toEqual([
{
sisId: '22F0CFC7-09C7-45DC-9388-AE9A9EA1356B',
firstname: 'Bo',
lastname: 'Burström',
className: '7C',
guardians: [
{
firstname: 'Allan',
lastname: 'Fridell',
address: 'Hult södregård',
mobile: '0690-6346216',
email: 'allan.fridell@mailinater.com',
},
],
},
])
})

View File

@ -0,0 +1,32 @@
import * as parse from '../'
let response: parse.EtjanstResponse
describe('etjanst', () => {
beforeEach(() => {
response = {
Success: true,
Error: null,
Data: [
{
Name: 'Some name',
},
],
}
})
it('returns data on success', () => {
expect(parse.etjanst(response)).toBeInstanceOf(Array)
})
it('throws error on Error', () => {
response.Success = false
response.Error = 'b0rk'
expect(() => parse.etjanst(response)).toThrowError('b0rk')
})
it('camelCases data keys', () => {
const parsed = parse.etjanst(response)
expect(parsed[0].name).toEqual(response.Data[0].Name)
})
})

View File

@ -0,0 +1,91 @@
import { EtjanstResponse } from '../'
import { menu, menuList } from '../menu'
let response: EtjanstResponse
describe('menu', () => {
beforeEach(() => {
response = {
Success: true,
Error: null,
Data: [
{
Title: 'Måndag - Vecka 52',
Description: 'Körrfärsrätt .<br/>Veg färs',
},
],
}
})
it(' menu correctly', () => {
expect(menu(response)).toEqual([
{
title: 'Måndag - Vecka 52',
description: 'Körrfärsrätt .\nVeg färs',
},
])
})
})
describe('menu-list', () => {
beforeEach(() => {
response = {
Success: true,
Error: null,
Data: {
SelectedWeek: 12,
Menus: [
{
Week: '12',
Mon: 'Köttfärslimpa med sås och potatis',
Tue: 'Curryfisk med ris',
Wed: 'Tagliatelle med vegetarisk sås',
Thu: 'Chorizo med stuvad potatis',
Fri: 'Ört och vitlöksinbakad fisk, potatis',
},
{
Week: '19',
Mon: 'FISKGRATÄNG WALEWSKA',
Tue: 'STEKT FLÄSK MED RAGGMUNK',
Wed: 'PENNEPASTA MED TONFISK',
Thu: 'KÖTTGRYTA MED POTATIS',
Fri: 'GRÖNSAKSGRATÄNG MED TZATZIKI',
},
{
Week: '20',
Mon: 'SPAGHETTI SALMONE ',
Tue: 'STEKT FALUKORV MED SENAPSSÅS OCH POTATIS',
Wed: 'SOPPA MED RISONI OCH HEMBAKAT BRÖD',
Thu: 'PANERAD FISK MED SKAGEN OCH POTATIS',
Fri: 'TACOS',
},
],
},
}
})
it(' menu correctly', () => {
const result = menuList(response)
expect(result).toEqual([
{
title: 'Måndag - Vecka 12',
description: 'Köttfärslimpa med sås och potatis',
},
{
title: 'Tisdag - Vecka 12',
description: 'Curryfisk med ris',
},
{
title: 'Onsdag - Vecka 12',
description: 'Tagliatelle med vegetarisk sås',
},
{
title: 'Torsdag - Vecka 12',
description: 'Chorizo med stuvad potatis',
},
{
title: 'Fredag - Vecka 12',
description: 'Ört och vitlöksinbakad fisk, potatis',
},
])
})
})

View File

@ -0,0 +1,333 @@
import { EtjanstResponse } from '../'
import { news, newsItemDetails } from '../news'
let response: EtjanstResponse
describe('news', () => {
describe('parsing', () => {
beforeEach(() => {
response = {
Success: true,
Error: null,
Data: {
CurrentChild: null,
NewsItems: [
{
NewsId: 'news id',
SiteId:
'elevstockholm.sharepoint.com,27892ACC-BA2E-4DEC-97B8-25F7098C3BF6,A239466A-9A52-42FF-8A3F-D94C342F2700',
NewsListId: '3EC323A1-EA16-4D24-84C8-DAA49E76F9F4',
NewsItemId:
'elevstockholm.sharepoint.com,27892ACC-BA2E-4DEC-97B8-25F7098C3BF6,A239466A-9A52-42FF-8A3F-D94C342F2700_99',
Header: 'Problemet med att se betyg i bild, slöjd och teknik löst!',
PublicationDate: '/Date(1608304542000)/',
PubDateSE: '18 december 2020 16:15',
ModifiedDate: '/Date(1608304680000)/',
ModDateSE: '18 december 2020 16:18',
Source: 'Livets hårda skolklasser',
Preamble:
'Hej,Nu är problemet löst! Alla betyg syns som de ska.God jul!...',
BannerImageUrl: 'A703552D-DBF3-45B0-8E67-6E062105A0C5.jpeg',
BannerImageGuid: 'A703552D-DBF3-45B0-8E67-6E062105A0C5',
BannerImageListId: 'FFBE49E9-BDE1-4C75-BA0E-D98D4E2FCF21',
Body:
'<div><div data-sp-canvascontrol="" data-sp-canvasdataversion="1.0" data-sp-controldata="&#123;&quot;controlType&quot;&#58;4,&quot;id&quot;&#58;&quot;1212fc8d-dd6b-408a-8d5d-9f1cc787efbb&quot;,&quot;position&quot;&#58;&#123;&quot;controlIndex&quot;&#58;2,&quot;sectionIndex&quot;&#58;1,&quot;sectionFactor&quot;&#58;12,&quot;zoneIndex&quot;&#58;1,&quot;layoutIndex&quot;&#58;1&#125;,&quot;addedFromPersistedData&quot;&#58;true,&quot;emphasis&quot;&#58;&#123;&#125;&#125;"><div data-sp-rte=""><p>Hej,</p><p>Nu är problemet löst! Alla betyg syns som de ska.&#160;</p><p>God jul!</p></div></div><div data-sp-canvascontrol="" data-sp-canvasdataversion="1.0" data-sp-controldata="&#123;&quot;controlType&quot;&#58;0,&quot;pageSettingsSlice&quot;&#58;&#123;&quot;isDefaultDescription&quot;&#58;true,&quot;isDefaultThumbnail&quot;&#58;true&#125;&#125;"></div></div>',
BodyNoHtml: null,
AuthorDisplayName: 'Eva-Lotta Rönnberg',
altText: 'Nyhetsbild. Bildtext ej tillgänglig.',
},
],
ViewGlobalTranslations: {},
ViewLocalTranslations: {},
Children: null,
Status: null,
GlobalTranslationIds: [
'InformationalHeader',
'ContactUsMessageLabel',
'Send',
'RequiredFieldMessageInfo',
'Sex',
'Male',
'Female',
'SSN',
'FirstName',
'LastName',
'Email',
'Zip',
'Address',
'ValidationRequiredFieldMessage',
'ValidationErrorMessage',
],
LocalTranslationIds: ['IndexPageHeading1'],
},
}
})
it(' news items (except body) correctly', () => {
const [item] = news(response)
expect(item.id).toEqual('news id')
expect(item.author).toEqual('Eva-Lotta Rönnberg')
expect(item.header).toEqual(
'Problemet med att se betyg i bild, slöjd och teknik löst!'
)
expect(item.imageUrl).toEqual('A703552D-DBF3-45B0-8E67-6E062105A0C5.jpeg')
expect(item.fullImageUrl).toEqual(
'https://etjanst.stockholm.se/Vardnadshavare/inloggad2/NewsBanner?url=A703552D-DBF3-45B0-8E67-6E062105A0C5.jpeg'
)
expect(item.imageAltText).toEqual('Nyhetsbild. Bildtext ej tillgänglig.')
expect(item.intro).toEqual(
'Hej, Nu är problemet löst! Alla betyg syns som de ska. God jul!...'
)
expect(item.modified).toEqual('2020-12-18T15:18:00.000Z')
expect(item.published).toEqual('2020-12-18T15:15:42.000Z')
})
it(' body correctly', () => {
const [item] = news(response)
const expected =
'Hej, Nu är problemet löst! Alla betyg syns som de ska. God jul!'
const trimmed = (item.body || '')
.split('\n')
.map((t) => t.trim())
.join(' ')
expect(trimmed).toEqual(expected)
})
})
describe('sorting', () => {
beforeEach(() => {
response = {
Success: true,
Error: null,
Data: {
CurrentChild: null,
NewsItems: [
{
NewsId: 'news id',
SiteId:
'elevstockholm.sharepoint.com,27892ACC-BA2E-4DEC-97B8-25F7098C3BF6,A239466A-9A52-42FF-8A3F-D94C342F2700',
NewsListId: '3EC323A1-EA16-4D24-84C8-DAA49E76F9F4',
NewsItemId:
'elevstockholm.sharepoint.com,27892ACC-BA2E-4DEC-97B8-25F7098C3BF6,A239466A-9A52-42FF-8A3F-D94C342F2700_99',
Header: 'Problemet med att se betyg i bild, slöjd och teknik löst!',
PublicationDate: '/Date(1608304542000)/',
PubDateSE: '18 december 2020 16:15',
ModifiedDate: '/Date(1608304680000)/',
ModDateSE: '18 december 2020 16:18',
Source: 'Livets hårda skolklasser',
Preamble:
'Hej,Nu är problemet löst! Alla betyg syns som de ska.God jul!...',
BannerImageUrl: 'A703552D-DBF3-45B0-8E67-6E062105A0C5.jpeg',
BannerImageGuid: 'A703552D-DBF3-45B0-8E67-6E062105A0C5',
BannerImageListId: 'FFBE49E9-BDE1-4C75-BA0E-D98D4E2FCF21',
Body:
'<div><div data-sp-canvascontrol="" data-sp-canvasdataversion="1.0" data-sp-controldata="&#123;&quot;controlType&quot;&#58;4,&quot;id&quot;&#58;&quot;1212fc8d-dd6b-408a-8d5d-9f1cc787efbb&quot;,&quot;position&quot;&#58;&#123;&quot;controlIndex&quot;&#58;2,&quot;sectionIndex&quot;&#58;1,&quot;sectionFactor&quot;&#58;12,&quot;zoneIndex&quot;&#58;1,&quot;layoutIndex&quot;&#58;1&#125;,&quot;addedFromPersistedData&quot;&#58;true,&quot;emphasis&quot;&#58;&#123;&#125;&#125;"><div data-sp-rte=""><p>Hej,</p><p>Nu är problemet löst! Alla betyg syns som de ska.&#160;</p><p>God jul!</p></div></div><div data-sp-canvascontrol="" data-sp-canvasdataversion="1.0" data-sp-controldata="&#123;&quot;controlType&quot;&#58;0,&quot;pageSettingsSlice&quot;&#58;&#123;&quot;isDefaultDescription&quot;&#58;true,&quot;isDefaultThumbnail&quot;&#58;true&#125;&#125;"></div></div>',
BodyNoHtml: null,
AuthorDisplayName: 'Eva-Lotta Rönnberg',
altText: 'Nyhetsbild. Bildtext ej tillgänglig.',
},
{
NewsId: 'news id updated',
SiteId:
'elevstockholm.sharepoint.com,27892ACC-BA2E-4DEC-97B8-25F7098C3BF6,A239466A-9A52-42FF-8A3F-D94C342F2700',
NewsListId: '3EC323A1-EA16-4D24-84C8-DAA49E76F9F4',
NewsItemId:
'elevstockholm.sharepoint.com,27892ACC-BA2E-4DEC-97B8-25F7098C3BF6,A239466A-9A52-42FF-8A3F-D94C342F2700_99',
Header: 'Problemet med att se betyg i bild, slöjd och teknik löst!',
PublicationDate: '/Date(1608304542000)/',
PubDateSE: '18 november 2021 16:15',
ModifiedDate: '/Date(1608304680000)/',
ModDateSE: '18 december 2020 16:18',
Source: 'Livets hårda skolklasser',
Preamble:
'Hej,Nu är problemet löst! Alla betyg syns som de ska.God jul!...',
BannerImageUrl: 'A703552D-DBF3-45B0-8E67-6E062105A0C5.jpeg',
BannerImageGuid: 'A703552D-DBF3-45B0-8E67-6E062105A0C5',
BannerImageListId: 'FFBE49E9-BDE1-4C75-BA0E-D98D4E2FCF21',
Body:
'<div><div data-sp-canvascontrol="" data-sp-canvasdataversion="1.0" data-sp-controldata="&#123;&quot;controlType&quot;&#58;4,&quot;id&quot;&#58;&quot;1212fc8d-dd6b-408a-8d5d-9f1cc787efbb&quot;,&quot;position&quot;&#58;&#123;&quot;controlIndex&quot;&#58;2,&quot;sectionIndex&quot;&#58;1,&quot;sectionFactor&quot;&#58;12,&quot;zoneIndex&quot;&#58;1,&quot;layoutIndex&quot;&#58;1&#125;,&quot;addedFromPersistedData&quot;&#58;true,&quot;emphasis&quot;&#58;&#123;&#125;&#125;"><div data-sp-rte=""><p>Hej,</p><p>Nu är problemet löst! Alla betyg syns som de ska.&#160;</p><p>God jul!</p></div></div><div data-sp-canvascontrol="" data-sp-canvasdataversion="1.0" data-sp-controldata="&#123;&quot;controlType&quot;&#58;0,&quot;pageSettingsSlice&quot;&#58;&#123;&quot;isDefaultDescription&quot;&#58;true,&quot;isDefaultThumbnail&quot;&#58;true&#125;&#125;"></div></div>',
BodyNoHtml: null,
AuthorDisplayName: 'Eva-Lotta Rönnberg',
altText: 'Nyhetsbild. Bildtext ej tillgänglig.',
},
],
ViewGlobalTranslations: {},
ViewLocalTranslations: {},
Children: null,
Status: null,
GlobalTranslationIds: [
'InformationalHeader',
'ContactUsMessageLabel',
'Send',
'RequiredFieldMessageInfo',
'Sex',
'Male',
'Female',
'SSN',
'FirstName',
'LastName',
'Email',
'Zip',
'Address',
'ValidationRequiredFieldMessage',
'ValidationErrorMessage',
],
LocalTranslationIds: ['IndexPageHeading1'],
},
}
})
it('sorts by modified date desc', () => {
const [item] = news(response)
expect(item.id).toEqual('news id updated')
})
})
})
describe('newsItem', () => {
beforeEach(() => {
response = {
Success: true,
Error: null,
Data: {
CurrentNewsItem: {
NewsId: '123',
SiteId:
'elevstockholm.sharepoint.com,d112c398-71d4-468f-9a59-84d806751b08,3addab10-546a-4551-8076-72c9cd67f961',
NewsListId: '95df7d70-fbf0-470d-9926-e4e633f77f27',
NewsItemId:
'elevstockholm.sharepoint.com,d112c398-71d4-468f-9a59-84d806751b08,3addab10-546a-4551-8076-72c9cd67f961_40',
Header: 'Avlusningsdagarna 5-7 februari 2021',
PublicationDate: '/Date(1612445471000)/',
PubDateSE: '4 februari 2021 14:31',
ModifiedDate: '/Date(1612445852000)/',
ModDateSE: '14 februari 2021 14:37',
Source: 'Södra Ängby skola',
Preamble: 'Kära vårdnadshavare!I helgen är det avlusningsdagar!',
BannerImageUrl: '123123.jpeg',
BannerImageGuid: '7a8142d9d9d54cf090e8457e4c629227',
BannerImageListId: 'a88c22e8-7094-4a71-b4fd-8792c62a7b4a',
Body:
'<div data-sp-rte=""><p><span><span><span>Kära vårdnadshavare!</span></span></span></p><p><span><span><span>I helgen är det avlusningsdagar! Ta <strong>tillfället </strong>i akt att luskamma ditt barn </span></span></span></p><p><span><span><span>Du finner all info du behöver på <a href="https&#58;//www.1177.se/sjukdomar--besvar/hud-har-och-naglar/harbotten-och-harsackar/huvudloss/" data-cke-saved-href="https&#58;//www.1177.se/sjukdomar--besvar/hud-har-och-naglar/harbotten-och-harsackar/huvudloss/" data-interception="on" title="https&#58;//www.1177.se/sjukdomar--besvar/hud-har-och-naglar/harbotten-och-harsackar/huvudloss/">1177 hemsida </a></span></span></span><span><span><span></span></span></span></p><p><span><span><span>Trevlig helg!</span></span></span></p><p><span><span><span></span></span></span></p></div>',
BodyNoHtml: null,
AuthorDisplayName: 'Tieto Evry',
altText: null,
OriginalSourceUrl: null,
},
CurrentChild: null,
ViewGlobalTranslations: {},
ViewLocalTranslations: {},
Children: null,
Status: null,
GlobalTranslationIds: [
'InformationalHeader',
'ContactUsMessageLabel',
'Send',
'RequiredFieldMessageInfo',
'Sex',
'Male',
'Female',
'SSN',
'FirstName',
'LastName',
'Email',
'Zip',
'Address',
'ValidationRequiredFieldMessage',
'ValidationErrorMessage',
],
LocalTranslationIds: ['IndexPageHeading1'],
},
}
})
it(' news details (except body) correctly', () => {
const item = newsItemDetails(response)
expect(item.id).toEqual('123')
expect(item.header).toEqual('Avlusningsdagarna 5-7 februari 2021')
expect(item.imageUrl).toEqual('123123.jpeg')
expect(item.intro).toEqual(
'Kära vårdnadshavare! I helgen är det avlusningsdagar!'
)
expect(item.published).toEqual('2021-02-04T13:31:11.000Z')
expect(item.modified).toEqual('2021-02-04T13:37:32.000Z')
expect(item.author).toEqual('Tieto Evry')
})
it(' body correctly', () => {
const item = newsItemDetails(response)
const expected =
'[1177 hemsida](https://www.1177.se/sjukdomar--besvar/hud-har-och-naglar/harbotten-och-harsackar/huvudloss/)'
expect(item.body).toContain(expected)
expect(item.body).toContain(' **tillfället** ')
})
})
describe('newsItem', () => {
beforeEach(() => {
response = {
Success: true,
Error: null,
Data: {
CurrentNewsItem: {
NewsId: '123',
SiteId:
'elevstockholm.sharepoint.com,d112c398-71d4-468f-9a59-84d806751b08,3addab10-546a-4551-8076-72c9cd67f961',
NewsListId: '95df7d70-fbf0-470d-9926-e4e633f77f27',
NewsItemId:
'elevstockholm.sharepoint.com,d112c398-71d4-468f-9a59-84d806751b08,3addab10-546a-4551-8076-72c9cd67f961_40',
Header: 'Avlusningsdagarna 5-7 februari 2021',
PublicationDate: '/Date(1612445471000)/',
PubDateSE: '4 februari 2021 14:31',
ModifiedDate: '/Date(1612445852000)/',
ModDateSE: '14 februari 2021 14:37',
Source: 'Södra Ängby skola',
Preamble: 'Kära vårdnadshavare!I helgen är det avlusningsdagar!',
BannerImageUrl: '123123.jpeg',
BannerImageGuid: '7a8142d9d9d54cf090e8457e4c629227',
BannerImageListId: 'a88c22e8-7094-4a71-b4fd-8792c62a7b4a',
Body:
'<i>italic</i> <b>bold</b> <em>emphasis </em><br/><strong>strong</strong><strong>nbsp&#160;</strong>',
BodyNoHtml: null,
AuthorDisplayName: 'Tieto Evry',
altText: null,
OriginalSourceUrl: null,
},
CurrentChild: null,
ViewGlobalTranslations: {},
ViewLocalTranslations: {},
Children: null,
Status: null,
GlobalTranslationIds: [
'InformationalHeader',
'ContactUsMessageLabel',
'Send',
'RequiredFieldMessageInfo',
'Sex',
'Male',
'Female',
'SSN',
'FirstName',
'LastName',
'Email',
'Zip',
'Address',
'ValidationRequiredFieldMessage',
'ValidationErrorMessage',
],
LocalTranslationIds: ['IndexPageHeading1'],
},
}
})
it(' emphasizes correctly', () => {
const item = newsItemDetails(response)
expect(item.body).toContain('*italic*')
expect(item.body).toContain('**bold**')
expect(item.body).toContain('*emphasis*')
expect(item.body).toContain('**strong**')
expect(item.body).toContain('**nbsp**')
})
})

View File

@ -0,0 +1,64 @@
import { EtjanstResponse } from '../'
import { notifications } from '../notifications'
let response: EtjanstResponse
beforeEach(() => {
response = {
Success: true,
Error: null,
Data: [
{
Notification: {
Messageid: 'E2E3A567-307F-4859-91BA-31B1F4522A7B',
Messagecorrelationid: 'BB54DC8E-BB02-49A5-9806-4A2433031AA7',
Message:
'{"messages":{"message":{"messageid":"E2E3A567-307F-4859-91BA-31B1F4522A7B","messagecorrelationid":"BB54DC8E-BB02-49A5-9806-4A2433031AA7","messagetext":"Betygen är publicerade.","messagesubject":"Betyg klara","messagetime":"2020-12-18T15:59:43.195","linkbackurl":"https://elevdokumentation.stockholm.se/loa3/gradesStudent.do","sender":{"name":"Elevdokumentation"},"recipient":{"recipient":"195709227283","role":"Guardian"},"messagetype":{"type":"webnotify"},"system":"Elevdokumentation","participant":"BB7DE89D-D714-4EB2-85CD-36F9991E7C34"}}}',
Readreceipt: false,
Recipient: '195709227283',
Id: 5880387,
DateCreated: '2020-12-18T15:59:46.34',
DateModified: '/Date(1608307186340)/',
Role: 'Guardian',
Participant: 'BB7DE89D-D714-4EB2-85CD-36F9991E7C34',
},
NotificationMessage: {
Messages: {
Message: {
Messageid: 'E2E3A567-307F-4859-91BA-31B1F4522A7B',
Messagecorrelationid: 'BB54DC8E-BB02-49A5-9806-4A2433031AA7',
Messagetext: 'Betygen är publicerade.',
Messagetime: '/Date(1608303583195)/',
Linkbackurl:
'https://elevdokumentation.stockholm.se/loa3/gradesStudent.do',
Category: null,
Sender: { Name: 'Elevdokumentation' },
Recipient: {
RecipientRecipient: '195709227283',
Role: 'Guardian',
Schooltype: null,
},
Messagetype: { Type: 'webnotify' },
System: 'Elevdokumentation',
},
},
},
},
],
}
})
it(' notifications correctly', () => {
expect(notifications(response)).toEqual([
{
id: 'E2E3A567-307F-4859-91BA-31B1F4522A7B',
message: 'Betygen är publicerade.',
sender: 'Elevdokumentation',
url: 'https://elevdokumentation.stockholm.se/loa3/gradesStudent.do',
dateCreated: '2020-12-18T14:59:46.340Z',
dateModified: "2020-12-18T15:59:46.340Z",
category: null,
type: 'webnotify',
},
])
})

View File

@ -0,0 +1,47 @@
import { EtjanstResponse } from '../'
import { schedule } from '../schedule'
let response: EtjanstResponse
beforeEach(() => {
response = {
Success: true,
Error: null,
Data: [
{
Title: 'Canceled: Julavslutning 8C',
Id: 0,
Description: 'Nåt kul',
Location: 'Lakritskolan',
EventDate: '2020-12-14',
EventDateTime: '14:10',
LongEventDateTime: '2020-12-14 14:10',
EndDate: '2020-12-14',
EndDateTime: '14:40',
LongEndDateTime: '2020-12-14 14:40',
EventDateDayNumber: '14',
EventDateMonthName: 'dec',
EventDateMonthFullName: 'december',
FullDateDescription: '2020-12-14 14:10 - 2020-12-14 14:40',
IsSameDay: true,
AllDayEvent: false,
ListId: null,
Mentor: null,
},
],
}
})
it('parses schedule correctly', () => {
expect(schedule(response)).toEqual([
{
title: 'Canceled: Julavslutning 8C',
description: 'Nåt kul',
location: 'Lakritskolan',
startDate: '2020-12-14T13:10:00.000Z',
endDate: '2020-12-14T13:40:00.000Z',
oneDayEvent: true,
allDayEvent: false,
},
])
})

View File

@ -0,0 +1,149 @@
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, 'sv')
expect(entry.id).toEqual('ZTQ1NWE0N2EtNzAwOS0wZTAzLTQ1ZDYtNTA1NWI4Y2JhNDYw')
expect(entry.code).toEqual('BL')
expect(entry.name).toEqual('Bild')
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, 'sv')
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, 'sv')).toThrow('b0rk')
})
it('parses lessonInfo', () => {
const table = timetable(response, 2021, 15, 'sv')
expect(table).toHaveLength(2)
expect(table[0].id).toEqual('N2FjMDc1NjYtZmM2Yy0wZDQyLTY3M2YtZWI5NGNiZDA3ZGU4')
expect(table[1].id).toEqual('ZTQ1NWE0N2EtNzAwOS0wZTAzLTQ1ZDYtNTA1NWI4Y2JhNDYw')
})
})
})

View File

@ -0,0 +1,27 @@
import { user } from '../user'
let response: any
beforeEach(() => {
response = {
socialSecurityNumber: '197106171635',
isAuthenticated: true,
userFirstName: 'Per-Ola',
userLastName: 'Assarsson',
userEmail: 'per-ola.assarsson@dodgit.com',
notificationId:
'B026594053D44299AB64ED81990B49C04D32F635C9A3454A84030439BFDDEF04',
}
})
it('parses user correctly', () => {
expect(user(response)).toEqual({
personalNumber: '197106171635',
firstName: 'Per-Ola',
lastName: 'Assarsson',
email: 'per-ola.assarsson@dodgit.com',
isAuthenticated: true,
notificationId:
'B026594053D44299AB64ED81990B49C04D32F635C9A3454A84030439BFDDEF04',
})
})

View File

@ -0,0 +1,24 @@
import { etjanst } from './etjanst'
import { CalendarItem } from '../types'
import { parseDate } from '../utils/dateHandling'
export const calendarItem = ({
id,
title,
description,
location,
longEventDateTime,
longEndDateTime,
allDayEvent,
}: any): CalendarItem => ({
id,
title,
description,
location,
allDay: allDayEvent,
startDate: parseDate(longEventDateTime),
endDate: parseDate(longEndDateTime),
})
export const calendar = (data: any): CalendarItem[] =>
etjanst(data).map(calendarItem)

View File

@ -0,0 +1,12 @@
import { etjanst } from './etjanst'
import { Child } from '../types'
export const child = ({ id, sdsId, name, status, schoolId }: any): Child => ({
id,
sdsId,
name,
status,
schoolId,
})
export const children = (data: any): Child[] => etjanst(data).map(child)

View File

@ -0,0 +1,33 @@
import { etjanst } from './etjanst'
import { Classmate, Guardian } from '../types'
export const guardian = ({
emailhome,
firstname,
lastname,
address,
telmobile,
}: any): Guardian => ({
firstname,
lastname,
address,
mobile: telmobile,
email: emailhome,
})
export const classmate = ({
sisId,
firstname,
lastname,
className,
guardians = [],
}: any): Classmate => ({
sisId,
firstname,
lastname,
className,
guardians: guardians.map(guardian),
})
export const classmates = (data: any): Classmate[] =>
etjanst(data).map(classmate)

View File

@ -0,0 +1,14 @@
const camel = require('camelcase-keys')
export interface EtjanstResponse {
Success: boolean
Error: string | null
Data: any | any[]
}
export const etjanst = (response: EtjanstResponse): any | any[] => {
if (!response.Success) {
throw new Error(response.Error || '')
}
return camel(response.Data, { deep: true })
}

View File

@ -0,0 +1,10 @@
export * from './calendar'
export * from './children'
export * from './classmates'
export * from './etjanst'
export * from './menu'
export * from './news'
export * from './notifications'
export * from './schedule'
export * from './timetable'
export * from './user'

View File

@ -0,0 +1,48 @@
import { etjanst } from './etjanst'
import { toMarkdown } from '../parseHtml'
import { MenuItem, MenuList } from '../types'
export const menuItem = ({ title, description }: any): MenuItem => ({
title,
description: toMarkdown(description),
})
export const menu = (data: any): MenuItem[] => etjanst(data).map(menuItem)
export const menuList = (data: any): MenuItem[] => {
const etjanstData = etjanst(data)
const menuFS = etjanstData as MenuList
const currentWeek = menuFS.menus.find(
(item) => menuFS.selectedWeek === Number.parseInt(item.week, 10)
)
if (!currentWeek) {
return []
}
const menuItemsFS = [
{
title: `Måndag - Vecka ${currentWeek.week}`,
description: toMarkdown(currentWeek.mon) ,
},
{
title: `Tisdag - Vecka ${currentWeek.week}`,
description: toMarkdown(currentWeek.tue),
},
{
title: `Onsdag - Vecka ${currentWeek.week}`,
description: toMarkdown(currentWeek.wed),
},
{
title: `Torsdag - Vecka ${currentWeek.week}`,
description: toMarkdown(currentWeek.thu),
},
{
title: `Fredag - Vecka ${currentWeek.week}`,
description: toMarkdown(currentWeek.fri),
},
]
return menuItemsFS
}

View File

@ -0,0 +1,42 @@
import { etjanst } from './etjanst'
import { toMarkdown } from '../parseHtml'
import { NewsItem } from '../types'
import { parseDate } from '../utils/dateHandling'
const IMAGE_HOST =
'https://etjanst.stockholm.se/Vardnadshavare/inloggad2/NewsBanner?url='
export const newsItem = ({
newsId,
header,
preamble,
body,
bannerImageUrl,
publicationDate,
modifiedDate,
authorDisplayName,
altText,
}: any): NewsItem => ({
header,
published: parseDate(publicationDate) || '',
modified: parseDate(modifiedDate) || '',
id: newsId,
author: authorDisplayName,
intro: preamble.replace(/([!,.])(\w)/gi, '$1 $2'),
imageUrl: bannerImageUrl,
fullImageUrl: `${IMAGE_HOST}${bannerImageUrl}`,
imageAltText: altText,
body: toMarkdown(body),
})
const newsSort = (item1: NewsItem, item2: NewsItem): number => {
const m1 = item1.modified || item1.published
const m2 = item2.modified || item2.published
return m1 < m2 ? 1 : -1
}
export const news = (data: any): NewsItem[] =>
etjanst(data).newsItems.map(newsItem).sort(newsSort)
export const newsItemDetails = (data: any): NewsItem =>
newsItem(etjanst(data).currentNewsItem)

View File

@ -0,0 +1,36 @@
import { etjanst } from './etjanst'
import { parseDate } from '../utils/dateHandling'
import { Notification } from '../types'
export const notification = ({
notification: { messageid, dateCreated, dateModified },
notificationMessage: {
messages: {
message: {
category,
messagetext,
linkbackurl,
messagetype: { type },
sender: { name },
},
},
},
}: any): Notification => ({
id: messageid,
message: messagetext,
sender: name,
url: linkbackurl,
dateCreated: parseDate(dateCreated) || '',
dateModified: parseDate(dateModified) || '',
category,
type,
})
const notificationsSort = (item1: Notification, item2: Notification): number => {
const m1 = item1.dateModified || item1.dateCreated
const m2 = item2.dateModified || item2.dateCreated
return m1 < m2 ? 1 : -1
}
export const notifications = (data: any): Notification[] =>
etjanst(data).map(notification).sort(notificationsSort)

View File

@ -0,0 +1,40 @@
import { etjanst } from './etjanst'
import { ScheduleItem } from '../types'
import { parseDate } from '../utils/dateHandling'
export const scheduleItem = ({
title,
description,
location,
longEventDateTime,
longEndDateTime,
isSameDay,
allDayEvent,
}: any): ScheduleItem => ({
title,
description,
location,
allDayEvent,
startDate: parseDate(longEventDateTime),
endDate: parseDate(longEndDateTime),
oneDayEvent: isSameDay,
})
export const schedule = (data: any): ScheduleItem[] => {
try{
const scheduleData = etjanst(data)
const mapped = scheduleData.map(scheduleItem)
return mapped
}
catch(e){
if (e instanceof Error) {
// If this happens the child has no schedule
// It is the same on the official web
// Instead of retrying and spamming errors - lets return en empty array
if(e.message === 'A task was canceled.'){
return new Array<ScheduleItem>()
}
}
throw e
}
}

View File

@ -0,0 +1,63 @@
import parse from '@skolplattformen/curriculum'
import { Language } from '@skolplattformen/curriculum/dist/translations'
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({
weekYear: 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, lang: Language): TimetableEntry
}
export const timetableEntry: EntryParser = ({
guidId, texts: [code, teacher, location], timeStart, timeEnd, dayOfWeekNumber, blockName,
}, year, week, lang) => ({
...parse(code, lang),
id: guidId,
blockName,
dayOfWeek: dayOfWeekNumber,
location,
teacher,
timeEnd,
timeStart,
dateStart: calculateDate(year, week, dayOfWeekNumber, timeStart),
dateEnd: calculateDate(year, week, dayOfWeekNumber, timeEnd),
})
export const timetable = (response: TimetableResponse, year: number, week: number, lang: Language) => {
if (response.error) {
throw new Error(response.error)
}
return response.data.lessonInfo.map((entry) => timetableEntry(entry, year, week, lang))
}

View File

@ -0,0 +1,17 @@
import { User } from '../types'
export const user = ({
socialSecurityNumber,
isAuthenticated,
userFirstName,
userLastName,
userEmail,
notificationId,
}: any): User => ({
personalNumber: socialSecurityNumber,
firstName: userFirstName,
lastName: userLastName,
email: userEmail,
isAuthenticated,
notificationId,
})

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,110 @@
import * as h2m from 'h2m'
import { htmlDecode } from 'js-htmlencode'
import { decode } from 'he'
import { parse, HTMLElement, TextNode } from 'node-html-parser'
const noChildren = ['strong', 'b', 'em', 'i', 'u', 's']
const trimNodes = [...noChildren, 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'a']
const cleanText = (node: TextNode, parentType: string): TextNode => {
const text =
parentType && trimNodes.includes(parentType.toLowerCase())
? node.rawText.trim()
: node.rawText
return new TextNode(text)
}
const deepClean = (node: HTMLElement): HTMLElement => {
const attributes = Object.entries(node.attributes)
.map(([key, val]) => {
if (key === 'href' && val) {
// eslint-disable-next-line no-param-reassign
val = val.replace(/ /g, '%20')
}
return `${key}="${val}"`
})
.join(' ')
const cleaned = new HTMLElement(node.tagName, {}, attributes, node.parentNode)
node.childNodes.forEach((childNode) => {
if (childNode instanceof HTMLElement) {
if (node.tagName && noChildren.includes(node.tagName.toLowerCase())) {
cleaned.childNodes.push(
cleanText(new TextNode(childNode.innerText), node.tagName)
)
} else {
cleaned.childNodes.push(deepClean(childNode))
}
} else if (childNode instanceof TextNode) {
cleaned.childNodes.push(cleanText(childNode, node.tagName))
}
})
return cleaned
}
const rearrangeWhitespace = (html: string = ''): string => {
let content = html
.replace(/<span[^>]*>/gm, '')
.split('</span>').join('')
.replace(/<div[^>]*>/gm, '')
.split('</div>').join('')
.split('&#160;').join('&amp;nbsp;')
// FIXME: Make a loop that doesn't break linting
trimNodes.forEach((trimNode) => {
content = content.split(`<${trimNode}> `).join(` <${trimNode}>`)
content = content.split(` </${trimNode}>`).join(`</${trimNode}> `)
content = content.split(`<${trimNode}>&amp;nbsp;`).join(` <${trimNode}>`)
content = content.split(`&amp;nbsp;</${trimNode}>`).join(`</${trimNode}> `)
})
trimNodes.forEach((trimNode) => {
content = content.split(`<${trimNode}> `).join(` <${trimNode}>`)
content = content.split(` </${trimNode}>`).join(`</${trimNode}> `)
content = content.split(`<${trimNode}>&amp;nbsp;`).join(` <${trimNode}>`)
content = content.split(`&amp;nbsp;</${trimNode}>`).join(`</${trimNode}> `)
})
trimNodes.forEach((trimNode) => {
content = content.split(`<${trimNode}> `).join(` <${trimNode}>`)
content = content.split(` </${trimNode}>`).join(`</${trimNode}> `)
content = content.split(`<${trimNode}>&amp;nbsp;`).join(` <${trimNode}>`)
content = content.split(`&amp;nbsp;</${trimNode}>`).join(`</${trimNode}> `)
})
trimNodes.forEach((trimNode) => {
content = content.split(`<${trimNode}> `).join(` <${trimNode}>`)
content = content.split(` </${trimNode}>`).join(`</${trimNode}> `)
content = content.split(`<${trimNode}>&amp;nbsp;`).join(` <${trimNode}>`)
content = content.split(`&amp;nbsp;</${trimNode}>`).join(`</${trimNode}> `)
})
return content
}
export const clean = (html: string = ''): string =>
deepClean(parse(decode(html))).outerHTML
interface Node {
name: string
attrs: { [key: string]: string }
isInPreNode: boolean
md: string
}
const converter = 'MarkdownExtra'
const overides = {
a: (node: Node) => `[${node.md}](${node.attrs.href})`,
img: (node: Node) => `![${node.attrs.title || ''}](${node.attrs.src})`,
i: (node: Node) => `*${node.md}*`,
b: (node: Node) => `**${node.md}**`,
'h1': (node: Node) => `# ${node.md}\n`,
'h2': (node: Node) => `## ${node.md}\n`,
'h3': (node: Node) => `### ${node.md}\n`,
'h4': (node: Node) => `#### ${node.md}\n`,
'h5': (node: Node) => `##### ${node.md}\n`,
'h6': (node: Node) => `###### ${node.md}\n`,
}
export const toMarkdown = (html: string): string => {
const rearranged = rearrangeWhitespace(html)
const trimmed = clean(rearranged)
const markdown = h2m(trimmed, { overides, converter })
const decoded = htmlDecode(markdown)
return decoded
}

View File

@ -0,0 +1,80 @@
export const login = (personalNumber?: string) => {
const baseUrl = 'https://login003.stockholm.se/NECSadcmbid/authenticate/NECSadcmbid?TARGET=-SM-HTTPS%3a%2f%2flogin001%2estockholm%2ese%2fNECSadc%2fmbid%2fb64startpage%2ejsp%3fstartpage%3daHR0cHM6Ly9ldGphbnN0LnN0b2NraG9sbS5zZS92YXJkbmFkc2hhdmFyZS9pbmxvZ2dhZDIvaGVt'
const optionalPersonalNumber = personalNumber === undefined ? '' : `&personalNumber=${personalNumber}`
return `${baseUrl}&initialize=bankid${optionalPersonalNumber}&_=${Date.now()}`
}
export const loginStatus = (order: string) =>
`https://login003.stockholm.se/NECSadcmbid/authenticate/NECSadcmbid?TARGET=-SM-HTTPS%3a%2f%2flogin001%2estockholm%2ese%2fNECSadc%2fmbid%2fb64startpage%2ejsp%3fstartpage%3daHR0cHM6Ly9ldGphbnN0LnN0b2NraG9sbS5zZS92YXJkbmFkc2hhdmFyZS9pbmxvZ2dhZDIvaGVt&verifyorder=${order}&_=${Date.now()}`
export const loginCookie =
'https://login003.stockholm.se/NECSadcmbid/authenticate/SiteMinderAuthADC?TYPE=33554433&REALMOID=06-42f40edd-0c5b-4dbc-b714-1be1e907f2de&GUID=&SMAUTHREASON=0&METHOD=GET&SMAGENTNAME=IfNE0iMOtzq2TcxFADHylR6rkmFtwzoxRKh5nRMO9NBqIxHrc38jFyt56FASdxk1&TARGET=-SM-HTTPS%3a%2f%2flogin001%2estockholm%2ese%2fNECSadc%2fmbid%2fb64startpage%2ejsp%3fstartpage%3daHR0cHM6Ly9ldGphbnN0LnN0b2NraG9sbS5zZS92YXJkbmFkc2hhdmFyZS9pbmxvZ2dhZDIvR2V0Q2hpbGRyZW4%3d'
const urlLoggedIn = `https://etjanst.stockholm.se/vardnadshavare/inloggad2`
export const children = `${urlLoggedIn}/GetChildren`
export const calendar = (childId: string) =>
`${urlLoggedIn}/Calender/GetSchoolCalender?childId=${childId}&rowLimit=50`
export const classmates = (childId: string) =>
`${urlLoggedIn}/contacts/GetStudentsByClass?studentId=${childId}`
export const user =
'https://etjanst.stockholm.se/vardnadshavare/base/getuserdata'
export const news = (childId: string) =>
`${urlLoggedIn}/News/GetNewsArchive?bannerImageLimit=5000&childId=${childId}`
export const newsDetails = (childId: string, newsId: string) =>
`${urlLoggedIn}/News/GetNewsArticle?newsItemId=${newsId}&childId=${childId}`
export const image = (url: string) => `${urlLoggedIn}/NewsBanner?url=${url}`
export const notifications = (childId: string) =>
`${urlLoggedIn}/notifications/getnotifications?childId=${childId}`
export const menuRss = (childId: string) =>
`${urlLoggedIn}/Matsedel/GetMatsedelRSS?childId=${childId}`
export const menuList = (childId: string) =>
`${urlLoggedIn}/Matsedel/GetMatsedelList?childId=${childId}`
export const menuChoice = (childId: string) =>
`${urlLoggedIn}/Matsedel/GetMatsedelChoice?childId=${childId}`
export const schedule = (childId: string, fromDate: string, endDate: string) =>
`${urlLoggedIn}/Calender/GetSchema?childId=${childId}&startDate=${fromDate}&endDate=${endDate}`
export const cdn = 'https://etjanst.stockholm.se/vardnadshavare/base/cdn'
export const auth = 'https://etjanst.stockholm.se/vardnadshavare/base/auth'
export const startBundle =
'https://etjanst.stockholm.se/vardnadshavare/bundles/start'
export const hemPage =
'https://etjanst.stockholm.se/vardnadshavare/inloggad2/hem'
export const navigationControllerScript =
'https://etjanst.stockholm.se/vardnadshavare/bundles/navigationController'
export const baseEtjanst = 'https://etjanst.stockholm.se'
export const childcontrollerScript = `https://etjanst.stockholm.se/vardnadshavare/bundles/childcontroller?v=${Date.now()}`
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'
export const topologyConfigUrl = 'https://fantomenkrypto.vercel.app/api/getConfig'

View File

@ -0,0 +1,212 @@
import { Subject } from '@skolplattformen/curriculum'
export interface Cookie {
name: string
value: string
path?: string
domain?: string
version?: string
expires?: string
secure?: boolean
httpOnly?: boolean
}
export interface CookieManager {
setCookie: (cookie: Cookie, url: string) => Promise<void>
getCookies: (url: string) => Promise<Cookie[]>
setCookieString: (cookieString: string, url: string) => Promise<void>
getCookieString: (url: string) => Promise<string>
clearAll: () => Promise<void>
}
export interface RequestInit {
headers?: any
method?: string
body?: string
/**
* Set to `manual` to extract redirect headers, `error` to reject redirect */
redirect?: string
}
export interface Headers {
get(name: string): string | null
}
export interface Response {
headers: Headers
ok: boolean
status: number
statusText: string
text: () => Promise<string>
json: () => Promise<any>
}
export interface Fetch {
(url: string, init?: RequestInit): Promise<Response>
}
export interface AuthTicket {
order: string
token: string
}
/**
* @export
* @interface CalendarItem
*/
export interface CalendarItem {
id: number
title: string
description?: string
location?: string
startDate?: string
endDate?: string
allDay?: boolean
}
/**
* @export
* @interface Child
*/
export interface EtjanstChild {
id: string
/**
* <p>Special ID used to access certain subsystems</p>
* @type {string}
* @memberof Child
*/
sdsId: string
name: string
/**
* <p>F - förskola, GR - grundskola?</p>
* @type {string}
* @memberof Child
*/
status?: string
schoolId?: string
}
export interface Child extends EtjanstChild, Skola24Child {}
/**
* @export
* @interface Classmate
*/
export interface Classmate {
sisId: string
className?: string
firstname: string
lastname: string
guardians: Guardian[]
}
/**
* @export
* @interface Guardian
*/
export interface Guardian {
email?: string
firstname: string
lastname: string
mobile?: string
address?: string
}
/**
* <p>A news item from the school, for example a weekly news letter</p>
* @export
* @interface NewsItem
*/
export interface NewsItem {
id: string
author?: string
header?: string
intro?: string
body?: string
published: string
modified?: string
imageUrl?: string
fullImageUrl?: string
imageAltText?: string
}
/**
* @export
* @interface Notification
*/
export interface Notification {
id: string
sender: string
dateCreated: string
dateModified: string
message: string
url: string
category: string | null
type: string
}
/**
* @export
* @interface ScheduleItem
*/
export interface ScheduleItem {
title: string
description?: string
location?: string
startDate?: string
endDate?: string
oneDayEvent: boolean
allDayEvent: boolean
}
export interface MenuItem {
title: string
description: string
}
export interface MenuList {
selectedWeek: number
menus: MenuListItem[]
}
export interface MenuListItem {
week: string
mon: string
tue: string
wed: string
thu: string
fri: string
}
export interface User {
personalNumber?: string
isAuthenticated?: boolean
firstName?: string
lastName?: string
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 extends Subject {
id: string
teacher: string
location: string
timeStart: string
timeEnd: string
dayOfWeek: number
blockName: string
dateStart: string
dateEnd: string
}

View File

@ -0,0 +1,15 @@
import { parseDate } from '../dateHandling'
test.each([
['2020-12-21 09:00', '2020-12-21T08:00:00.000Z'],
['2021-05-28', '2021-05-27T22:00:00.000Z'],
['2 oktober 2020', '2020-10-01T22:00:00.000Z'],
['12 oktober 2020', '2020-10-11T22:00:00.000Z'],
['5 oktober 2020 11:34', '2020-10-05T09:34:00.000Z'],
['15 oktober 2020 11:34', '2020-10-15T09:34:00.000Z'],
['2020-12-18T15:59:46.34', '2020-12-18T14:59:46.340Z'],
['2020-12-18T15:59:46.340Z', '2020-12-18T15:59:46.340Z'],
['This is an invalid date', undefined],
])('handles date parsing of %s', (input, expected) => {
expect(parseDate(input)).toEqual(expected)
})

View File

@ -0,0 +1,60 @@
import { DateTime } from 'luxon'
const options = {
locale: 'sv',
}
const toISOString = (date: DateTime) => date.toUTC().toISO()
const aspNetJsonRegex = /^\/?Date\((-?\d+)/i
export const parseDate = (input?: string): string | undefined => {
if (!input) {
return undefined
}
// First try and parse old Aps.Net format
// \/Date(1612525846000)\/
// where the numbers are milliseconds from Epoc
const matched = aspNetJsonRegex.exec(input)
if (matched !== null) {
const millisecondsSinceEpoc = parseInt(matched[1], 10)
const date = DateTime.fromMillis(millisecondsSinceEpoc)
return toISOString(date)
}
const dateParse = (format: string) =>
DateTime.fromFormat(input, format, options)
const dateISO = DateTime.fromISO(input)
if (dateISO.isValid) {
return toISOString(dateISO)
}
const dateAndTime = dateParse('yyyy-MM-dd HH:mm')
if (dateAndTime.isValid) {
return toISOString(dateAndTime)
}
const onlyDate = dateParse('yyyy-MM-dd')
if (onlyDate.isValid) {
return toISOString(onlyDate)
}
const dateLongForm = dateParse('d MMMM yyyy')
if (dateLongForm.isValid) {
return toISOString(dateLongForm)
}
const dateTimeLongForm = dateParse('d MMMM yyyy HH:mm')
if (dateTimeLongForm.isValid) {
return toISOString(dateTimeLongForm)
}
// Explicit return to satisfy ESLint
return undefined
}

View File

@ -0,0 +1,61 @@
{
"name": "@skolplattformen/embedded-api",
"version": "0.15.0",
"description": "Since the proxy was blocked (and also deemed a bad idea by some), this is a reboot of the API running in process in the app(s).",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
"repository": "git@github.com:kolplattformen/embedded-api.git",
"author": "Johan Öbrink <johan.obrink@gmail.com>",
"license": "Apache-2.0",
"private": false,
"scripts": {
"lint": "eslint 'lib/**/*.{js,ts}' --quiet --fix",
"test": "jest",
"build": "tsc",
"prepare": "yarn build",
"run-dev": "yarn run build && node run",
"publish-package": "npm publish --access public"
},
"peerDependencies": {
"@skolplattformen/curriculum": "^1.3.0"
},
"devDependencies": {
"@react-native-cookies/cookies": "^6.0.7",
"@skolplattformen/curriculum": "^1.4.2",
"@types/base-64": "^1.0.0",
"@types/he": "^1.1.1",
"@types/jest": "^26.0.22",
"@types/luxon": "^1.26.4",
"@types/node-fetch": "^2.5.10",
"@types/tough-cookie": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^4.22.0",
"@typescript-eslint/parser": "^4.22.0",
"eslint": "^7.24.0",
"eslint-config-airbnb-typescript": "^12.3.1",
"eslint-config-prettier": "^8.2.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-prettier": "^3.4.0",
"fetch-cookie": "^0.11.0",
"https-proxy-agent": "^5.0.0",
"jest": "^26.6.3",
"node-blob": "^0.0.2",
"node-fetch": "^2.6.1",
"prettier": "^2.2.1",
"tough-cookie": "^4.0.0",
"ts-jest": "^26.5.5",
"typescript": "^4.2.4"
},
"dependencies": {
"camelcase-keys": "^6.2.2",
"change-case": "^4.1.2",
"events": "^3.3.0",
"h2m": "^0.7.0",
"he": "^1.2.0",
"js-htmlencode": "^0.3.0",
"luxon": "^1.26.0",
"node-html-parser": "^2.1.0"
}
}

View File

@ -0,0 +1,158 @@
function requestLogger(httpModule) {
var original = httpModule.request
httpModule.request = function (options, callback) {
console.log('-----------------------------------------------')
console.log(
options.href || options.proto + '://' + options.host + options.path,
options.method
)
console.log(options.headers)
console.log('-----------------------------------------------')
return original(options, callback)
}
}
requestLogger(require('http'))
requestLogger(require('https'))
const { DateTime } = require('luxon')
const nodeFetch = require('node-fetch')
const { CookieJar } = require('tough-cookie')
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
const [, , personalNumber] = process.argv
if (!personalNumber) {
console.error(
'You must pass in a valid personal number, eg `node run 197001011111`'
)
process.exit(1)
}
function ensureDirectoryExistence(filePath) {
var dirname = path.dirname(filePath)
if (fs.existsSync(dirname)) {
return true
}
ensureDirectoryExistence(dirname)
fs.mkdirSync(dirname)
}
const record = async (info, data) => {
const name = info.error ? `${info.name}_error` : info.name
const filename = `./record/${name}.json`
ensureDirectoryExistence(filename)
const content = {
url: info.url,
headers: info.headers,
status: info.status,
statusText: info.statusText,
}
if (data) {
switch (info.type) {
case 'json':
content.json = data
break
case 'text':
content.text = data
break
case 'blob':
const buffer = await data.arrayBuffer()
content.blob = Buffer.from(buffer).toString('base64')
break
}
} else if (info.error) {
const { message, stack } = info.error
content.error = {
message,
stack,
}
}
await writeFile(filename, JSON.stringify(content, null, 2))
}
async function run() {
const cookieJar = new CookieJar()
const fetch = fetchCookie(nodeFetch, cookieJar)
try {
const api = init(fetch, cookieJar, { record })
const status = await api.login(personalNumber)
status.on('PENDING', () => console.log('PENDING'))
status.on('USER_SIGN', () => console.log('USER_SIGN'))
status.on('ERROR', () => console.error('ERROR'))
status.on('OK', () => console.log('OK'))
status.on('CANCELLED', () => {
console.log('User cancelled login')
process.exit(0)
})
api.on('login', async () => {
console.log('Logged in')
// console.log('user')
// const user = await api.getUser()
// console.log(user)
console.log('children')
const children = await api.getChildren()
console.log(children)
console.log('calendar')
const calendar = await api.getCalendar(children[0])
console.log(calendar)
/*
console.log('classmates')
const classmates = await api.getClassmates(children[0])
console.log(classmates)
console.log('schedule')
const schedule = await api.getSchedule(children[0], DateTime.local(), DateTime.local().plus({ week: 1 }))
console.log(schedule)
console.log('news')
const news = await api.getNews(children[0])
*/
/* 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('menu')
const menu = await api.getMenu(children[0])
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()
})
api.on('logout', () => {
console.log('Logged out')
process.exit(0)
})
} catch (err) {
console.error(err)
}
}
run()

View File

@ -0,0 +1,203 @@
<div>
<div data-sp-canvascontrol="" data-sp-canvasdataversion="1.0" data-sp-controldata="{&quot;controlType&quot;:4,&quot;id&quot;:&quot;1212fc8d-dd6b-408a-8d5d-9f1cc787efbb&quot;,&quot;position&quot;:{&quot;controlIndex&quot;:2,&quot;sectionIndex&quot;:1,&quot;sectionFactor&quot;:12,&quot;zoneIndex&quot;:1,&quot;layoutIndex&quot;:1},&quot;addedFromPersistedData&quot;:true,&quot;emphasis&quot;:{}}">
<div data-sp-rte="">
**Veckobrev 4EF     Inför v.6 **
I torsdags i förra veckan gjordes en uppdatering i Teams/Skolplattformen vilket har lett till att vissa av er vårdnadshavare inte ser klasspecifika inlägg (aktuellt- och ämnessidor).  
Det är inte bara vi på AF som har stött på detta, utan fler skolor i staden verkar uppleva samma problem. Det får oss att hoppas att jakten på en lösning prioriteras högt även centralt. 
Nästa vecka startar utvecklingssamtalen, håll koll på er tid. Välkomna! 
**Information från idrotten: **
Vecka 6:  
Tema lek & samarbete, 4F IDH-sal plan 3 och 4E IDH-sal plan 2, ej ombytta men med inomhusskor. 
 Vecka 7-8:  
Skridskor i Vasaparken, samling vid skolans flaggstång vid lektionsstart, ta med skridskor, hjälm och varma kläder! 
**Lite om ämnena: **
Ma: Vi arbetar med multiplikationstabellerna och just nu arbetar vi med 9:ans, 8:ans tabell, 7:ans och 6:ans. 
No: Vi har arbetat vidare med vad ett grundämne är och hur många de är. Vi har också bekantat oss med periodiska systemet. 
Sv: Vi kommer att jobba med läsgrupper och öva på att skriva faktatexter. 
Eng: Vi har börjat prata om kläder och hur man använder begreppen do/dont, does/doesnt 
SO:  Alla har fått en ny planering för historia och vi kommer att jobba med Vikingatiden fram till vecka 13. 
**Kom ihåg/Läxor och prov/Rep ochkonserter. **
v.3-6 Ma-läxa multiplikationstabellerna 9 till 6\. Se läxlapp längre ner i flödet 
9/2, 11/2, 15/2, 17/2 Utvecklingssamtal 
<div class="canvasRteResponsiveTable">
<div class="tableWrapper">
<table border="1" title="Tabell">
<tbody>
<tr>
<td role="rowheader">
**v.6**
</td>
<td role="columnheader"></td>
</tr>
<tr>
<td role="rowheader">
**Mån **
</td>
<td></td>
</tr>
<tr>
<td role="rowheader">
**Tis **
</td>
<td></td>
</tr>
<tr>
<td role="rowheader">
**Ons **
</td>
<td>
Eng: Orden till texten på sidan 59 i textbok. 
Alla ord till texten finns på sidan 106 som en ordlista. Eleverna har skrivit ner orden i sin skrivbok. Orden till nästa vecka är: 
1. April=april 
1. autumn=höst 
1. the leaves=löven (tips: ett löv, a leaf) 
1. yellow=gul 
1. spring=vår 
1. flowers=blommor (tips: en blomma, a flower) 
1. out=utslagna 
1. countries=länder (tips: ett land, a country) 
1. dry season=torrperiod 
1. wet season=regnperiod 
</td>
</tr>
<tr>
<td role="rowheader">
**Tor **
</td>
<td></td>
</tr>
<tr>
<td role="rowheader">
**Fre **
</td>
<td>
Ma-läxa Multiplikationstabell 6 
Se tidigare läxlapp. 
</td>
</tr>
</tbody>
</table>
</div>
</div>
Trevlig helg! 
Lotten och Josefin 
</div>
</div>
<div data-sp-canvascontrol="" data-sp-canvasdataversion="1.0" data-sp-controldata="{&quot;controlType&quot;:4,&quot;id&quot;:&quot;e1cf6487-8d35-4781-a7ad-ea73921dbbc0&quot;,&quot;position&quot;:{&quot;zoneIndex&quot;:1,&quot;sectionIndex&quot;:1,&quot;controlIndex&quot;:3,&quot;layoutIndex&quot;:1,&quot;sectionFactor&quot;:12},&quot;emphasis&quot;:{}}">
<div data-sp-rte="">
### Följande gäller endast elever i 4F:
<span><span><span>Hej!</span></span></span>
<span><span><span>I årskurs 4 erbjuds alla elever i Stockholms Stad ett hälsobesök hos skolsköterskan.</span></span></span>
<span><span><span>I hälsobesöket ingår ett hälsosamtal samt tillväxtmätning och ryggundersökning.</span></span></span>
<span><span><span>Inför hälsobesöket fyller eleven i klassrummet i en digital hälsoprofil, som ligger till grund för hälsosamtalet. Hälsosamtalet kan handla om trivsel, kamratrelationer, arbetsmiljö, eventuella hälsoproblem, inlärningssvårigheter, sömn, mat, fysisk aktivitet, pubertet och annat som en elev i årskurs 4 kan fundera över.</span></span></span>
<span><span><span>Det är dags för hälsobesök för 4F. På måndag kommer jag att besöka klassen och hjälpa dem att fylla i hälsoprofilen. De kommer även att se en kort presentation om vad man själv kan göra för att må bra (om mat, fysisk aktivitet, sömn, fritid och att hålla skärmtiden nere). Sedan kommer eleverna på hälsobesök enligt hur det passar klassens schema. Efter hälsobesöket kontaktas vårdnadshavare till de elever där extra uppföljningar av tillväxt eller rygg skulle behövas eller om det framkommer något på hälsosamtalet som behöver åtgärdas/jobbas med i eller utanför skolan. Inga samtal om tillväxt görs direkt med eleven.</span></span></span>
<span><span><span>Skolsköterskan använder munskydd och visir vid besöken.</span></span></span>
<span><span><span></span></span></span>
<span><span><span>Ta gärna kontakt om du undrar över något eller vill prata om något som rör ditt barns hälsa!</span></span></span>
<span><span><span></span></span></span>
<span><span><span>Med vänliga hälsningar,</span></span></span>
<span><span><span>Anette Hasselberg, skolsköterska för DEF-klasserna samt klassen 7-9 AF tel: 076 825 0778</span></span></span>
</div>
</div>
</div>

View File

@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["**/*.ts", "**/*.js"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES6",
"module": "CommonJS",
"declaration": true,
"outDir": "./dist",
"strict": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true
},
"include": [
"lib"
],
"exclude": [
"node_modules",
"**/__tests__/*",
"**/__mocks__/*",
"**/*.test.ts"
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'prettier',
],
parserOptions: {
project: './tsconfig.eslint.json',
ecmaVersion: 2018,
sourceType: 'module',
},
rules: {
'@typescript-eslint/semi': [2, 'never'],
},
}

View File

@ -0,0 +1,33 @@
name: Release
on:
push:
branches:
- main
jobs:
release:
name: Release
runs-on: ubuntu-18.04
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v1
with:
node-version: 14
- name: Setup timezone
uses: zcong1993/setup-timezone@master
with:
timezone: Europe/Stockholm
- name: Install dependencies
run: yarn install --immutable --silent --non-interactive 2> >(grep -v warning 1>&2)
- name: Audit
run: yarn audit
- name: Build
run: yarn build
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-release

View File

@ -0,0 +1,27 @@
# This workflow will do a clean install of node dependencies and run tests
name: Test
on:
pull_request:
branches: [main]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js and run tests
uses: actions/setup-node@v2.1.2
with:
node-version: 14.x
- name: Setup timezone
uses: zcong1993/setup-timezone@master
with:
timezone: Europe/Stockholm
- run: yarn install --immutable --silent --non-interactive 2> >(grep -v warning 1>&2)
- run: yarn audit
- run: yarn lint
- run: yarn test

106
libs/curriculum/.gitignore vendored Normal file
View File

@ -0,0 +1,106 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
record

View File

@ -0,0 +1,10 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"jsxBracketSameLine": false
}

View File

@ -0,0 +1,3 @@
{
"branches": ["main"]
}

201
libs/curriculum/LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

22
libs/curriculum/README.md Normal file
View File

@ -0,0 +1,22 @@
# curriculum
Translations of curriculum codes to clear text descriptions
## Installing
`npm i -S @skolplattformen/embedded-api` or `yarn add @skolplattformen/embedded-api`
## Calling
```javascript
import parse from '@skolplattformen/curriculum'
// Swedish
parse('MU', 'sv') // { code: 'MU', category: '', name: 'Musik' }
parse('M1SP', 'sv') // { code: 'M1SP', category: 'Moderna språk, elevens val', name: 'Spanska' }
parse('M2TY', 'sv') // { code: 'M2TY', category: 'Moderna språk, språkval', name: 'Tyska' }
parse('MLSMI', 'sv') // { code: 'M2TY', category: 'Modersmål', name: 'Samiska' }
// English
parse('MU', 'en') // { code: 'MU', category: '', name: 'Music' }
```

View File

@ -0,0 +1,4 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
}

View File

@ -0,0 +1,37 @@
{
"name": "@skolplattformen/curriculum",
"version": "0.1.0",
"description": "Swedish curriculum codes to plain text",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
"repository": "git@github.com:kolplattformen/curriculum.git",
"author": "Johan Öbrink <johan.obrink@gmail.com>",
"license": "Apache-2.0",
"private": false,
"scripts": {
"lint": "eslint 'src/*.ts' --quiet --fix",
"test": "jest",
"build": "tsc",
"prepare": "yarn build",
"publish-package": "npm publish --access public"
},
"devDependencies": {
"@types/eslint": "^7.2.9",
"@types/jest": "^26.0.22",
"@typescript-eslint/eslint-plugin": "^4.21.0",
"@typescript-eslint/parser": "^4.21.0",
"eslint": "^7.24.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-prettier": "^3.3.1",
"jest": "^26.6.3",
"prettier": "^2.2.1",
"ts-jest": "^26.5.4",
"typescript": "^4.2.4"
},
"dependencies": {
"deepmerge": "^4.2.2"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,99 @@
import translate, { Language, Translation } from './translations'
export interface Subject {
code: string
category: string
name: string
comment?: string
}
type Parser = (translation: Translation, code: string) => Subject | null
const parseSubject: Parser = ({ subjects }, code) => {
if (!subjects[code]) return null
return {
code,
category: '',
name: subjects[code] as string,
}
}
const parseTrainingSubject: Parser = (
{ categories, traningsskolaSubjects },
code
) => {
if (!traningsskolaSubjects[code]) return null
return {
code,
category: categories.trainingSchool,
name: traningsskolaSubjects[code] as string,
}
}
const parseLanguage: Parser = ({ categories, languages }, code) => {
if (!code.startsWith('M1') && !code.startsWith('M2')) return null
const category = `${categories.modernLanguages}, ${
code.startsWith('M1')
? categories.modernLanguagesA1
: categories.modernLanguagesA2
}`
const language = code.substr(2)
return {
code,
category,
name: languages[language] || categories.unknown,
}
}
const parseAltLanguage: Parser = ({ categories, languages }, code) => {
if (!code.startsWith('ASSV')) return null
const language = code.substr(4)
return {
code,
category: categories.modernLanguagesAlt,
name: languages[language] || categories.unknown,
}
}
const parseNativeLanguage: Parser = ({ categories, languages }, code) => {
if (!code.startsWith('ML')) return null
const language = code.substr(2)
return {
code,
category: categories.motherTounge,
name: languages[language] || categories.unknown,
}
}
const parseMisc: Parser = ({ categories, misc }, code) => {
if (!misc[code.toUpperCase()]) return null
return {
code,
category: categories.misc,
name: misc[code.toUpperCase()] as string,
}
}
const parse = (code: string, lang: Language = 'sv'): Subject => {
const translation = translate(lang)
const [subjectCode, ...rest] = code.split(' ')
const result: Subject = parseSubject(translation, subjectCode) ||
parseTrainingSubject(translation, subjectCode) ||
parseLanguage(translation, subjectCode) ||
parseAltLanguage(translation, subjectCode) ||
parseNativeLanguage(translation, subjectCode) ||
parseMisc(translation, subjectCode) || {
code: subjectCode,
category: translation.categories.unknown,
name: subjectCode,
}
if (rest.length) result.comment = rest.join(' ').trim()
return result
}
export default parse

View File

@ -0,0 +1,23 @@
{
"subjects": {
"TN": "Zei­chen­spra­che",
"SVA": "Schewdishe als främdsprache",
"SV": "Schwedish",
"SL": "Handwerk",
"SH": "Staats­bür­ger­kun­de",
"RE": "Re­li­gi­on",
"HI": "Historie",
"GE": "Geografie",
"NO": "Naturwissenschaftlichen Themen",
"SO": "Gesellschaftsorientierte Themen",
"KE": "Chemie",
"FY": "Physik",
"BI": "Biologie",
"MU": "Musik",
"MA": "Mathematik",
"IDH": "Sport und Gesundheit",
"HKK": "Hauswirtschaft",
"EN": "Englisch",
"BL": "Kunst"
}
}

View File

@ -0,0 +1,225 @@
{
"subjects": {
"BL": "Art",
"EN": "English",
"HKK": "Home and consumer studies",
"IDH": "Physical education and health",
"MA": "Mathematics",
"MU": "Music",
"NO": "Science studies",
"BI": "Biology",
"FY": "Physics",
"KE": "Chemistry",
"SO": "Social study subjects",
"GE": "Geography",
"HI": "History",
"RE": "Religion",
"SH": "Civics",
"SL": "Crafts",
"SV": "Swedish",
"SVA": "Swedish as a second language",
"TN": "Sign language",
"TK": "Technology",
"DA": "Preparatory dancing studies",
"JU": "Jewish studies",
"ES": "Aesthetics"
},
"traningsskolaSubjects": {
"KOM": "Communication",
"MOT": "Physical coordination",
"VAA": "Everyday activities",
"VEU": "Perception"
},
"specialLanguages": {
"EN": "English",
"FR": "French",
"FI": "Finnish",
"IT": "Italian",
"JAP": "Japanese",
"KI": "Chinese",
"PO": "Portugese",
"RY": "Russian",
"SAM": "Sami",
"SP": "Spanish",
"SV": "Swedish",
"SVA": "Swedish as a second language",
"TN": "Sign language",
"TY": "German"
},
"languages": {
"ACE": "Acehnesic",
"ACH": "Acoli",
"AAR": "Afar, Danakil",
"AFR": "Afrikaans",
"AKA": "Akan, Asante, Fante",
"SQI": "Albanian",
"AMH": "Amharic",
"ARA": "Arabic",
"HYE": "Armenic",
"AII": "Assyric, New arameic",
"AYM": "Aymara",
"AZE": "Azerbaijani",
"BAL": "Baluchi, Baloci, Baluci, Makrani",
"BAM": "Bambara",
"BAI": "Bamileke",
"EUS": "Baskiska",
"BEM": "Bemba, Chibemba, Chiwemba, Ichibemba, Wemba",
"BEN": "Bengali",
"BER": "Berber",
"BIL": "Bile",
"BYN": "Bilen, Bilein, Bileno, Bilin",
"BOS": "Bosnian",
"BUL": "Bulgarian",
"MYA": "Burmese",
"CEB": "Cebuan, Binisaya, Sebuano, Sugbuanon, Sugbuhanon, Visayan",
"DAN": "Danish",
"DAR": "Dargin, Dargi, Dargin, Dargintsy, Khiurkilinskii, Uslar",
"PRS": "Dari, Parsi, Persian",
"DMQ": "Dimli",
"DIV": "Divehi",
"ENG": "Engelish",
"EST": "Estonian",
"EWE": "Ewe",
"FIJ": "Fijian",
"FIN": "Finnish",
"VLS": "Flemish",
"FRA": "French",
"FAO": "Faroese",
"GAA": "Ga",
"KAT": "Georgian",
"GRE": "Greek",
"KAL": "Greenlandic",
"GUJ": "Gujarati",
"HEB": "Hebrew",
"HIN": "Hindi",
"IBO": "Ibo",
"IND": "Indonesian",
"ISL": "Icelandic",
"ITA": "Italian",
"JPN": "Japanese",
"YID": "Yiddish",
"KAM": "Kamba, Kekamba, Kikamba",
"KHM": "Khmer",
"KAN": "Kannada",
"KAR": "Karen",
"CAT": "Catalan",
"KAZ": "Kazakh",
"KIK": "Kikuyu",
"ZHO": "Chinese",
"CMN": "Chinese, Mandarin",
"HAK": "Chinese, Hakka",
"YUE": "Chinese, Cantonese",
"NAN": "Chinese, Min Nan",
"KIN": "Kinyarwanda",
"KIR": "Kyrgyz",
"RUN": "Kirundi",
"KON": "Kongo",
"KOR": "Korean",
"ROP": "Creole",
"HRV": "Croatian",
"KRO": "Kru",
"KUR": "Kurdish",
"CKB": "Kurdish, centr.",
"KMR": "Kurdish, north",
"SDH": "Kurdish, south",
"LAO": "Laotian",
"LAV": "Latvian",
"LMA": "Limba",
"LIN": "Lingala",
"LIT": "Lithuanian",
"LUG": "Luganda/Ganda",
"LUO": "Luo",
"MKD": "Macedonian",
"MLG": "Malagaskiska",
"MSA": "Malagasy",
"MAL": "Malayalami",
"MLT": "Maltese",
"MNK": "Mandinka",
"MRI": "Maori",
"MAR": "Marathi",
"MYX": "Masaaba, Gisu, Gugisu, Lumasaaba, Masaba",
"FIT": "Meänkieli",
"MON": "Mongolian",
"NLD": "Dutch",
"NEP": "Nepalese",
"NOR": "Norwegian",
"NYA": "Nyanja",
"ORM": "Oromo",
"PUS": "Pashto",
"PTN": "Patani",
"FAS": "Persian",
"POL": "Polish",
"POR": "Portuguese",
"PAN": "Punjabi",
"ROM": "Romani",
"RMC": "Romani, Carpathians",
"RML": "Romani, Baltic",
"RMN": "Romani, Arli",
"RMF": "Romani, Kalé",
"RMO": "Romani, Sinti",
"RMU": "Romani, Tavringer",
"RMY": "Romani, Lovari, Kalderari",
"RON": "Romanian",
"RUS": "Russian",
"SSY": "Saho",
"NSM": "Sami, (northern)",
"SMI": "Sami",
"SMJ": "Sami, Lule Sami",
"SJE": "Sami, Pite Sami",
"SMA": "Sami, Syd Sami",
"SJU": "Sami, Ume Sami",
"SMO": "Samoan",
"SRP": "Serbian",
"HBS": "Serbo-Croatian",
"SOT": "Sesotho, Sisutho, Souto, Suthu, Suto",
"SNA": "Shona",
"SIN": "Sinhalese",
"SLK": "Slovak",
"SLV": "Slovenian",
"SOM": "Somali",
"SPA": "Spanish",
"SWA": "Swahili",
"SYC": "Syrian/Assyrian, suryaya, suryoyo",
"SYR": "Syrian",
"TRU": "Syrian, Turoyo",
"TLG": "Tagalog",
"TAM": "Tamil",
"TAT": "Tatar",
"TEL": "Telugu",
"THA": "Thai",
"TIB": "Tibetan",
"TIG": "Tigre",
"TIR": "Tigrinya",
"CES": "Czech",
"TON": "Tongan",
"TSN": "Tswana, Setswana",
"TUR": "Turkish",
"DEU": "German",
"UIG": "Uighur",
"UKR": "Ukrainian",
"HUN": "Hungarian",
"URD": "Urdu",
"UZB": "Uzbek",
"VIE": "Vietnamese",
"WOL": "Wolof",
"YOR": "Yoruba, Yariba, Yooba",
"ZUL": "Zulu",
"SPK": "Other language"
},
"categories": {
"trainingSchool": "Compulsory school for children with severe learning disabilities",
"modernLanguages": "Modern languages",
"modernLanguagesA1": "CEFR min. A1+",
"modernLanguagesA2": "CEFR min. A2",
"modernLanguagesAlt": "Alt modern language, CEFR min. A2",
"motherTounge": "Mother tongue tuition",
"unknown": "Unknown",
"misc": "Miscellaneous"
},
"misc": {
"LUNCH": "Lunch",
"PRANDIUM": "Lunch",
"MTID": "Mentor time",
"RAST": "Break"
}
}

View File

@ -0,0 +1,41 @@
{
"specialLanguages": {
"SV": "Sueco",
"SP": "Español",
"SAM": "Sami",
"RY": "Ruso",
"PO": "Portugués",
"KI": "Chino",
"JAP": "Japonés",
"IT": "Italiano",
"FI": "Finlandés",
"FR": "Francés",
"EN": "Inglés"
},
"traningsskolaSubjects": {
"VAA": "Actividades cotidianas",
"KOM": "Comunicación"
},
"subjects": {
"ES": "Estética",
"DA": "Estudios preparatorios de baile",
"TK": "Tecnología",
"TN": "Lenguaje de signos",
"SVA": "Sueco como segunda lengua",
"SV": "Sueco",
"SL": "Artesanía",
"SH": "Educación cívica",
"RE": "Religión",
"HI": "Historia",
"GE": "Geografía",
"KE": "Química",
"FY": "Física",
"BI": "Biología",
"NO": "Ciencias Naturales",
"MU": "Música",
"MA": "Matemáticas",
"IDH": "Salud y educación física",
"HKK": "Estudios sobre el hogar y el consumidor",
"EN": "inglés"
}
}

View File

@ -0,0 +1,78 @@
{
"languages": {
"VIE": "Vietnamien",
"UZB": "Ouzbek",
"URD": "Ourdou",
"HUN": "Hongrois",
"UKR": "Ukrainien",
"UIG": "Ouïghour",
"ZUL": "Zoulou",
"YOR": "Yorouba",
"WOL": "Wolof",
"SPK": "Autre langue",
"SQI": "Albanais"
},
"categories": {
"trainingSchool": "École obligatoire pour les enfants souffrant de graves difficultés d'apprentissage",
"motherTounge": "Cours de langue maternelle",
"modernLanguagesAlt": "Langue moderne Alt, CECR min. A2",
"modernLanguagesA2": "CECR min. A2",
"modernLanguagesA1": "CECR min. A1+",
"modernLanguages": "Langues modernes",
"misc": "Divers",
"unknown": "Inconnu"
},
"misc": {
"RAST": "Pause",
"MTID": "Tutorat",
"PRANDIUM": "Déjeuner",
"LUNCH": "Déjeuner"
},
"specialLanguages": {
"TY": "Allemand",
"TN": "Langue des signes",
"SVA": "Suédois comme deuxième langue",
"SV": "Suédois",
"SP": "Espagnol",
"SAM": "Same",
"RY": "Russe",
"PO": "Portugais",
"KI": "Chinois",
"JAP": "Japonais",
"IT": "Italien",
"FI": "Finnois",
"FR": "Français",
"EN": "Anglais"
},
"traningsskolaSubjects": {
"VEU": "Perception",
"VAA": "Activités quotidiennes",
"MOT": "Coordination physique",
"KOM": "Communication"
},
"subjects": {
"ES": "Esthétique",
"JU": "Études juives",
"DA": "Études préparatoires de danse",
"TK": "Technologie",
"TN": "Langue des signes",
"SVA": "Suédois comme deuxième langue",
"SV": "Suédois",
"SL": "Travaux manuels",
"SH": "Éducation civique",
"RE": "Religion",
"HI": "Histoire",
"GE": "Géographie",
"SO": "Sujets d'étude sociale",
"KE": "Chimie",
"FY": "Physique",
"BI": "Biologie",
"NO": "Études scientifiques",
"MU": "Musique",
"MA": "Mathématiques",
"IDH": "Éducation physique et santé",
"HKK": "Études économiques et domestiques",
"EN": "Anglais",
"BL": "Art"
}
}

View File

@ -0,0 +1,53 @@
import merge from 'deepmerge'
type Repo = Record<string, string>
export interface Translation {
subjects: Repo
traningsskolaSubjects: Repo
languages: Repo
categories: Repo
misc: Repo
}
interface RawTranslation extends Translation {
specialLanguages: Repo
}
const translations: Translations = {
sv: require('./sv.json'),
de: require('./de.json'),
en: require('./en.json'),
es: require('./es.json'),
fr: require('./fr.json'),
it: require('./it.json'),
la: require('./la.json'),
nb_NO: require('./nb_NO.json'),
pl: require('./pl.json'),
}
const languageList: string[] = Object.keys(translations)
export type Language = typeof languageList[number]
type Translations = Record<Language, RawTranslation>
const translate = (lang: Language): Translation => {
const selectedLanguage = languageList.includes(lang) ? lang : languageList[0]
const {
subjects,
traningsskolaSubjects,
specialLanguages,
languages,
categories,
misc,
} = merge(translations.sv, translations[selectedLanguage])
return {
subjects,
traningsskolaSubjects,
categories,
misc,
languages: {
...specialLanguages,
...languages,
},
}
}
export default translate

View File

@ -0,0 +1,51 @@
{
"misc": {
"PRANDIUM": "Pranzo",
"LUNCH": "Pranzo"
},
"categories": {
"unknown": "Sconosciuta",
"motherTounge": "Madre lingua"
},
"languages": {
"ITA": "Italiano",
"FRA": "Francese",
"TUR": "Turco",
"SPA": "Spagnolo",
"RUS": "Russo",
"POR": "Portogese",
"NOR": "Norvegese",
"KOR": "Coreano",
"ZHO": "Cinese",
"JPN": "Giapponese",
"ENG": "Inglese",
"DAN": "Danese",
"FIN": "Finlandese"
},
"specialLanguages": {
"SVA": "Svedese come secondo lingua",
"SV": "Svedese",
"SP": "Spangnolo",
"IT": "Italiano",
"FR": "Francese",
"EN": "Inglese"
},
"subjects": {
"TK": "Tecnologia",
"SVA": "Svedese come seconda lingua",
"SV": "Svedese",
"RE": "Religione",
"HI": "Storia",
"GE": "Geografia",
"KE": "Chimica",
"FY": "Fisica",
"BI": "Biologia",
"NO": "Scienze",
"MU": "Musica",
"MA": "Matematica",
"IDH": "Ed. fisica",
"HKK": "Cuchina",
"EN": "Inglese",
"BL": "Artistica"
}
}

View File

@ -0,0 +1,63 @@
{
"languages": {
"AZE": "Lingua atropatenica",
"AYM": "Lingua aymara",
"AII": "Lingua Assyriae",
"HYE": "Lingua armeniaca",
"ARA": "Lingua arabica",
"AMH": "Lingua amharica",
"SQI": "Lingua albanica",
"AKA": "Lingua acanica",
"AFR": "Lingua batava capitensis",
"AAR": "Lingua afarica",
"ACH": "Lingua acholica",
"ACE": "Lingua acehnesiana"
},
"specialLanguages": {
"TY": "Lingua germanica",
"TN": "Lingua gesticulationum",
"SVA": "Lingua suecica extranea",
"SV": "Lingua suecica",
"SP": "Lingua hispanica",
"SAM": "Lingua samica",
"RY": "Lingua russica",
"PO": "Lingua lusitanica",
"KI": "Lingua sinica",
"JAP": "Lingua iaponica",
"IT": "Lingua italica",
"FI": "Lingua finnica",
"FR": "Lingua francogallica",
"EN": "Lingua anglica"
},
"traningsskolaSubjects": {
"VEU": "Perceptio",
"VAA": "Actiones vulgares",
"MOT": "Coordinatio corporalis",
"KOM": "Communicatio"
},
"subjects": {
"ES": "Opera formosa",
"JU": "Studia iudaea",
"DA": "Studia praeparatoria saltatoria",
"TK": "Technologia",
"TN": "Lingua gesticulationum",
"SVA": "Lingua suecica extranea",
"SV": "Lingua suecica",
"SL": "Artes practicae",
"SH": "Scientia civilis",
"RE": "Scientia religiosa",
"HI": "Historia",
"GE": "Geographia",
"SO": "Scientiae rei publicae",
"KE": "Chemia",
"FY": "Physica",
"BI": "Biologia",
"HKK": "Scientia domi emptorisque",
"NO": "Scientia naturalis",
"MU": "Musica",
"MA": "Mathematica",
"IDH": "Exercitium sanitasque",
"EN": "Lingua anglica",
"BL": "Ars"
}
}

View File

@ -0,0 +1,76 @@
{
"languages": {
"POR": "Portugisisk",
"POL": "Polsk",
"JPN": "Japansk",
"ITA": "Italiensk",
"ISL": "Islandsk",
"IND": "Indonesisk",
"SPK": "Annet språk",
"FRA": "Fransk",
"DEU": "Tysk"
},
"traningsskolaSubjects": {
"VAA": "Hverdagsaktiviteter",
"KOM": "Kommunikasjon",
"VEU": "Virkelighetsoppfatning",
"MOT": "Motorikk"
},
"subjects": {
"ES": "Estetisk virksomhet",
"NO": "Naturfag",
"HKK": "Heimkunnskap",
"JU": "Jødiske studier",
"SO": "Samfunnsfag",
"SH": "Samfunnslære",
"KE": "Kjemi",
"IDH": "Idrett og helse",
"SL": "Sløyd",
"DA": "Forberedende dansestudie",
"TK": "Teknologi",
"TN": "Tegnspråk",
"SVA": "Svensk som andrespråk",
"SV": "Svensk",
"RE": "Religion",
"HI": "Historie",
"GE": "Geografi",
"FY": "Fysikk",
"BI": "Biologi",
"MU": "Musikk",
"MA": "Matematikk",
"EN": "Engelsk",
"BL": "Kunst"
},
"misc": {
"PRANDIUM": "Lunsj",
"LUNCH": "Lunsj",
"RAST": "Pause",
"MTID": "Mentortid"
},
"categories": {
"misc": "Ymse",
"unknown": "Ukjent",
"modernLanguages": "Moderne språk",
"motherTounge": "Morsmålsopplæring",
"modernLanguagesAlt": "Alt moderne språk, språkvalg",
"modernLanguagesA2": "Språkvalg",
"modernLanguagesA1": "Elevens valg",
"trainingSchool": "Grunnskole for elever med lærevansker"
},
"specialLanguages": {
"TY": "Tysk",
"TN": "Tegnspråk",
"SVA": "Svensk som andrespråk",
"SV": "Svensk",
"SP": "Spansk",
"SAM": "Samisk",
"RY": "Russisk",
"PO": "Portugisisk",
"KI": "Kinesisk",
"JAP": "Japansk",
"IT": "Italiensk",
"FI": "Finsk",
"FR": "Fransk",
"EN": "Engelsk"
}
}

View File

@ -0,0 +1,225 @@
{
"languages": {
"MAL": "Malayalami",
"MSA": "Język malgaski",
"MKD": "Język macedoński",
"LUO": "Luo",
"LUG": "Luganda/Ganda",
"LIT": "Język litewski",
"LIN": "Język lingala",
"LMA": "Limba",
"LAV": "Język łotewski",
"LAO": "Język laotański",
"SDH": "Kurdyjski, południowy",
"KMR": "Kurdyjski, północny",
"CKB": "Kurdyjski, centrowy.",
"KUR": "Język kurdyjski",
"HRV": "Język chorwacki",
"ROP": "Kreolski",
"KOR": "Język koreański",
"KIR": "Język kirgiski",
"NAN": "Chiński, Min Nan",
"YUE": "Chiński, kantoński",
"HAK": "Chiński, hakka",
"CMN": "Język chiński, mandaryński",
"ZHO": "Język chiński",
"KAZ": "Język kazachski",
"CAT": "Język kataloński",
"KAR": "Język kareński",
"KAN": "Język kannada",
"KHM": "Język khmerski",
"YID": "Język jidisz",
"JPN": "Język japoński",
"ITA": "Język włoski",
"ISL": "Język islandzki",
"IND": "Język indonezyjski",
"HIN": "Język hindi",
"HEB": "Język hebrajski",
"GUJ": "Język gujarati",
"KAL": "Język grenlandzki",
"GRE": "Język grecki",
"KAT": "Język gruziński",
"GAA": "Język ga",
"FAO": "Język farerski",
"FRA": "Język francuski",
"VLS": "Język flamandzki",
"FIN": "Język fiński",
"FIJ": "Język fidżyjski",
"EST": "Język estoński",
"ENG": "Język angielski",
"PRS": "Dari, Parsi, perski",
"DAN": "Język duński",
"MYA": "Język birmański",
"TIG": "Język tigre",
"TIB": "Język tybetański",
"THA": "Język tajski",
"TEL": "Język telugu",
"TAT": "Język tatarski",
"TAM": "Język tamilski",
"TLG": "Język tagalski",
"TRU": "Syryjski, Turoyo",
"SYR": "Język syryjski",
"SYC": "Syryjski/Asyryjski, suryaya, suryoyo",
"SWA": "Język suahili",
"SPA": "Język hiszpański",
"SOM": "Język somalijski",
"SLV": "Język słoweński",
"SLK": "Język słowacki",
"SIN": "Język cejloński",
"SNA": "Shona",
"SOT": "Sesotho, Sisutho, Souto, Suthu, Suto",
"HBS": "Język serbsko-chorwacki",
"SRP": "Język serbski",
"SMO": "Język samoański",
"SJU": "Język ume",
"SMA": "Język południowosaamski",
"SJE": "Język saamski pite",
"SMJ": "Język lule",
"SMI": "Język saamski",
"NSM": "Język północnosaamski",
"SSY": "Język saho",
"RUS": "Język rosyjski",
"RON": "Język rumuński",
"RMY": "Romani, Lovari, Kalderari",
"RMU": "Romani, Tavringer",
"RMO": "Romani, Sinti",
"RMF": "Romani, Kalé",
"RMN": "Romani, Arli",
"RML": "Romani, Baltic",
"RMC": "Romani, Karpaty",
"ROM": "Romani",
"PAN": "Język pendżabski",
"POR": "Język portugalski",
"POL": "Język polski",
"FAS": "Język perski",
"PTN": "Patani",
"PUS": "Język paszto",
"ORM": "Język oromo",
"NYA": "Język nyanja",
"NOR": "Język norweski",
"NEP": "Język nepalski",
"NLD": "Język holenderski",
"MON": "Język mongolski",
"FIT": "Meänkieli",
"MYX": "Masaaba, Gisu, Gugisu, Lumasaaba, Masaba",
"MAR": "Marathi",
"MRI": "Język maoryjski",
"MNK": "Język mandinka",
"MLT": "Język maltański",
"BUL": "Język bułgarski",
"BOS": "Język bośniacki",
"BER": "Język berberyjski",
"BEN": "Język bengalski",
"BAM": "Język bambara",
"AZE": "Język azerski",
"ARA": "Język arabski",
"AMH": "Język amharski",
"SQI": "Język albański",
"AFR": "Język afrikaans",
"ACH": "Język akoli",
"YOR": "Yoruba, Yariba, Yooba",
"TSN": "Tswana, Setswana",
"TON": "Język tongan",
"DAR": "Darginska, Dargi, Dargin, Dargintsy, Khiurkilinskii, Uslar",
"AYM": "Ajmarski",
"MLG": "Malagaskiska",
"KRO": "Kru",
"KON": "Kongo",
"RUN": "Kirundi",
"KIN": "Kinyarwanda",
"KIK": "Kikuyu",
"KAM": "Kamba, Kekamba, Kikamba",
"IBO": "Ibo",
"EWE": "Ewe",
"DIV": "Divehi",
"DMQ": "Dimli",
"CEB": "Cebuanska, Binisaya, Sebuano, Sugbuanon, Sugbuhanon, Visayan",
"BYN": "Bilen, Bilein, Bileno, Bilin",
"BIL": "Bile",
"BEM": "Bemba, Chibemba, Chiwemba, Ichibemba, Wemba",
"EUS": "Język baskijski",
"BAI": "Język bamileke",
"BAL": "Baluchi, Baloci, Baluci, Makrani",
"AII": "Assyriska, Nyarameiska",
"HYE": "Język ormiański",
"AKA": "Akan, Asante, Fante",
"AAR": "Afar, Danakil",
"ACE": "Acehnesiska",
"SPK": "Inne języki",
"ZUL": "Język zulu",
"WOL": "Język wolof",
"VIE": "Język wietnamski",
"UZB": "Język uzbecki",
"URD": "Język urdu",
"HUN": "Język węgierski",
"UKR": "Język ukraiński",
"UIG": "Język ujgurski",
"DEU": "Język niemiecki",
"TUR": "Język turecki",
"CES": "Język czeski",
"TIR": "Język tigrinia"
},
"specialLanguages": {
"TY": "Język niemiecki",
"TN": "Język migowy",
"SVA": "Szwedzki jako drugi język",
"SV": "Język szwedzki",
"SP": "Język hiszpański",
"SAM": "Język saamski",
"RY": "Język rosyjski",
"PO": "Język portugalski",
"KI": "Język chiński",
"JAP": "Język japoński",
"IT": "Język włoski",
"FI": "Język fiński",
"FR": "Język francuski",
"EN": "Język angielski"
},
"traningsskolaSubjects": {
"MOT": "Motoryka",
"KOM": "Komunikacja",
"VAA": "Codzienne czynności",
"VEU": "Postrzeganie rzeczywistości"
},
"subjects": {
"TK": "Technika",
"TN": "Język migowy",
"SVA": "Szwedzki jako drugi język",
"SV": "Język szwedzki",
"RE": "Religioznawstwo",
"HI": "Historia",
"GE": "Geografia",
"KE": "Chemia",
"FY": "Fizyka",
"BI": "Biologia",
"MU": "Muzyka",
"MA": "Matematyka",
"IDH": "Wychowanie fizyczne",
"EN": "Język angielski",
"BL": "Zajęcia plastyczne",
"ES": "Estetyka",
"DA": "Przygotowawcze studia taneczne",
"SL": "Rzemieślnictwo",
"SH": "Wiedza o społeczeństwie",
"SO": "Przedmioty społeczne",
"NO": "Nauki ścisłe",
"HKK": "Wiedza o domu i konsumentach",
"JU": "Studia żydowskie"
},
"categories": {
"trainingSchool": "Szkoła obowiązkowa dla dzieci ze znacznymi trudnościami w nauce",
"misc": "Różne",
"unknown": "Nieznany",
"motherTounge": "Język ojczysty",
"modernLanguagesAlt": "Alternatywny język nowożytny, CEFR min. A2",
"modernLanguagesA2": "språkval",
"modernLanguagesA1": "elevens val",
"modernLanguages": "Języki nowożytne"
},
"misc": {
"LUNCH": "Lunch",
"MTID": "Czas z mentorem",
"PRANDIUM": "Lunch",
"RAST": "Przerwa"
}
}

View File

@ -0,0 +1,225 @@
{
"subjects": {
"BL": "Bild",
"EN": "Engelska",
"HKK": "Hem & Konsumentkunskap",
"IDH": "Idrott & Hälsa",
"MA": "Matematik",
"MU": "Musik",
"NO": "Naturorienterande ämnen",
"BI": "Biologi",
"FY": "Fysik",
"KE": "Kemi",
"SO": "Samhällsorienterande ämnen",
"GE": "Geografi",
"HI": "Historia",
"RE": "Religionskunskap",
"SH": "Samhällskunskap",
"SL": "Slöjd",
"SV": "Svenska",
"SVA": "Svenska som andraspråk",
"TN": "Teckenspråk",
"TK": "Teknik",
"DA": "Förberedande dansarutbildning",
"JU": "Judiska studier",
"ES": "Estetisk verksamhet"
},
"traningsskolaSubjects": {
"KOM": "Kommunikation",
"MOT": "Motorik",
"VAA": "Vardagsaktiviteter",
"VEU": "Verklighetsuppfattning"
},
"specialLanguages": {
"EN": "Engelska",
"FR": "Franska",
"FI": "Finska",
"IT": "Italienska",
"JAP": "Japanska",
"KI": "Kinesiska",
"PO": "Portugisiska",
"RY": "Ryska",
"SAM": "Samiska",
"SP": "Spanska",
"SV": "Svenska",
"SVA": "Svenska som andraspråk",
"TN": "Teckenspråk",
"TY": "Tyska"
},
"languages": {
"ACE": "Acehnesiska",
"ACH": "Acoli",
"AAR": "Afar, Danakil",
"AFR": "Afrikaans",
"AKA": "Akan",
"SQI": "Albanska",
"AMH": "Amhariska",
"ARA": "Arabiska",
"HYE": "Armeniska",
"AII": "Assyriska, Nyarameiska",
"AYM": "Aymara",
"AZE": "Azerbadjanska",
"BAL": "Baluchiska",
"BAM": "Bambara",
"BAI": "Bamileke",
"EUS": "Baskiska",
"BEM": "Bemba",
"BEN": "Bengaliska",
"BER": "Berbiska",
"BIL": "Bile",
"BYN": "Bilen, Bilein, Bileno, Bilin",
"BOS": "Bosniska",
"BUL": "Bulgariska",
"MYA": "Burmesiska",
"CEB": "Cebuanska, Binisaya, Sebuano, Sugbuanon, Sugbuhanon, Visayan",
"DAN": "Danska",
"DAR": "Darginska, Dargi, Dargin, Dargintsy, Khiurkilinskii, Uslar",
"PRS": "Dari, Parsi, Persian",
"DMQ": "Dimli",
"DIV": "Divehi",
"ENG": "Engelska",
"EST": "Estniska",
"EWE": "Ewe",
"FIJ": "Fijianska",
"FIN": "Finska",
"VLS": "Flamländska",
"FRA": "Franska",
"FAO": "Färöiska",
"GAA": "Ga",
"KAT": "Georgiska",
"GRE": "Grekiska",
"KAL": "Grönländska",
"GUJ": "Gujarati",
"HEB": "Hebreiska",
"HIN": "Hindi",
"IBO": "Ibo",
"IND": "Indonesiska",
"ISL": "Isländska",
"ITA": "Italienska",
"JPN": "Japanska",
"YID": "Jiddisch",
"KAM": "Kamba",
"KHM": "Khmer",
"KAN": "Kannada",
"KAR": "Karenska",
"CAT": "Katalanska",
"KAZ": "Kazakiska",
"KIK": "Kikuyu",
"ZHO": "Kinesiska",
"CMN": "Kinesiska, Mandarin",
"HAK": "Kinesiska, Hakka",
"YUE": "Kinesiska, Kantonesiska",
"NAN": "Kinesiska, Min Nan",
"KIN": "Kinyarwanda",
"KIR": "Kirgisiska",
"RUN": "Kirundi",
"KON": "Kongo",
"KOR": "Koreanska",
"ROP": "Kreolska",
"HRV": "Kroatiska",
"KRO": "Kru",
"KUR": "Kurdiska",
"CKB": "Kurdiska, centr.",
"KMR": "Kurdiska, norra",
"SDH": "Kurdiska, södra",
"LAO": "Laotiska",
"LAV": "Lettiska",
"LMA": "Limba",
"LIN": "Lingala",
"LIT": "Litauiska",
"LUG": "Luganda/Ganda",
"LUO": "Luo",
"MKD": "Makedonska",
"MLG": "Malagaskiska",
"MSA": "Malajiska",
"MAL": "Malayalami",
"MLT": "Maltesiska",
"MNK": "Mandinka",
"MRI": "Maori",
"MAR": "Marathi",
"MYX": "Masaaba, Gisu, Gugisu, Lumasaaba, Masaba",
"FIT": "Meänkieli",
"MON": "Mongoliska",
"NLD": "Nederländska",
"NEP": "Nepalesiska",
"NOR": "Norska",
"NYA": "Nyanja",
"ORM": "Oromo",
"PUS": "Pashto",
"PTN": "Patani",
"FAS": "Persiska",
"POL": "Polska",
"POR": "Portugisiska",
"PAN": "Punjabi",
"ROM": "Romani",
"RMC": "Romani, Karpaterna",
"RML": "Romani, Baltisk",
"RMN": "Romani, Arli",
"RMF": "Romani, Kalé",
"RMO": "Romani, Sinti",
"RMU": "Romani, Tavringer",
"RMY": "Romani, Lovari, Kalderari",
"RON": "Rumänska",
"RUS": "Ryska",
"SSY": "Saho",
"NSM": "Samiska, (norra)",
"SMI": "Samiska",
"SMJ": "Samiska, Lulesamiska",
"SJE": "Samiska, Pitesamiska",
"SMA": "Samiska, Sydsamiska",
"SJU": "Samiska, Umesamiska",
"SMO": "Samoanska",
"SRP": "Serbiska",
"HBS": "Serbokroatiska",
"SOT": "Sydsotho",
"SNA": "Shona",
"SIN": "Singalesiska",
"SLK": "Slovakiska",
"SLV": "Slovenska",
"SOM": "Somaliska",
"SPA": "Spanska",
"SWA": "Swahili",
"SYC": "Syrianska/assyriska, suryaya, suryoyo",
"SYR": "Syriska",
"TRU": "Syriska, Turoyo",
"TLG": "Tagalog",
"TAM": "Tamil",
"TAT": "Tatariska",
"TEL": "Telugu",
"THA": "Thai",
"TIB": "Tibetanska",
"TIG": "Tigre",
"TIR": "Tigrinja",
"CES": "Tjeckiska",
"TON": "Tonganska",
"TSN": "Tswana",
"TUR": "Turkiska",
"DEU": "Tyska",
"UIG": "Uiguriska",
"UKR": "Ukrainska",
"HUN": "Ungerska",
"URD": "Urdu",
"UZB": "Uzbekiska",
"VIE": "Vietnamesiska",
"WOL": "Wolof",
"YOR": "Yoruba",
"ZUL": "Zulu",
"SPK": "Övriga språk"
},
"categories": {
"trainingSchool": "Träningsskolan",
"modernLanguages": "Moderna språk",
"modernLanguagesA1": "elevens val",
"modernLanguagesA2": "språkval",
"modernLanguagesAlt": "Alt moderna språk, språkval",
"motherTounge": "Modersmål",
"unknown": "Okänd",
"misc": "Diverse"
},
"misc": {
"LUNCH": "Lunch",
"PRANDIUM": "Lunch",
"MTID": "Mentorstid",
"RAST": "Rast"
}
}

View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"include": [
"src/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2015",
"module": "CommonJS",
"esModuleInterop": true,
"declaration": true,
"outDir": "./dist",
"resolveJsonModule": true,
"strict": true,
"sourceMap": true
},
"include": [
"src",
"src/**/*.json"
],
"exclude": [
"node_modules",
"**/*.test.ts"
]
}

4076
libs/curriculum/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

15
libs/hooks/.eslintrc.js Normal file
View File

@ -0,0 +1,15 @@
module.exports = {
extends: [
'airbnb-typescript',
'plugin:jest/recommended'
],
parserOptions: {
project: `./tsconfig.json`,
},
rules: {
'@typescript-eslint/semi': ['error', 'never'],
'jest/no-mocks-import': [0],
'max-len': [1, 110],
'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx', '.tsx'] }],
},
}

View File

@ -0,0 +1,27 @@
name: Release
on:
push:
branches:
- main
jobs:
release:
name: Release
runs-on: ubuntu-18.04
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v1
with:
node-version: 14
- name: Install dependencies
run: yarn install --immutable --silent --non-interactive 2> >(grep -v warning 1>&2)
- name: Build
run: yarn build
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-release

20
libs/hooks/.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,20 @@
# This workflow will do a clean install of node dependencies and run tests
name: Test
on:
pull_request:
branches: [main]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js and run tests
uses: actions/setup-node@v2.1.2
with:
node-version: 14.x
- run: yarn install --immutable --silent --non-interactive 2> >(grep -v warning 1>&2)
- run: yarn lint
- run: yarn test

106
libs/hooks/.gitignore vendored Normal file
View File

@ -0,0 +1,106 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
record

3
libs/hooks/.releaserc Normal file
View File

@ -0,0 +1,3 @@
{
"branches": ["main"]
}

201
libs/hooks/LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2021 Johan Öbrink
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

359
libs/hooks/README.md Normal file
View File

@ -0,0 +1,359 @@
# @skolplattformen/api-hooks
1. [Installing](#installing)
1. [Login / logout](#login--logout)
1. [Get data](#get-data)
1. [Fake mode](#fake-mode)
## Installing
```npm i -S @skolplattformen/api-hooks @skolplattformen/embedded-api```
```yarn add @skolplattformen/api-hooks @skolplattformen/embedded-api```
## ApiProvider
In order to use api hooks, you must wrap your app in an ApiProvider
```javascript
import React from 'react'
import { ApiProvider } from '@skolplattformen/api-hooks'
import init from '@skolplattformen/embedded-api'
import { CookieManager } from '@react-native-community/cookies'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { RootComponent } from './components/root'
import crashlytics from '@react-native-firebase/crashlytics'
const api = init(fetch, () => CookieManager.clearAll())
const reporter = {
log: (message) => crashlytics().log(message),
error: (error, label) => crashlytics().recordError(error, label),
}
export default () => (
<ApiProvider api={api} reporter={reporter} storage={AsyncStorage}>
<RootComponent />
</ApiProvider>
)
```
## Login / logout
```javascript
import { useApi } from '@skolplattformen/api-hooks'
export default function LoginController () {
const { api, isLoggedIn } = useApi()
api.on('login', () => { /* do login stuff */ })
api.on('logout', () => { /* do logout stuff */ })
const [personalNumber, setPersonalNumber] = useState()
const [bankIdStatus, setBankIdStatus] = useState('')
const doLogin = async () => {
const status = await api.login(personalNumber)
openBankID(status.token)
status.on('PENDING', () => { setBankIdStatus('BankID app not yet opened') })
status.on('USER_SIGN', () => { setBankIdStatus('BankID app is open') })
status.on('OK', () => { setBankIdStatus('BankID signed. NOTE! User is NOT yet logged in!') })
status.on('ERROR', (err) => { setBankIdStatus('BankID failed') })
})
return (
<View>
<Input value={personalNumber} onChange={(value) = setPersonalNumber(value)} />
<Button onClick={() => doLogin()}>
<Text>{bankIdStatus}</Text>
<Text>Logged in: {isLoggedIn}</Text>
</View>
)
}
```
## Get data
1. [General](#general)
1. [useCalendar](#usecalendar)
1. [useChildList](#usechildList)
1. [useClassmates](#useclassmates)
1. [useMenu](#usemenu)
1. [useNews](#usenews)
1. [useNotifications](#usenotifications)
1. [useSchedule](#useschedule)
1. [useUser](#useuser)
### General
The data hooks return a `State<T>` object exposing the following properties:
| Property | Description |
|----------|----------------------------------|
| `status` | `pending` `loading` `loaded` |
| `data` | The requested data |
| `error` | Error from the API call if any |
| `reload` | Function that triggers a reload |
The hook will return a useable default for data at first (usually empty `[]`).
It then checks the cache (`AsyncStorage`) for any value and, if exists, updates data.
Simultaneously the API is called. This only automatically happens once during the
lifetime of the app. If several instances of the same hook are used, the data will be
shared and only one API call made.
When `reload` is called, a new API call will be made and all hook instances will have
their `status`, `data` and `error` updated.
### useCalendar
```javascript
import { useCalendar } from '@skolplattformen/api-hooks'
export default function CalendarComponent ({ selectedChild }) => {
const { status, data, error, reload } = useCalendar(selectedChild)
return (
<View>
{ status === 'loading' && <Spinner />}
{ error && <Text>{ error.message }</Text>}
{ data.map((item) => (
<CalendarItem item={item} />
))}
{ status !== 'loading' && status !== 'pending' && <Button onClick={() => reload()}> }
</View>
)
}
```
### useChildList
```javascript
import { useChildList } from '@skolplattformen/api-hooks'
export default function ChildListComponent () => {
const { status, data, error, reload } = useChildList()
return (
<View>
{ status === 'loading' && <Spinner />}
{ error && <Text>{ error.message }</Text>}
{ data.map((child) => (
<Text>{child.firstName} {child.lastName}</Text>
))}
{ status !== 'loading' && status !== 'pending' && <Button onClick={() => reload()}> }
</View>
)
}
```
### useClassmates
```javascript
import { useClassmates } from '@skolplattformen/api-hooks'
export default function ClassmatesComponent ({ selectedChild }) => {
const { status, data, error, reload } = useClassmates(selectedChild)
return (
<View>
{ status === 'loading' && <Spinner />}
{ error && <Text>{ error.message }</Text>}
{ data.map((classmate) => (
<Classmate item={classmate} />
))}
{ status !== 'loading' && status !== 'pending' && <Button onClick={() => reload()}> }
</View>
)
}
```
### useMenu
```javascript
import { useMenu } from '@skolplattformen/api-hooks'
export default function MenuComponent ({ selectedChild }) => {
const { status, data, error, reload } = useMenu(selectedChild)
return (
<View>
{ status === 'loading' && <Spinner />}
{ error && <Text>{ error.message }</Text>}
{ data.map((item) => (
<MenuItem item={item} />
))}
{ status !== 'loading' && status !== 'pending' && <Button onClick={() => reload()}> }
</View>
)
}
```
### useNews
```javascript
import { useNews } from '@skolplattformen/api-hooks'
export default function NewsComponent ({ selectedChild }) => {
const { status, data, error, reload } = useNews(selectedChild)
return (
<View>
{ status === 'loading' && <Spinner />}
{ error && <Text>{ error.message }</Text>}
{ data.map((item) => (
<NewsItem item={item} />
))}
{ status !== 'loading' && status !== 'pending' && <Button onClick={() => reload()}> }
</View>
)
}
```
To display image from `NewsItem`:
```javascript
import { useApi } from '@skolplattformen/api-hooks'
export default function NewsItem ({ item }) => {
const { api } = useApi()
const cookie = api.getSessionCookie()
return (
<View>
{ cookie &&
<Image source={{ uri: item.fullImageUrl, headers: { cookie } }} /> }
</View>
)
}
```
### useNotifications
```javascript
import { useNotifications } from '@skolplattformen/api-hooks'
export default function NotificationsComponent ({ selectedChild }) => {
const { status, data, error, reload } = useNotifications(selectedChild)
return (
<View>
{ status === 'loading' && <Spinner />}
{ error && <Text>{ error.message }</Text>}
{ data.map((item) => (
<Notification item={item} />
))}
{ status !== 'loading' && status !== 'pending' && <Button onClick={() => reload()}> }
</View>
)
}
```
To show content of `NotificationItem` url:
```javascript
import { useApi } from '@skolplattformen/api-hooks'
import { WebView } from 'react-native-webview'
export default function Notification ({ item }) => {
const { cookie } = useApi()
return (
<View>
<WebView source={{ uri: item.url, headers: { cookie }}} />
</View>
)
}
```
### useSchedule
```javascript
import { DateTime } from 'luxon'
import { useSchedule } from '@skolplattformen/api-hooks'
export default function ScheduleComponent ({ selectedChild }) => {
const from = DateTime.local()
const to = DateTime.local.plus({ week: 1 })
const { status, data, error, reload } = useSchedule(selectedChild, from, to)
return (
<View>
{ status === 'loading' && <Spinner />}
{ error && <Text>{ error.message }</Text>}
{ data.map((item) => (
<ScheduleItem item={item} />
))}
{ status !== 'loading' && status !== 'pending' && <Button onClick={() => reload()}> }
</View>
)
}
```
### useUser
```javascript
import { useUser } from '@skolplattformen/api-hooks'
export default function UserComponent () => {
const { status, data, error, reload } = useUser()
return (
<View>
{ status === 'loading' && <Spinner />}
{ error && <Text>{ error.message }</Text>}
{ data &&
<>
<Text>{data.firstName} {data.lastName}</Text>
<Text>{data.email}</Text>
</>
}
{ status !== 'loading' && status !== 'pending' && <Button onClick={() => reload()}> }
</View>
)
}
```
## Fake mode
To make testing easier, fake mode can be enabled at login. Just use any of the magic
personal numbers: `12121212121212`, `201212121212` or `1212121212`.
The returned login status will have `token` set to `'fake'`.
```javascript
import { useApi } from '@skolplattformen/api-hooks'
import { useApi } from '@skolplattformen/api-hooks'
export default function LoginController () {
const { api, isLoggedIn } = useApi()
const [personalNumber, setPersonalNumber] = useState()
const [bankIdStatus, setBankIdStatus] = useState('')
api.on('login', () => { /* do login stuff */ })
api.on('logout', () => { /* do logout stuff */ })
const doLogin = async () => {
const status = await api.login(personalNumber)
if (status.token !== 'fake') {
openBankID(status.token)
} else {
// Login will succeed
// All data will be faked
// No server calls will be made
}
})
return (
<View>
<Input value={personalNumber} onChange={(value) = setPersonalNumber(value)} />
<Button onClick={() => doLogin()}>
<Text>{bankIdStatus}</Text>
<Text>Logged in: {isLoggedIn}</Text>
</View>
)
}
```

View File

@ -0,0 +1,7 @@
module.exports = {
presets: [
'@babel/preset-env',
'@babel/preset-react',
'@babel/preset-typescript',
],
}

13
libs/hooks/jest.config.js Normal file
View File

@ -0,0 +1,13 @@
module.exports = {
// Automatically clear mock calls and instances between every test
clearMocks: true,
// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',
// Indicates which provider should be used to instrument code for coverage
coverageProvider: 'v8',
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: ['<rootDir>/jest.setup.js'],
// The test environment that will be used for testing
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
}

1
libs/hooks/jest.setup.js Normal file
View File

@ -0,0 +1 @@
import 'regenerator-runtime/runtime'

64
libs/hooks/package.json Normal file
View File

@ -0,0 +1,64 @@
{
"name": "@skolplattformen/api-hooks",
"description": "React hooks for accessing api with cached results",
"version": "0.0.1",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
"repository": "git@github.com:kolplattformen/api-hooks.git",
"author": "Johan Öbrink <johan.obrink@gmail.com>",
"license": "Apache-2.0",
"private": false,
"scripts": {
"lint": "eslint 'src/**/*.ts' --quiet --fix",
"test": "jest",
"build": "tsc",
"prepare": "yarn build",
"publish-package": "npm publish --access public"
},
"dependencies": {
"luxon": "^1.26.0",
"react-redux": "^7.2.3",
"redux": "^4.0.5"
},
"peerDependencies": {
"@skolplattformen/curriculum": "^1.3.0",
"@skolplattformen/embedded-api": "^5.1.0",
"react": "^16.11.0"
},
"devDependencies": {
"@babel/preset-env": "^7.13.15",
"@babel/preset-react": "^7.13.13",
"@babel/preset-typescript": "^7.13.0",
"@skolplattformen/curriculum": "^1.3.0",
"@skolplattformen/embedded-api": "^5.1.0",
"@testing-library/jest-dom": "^5.11.10",
"@testing-library/react": "^11.2.6",
"@testing-library/react-hooks": "^5.1.1",
"@types/jest": "^26.0.22",
"@types/luxon": "^1.26.4",
"@types/react": "^16.14.3",
"@types/react-redux": "^7.1.16",
"@typescript-eslint/eslint-plugin": "^4.21.0",
"babel-jest": "^26.6.3",
"eslint": "^7.23.0",
"eslint-config-airbnb-typescript": "^12.3.1",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "^24.3.4",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-react": "^7.23.2",
"eslint-plugin-react-hooks": "^4.2.0",
"events": "^3.3.0",
"jest": "^26.6.3",
"react": "^16.11.0",
"react-dom": "^16.11.0",
"react-test-renderer": "^16.11.0",
"regenerator-runtime": "^0.13.7",
"typescript": "^3.9.7"
},
"publishConfig": {
"access": "public"
}
}

View File

@ -0,0 +1,30 @@
import { EventEmitter } from 'events'
const emitter = new EventEmitter()
const createApi = () => ({
emitter,
isLoggedIn: false,
login: jest.fn(),
logout: jest.fn(),
on: jest.fn().mockImplementation((...args) => emitter.on(...args)),
off: jest.fn().mockImplementation((...args) => emitter.off(...args)),
getSession: jest.fn(),
getPersonalNumber: jest.fn(),
getCalendar: jest.fn(),
getChildren: jest.fn(),
getSkola24Children: jest.fn(),
getClassmates: jest.fn(),
getMenu: jest.fn(),
getNews: jest.fn(),
getNewsDetails: jest.fn(),
getNotifications: jest.fn(),
getSchedule: jest.fn(),
getTimetable: jest.fn(),
getUser: jest.fn(),
})
const init = jest.fn().mockImplementation(() => createApi())
export default init

Some files were not shown because too many files have changed in this diff Show More