|
1
|
|
|
<?php |
|
2
|
|
|
|
|
3
|
|
|
namespace App\Untis\Html\Timetable; |
|
4
|
|
|
|
|
5
|
|
|
use App\Untis\Html\AbstractHtmlReader; |
|
6
|
|
|
use App\Untis\Html\HtmlParseException; |
|
7
|
|
|
use DOMNode; |
|
8
|
|
|
use DOMXPath; |
|
9
|
|
|
use InvalidArgumentException; |
|
10
|
|
|
|
|
11
|
|
|
class TimetableReader extends AbstractHtmlReader { |
|
12
|
|
|
private const ObjectiveSelector = '/html/body/center/font[3]'; |
|
13
|
|
|
private const TableSelector = "//table[@rules='all']"; |
|
14
|
|
|
private const LessonIndicatorCellSelector = './tr[position()>1]/td[1]'; // First tr is the header |
|
15
|
|
|
|
|
16
|
|
|
private const FirstLesson = 1; |
|
17
|
|
|
|
|
18
|
|
|
private array $gradeCellInformation; |
|
19
|
|
|
private array $subjectCellInformation; |
|
20
|
|
|
|
|
21
|
|
|
public function __construct() { |
|
22
|
|
|
$this->gradeCellInformation = [ |
|
23
|
|
|
CellInformationType::Weeks, CellInformationType::Subject, CellInformationType::Teacher, CellInformationType::Room |
|
24
|
|
|
]; |
|
25
|
|
|
|
|
26
|
|
|
$this->subjectCellInformation = [ |
|
27
|
|
|
CellInformationType::Weeks, CellInformationType::Teacher, CellInformationType::Room |
|
28
|
|
|
]; |
|
29
|
|
|
} |
|
30
|
|
|
|
|
31
|
|
|
/** |
|
32
|
|
|
* @throws HtmlParseException |
|
33
|
|
|
*/ |
|
34
|
|
|
public function readHtml(string $html, TimetableType $type): TimetableResult { |
|
35
|
|
|
$xpath = $this->getXPath($html); |
|
36
|
|
|
$objective = $this->parseObjective($xpath); |
|
37
|
|
|
$lessons = $this->parseLessons($xpath, $type); |
|
38
|
|
|
|
|
39
|
|
|
if($type === TimetableType::Grade) { |
|
40
|
|
|
foreach($lessons as $lesson) { |
|
41
|
|
|
$lesson->setGrade($objective); |
|
42
|
|
|
} |
|
43
|
|
|
} else if($type === TimetableType::Subject) { |
|
44
|
|
|
foreach($lessons as $lesson) { |
|
45
|
|
|
$lesson->setSubject($objective); |
|
46
|
|
|
} |
|
47
|
|
|
} |
|
48
|
|
|
|
|
49
|
|
|
return new TimetableResult($objective, $lessons); |
|
50
|
|
|
} |
|
51
|
|
|
|
|
52
|
|
|
/** |
|
53
|
|
|
* @throws HtmlParseException |
|
54
|
|
|
*/ |
|
55
|
|
|
private function parseObjective(DOMXPath $xpath): string { |
|
56
|
|
|
$elements = $xpath->query(self::ObjectiveSelector); |
|
57
|
|
|
$firstElement = $elements !== false ? $elements->item(0) : null; |
|
58
|
|
|
|
|
59
|
|
|
if($firstElement === null) { |
|
60
|
|
|
throw new HtmlParseException('XPath for objective failed.'); |
|
61
|
|
|
} |
|
62
|
|
|
|
|
63
|
|
|
return trim($firstElement->nodeValue); |
|
64
|
|
|
} |
|
65
|
|
|
|
|
66
|
|
|
/** |
|
67
|
|
|
* @return Lesson[] |
|
68
|
|
|
* @throws HtmlParseException |
|
69
|
|
|
*/ |
|
70
|
|
|
private function parseLessons(DOMXPath $xpath, TimetableType $type): array { |
|
71
|
|
|
$lessons = [ ]; |
|
72
|
|
|
|
|
73
|
|
|
$table = $xpath->query(self::TableSelector); |
|
74
|
|
|
$table = $table !== false ? $table->item(0) : null; |
|
75
|
|
|
|
|
76
|
|
|
if($table === null) { |
|
77
|
|
|
throw new HtmlParseException('XPath for lessons table failed.'); |
|
78
|
|
|
} |
|
79
|
|
|
|
|
80
|
|
|
$numberOfLessons = $this->getNumberOfLessons($xpath, $table); |
|
81
|
|
|
$lessonStarts = $this->computeLessonStarts($xpath, $table, $numberOfLessons); |
|
82
|
|
|
|
|
83
|
|
|
$trNodes = $xpath->query('./tr', $table); |
|
84
|
|
|
$currentLesson = 0; |
|
85
|
|
|
|
|
86
|
|
|
$cellTypes = $type === TimetableType::Grade ? $this->gradeCellInformation : $this->subjectCellInformation; |
|
87
|
|
|
|
|
88
|
|
|
for($idx = 1; $idx < $trNodes->count(); $idx++) { |
|
89
|
|
|
$trNode = $trNodes->item($idx); |
|
90
|
|
|
$tdNodes = $xpath->query('./td', $trNode); |
|
91
|
|
|
|
|
92
|
|
|
if($tdNodes === false || $tdNodes->count() === 0) { |
|
93
|
|
|
// Every second row is empty/has no child |
|
94
|
|
|
continue; |
|
95
|
|
|
} |
|
96
|
|
|
|
|
97
|
|
|
$currentDay = 0; |
|
98
|
|
|
|
|
99
|
|
|
for($tdIdx = 1; $tdIdx < $tdNodes->count(); $tdIdx++) { |
|
100
|
|
|
$currentDay += $this->computeAdvanceDayCount($lessonStarts, $currentLesson, $currentDay); |
|
101
|
|
|
|
|
102
|
|
|
$lessonStart = $currentLesson; |
|
103
|
|
|
$numberOfLessonsStartingAtLessonStart = count((array) array_filter($lessonStarts[$currentDay], fn(int $start) => $start === $lessonStart)); |
|
104
|
|
|
$lessonEnd = $currentLesson + $numberOfLessonsStartingAtLessonStart - 1; |
|
105
|
|
|
|
|
106
|
|
|
$lessons = array_merge($lessons, $this->parseLessonsFromCell($xpath, $tdNodes->item($tdIdx), $currentDay, $lessonStart, $lessonEnd, $cellTypes, true)); |
|
107
|
|
|
$currentDay++; |
|
108
|
|
|
} |
|
109
|
|
|
|
|
110
|
|
|
$currentLesson++; |
|
111
|
|
|
} |
|
112
|
|
|
|
|
113
|
|
|
return $lessons; |
|
114
|
|
|
} |
|
115
|
|
|
|
|
116
|
|
|
/** |
|
117
|
|
|
* @throws HtmlParseException |
|
118
|
|
|
*/ |
|
119
|
|
|
private function getNumberOfLessons(DOMXPath $xpath, DOMNode $table): int { |
|
120
|
|
|
$tdNodes = $xpath->query(self::LessonIndicatorCellSelector, $table); |
|
121
|
|
|
|
|
122
|
|
|
if($tdNodes === false) { |
|
123
|
|
|
throw new HtmlParseException('XPath for getting number of lessons failed.'); |
|
124
|
|
|
} |
|
125
|
|
|
|
|
126
|
|
|
return count($tdNodes); |
|
127
|
|
|
} |
|
128
|
|
|
|
|
129
|
|
|
/** |
|
130
|
|
|
* @throws HtmlParseException |
|
131
|
|
|
*/ |
|
132
|
|
|
private function computeLessonStarts(DOMXPath $xpath, DOMNode $table, int $numberOfLessons): array { |
|
133
|
|
|
$trNodes = $xpath->query('./tr', $table); |
|
134
|
|
|
$lessonStarts = [ ]; |
|
135
|
|
|
|
|
136
|
|
|
for($day = 0; $day < 5; $day++) { |
|
137
|
|
|
$lessonStarts[$day] = range(0, $numberOfLessons - 1); |
|
138
|
|
|
} |
|
139
|
|
|
|
|
140
|
|
|
if($trNodes === false) { |
|
141
|
|
|
throw new HtmlParseException('XPath for getting trNodes failed.'); |
|
142
|
|
|
} |
|
143
|
|
|
|
|
144
|
|
|
$lesson = 0; |
|
145
|
|
|
|
|
146
|
|
|
for($idx = 1; $idx < count($trNodes); $idx++) { |
|
|
|
|
|
|
147
|
|
|
$trNode = $trNodes->item($idx); |
|
148
|
|
|
$tdNodes = $xpath->query('./td', $trNode); |
|
149
|
|
|
|
|
150
|
|
|
if($tdNodes === false || $tdNodes->count() === 0) { |
|
151
|
|
|
// every second row is empty/has no child |
|
152
|
|
|
continue; |
|
153
|
|
|
} |
|
154
|
|
|
|
|
155
|
|
|
$day = 0; |
|
156
|
|
|
|
|
157
|
|
|
for($tdIdx = 1; $tdIdx < $tdNodes->count(); $tdIdx++) { |
|
158
|
|
|
$tdNode = $tdNodes->item($tdIdx); |
|
159
|
|
|
if($lessonStarts[$day][$lesson] == $lesson) { |
|
160
|
|
|
$rowSpanAttribute = $tdNode->attributes->getNamedItem('rowspan'); |
|
|
|
|
|
|
161
|
|
|
|
|
162
|
|
|
if($rowSpanAttribute !== null) { |
|
163
|
|
|
$rowSpan = intval($rowSpanAttribute->nodeValue); |
|
164
|
|
|
$duration = $rowSpan / 2; // Untis needs two rows per lesson |
|
165
|
|
|
|
|
166
|
|
|
for($currentLesson = $lesson + 1; $currentLesson < $lesson + $duration; $currentLesson++) { |
|
167
|
|
|
$lessonStarts[$day][$currentLesson] = $lesson; |
|
168
|
|
|
} |
|
169
|
|
|
} |
|
170
|
|
|
} |
|
171
|
|
|
|
|
172
|
|
|
$day++; |
|
173
|
|
|
} |
|
174
|
|
|
|
|
175
|
|
|
$lesson++; |
|
176
|
|
|
} |
|
177
|
|
|
|
|
178
|
|
|
return $lessonStarts; |
|
179
|
|
|
} |
|
180
|
|
|
|
|
181
|
|
|
private function computeAdvanceDayCount(array $lessonStarts, int $lesson, int $day): int { |
|
182
|
|
|
if($day > count($lessonStarts)) { |
|
183
|
|
|
throw new InvalidArgumentException(sprintf('Parameter $day must be less than %d (%d given)', count($lessonStarts), $day)); |
|
184
|
|
|
} |
|
185
|
|
|
|
|
186
|
|
|
if($lesson > (is_countable($lessonStarts[0]) ? count($lessonStarts[0]) : 0)) { |
|
187
|
|
|
throw new InvalidArgumentException(sprintf('Parameter $lesson mut be less than %d (%d given)', is_countable($lessonStarts[0]) ? count($lessonStarts[0]) : 0, $lesson)); |
|
188
|
|
|
} |
|
189
|
|
|
|
|
190
|
|
|
$advance = 0; |
|
191
|
|
|
|
|
192
|
|
|
for($currentDay = $day; $currentDay < count($lessonStarts); $currentDay++) { |
|
|
|
|
|
|
193
|
|
|
if($lessonStarts[$currentDay][$lesson] != $lesson) { |
|
194
|
|
|
$advance++; |
|
195
|
|
|
} else { |
|
196
|
|
|
break; |
|
197
|
|
|
} |
|
198
|
|
|
} |
|
199
|
|
|
|
|
200
|
|
|
return $advance; |
|
201
|
|
|
} |
|
202
|
|
|
|
|
203
|
|
|
/** |
|
204
|
|
|
* @param CellInformationType[] $cellTypes |
|
205
|
|
|
* @return Lesson[] |
|
206
|
|
|
*/ |
|
207
|
|
|
private function parseLessonsFromCell(DOMXPath $xpath, DOMNode $tdNode, int $day, int $lessonStart, int $lessonEnd, array $cellTypes, bool $useWeeks = true): array { |
|
208
|
|
|
$lessons = [ ]; |
|
209
|
|
|
|
|
210
|
|
|
$nodes = $xpath->query('./table/tr', $tdNode); |
|
211
|
|
|
|
|
212
|
|
|
if($nodes === false || $nodes->count() === 0) { |
|
213
|
|
|
return $lessons; |
|
214
|
|
|
} |
|
215
|
|
|
|
|
216
|
|
|
for($idx = 0; $idx < $nodes->count(); $idx++) { |
|
217
|
|
|
$lessonNode = $nodes->item($idx); |
|
218
|
|
|
$tdNodes = $xpath->query('./td', $lessonNode); |
|
219
|
|
|
|
|
220
|
|
|
if($tdNodes === false || $tdNodes->count() < 2) { |
|
221
|
|
|
continue; |
|
222
|
|
|
} |
|
223
|
|
|
|
|
224
|
|
|
$lesson = new Lesson(); |
|
225
|
|
|
$lesson->setLessonStart($lessonStart + self::FirstLesson); |
|
226
|
|
|
$lesson->setLessonEnd($lessonEnd + self::FirstLesson); |
|
227
|
|
|
$lesson->setDay($day + 1); |
|
228
|
|
|
|
|
229
|
|
|
for($nodeIdx = 0; $nodeIdx < $tdNodes->count(); $nodeIdx++) { |
|
230
|
|
|
$value = trim($tdNodes->item($nodeIdx)->nodeValue); |
|
231
|
|
|
$property = $cellTypes[$useWeeks ? $nodeIdx : $nodeIdx + 1]; |
|
232
|
|
|
|
|
233
|
|
|
if($property === CellInformationType::Room) { |
|
234
|
|
|
$lesson->setRoom($value); |
|
235
|
|
|
} else if($property === CellInformationType::Subject) { |
|
236
|
|
|
$lesson->setSubject($value); |
|
237
|
|
|
} else if($property === CellInformationType::Teacher) { |
|
238
|
|
|
$lesson->setTeacher($value); |
|
239
|
|
|
} else if($property === CellInformationType::Weeks) { |
|
240
|
|
|
$lesson->setWeeks(explode(',', $value)); |
|
241
|
|
|
} |
|
242
|
|
|
} |
|
243
|
|
|
|
|
244
|
|
|
$lessons[] = $lesson; |
|
245
|
|
|
} |
|
246
|
|
|
|
|
247
|
|
|
return $lessons; |
|
248
|
|
|
} |
|
249
|
|
|
} |
If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration: