ctucx.git: trainsearch

web based trip-planner, fork of https://cyberchaos.dev/yuka/trainsearch

commit 962a376db6202fdaf011cfb7223febdea8dbf86e
parent 3a64b5e7dca03e6110e468eb0f7c88ff822a6b97
Author: Yureka <yuka@yuka.dev>
Date: Sun, 13 Feb 2022 12:52:21 +0100

loader, back buttons, refresh, and lots more
18 files changed, 872 insertions(+), 99 deletions(-)
M
package.json
|
4
++--
M
src/app_functions.js
|
93
++++++++++++++++++++++++++++++++++++++++++-------------------------------------
M
src/canvas.js
|
24
+++++++++++++++++-------
M
src/hafas_client.js
|
5
++---
M
src/journeyView.js
|
41
++++++++++++++++++++++++++++++-----------
M
src/journeysView.js
|
73
++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
M
src/languages.js
|
3
+++
M
src/main.js
|
14
+++++++++++---
M
src/overlays.js
|
2
--
A
src/refresh_token.js
|
8
++++++++
M
src/router.js
|
7
+++++--
M
src/searchView.js
|
12
++++++++++--
M
static/style.css
|
1
+
A
trainsearch-refresh-token/.gitignore
|
1
+
A
trainsearch-refresh-token/Cargo.lock
|
248
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
trainsearch-refresh-token/Cargo.toml
|
21
+++++++++++++++++++++
A
trainsearch-refresh-token/src/lib.rs
|
410
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
M
webpack.config.js
|
4
++++
diff --git a/package.json b/package.json
@@ -4,8 +4,8 @@
   "description": "",
   "main": "service-worker.js",
   "scripts": {
-    "build": "rm -rf dist pkg && webpack",
-    "start": "rm -rf dist pkg && webpack-dev-server --open -d"
+    "build": "rm -rf dist hafas-rs/pkg trainsearch-refresh-token/pkg && webpack",
+    "start": "rm -rf dist hafas-rs/pkg trainsearch-refresh-token/pkg && webpack-dev-server --open -d"
   },
   "author": "",
   "license": "AGPL-3.0",
diff --git a/src/app_functions.js b/src/app_functions.js
@@ -1,10 +1,11 @@
 import { db } from './dataStorage.js';
 import { settings, subscribeSettings } from './settings.js';
-import { showModal } from './overlays.js';
+import { showLoader, hideOverlay, showModal, showAlertModal } from './overlays.js';
 import { languages } from './languages.js';
 import { html } from 'lit-html';
 import { formatDateTime, getFrom, getTo } from './helpers.js';
 import { client } from './hafas_client.js';
+import { trainsearchToHafas, hafasToTrainsearch } from './refresh_token';
 
 let ds100 = {};
 

@@ -35,6 +36,7 @@ const addJourneys = async data => {
 	const journeysHistoryStore = tx.objectStore('journeysHistory');
 	let proms = data.journeys.map(j => {
 		j.settings = data.settings;
+		j.slug = data.slug;
 		journeyStore.put(j);
 	});
 	proms.push(journeysOverviewStore.put({

@@ -86,80 +88,83 @@ const processJourney = journey => {
 };
 
 export const getJourneys = async slug => {
-	if (!slug) return;
-
 	let data = await db.get('journeysOverview', slug);
 	data.journeys = await Promise.all(data.journeys.map(getJourney));
-
-	return processJourneys(data);
+	return data;
 };
 
 export const getJourney = async refreshToken => {
-	if (!refreshToken) return;
-
 	let data = await db.get('journey', refreshToken);
 	const settings = mkSettings();
 	if (!data || JSON.stringify(data.settings) != JSON.stringify(settings)) {
-		data = await client.journeys("${refreshToken}", settings);
-		data.settings = settings;
-		await db.put('journey', data);
+		await refreshJourney(refreshToken);
 	}
-
 	return processJourney(data);
 };
 
-export const getMoreJourneys = async (slug, mode, hideLoader) => {
-	if (!slug) return;
-
+export const getMoreJourneys = async (slug, mode) => {
 	const saved = await db.get('journeysOverview', slug);
 	const params = { ...saved.params, ...mkSettings() };
 	params[mode+'Than'] = saved[mode+'Ref'];
 	let { departure, arrival, from, to, ...moreOpt } = params;
-	console.log(moreOpt);
 	const [newData, ...existingJourneys] = await Promise.all(
 		[ client.journeys(from, to, moreOpt) ]
 			.concat(saved.journeys.map(getJourney))
 	);
-	console.log(newData);
-
-        const res = {
-                ...saved,
-                ...newData,
-        };
-        if (mode === 'earlier') {
-                res.journeys = newData.journeys.concat(existingJourneys);
-                res.indexOffset += newData.journeys.length;
-                // keep old laterRef
-                res.laterRef = saved.laterRef;
-        } else {
-                res.journeys = existingJourneys.concat(newData.journeys);
-                // keep old earlierRef
-                res.earlierRef = saved.earlierRef;
-        }
-	console.log(res);
+
+	const res = {
+		...saved,
+		...newData,
+	};
+	for (const journey of newData.journeys) {
+		journey.refreshToken = hafasToTrainsearch(journey.refreshToken);
+	}
+	if (mode === 'earlier') {
+		res.journeys = newData.journeys.concat(existingJourneys);
+		res.indexOffset += newData.journeys.length;
+		// keep old laterRef
+		res.laterRef = saved.laterRef;
+	} else {
+		res.journeys = existingJourneys.concat(newData.journeys);
+		// keep old earlierRef
+		res.earlierRef = saved.earlierRef;
+	}
 
 	await addJourneys(res);
-	return processJourneys(res);
 };
-export const refreshJourneys = async (reqId, hideLoader) => {
+export const refreshJourneys = async (slug) => {
+	const saved = await db.get('journeysOverview', slug);
+	await Promise.all(saved.journeys.map(refreshJourney));
 };
-export const refreshJourney = async (refreshToken, hideLoader) => {
+export const refreshJourney = async (refreshToken) => {
+	const settings = mkSettings();
+	const [saved, data] = await Promise.all([
+		db.get('journey', refreshToken),
+		client.refreshJourney(trainsearchToHafas(refreshToken), settings)
+	]);
+	data.settings = settings;
+	data.refreshToken = hafasToTrainsearch(data.refreshToken);
+	if (saved) data.slug = saved.slug;
+	db.put('journey', data);
 };
 
 const generateSlug = () => {
-        const len = 8;
-        let result = '';
-        const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
-        for (let i = 0; i < len; i++)
-                result += characters.charAt(Math.floor(Math.random() * characters.length));
-        return result;
+	const len = 8;
+	let result = '';
+	const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+	for (let i = 0; i < len; i++)
+		result += characters.charAt(Math.floor(Math.random() * characters.length));
+	return result;
 };
 
-export const newJourneys = async (params, hideLoader) => {
+export const newJourneys = async (params) => {
 	const settings = mkSettings();
 	const { from, to, ...moreOpts } = params;
-	console.log(from, to, { ...settings, ...moreOpts });
-	const data = await client.journeys(from, to, { ...settings, ...moreOpts });
+	let data;
+	data = await client.journeys(from, to, { ...settings, ...moreOpts });
+	for (const journey of data.journeys) {
+		journey.refreshToken = hafasToTrainsearch(journey.refreshToken);
+	}
 	data.slug = generateSlug();
 	data.indexOffset = 0;
 	data.params = params;
diff --git a/src/canvas.js b/src/canvas.js
@@ -70,8 +70,25 @@ export const setupCanvas = (data, isUpdate) => {
 
 	canvas.addEventListener('mousedown', mouseDownHandler, {passive: true});
 	canvas.addEventListener('touchstart', mouseDownHandler, {passive: true});
+	window.addEventListener('mouseup', mouseUpHandler);
+	window.addEventListener('touchend', mouseUpHandler);
+	window.addEventListener('mousemove', mouseMoveHandler);
+	window.addEventListener('touchmove', mouseMoveHandler);
+	window.addEventListener('resize', resizeHandler);
+	window.addEventListener('zoom', resizeHandler);
 	updateTextCache();
 	resizeHandler();
+
+	return () => {
+		canvas.removeEventListener('mousedown', mouseDownHandler);
+		canvas.removeEventListener('touchstart', mouseDownHandler);
+		window.removeEventListener('mouseup', mouseUpHandler);
+		window.removeEventListener('touchend', mouseUpHandler);
+		window.removeEventListener('mousemove', mouseMoveHandler);
+		window.removeEventListener('touchmove', mouseMoveHandler);
+		window.removeEventListener('resize', resizeHandler);
+		window.removeEventListener('zoom', resizeHandler);
+	};
 };
 
 const addTextToCache = (text, color, fixedHeight) => {

@@ -353,10 +370,3 @@ const mouseMoveHandler = (evt) => {
 		return true;
 	}
 };
-
-window.addEventListener('mouseup', mouseUpHandler);
-window.addEventListener('touchend', mouseUpHandler);
-window.addEventListener('mousemove', mouseMoveHandler);
-window.addEventListener('touchmove', mouseMoveHandler);
-window.addEventListener('resize', resizeHandler);
-window.addEventListener('zoom', resizeHandler);
diff --git a/src/hafas_client.js b/src/hafas_client.js
@@ -1,7 +1,6 @@
-
 export let client;
 
-(async () => {
+export const initHafasClient = async () => {
 	const { JsFetchRequester, DbProfile, HafasClient } = await import('../hafas-rs/pkg/index.js');
 	client = new HafasClient(new DbProfile(), new JsFetchRequester());
-}) ()
+};
diff --git a/src/journeyView.js b/src/journeyView.js
@@ -1,7 +1,7 @@
 import { settings } from './settings.js';
 import { showDiv, hideDiv, ElementById, formatDateTime, formatDuration, formatPrice } from './helpers.js';
-import { ConsoleLog, parseName, ds100Names, t, timeTemplate, getJourney, refreshJourneys } from './app_functions.js';
-import { showModal } from './overlays.js';
+import { ConsoleLog, parseName, ds100Names, t, timeTemplate, getJourney, refreshJourney } from './app_functions.js';
+import { showAlertModal, showLoader, hideOverlay, showModal } from './overlays.js';
 import { go } from './router.js';
 import { html, render } from 'lit-html';
 

@@ -159,12 +159,16 @@ const journeyTemplate = (data) => {
 	return html`
 		<div class="journey">
 			<header>
-				<a class="back icon-back" title="${t("back")}" href="#/">${t("back")}</a>
+				${data.slug ? html`
+					<a class="back icon-back" href="#/${data.slug}" title="${t('back')}">${t('back')}</a>
+				` : html`
+					<a class="back icon-back invisible"></a>
+				`}
 				<div class="header-content">
 					<h3>${parseName(data.legs[0].origin)} → ${parseName(data.legs[data.legs.length - 1].destination)}</h3>
 					<p><b>${t('duration')}: ${formatDuration(duration)} | ${t('changes')}: ${changes-1} | ${t('date')}: ${formatDateTime(data.legs[0].plannedDeparture, 'date')}${settings.showPrices && data.price ? html` | ${t('price')}: <td><span>${formatPrice(data.price)}</span></td>` : ''}</b></p>
 				</div>
-				<a class="reload icon-reload" title="${t("reload")}" @click=${() => refreshJourneyView(requestId, journeyId)}>${t("reload")}</a>
+				<a class="reload icon-reload" title="${t("reload")}" @click=${() => refreshJourneyView(data.refreshToken)}>${t("reload")}</a>
 			</header>
 
 			${legs.map(legTemplate)}

@@ -196,13 +200,22 @@ const stopPlatformTemplate = (data) => {
 	}
 };
 
-export const journeyView = async (match) => {
-	const refreshToken = decodeURIComponent(match[0]);
-
-	let data = await getJourney(refreshToken);
+export const journeyView = async (match, isUpdate) => {
+	if (!isUpdate) showLoader();
+	let refreshToken, data;
+	try {
+		refreshToken = decodeURIComponent(match[0]);
+		data = await getJourney(refreshToken);
+	} catch(e) {
+		showAlertModal(e.toString());
+		throw e;
+	}
+	hideOverlay();
 
 	ConsoleLog(data);
 	render(journeyTemplate(data), ElementById('content'));
+	
+	//if (!isUpdate) refreshJourneyView(refreshToken); // update data in the background
 
 	/*const history_id = dataStorage.journeysHistory.findIndex(obj => obj.reqId === reqId);
 

@@ -212,9 +225,15 @@ export const journeyView = async (match) => {
 	}*/
 };
 
-const refreshJourneyView = async (reqId, journeyId) => {
+const refreshJourneyView = async (refreshToken) => {
 	document.querySelector('.reload').classList.add('spinning');
-	await refreshJourneys(reqId, true);
-	journeyView([reqId, journeyId]);
+	try {
+		await refreshJourney(refreshToken);
+	} catch(e) {
+		showAlertModal(e.toString());
+		document.querySelector('.reload').classList.remove('spinning');
+		throw e;
+	}
+	journeyView([refreshToken], true);
 	document.querySelector('.reload').classList.remove('spinning');
 };
diff --git a/src/journeysView.js b/src/journeysView.js
@@ -1,10 +1,10 @@
 import { showDiv, hideDiv, ElementById, formatDuration, formatFromTo, getFrom, getTo, padZeros, formatPrice } from './helpers.js';
-import { parseName, ConsoleLog, t, timeTemplate, getJourneys, getMoreJourneys } from './app_functions.js';
+import { parseName, ConsoleLog, t, timeTemplate, getJourneys, getMoreJourneys, refreshJourneys } from './app_functions.js';
 import { settings, modifySettings } from './settings.js';
 import { setupCanvas } from './canvas.js';
 import { go } from './router.js';
 import { html, render } from 'lit-html';
-import { showAlertModal } from './overlays.js';
+import { showAlertModal, showLoader, hideOverlay } from './overlays.js';
 
 const journeysTemplate = (data) => html`
 	<div class="journeys">

@@ -15,16 +15,16 @@ const journeysTemplate = (data) => html`
 				<div>
 					<h3>${t('to')}: ${parseName(getTo(data.journeys))}</h3>
 					<div class="mode-changers">
-						<a @click=${changeMode('table')} class="${settings.journeysViewMode === 'table' ? 'active' : ''}">
+						<a href="#/${data.slug}/table" class="${settings.journeysViewMode === 'table' ? 'active' : ''}">
 							<div class="icon-table"></div>
 							<span>${t('table-view')}</span>
 						</a>
-						<a @click=${changeMode('canvas')} class="${settings.journeysViewMode === 'canvas' ? 'active' : ''}">
+						<a href="#/${data.slug}/canvas" class="${settings.journeysViewMode === 'canvas' ? 'active' : ''}">
 							<div class="icon-canvas"></div>
 							<span>${t('canvas-view')}</span>
 						</a>
 						${settings.showMap ? html`
-							<a @click=${changeMode('map')} class="${settings.journeysViewMode === 'map' ? 'active' : ''}">
+							<a href="#/${data.slug}/map" class="${settings.journeysViewMode === 'map' ? 'active' : ''}">
 								<div class="icon-map"></div>
 								<span>${t('map-view')}</span>
 							</a>

@@ -32,7 +32,7 @@ const journeysTemplate = (data) => html`
 					</div>
 				</div>
 			</div>
-			<a class="back invisible" href="#/"></a>
+			<a class="reload icon-reload" title="${t("reload")}" @click=${() => refreshJourneysView(data.slug)}>${t("reload")}</a>
 		</header>
 
 		${settings.journeysViewMode === 'canvas' ? html`

@@ -56,7 +56,7 @@ const journeysTemplate = (data) => html`
 						<th>${t('duration')}</th>
 						<th>${t('changes')}</th>
 						<th>${t('products')}</th>
-						${settings.showPrices ? html`<th>Price</th>` : ''}
+						${settings.showPrices ? html`<th>${t('price')}</th>` : ''}
 						<th></th>
 					</tr>
 				</thead>

@@ -122,31 +122,58 @@ const journeyOverviewTemplate = (entry, slug, key) => {
 
 export const journeysView = async (match, isUpdate) => {
 	const slug = match[0];
+	const mode = match[1] && match[1].substring(1);
+	if (!mode) return go(`/${slug}/${settings.journeysViewMode || "canvas"}`);
 
-	const data = await getJourneys(slug);
-	console.log(data);
+	if (settings.journeysViewMode != mode) {
+		await modifySettings(settings => {
+			settings.journeysViewMode = mode;
+			return settings;
+		});
+	}
+
+	let data;
+	if (!isUpdate) showLoader();
+	try {
+		data = await getJourneys(slug);
+	} catch(e) {
+		showAlertModal(e.toString());
+		throw e;
+	}
+	hideOverlay();
 
 	render(journeysTemplate(data), ElementById('content'));
 
-	if (settings.journeysViewMode === 'canvas') setupCanvas(data, isUpdate);
+	// if (!isUpdate) refreshJourneysView(slug); // update data in the background
+
+	if (settings.journeysViewMode === 'canvas') return setupCanvas(data, isUpdate);
 	if (settings.journeysViewMode === 'map') {
 		const module = await import('./map.js');
-		module.setupMap(data, isUpdate);
+		return module.setupMap(data, isUpdate);
 	}
 };
 
-const changeMode = (mode) => {
-	return async () => {
-		await modifySettings(settings => {
-			settings.journeysViewMode = mode;
-			return settings;
-		});
-		const match = /^\/([a-zA-Z0-9]+)$/.exec(window.location.hash.slice(1)).slice(1);
-		journeysView(match);
-	};
+export const moreJourneys = async (slug, mode) => {
+	showLoader();
+	try {
+		await getMoreJourneys(slug, mode);
+	} catch(e) {
+		showAlertModal(e.toString());
+		throw e;
+	}
+	hideOverlay();
+	journeysView([slug, `/${settings.journeysViewMode}`], true);
 };
 
-export const moreJourneys = async (slug, mode) => {
-	await getMoreJourneys(slug, mode);
-	journeysView([slug], true);
+const refreshJourneysView = async (slug) => {
+	document.querySelector('.reload').classList.add('spinning');
+	try {
+		await refreshJourneys(slug, true);
+	} catch(e) {
+		showAlertModal(e.toString());
+		document.querySelector('.reload').classList.remove('spinning');
+		throw e;
+	}
+	journeysView([slug, `/${settings.journeysViewMode}`], true);
+	document.querySelector('.reload').classList.remove('spinning');
 };
diff --git a/src/languages.js b/src/languages.js
@@ -199,5 +199,8 @@ export const languages = {
 		'show-map':          'Show geographical map view',
 		'experimental-features': 'Experimental features',
 		'show-prices':       'Show prices',
+		'price':             'Price',
+		'back':              'Back',
+		'reload':           'Refresh data',
 	}
 };
diff --git a/src/main.js b/src/main.js
@@ -4,15 +4,23 @@ import { journeysView } from './journeysView.js';
 import { journeyView } from './journeyView.js';
 import { initSettings } from './settings.js';
 import { initDataStorage } from './dataStorage.js';
+import { initHafasClient } from './hafas_client.js';
+import { initRefreshTokenLib } from './refresh_token.js';
 import { showDiv, hideDiv, ElementById } from './helpers.js';
 
 (async () => {
 	// read settings from indexeddb
-	await initDataStorage();
-	await initSettings();
+	await Promise.all([
+		((async () => {
+			await initDataStorage();
+			await initSettings();
+		}) ()),
+		initHafasClient(),
+		initRefreshTokenLib(),
+	]);
 
 	route(/^\/$/, searchView);
-	route(/^\/([a-zA-Z0-9]+)$/, journeysView);
+	route(/^\/([a-zA-Z0-9]+)(\/[a-z]+)?$/, journeysView);
 	route(/^\/j\/(.+)$/, journeyView);
 
 	hideDiv('overlay');
diff --git a/src/overlays.js b/src/overlays.js
@@ -1,5 +1,3 @@
-
-
 import { showDiv, hideDiv, ElementById } from './helpers.js';
 import { html, render } from 'lit-html';
 
diff --git a/src/refresh_token.js b/src/refresh_token.js
@@ -0,0 +1,8 @@
+export let hafasToTrainsearch;
+export let trainsearchToHafas;
+
+export const initRefreshTokenLib = async () => {
+	const lib = await import('../trainsearch-refresh-token/pkg/index.js');
+	hafasToTrainsearch = lib.hafasToTrainsearch;
+	trainsearchToHafas = lib.trainsearchToHafas;
+};
diff --git a/src/router.js b/src/router.js
@@ -1,4 +1,5 @@
 const routes = [];
+let currentRoute;
 
 export const route = (pattern, handler) => {
 	routes.push({

@@ -11,12 +12,14 @@ export const go = (dest) => {
 	window.location.hash = '#' + dest;
 };
 
-export const start = () => {
+export const start = async () => {
 	const dest = window.location.hash.slice(1);
+	if (currentRoute && currentRoute.unload) currentRoute.unload();
 	for (const route of routes) {
 		const match = route.pattern.exec(dest);
 		if (!match) continue;
-		return route.handler(match.slice(1));
+		currentRoute = await route.handler(match.slice(1));
+		return;
 	}
 };
 
diff --git a/src/searchView.js b/src/searchView.js
@@ -284,8 +284,16 @@ export const search = async (requestId) => {
 	else
 		params.arrival = timestamp;
 
-	const { slug } = await newJourneys(params);
-	go('/' + slug);
+	let data;
+	showLoader();
+	try {
+		data = await newJourneys(params);
+	} catch(e) {
+		showAlertModal(e.toString());
+		throw e;
+	}
+	hideOverlay();
+	go(`/${data.slug}`);
 	return false;
 };
 
diff --git a/static/style.css b/static/style.css
@@ -964,6 +964,7 @@ form>div.history {
 	display: flex;
 	padding: 0 1em;
 	cursor: pointer;
+	text-decoration: none;
 }
 .mode-changers a span {
 	font-weight: bold;
diff --git a/trainsearch-refresh-token/.gitignore b/trainsearch-refresh-token/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/trainsearch-refresh-token/Cargo.lock b/trainsearch-refresh-token/Cargo.lock
@@ -0,0 +1,248 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "anyhow"
+version = "1.0.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94a45b455c14666b85fc40a019e8ab9eb75e3a124e05494f5397122bc9eb06e0"
+
+[[package]]
+name = "autocfg"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
+
+[[package]]
+name = "base64"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
+
+[[package]]
+name = "bumpalo"
+version = "3.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4a45a46ab1f2412e53d3a0ade76ffad2025804294569aae387231a0cd6e0899"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "chrono"
+version = "0.4.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
+dependencies = [
+ "libc",
+ "num-integer",
+ "num-traits",
+ "time",
+ "winapi",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
+[[package]]
+name = "libc"
+version = "0.2.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e74d72e0f9b65b5b4ca49a346af3976df0f9c61d550727f349ecd559f251a26c"
+
+[[package]]
+name = "log"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db"
+dependencies = [
+ "autocfg",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029"
+dependencies = [
+ "unicode-xid",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "864d3e96a899863136fc6e99f3d7cae289dafe43bf2c5ac19b70df7210c0a145"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.136"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce31e24b01e1e524df96f1c2fdd054405f8d7376249a5110886fb4b658484789"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.136"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08597e7152fcd306f41838ed3e37be9eaeed2b61c42e2117266a554fab4662f9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "smaz"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f9ecc6775a24d971affc5b0e8549207ff53cf80eb661592165d67e0170a1fb0"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a65b3f4ffa0092e9887669db0eae07941f023991ab58ea44da8fe8e2d511c6b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-xid",
+]
+
+[[package]]
+name = "time"
+version = "0.1.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "trainsearch-refresh-token"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "base64",
+ "chrono",
+ "serde",
+ "smaz",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25f1af7423d8588a3d840681122e72e6a24ddbcb3f0ec385cac0d12d24256c06"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b21c0df030f5a177f3cba22e9bc4322695ec43e7257d865302900290bcdedca"
+dependencies = [
+ "bumpalo",
+ "lazy_static",
+ "log",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4203d69e40a52ee523b2529a773d5ffc1dc0071801c87b3d270b471b80ed01"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa8a30d46208db204854cadbb5d4baf5fcf8071ba5bf48190c3e59937962ebc"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d958d035c4438e28c70e4321a2911302f10135ce78a9c7834c0cab4123d06a2"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
diff --git a/trainsearch-refresh-token/Cargo.toml b/trainsearch-refresh-token/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "trainsearch-refresh-token"
+version = "0.1.0"
+edition = "2021"
+repository = "https://cyberchaos.dev/yu-re-ka/trainsearch.git"
+license = "AGPL-3.0"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+serde = { version = "1.0", features = [ "derive" ] }
+anyhow = "1.0"
+smaz = "0.1"
+base64 = "0.13"
+chrono = "0.4"
+wasm-bindgen = { version = "0.2", optional = true }
+
+[features]
+wasm-bindings = [ "wasm-bindgen" ]
diff --git a/trainsearch-refresh-token/src/lib.rs b/trainsearch-refresh-token/src/lib.rs
@@ -0,0 +1,410 @@
+use std::convert::TryInto;
+use anyhow::anyhow;
+use std::collections::HashMap;
+use chrono::NaiveDateTime;
+use serde::{Serialize, Deserialize};
+#[cfg(feature = "wasm-bindings")]
+use wasm_bindgen::prelude::wasm_bindgen;
+
+#[derive(Debug, Deserialize, Serialize)]
+pub enum HafasLegType {
+    #[serde(rename = "JNY")]
+    Journey,
+    #[serde(rename = "WALK")]
+    Walk,
+    #[serde(rename = "TRSF")]
+    Transfer,
+    #[serde(rename = "DEVI")]
+    Devi,
+}
+
+fn read_byte(input: &mut &[u8]) -> anyhow::Result<u8> {
+    if input.len() < 1 { return Err(anyhow!("1 bytes")); }
+    let byte = input[0];
+    *input = &input[1..];
+    Ok(byte)
+}
+fn read_2_bytes(input: &mut &[u8]) -> anyhow::Result<[u8; 2]> {
+    if input.len() < 2 { return Err(anyhow!("2 bytes")); }
+    let res = input[..2].try_into()?;
+    *input = &input[2..];
+    Ok(res)
+}
+fn read_4_bytes(input: &mut &[u8]) -> anyhow::Result<[u8; 4]> {
+    if input.len() < 4 { return Err(anyhow!("4 bytes")); }
+    let res = input[..4].try_into()?;
+    *input = &input[4..];
+    Ok(res)
+}
+
+fn encode_string_compressed(text: &str) -> anyhow::Result<Vec<u8>> {
+    let mut res = vec![];
+    let mut compressed = smaz::compress(text.as_bytes());
+    let compressed_len: u8 = compressed.len().try_into()?;
+    res.push(compressed_len);
+    res.append(&mut compressed);
+    Ok(res)
+}
+fn decode_string_compressed(input: &mut &[u8]) -> anyhow::Result<String> {
+    let len = read_byte(input)? as usize;
+
+    if input.len() < len { return Err(anyhow!("dec string")) }
+    let compressed_string = &input[..len];
+    *input = &input[len..];
+
+    Ok(String::from_utf8(smaz::decompress(&compressed_string)?)?)
+}
+
+fn encode_string(text: &str) -> anyhow::Result<Vec<u8>> {
+    let mut res = vec![];
+    let len = text.len().try_into()?;
+    res.push(len);
+    res.append(&mut text.as_bytes().to_vec());
+    Ok(res)
+}
+fn decode_string(input: &mut &[u8]) -> anyhow::Result<String> {
+    let len = read_byte(input)? as usize;
+
+    if input.len() < len { return Err(anyhow!("dec string")) }
+    let bytes = &input[..len];
+    *input = &input[len..];
+
+    Ok(String::from_utf8(bytes.to_vec())?)
+}
+
+
+
+fn encode_trainsearch_date(datetime: NaiveDateTime) -> anyhow::Result<u32> {
+    Ok((datetime.timestamp() / 60).try_into()?)
+}
+fn decode_trainsearch_date(num: u32) -> NaiveDateTime {
+    chrono::NaiveDateTime::from_timestamp(num as i64 * 60, 0)
+}
+
+fn decode_hafas_date(text: &str) -> anyhow::Result<NaiveDateTime> {
+    Ok(chrono::NaiveDateTime::parse_from_str(text, "%Y%m%d%H%M")?)
+}
+fn encode_hafas_date(datetime: NaiveDateTime) -> String {
+    datetime.format("%Y%m%d%H%M").to_string()
+}
+
+fn encode_trainsearch_place(place: Place) -> anyhow::Result<Vec<u8>> {
+    let mut out = vec![];
+    match place {
+        Place::Station { id } => {
+            out.push(1);
+            out.append(&mut id.to_le_bytes().to_vec());
+        },
+        Place::Address { name, x, y } => {
+            out.push(2);
+            out.append(&mut encode_string_compressed(&name)?);
+            out.append(&mut x.to_le_bytes().to_vec());
+            out.append(&mut y.to_le_bytes().to_vec());
+        },
+        Place::Poi { id, name, x, y } => {
+            out.push(4);
+            out.append(&mut id.to_le_bytes().to_vec());
+            out.append(&mut encode_string_compressed(&name)?);
+            out.append(&mut x.to_le_bytes().to_vec());
+            out.append(&mut y.to_le_bytes().to_vec());
+        },
+    }
+    Ok(out)
+}
+
+fn decode_trainsearch_place(input: &mut &[u8]) -> anyhow::Result<Place> {
+    let t = read_byte(input)?;
+    Ok(match t {
+        1 => {
+            Place::Station { 
+                id: u32::from_le_bytes(read_4_bytes(input)?),
+            }
+        },
+        2 => {
+            Place::Address { 
+                name: decode_string_compressed(input)?,
+                x: i32::from_le_bytes(read_4_bytes(input)?),
+                y: i32::from_le_bytes(read_4_bytes(input)?),
+            }
+        },
+        4 => {
+            Place::Poi { 
+                id: u32::from_le_bytes(read_4_bytes(input)?),
+                name: decode_string_compressed(input)?,
+                x: i32::from_le_bytes(read_4_bytes(input)?),
+                y: i32::from_le_bytes(read_4_bytes(input)?),
+            }
+        },
+        _ => return Err(anyhow!("unknown place type")),
+    })
+}
+
+fn decode_hafas_place(text: &str) -> anyhow::Result<Place> {
+    let content = text.split("@")
+        .collect::<Vec<_>>().into_iter() // make iterator reversible
+        .rev().skip(1).rev() // remove last item ("")
+        .map(|x| {
+            let mut iter = x.split("=");
+            let first = iter.next().ok_or(anyhow!("enc place 1"))?;
+            let second = iter.next().ok_or(anyhow!("enc place 2"))?;
+            Ok((first, second))
+        })
+        .collect::<anyhow::Result<HashMap<&str, &str>>>()?;
+    Ok(match content.get("A") {
+        Some(&"1") => {
+            let id = content.get("L").ok_or(anyhow!("enc place 3"))?.parse()?;
+            Place::Station { id }
+        },
+        Some(&"2") => {
+            let name = content.get("O").ok_or(anyhow!("enc place 4"))?.to_string();
+            let x = content.get("X").ok_or(anyhow!("enc place 5"))?.parse()?;
+            let y = content.get("Y").ok_or(anyhow!("enc place 6"))?.parse()?;
+            Place::Address { name, x, y }
+        },
+        Some(&"4") => {
+            let id = content.get("L").ok_or(anyhow!("enc place 7"))?.parse()?;
+            let name = content.get("O").ok_or(anyhow!("enc place 8"))?.to_string();
+            let x = content.get("X").ok_or(anyhow!("enc place 9"))?.parse()?;
+            let y = content.get("Y").ok_or(anyhow!("enc place 10"))?.parse()?;
+            Place::Poi { name, x, y, id }
+        },
+        _ => return Err(anyhow!("enc place 11")),
+    })
+}
+
+fn encode_hafas_place(place: Place) -> anyhow::Result<String> {
+    let mut parts = vec![];
+    match place {
+        Place::Station { id } => {
+            parts.push(("A", format!("{}", 1)));
+            parts.push(("L", format!("{}", id)));
+        },
+        Place::Address { name, x, y } => {
+            parts.push(("A", format!("{}", 2)));
+            parts.push(("O", name));
+            parts.push(("X", format!("{}", x)));
+            parts.push(("Y", format!("{}", y)));
+        },
+        Place::Poi { id, name, x, y } => {
+            parts.push(("A", format!("{}", 4)));
+            parts.push(("L", format!("{}", id)));
+            parts.push(("O", name));
+            parts.push(("X", format!("{}", x)));
+            parts.push(("Y", format!("{}", y)));
+        },
+    }
+    Ok(parts.into_iter().map(|(a, b)| format!("{}={}@", a, b)).collect::<String>())
+}
+
+pub struct HafasReconstructionContext {
+    legs: Vec<HafasReconstructionContextLeg>,
+}
+
+pub struct HafasReconstructionContextLeg {
+    leg_type: HafasLegType,
+    from: Place,
+    to: Place,
+    departure: NaiveDateTime,
+    arrival: NaiveDateTime,
+    line: String,
+    field_7: u8, // possibly something about replacement trains?
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Place {
+    Station {
+        id: u32,
+    },
+    Address {
+        name: String,
+        x: i32,
+        y: i32,
+    },
+    Poi {
+        id: u32,
+        name: String,
+        x: i32,
+        y: i32,
+    },
+}
+
+pub(crate) fn decode_hafas(token: &str) -> anyhow::Result<HafasReconstructionContext> {
+    let token = token.split("¶GP¶").next().ok_or(anyhow!("enc token 1"))?;
+    let token = token.strip_prefix("¶HKI¶").ok_or(anyhow!("enc token 2"))?;
+
+    let mut legs = vec![];
+    for leg in token.split('§') {
+        let mut parts = leg.split('$');
+
+        legs.push(HafasReconstructionContextLeg {
+            leg_type: match parts.next().ok_or_else(|| anyhow!("wrong number of parts"))? {
+                "T" => HafasLegType::Journey,
+                "G@F" => HafasLegType::Walk,
+                "TF" => HafasLegType::Transfer,
+                "D" => HafasLegType::Devi,
+                other => return Err(anyhow!("unknown leg type: {}", other)),
+            },
+            from: match parts.next() {
+                None => return Err(anyhow!("wrong number of parts")),
+                Some(text) => decode_hafas_place(text)?,
+            },
+            to: match parts.next() {
+                None => return Err(anyhow!("wrong number of parts")),
+                Some(text) => decode_hafas_place(text)?,
+            },
+            departure: match parts.next() {
+                None => return Err(anyhow!("wrong number of parts")),
+                Some(text) => decode_hafas_date(text)?,
+            },
+            arrival: match parts.next() {
+                None => return Err(anyhow!("wrong number of parts")),
+                Some(text) => decode_hafas_date(text)?,
+            },
+            line: match parts.next() {
+                None => return Err(anyhow!("wrong number of parts")),
+                Some(text) => text.replace(" ", ""),
+            },
+            field_7: {
+                // field 6
+                match parts.next() {
+                    None => return Err(anyhow!("wrong number of parts")),
+                    Some("") => (),
+                    Some(other) => return Err(anyhow!("unexpected field with content: {}", other)),
+                }
+                // field 7
+                match parts.next() {
+                    None => return Err(anyhow!("wrong number of parts")),
+                    Some(text) => text.parse()?,
+                }
+            },
+        });
+        while let Some(part) = parts.next() {
+            if part != "" { return Err(anyhow!("unexpected field with content: {}", part)) }
+        }
+    }
+
+    Ok(HafasReconstructionContext { legs })
+}
+pub(crate) fn encode_hafas(data: HafasReconstructionContext) -> anyhow::Result<String> {
+    let mut legs = vec![];
+
+    for leg in data.legs {
+        let mut parts = vec![];
+        parts.push(match leg.leg_type {
+            HafasLegType::Journey => "T".to_string(),
+            HafasLegType::Walk => "G@F".to_string(),
+            HafasLegType::Transfer => "TF".to_string(),
+            HafasLegType::Devi => "D".to_string(),
+        });
+        parts.push(encode_hafas_place(leg.from)?);
+        parts.push(encode_hafas_place(leg.to)?);
+        parts.push(encode_hafas_date(leg.departure));
+        parts.push(encode_hafas_date(leg.arrival));
+        parts.push(leg.line);
+        parts.push("".to_string());
+        parts.push(leg.field_7.to_string());
+        parts.push("".to_string());
+        parts.push("".to_string());
+        parts.push("".to_string());
+        legs.push(parts.join("$"));
+    }
+    Ok(format!("¶HKI¶{}", legs.join("§")))
+}
+
+pub(crate) fn encode_trainsearch(token: HafasReconstructionContext) -> anyhow::Result<String> {
+    let mut out = vec![
+        0u8 // version
+    ];
+
+    out.append(&mut encode_trainsearch_place(token.legs.get(0).ok_or_else(|| anyhow!("token has no legs"))?.from.clone())?);
+
+    for leg in token.legs {
+        out.push(match leg.leg_type {
+            HafasLegType::Journey => 0,
+            HafasLegType::Walk => 1,
+            HafasLegType::Transfer => 2,
+            HafasLegType::Devi => 3,
+        });
+
+        out.append(&mut encode_trainsearch_place(leg.to)?);
+
+        {
+            let departure: u32 = encode_trainsearch_date(leg.departure)?;
+            let arrival_minus_departure: u16 = (encode_trainsearch_date(leg.arrival)? - departure).try_into()?;
+            out.append(&mut departure.to_le_bytes().to_vec());
+            out.append(&mut arrival_minus_departure.to_le_bytes().to_vec());
+        }
+
+        out.append(&mut encode_string(&leg.line)?);
+        out.push(leg.field_7);
+    }
+
+    Ok(base64::encode_config(&out, base64::URL_SAFE_NO_PAD))
+}
+
+pub(crate) fn decode_trainsearch(token: &str) -> anyhow::Result<HafasReconstructionContext> {
+    let input = base64::decode_config(token, base64::URL_SAFE_NO_PAD)?;
+    let mut input = input.as_slice();
+    let version = read_byte(&mut input)?;
+
+    Ok(match version {
+        0 => {
+            let mut legs = vec![];
+            let mut last_place = decode_trainsearch_place(&mut input)?;
+            while input.len() > 0 {
+                let leg_type = match read_byte(&mut input)? {
+                    0 => HafasLegType::Journey,
+                    1 => HafasLegType::Walk,
+                    2 => HafasLegType::Transfer,
+                    3 => HafasLegType::Devi,
+                    other => return Err(anyhow!("unknown leg type: {}", other)),
+                };
+                let to = decode_trainsearch_place(&mut input)?;
+
+                let (departure, arrival) = {
+                    let departure = u32::from_le_bytes(read_4_bytes(&mut input)?);
+                    let arrival_minus_departure = u16::from_le_bytes(read_2_bytes(&mut input)?) as u32;
+                    (
+                        decode_trainsearch_date(departure),
+                        decode_trainsearch_date(departure + arrival_minus_departure)
+                    )
+                };
+
+                let line = decode_string(&mut input)?;
+                let field_7 = read_byte(&mut input)?;
+
+                legs.push(HafasReconstructionContextLeg {
+                    leg_type,
+                    from: last_place,
+                    to: to.clone(),
+                    departure,
+                    arrival,
+                    line,
+                    field_7,
+                });
+                last_place = to;
+            }
+            HafasReconstructionContext { legs }
+        },
+        _ => return Err(anyhow!("unknown trainsearch token version")),
+    })
+}
+
+pub fn trainsearch_to_hafas(token: &str) -> anyhow::Result<String> {
+    Ok(encode_hafas(decode_trainsearch(token)?)?)
+}
+pub fn hafas_to_trainsearch(token: &str) -> anyhow::Result<String> {
+    Ok(encode_trainsearch(decode_hafas(token)?)?)
+}
+
+#[cfg(feature = "wasm-bindings")]
+#[wasm_bindgen(js_name = "trainsearchToHafas")]
+pub fn wasm_trainsearch_to_hafas(token: &str) -> Result<String, String> {
+    trainsearch_to_hafas(token).map_err(|e| e.to_string())
+}
+#[cfg(feature = "wasm-bindings")]
+#[wasm_bindgen(js_name = "hafasToTrainsearch")]
+pub fn wasm_hafas_to_trainsearch(token: &str) -> Result<String, String> {
+    hafas_to_trainsearch(token).map_err(|e| e.to_string())
+}
+
diff --git a/webpack.config.js b/webpack.config.js
@@ -25,5 +25,9 @@ module.exports = {
       crateDirectory: __dirname + "/hafas-rs",
       extraArgs: "--no-default-features --features all-profiles,js-fetch-requester,wasm-bindings,polylines",
     }),
+    new WasmPackPlugin({
+      crateDirectory: __dirname + "/trainsearch-refresh-token",
+      extraArgs: "--all-features",
+    }),
   ]
 };