diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md new file mode 100644 index 0000000..406ba16 --- /dev/null +++ b/.gitlab/issue_templates/Bug.md @@ -0,0 +1,25 @@ +Please add the following, if applicable, remove otherwise: + +## Summary ## + +(max. 60 words) + +## Basic information ## + + - Version: + - Component: + - Platform: (RasPi, x86_64, i686) + +## Description ## + +### Expected behaviour ### + +### Actual behaviour ### + +### Reproduction steps ## + +(Please attach files if necessary) + +## Other things to be mentioned ## + +(Ideas for fixing, requests for comments, …) diff --git a/.gitlab/issue_templates/Enhancement.md b/.gitlab/issue_templates/Enhancement.md new file mode 100644 index 0000000..0f25bc4 --- /dev/null +++ b/.gitlab/issue_templates/Enhancement.md @@ -0,0 +1,16 @@ +## Summary ## + +(max 60 words) + +## Basic information ## + + - Component: + - New node needed: (Yes/No) + +## Description ## + +As a , I want to + +## Acceptance criteria ## + +(How should this be in the end?) diff --git a/.gitlab/merge_request_templates/Ready_to_Merge.md b/.gitlab/merge_request_templates/Ready_to_Merge.md new file mode 100644 index 0000000..254b63e --- /dev/null +++ b/.gitlab/merge_request_templates/Ready_to_Merge.md @@ -0,0 +1,5 @@ +As mentioned in , this implements . + +This includes the following changes: + - … + - … diff --git a/.gitlab/merge_request_templates/Work_In_Progress.md b/.gitlab/merge_request_templates/Work_In_Progress.md new file mode 100644 index 0000000..dabf399 --- /dev/null +++ b/.gitlab/merge_request_templates/Work_In_Progress.md @@ -0,0 +1,9 @@ +As mentioned in , this implements + +For this, the following steps are needed: + + - [ ] List + - [ ] of + - [ ] steps + - [ ] and + - [ ] substeps diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed1cb4c --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +bayernfahrplan +============== + +A JSON-Getter written in D for bayernfahrplan +--------------------------------------------- + +bayernfahrplan is a tool wich lets you fetch departure tables from +bayernfahrplan.de and output them as JSON. + +Usage +----- + +``` +Usage: bayernfahrplan [options] + + Options: +-f --file The file that the data is written to. +-s --stop The bus stop for which to fetch data. +-r --replacement-file The file that contais the direction name replacement info. +-h --help This help information. +``` \ No newline at end of file diff --git a/dub.json b/dub.json index d5076dd..23a066c 100644 --- a/dub.json +++ b/dub.json @@ -5,7 +5,7 @@ "Oliver Rümpelein" ], "dependencies": { - "requests": "~>0.3.1", + "requests": "~>0.4.1", "kxml": "~>1.0.1" }, "description": "A minimal D application.", diff --git a/source/app.d b/source/app.d index e3b76eb..ce064ab 100644 --- a/source/app.d +++ b/source/app.d @@ -1,90 +1,72 @@ -import std.algorithm : filter, map, startsWith; -import std.array : array, empty, front, replace; -import std.conv : to; -import std.datetime : dur, TimeOfDay, Clock; +import std.array : array, replace; +import std.datetime : Clock; +import std.file : exists, isFile; +import std.format : format; import std.getopt : defaultGetoptPrinter, getopt; import std.json : JSONValue; -import std.regex : ctRegex, matchAll; -import std.stdio : File, stdout, writeln; -import std.string : strip; -import std.typecons : tuple; -import std.format : format; +import std.stdio : File, writeln; -import kxml.xml : readDocument, XmlNode; +import requests : getContent; -import requests : postContent; +import fahrplanparser; import substitution; -auto parseTime(in string input) -{ - auto matches = matchAll(input, ctRegex!(`(?P\d+):(?P\d+)`)); - auto actualTime = TimeOfDay(matches.front["hours"].to!int, matches.front["minutes"].to!int); - matches.popFront; - if (!matches.empty) - { - auto expectedTime = TimeOfDay(matches.front["hours"].to!int, - matches.front["minutes"].to!int); - return tuple(expectedTime, actualTime - expectedTime); - - } - return tuple(actualTime, dur!"minutes"(0)); -} - -auto getRowContents(XmlNode[] rows) -{ - return rows.map!(row => row.parseXPath("//td")[1 .. $ - 1].map!((column) { - auto link = column.parseXPath("//a"); - if (!link.empty) - return link.front.getCData.replace("...", ""); - return column.getCData;})); -} +enum baseURL = "http://mobile.defas-fgi.de/beg/"; +enum departureMonitorRequest = "XML_DM_REQUEST"; void main(string[] args) { string fileName; - string busStop = "Universität Regensburg"; + string busStop = "Regensburg Universität"; string substitutionFileName = "replacement.txt"; + // dfmt off auto helpInformation = getopt(args, - "file|f", "The file that the data is written to.", &fileName, - "stop|s", "The bus stop for which to fetch data.", &busStop, - "replacement-file|r", "The file that contais the direction name replacement info.", &substitutionFileName); + "file|f", "The file that the data is written to.", &fileName, + "stop|s", "The bus stop for which to fetch data.", &busStop, + "replacement-file|r", "The file that contais the direction name replacement info.", &substitutionFileName); + // dfmt on + if (helpInformation.helpWanted) { - defaultGetoptPrinter("Some information about the program.", helpInformation.options); + defaultGetoptPrinter("Usage: bayernfahrplan [options]\n\n Options:", helpInformation.options); return; } - auto content = postContent("http://txt.bayern-fahrplan.de/textversion/bcl_abfahrtstafel", - ["limit" : "20", - "useRealtime" : "1", - "name_dm" : busStop, - "mode" : "direct", - "type_dm" : "any", - "itdLPxx_bcl" : "true"]); + // dfmt off + auto content = getContent(baseURL ~ departureMonitorRequest, + ["outputFormat" : "XML", + "language" : "de", + "stateless" : "1", + "type_dm" : "stop", + "name_dm" : busStop, + "useRealtime" : "1", + "mode" : "direct", + "ptOptionActive" : "1", + "mergeDep" : "1", + "limit" : "20", + "deleteAssignedStops_dm" : "1"]); + // dfmt on + + if (substitutionFileName.exists && substitutionFileName.isFile) + { + loadSubstitutionFile(substitutionFileName); + } auto currentTime = Clock.currTime; - loadSubstitutionFile(substitutionFileName); JSONValue j = ["time" : "%02s:%02s".format(currentTime.hour, currentTime.minute)]; - j.object["departures"] = readDocument(cast(string) content.data) - .parseXPath(`//table[@id="departureMonitor"]/tbody/tr`)[1 .. $] - .getRowContents - .filter!(row => !row.empty) - .map!(a => ["departure" : a[0].parseTime[0].to!string[0 .. $ - 3], - "delay" : a[0].parseTime[1].total!"minutes".to!string, - "line" : a[1], - "direction" : a[2].substitute]) - .array.JSONValue; + j.object["departures"] = (cast(string) content.data).parsedFahrplan.array.JSONValue; + auto output = j.toPrettyString.replace("\\/", "/"); if (fileName !is null) { - auto output = File(fileName, "w"); - scope(exit) output.close; - output.writeln(j.toPrettyString.replace("\\/", "/")); + auto outfile = File(fileName, "w"); + scope (exit) + outfile.close; + outfile.writeln(output); } else { - j.toPrettyString.replace("\\/", "/").writeln; + output.writeln; } - } diff --git a/source/fahrplanparser.d b/source/fahrplanparser.d new file mode 100644 index 0000000..2273c87 --- /dev/null +++ b/source/fahrplanparser.d @@ -0,0 +1,242 @@ +module fahrplanparser; + +import std.algorithm : map; +import std.array : empty, front; +import std.conv : to; +import std.datetime : dur, TimeOfDay, DateTimeException; +import std.string : format; + +import kxml.xml : readDocument, XmlNode; + +import substitution; + +private: + +enum departureNodeName = "dp"; +enum timeNodeName = "t"; +enum realTimeNodeName = "rt"; + +enum departuresXPath = "/efa/dps/" ~ departureNodeName; +template timeXPath(string _timeNodeName = timeNodeName) +{ + enum timeXPath = "/st/" ~ _timeNodeName; +} + +enum useRealTimeXPath = "/realtime"; +enum lineXPath = "/m/nu"; +enum directionXPath = "/m/des"; + +public: + +/*********************************** +* Parses the departure monitor data and returns it as an associative array. +* data is expected to contain valid XML as returned by queries sent to http://mobile.defas-fgi.de/beg/. +*/ + +auto parsedFahrplan(in string data) +{ + // dfmt off + return data.readDocument + .parseXPath(departuresXPath) + .map!(dp => ["departure" : "%02s:%02s".format(dp.departureTime.hour, dp.departureTime.minute), + "delay" : dp.delay.total!"minutes".to!string, + "line": dp.parseXPath(lineXPath).front.getCData, + "direction": dp.parseXPath(directionXPath).front.getCData.substitute]); + // dfmt on +} + +/// +@system unittest +{ + import std.array : array; + + auto xml = ""; + assert(xml.parsedFahrplan.array == []); + + xml = ""; + assert(xml.parsedFahrplan.array == []); + + xml = "1122412426Wernerwerkstraße"; + assert(xml.parsedFahrplan.array == [["direction" : "Wernerwerkstraße", + "line" : "6", "departure" : "12:24", "delay" : "18"]]); + + xml = "012246Wernerwerkstraße"; + assert(xml.parsedFahrplan.array == [["direction" : "Wernerwerkstraße", + "line" : "6", "departure" : "12:24", "delay" : "0"]]); + + xml = "012246Wernerwerkstraße11353135611Burgweinting"; + assert(xml.parsedFahrplan.array == [["direction" : "Wernerwerkstraße", "line" : "6", + "departure" : "12:24", "delay" : "0"], ["direction" : "Burgweinting", + "line" : "11", "departure" : "13:53", "delay" : "3"]]); +} + +private: + +class UnexpectedValueException(T) : Exception +{ + this(T t, string node) @safe pure + { + super(`Unexpected value "%s" for node "%s"`.format(t, node)); + } +} + +class CouldNotFindeNodeException : Exception +{ + this(string node) @safe pure + { + super(`Could not find node "%s"`.format(node)); + } +} + +auto departureTime(string _timeNodeName = timeNodeName)(XmlNode dp) +in +{ + assert(dp.getName == departureNodeName); +} +body +{ + auto timeNodes = dp.parseXPath(timeXPath!_timeNodeName); + if(timeNodes.empty) + throw new CouldNotFindeNodeException(_timeNodeName); + + return TimeOfDay.fromISOString(timeNodes.front.getCData ~ "00"); +} + +@system unittest +{ + import std.exception : assertThrown; + + auto xml = "0000".readDocument.parseXPath("/dp").front; + assert(xml.departureTime == TimeOfDay(0, 0)); + + xml = "0013".readDocument.parseXPath("/dp").front; + assert(xml.departureTime == TimeOfDay(0, 13)); + + xml = "1100".readDocument.parseXPath("/dp").front; + assert(xml.departureTime == TimeOfDay(11, 00)); + + xml = "1242".readDocument.parseXPath("/dp").front; + assert(xml.departureTime == TimeOfDay(12, 42)); + + xml = "2359".readDocument.parseXPath("/dp").front; + assert(xml.departureTime == TimeOfDay(23, 59)); + + assertThrown!DateTimeException("2400".readDocument.parseXPath("/dp") + .front.departureTime); + assertThrown!DateTimeException("0061".readDocument.parseXPath("/dp") + .front.departureTime); + assertThrown!DateTimeException("2567".readDocument.parseXPath("/dp") + .front.departureTime); + assertThrown!DateTimeException("".readDocument.parseXPath("/dp") + .front.departureTime); + assertThrown!DateTimeException("0".readDocument.parseXPath("/dp") + .front.departureTime); + assertThrown!DateTimeException("00".readDocument.parseXPath("/dp") + .front.departureTime); + assertThrown!DateTimeException("000000".readDocument.parseXPath("/dp") + .front.departureTime); + assertThrown!DateTimeException("00:00".readDocument.parseXPath("/dp") + .front.departureTime); + assertThrown!DateTimeException("abcd".readDocument.parseXPath("/dp") + .front.departureTime); +} + +auto delay(XmlNode dp) +in +{ + assert(dp.getName == departureNodeName); +} +body +{ + immutable useRealtimeString = dp.parseXPath(useRealTimeXPath).front.getCData; + if (useRealtimeString == "0") + return dur!"minutes"(0); + else if (useRealtimeString == "1") + { + try + { + immutable expectedTime = dp.departureTime; + immutable realTime = dp.departureTime!realTimeNodeName; + auto timeDiff = realTime - expectedTime; + if (timeDiff < dur!"minutes"(0)) + timeDiff = dur!"hours"(24) + timeDiff; + return timeDiff; + } + catch (CouldNotFindeNodeException e) + { + return dur!"minutes"(0); + } + } + else + throw new UnexpectedValueException!string(useRealtimeString, "realtime"); +} + +@system unittest +{ + import std.exception : assertThrown; + import core.exception : AssertError; + + auto xml = "0".readDocument.parseXPath("/dp").front; + assert(xml.delay == dur!"minutes"(0)); + + xml = "".readDocument.parseXPath("/dp").front; + assertThrown!(UnexpectedValueException!string)(xml.delay); + + xml = "2".readDocument.parseXPath("/dp").front; + assertThrown!(UnexpectedValueException!string)(xml.delay); + + xml = "a".readDocument.parseXPath("/dp").front; + assertThrown!(UnexpectedValueException!string)(xml.delay); + + xml = "1".readDocument.parseXPath("/dp").front; + assertThrown!AssertError(xml.delay); + + xml = "1".readDocument.parseXPath("/dp").front; + assertThrown!DateTimeException(xml.delay); + + xml = "1".readDocument.parseXPath("/dp").front; + assertThrown!AssertError(xml.delay); + + xml = "".readDocument.parseXPath("/dp").front; + assertThrown!AssertError(xml.delay); + + xml = "1".readDocument.parseXPath("/dp") + .front; + assertThrown!DateTimeException(xml.delay); + + xml = "10000".readDocument.parseXPath("/dp") + .front; + assertThrown!DateTimeException(xml.delay); + + xml = "10000".readDocument.parseXPath("/dp") + .front; + assertThrown!DateTimeException(xml.delay); + + xml = "100000000" + .readDocument.parseXPath("/dp").front; + assert(xml.delay == dur!"minutes"(0)); + + xml = "100010000" + .readDocument.parseXPath("/dp").front; + assert(xml.delay == dur!"minutes"(1)); + + xml = "117531751" + .readDocument.parseXPath("/dp").front; + assert(xml.delay == dur!"minutes"(2)); + + xml = "110101000" + .readDocument.parseXPath("/dp").front; + assert(xml.delay == dur!"minutes"(10)); + + xml = "113011242" + .readDocument.parseXPath("/dp").front; + assert(xml.delay == dur!"minutes"(19)); + + xml = "100001242" + .readDocument.parseXPath("/dp").front; + assert(xml.delay == dur!"minutes"(678)); + + xml = "100002359" + .readDocument.parseXPath("/dp").front; + assert(xml.delay == dur!"minutes"(1)); +} diff --git a/source/substitution.d b/source/substitution.d index 0937dc2..07c239f 100644 --- a/source/substitution.d +++ b/source/substitution.d @@ -1,35 +1,105 @@ module substitution; +import std.file : slurp; +import std.meta : AliasSeq; +import std.traits : Parameters; + public: -void loadSubstitutionFile(string fileName) +/*********************************** +* Loads a substitution dictonary from a file. +*/ + +void loadSubstitutionFile(alias slurpFun = slurp)(string fileName) + if (is(Parameters!(slurpFun!(string, string)) == AliasSeq!(string, const char[]))) { - import std.file : slurp, exists, isFile; - import std.array : assocArray; import std.algorithm.iteration : each; - if (fileName.exists && fileName.isFile) - { - auto data = slurp!(string, string)(fileName, `"%s" = "%s"`); - map = (string[string]).init; - data.each!(pair => map[pair[0]] = pair[1]); - foreach (pair; data) - { - map[pair[0]] = pair[1]; - } - map.rehash; - } - else - { - map = (string[string]).init; - } + map = (string[string]).init; + slurpFun!(string, string)(fileName, `"%s" = "%s"`).each!(pair => map[pair[0]] = pair[1]); } -auto substitute(string s) +/// +@safe unittest +{ + import std.typecons : Tuple, tuple; + + static Tuple!(string, string)[] mockSlurpEmpty(Type1, Type2)(string filename, in char[] format) + { + return []; + } + + loadSubstitutionFile!mockSlurpEmpty(""); + assert(map.length == 0); + + static Tuple!(string, string)[] mockSlurpEmptyEntry(Type1, Type2)(string filename, + in char[] format) + { + return [tuple("", "")]; + } + + loadSubstitutionFile!mockSlurpEmptyEntry(""); + assert("" in map); + assert(map.length == 1); + assert(map[""] == ""); + + static Tuple!(string, string)[] mockSlurpSingleEntry(Type1, Type2)(string filename, + in char[] format) + { + return [tuple("foo", "bar")]; + } + + loadSubstitutionFile!mockSlurpSingleEntry(""); + assert("foo" in map); + assert(map.length == 1); + assert(map["foo"] == "bar"); + + static Tuple!(string, string)[] mockSlurpMultipleEntries(Type1, Type2)( + string filename, in char[] format) + { + return [tuple("", ""), tuple("0", "1"), tuple("Text in", "wird durch diesen ersetzt")]; + } + + loadSubstitutionFile!mockSlurpMultipleEntries(""); + assert("" in map); + assert("0" in map); + assert("Text in" in map); + assert(map.length == 3); + assert(map[""] == ""); + assert(map["0"] == "1"); + assert(map["Text in"] == "wird durch diesen ersetzt"); +} + +/*********************************** +* Substitutes a string with its corresponding replacement, if one is available. +* Otherwise just returns the original string. +*/ + +auto substitute(string s) @safe nothrow { return s in map ? map[s] : s; } +/// +@safe unittest +{ + map[""] = ""; + assert(substitute("") == ""); + + map["a"] = "b"; + assert(substitute("a") == "b"); + + map["Regensburg Danziger Freiheit"] = "Danziger Freiheit"; + assert(substitute("Regensburg Danziger Freiheit") == "Danziger Freiheit"); + + map["Regensburg Danziger Freiheit"] = "Anderer Test"; + assert(substitute("Regensburg Danziger Freiheit") == "Anderer Test"); + + assert(substitute("z") == "z"); + + assert(substitute("Regensburg Hauptbahnhof") == "Regensburg Hauptbahnhof"); +} + private: string[string] map;