commit 962a376db6202fdaf011cfb7223febdea8dbf86e
parent 3a64b5e7dca03e6110e468eb0f7c88ff822a6b97
Author: Yureka <yuka@yuka.dev>
Date: Sun, 13 Feb 2022 12:52:21 +0100
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
|
93
++++++++++++++++++++++++++++++++++++++++++-------------------------------------
A
|
248
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
410
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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", + }), ] };