@patimweb/pi-zep 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/index.ts +3 -0
- package/package.json +1 -1
- package/src/clients/zep-client.ts +48 -6
- package/src/clients/zep-parse.ts +67 -22
- package/src/config.ts +52 -14
- package/src/formatting/formatters.ts +2 -1
- package/src/session.ts +10 -1
- package/src/tools/zep-book.ts +12 -4
- package/src/tools/zep-doctor.ts +297 -0
- package/src/tools/zep-profile.ts +2 -0
- package/src/tools/zep-status.ts +8 -1
- package/src/types.ts +7 -0
package/README.md
CHANGED
|
@@ -107,17 +107,17 @@ Other message texts observed:
|
|
|
107
107
|
```
|
|
108
108
|
zep_setup name=work userid=patrick.weppelmann password=… mandant=zepneoimpulse
|
|
109
109
|
zep_status probe=true
|
|
110
|
-
zep_projects projekt=
|
|
110
|
+
zep_projects projekt=Contoso
|
|
111
111
|
zep_week
|
|
112
|
-
zep_book datum=2026-09-17 von=08:00 bis=12:00 projekt=413 vorgang=90-01 taetigkeit=co bemerkung="
|
|
112
|
+
zep_book datum=2026-09-17 von=08:00 bis=12:00 projekt=413 vorgang=90-01 taetigkeit=co bemerkung="4711 Beispiel-Ticket"
|
|
113
113
|
zep_book datum=2026-09-17 entries=[{von:"08:00",bis:"12:00",bemerkung:"…"},{von:"13:00",bis:"17:00",bemerkung:"…"}]
|
|
114
114
|
```
|
|
115
115
|
|
|
116
116
|
`projekt` and `vorgang` accept either an id (`413`, `3284`) or part of the label
|
|
117
|
-
(`
|
|
117
|
+
(`Contoso`, `90-01`). A full example, `dryRun` first:
|
|
118
118
|
|
|
119
119
|
```
|
|
120
|
-
zep_book projekt=
|
|
120
|
+
zep_book projekt=Contoso vorgang=90-01 datum=2026-09-17 von=08:00 bis=12:00 dryRun=true
|
|
121
121
|
```
|
|
122
122
|
|
|
123
123
|
## Important behaviour
|
package/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* PHP endpoint instead of the paid ZEP REST API.
|
|
6
6
|
*
|
|
7
7
|
* Tools:
|
|
8
|
+
* - zep_doctor: diagnose config, session and reachability without spending a login attempt
|
|
8
9
|
* - zep_setup: store userid/password/Mandant
|
|
9
10
|
* - zep_login: log in (password, or adopt a browser tab session)
|
|
10
11
|
* - zep_status: show config, log in, prove the session works
|
|
@@ -26,6 +27,7 @@
|
|
|
26
27
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
27
28
|
|
|
28
29
|
import { loadConfig } from "./src/config.ts";
|
|
30
|
+
import { ZepDoctorTool } from "./src/tools/zep-doctor.ts";
|
|
29
31
|
import { ZepSetupTool } from "./src/tools/zep-setup.ts";
|
|
30
32
|
import { ZepLoginTool } from "./src/tools/zep-login.ts";
|
|
31
33
|
import { ZepStatusTool } from "./src/tools/zep-status.ts";
|
|
@@ -38,6 +40,7 @@ import { ZepDeleteTool } from "./src/tools/zep-delete.ts";
|
|
|
38
40
|
export default function (pi: ExtensionAPI) {
|
|
39
41
|
loadConfig();
|
|
40
42
|
|
|
43
|
+
pi.registerTool(ZepDoctorTool);
|
|
41
44
|
pi.registerTool(ZepSetupTool);
|
|
42
45
|
pi.registerTool(ZepLoginTool);
|
|
43
46
|
pi.registerTool(ZepStatusTool);
|
package/package.json
CHANGED
|
@@ -51,6 +51,14 @@ export class ZepError extends Error {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
export interface ZepPing {
|
|
55
|
+
readonly status: number;
|
|
56
|
+
/** The login form rendered, i.e. the server is up and reachable. */
|
|
57
|
+
readonly loginPageOk: boolean;
|
|
58
|
+
/** login.php bounced us into the app, i.e. we are already authenticated. */
|
|
59
|
+
readonly redirectedToApp: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
54
62
|
export interface ZepClientOptions {
|
|
55
63
|
/** Called whenever a fresh session was established, so it can be cached. */
|
|
56
64
|
readonly onSession?: (session: ZepSession) => void;
|
|
@@ -117,6 +125,17 @@ export class ZepClient {
|
|
|
117
125
|
* where `noch` is the remaining attempt count before a lockout.
|
|
118
126
|
*/
|
|
119
127
|
async login(): Promise<LoginResult> {
|
|
128
|
+
// Refuse before touching the network. Without this guard an absent
|
|
129
|
+
// password becomes the literal string "undefined" in the form body, which
|
|
130
|
+
// ZEP counts as a real failed attempt - and the account is locked after 5.
|
|
131
|
+
if (!this.account.password) {
|
|
132
|
+
throw new ZepError(
|
|
133
|
+
`No password stored for ZEP user "${this.account.userid}". Add a "password" to the ` +
|
|
134
|
+
"profile in ~/.pi/zep-config.json (or run zep_setup). Refusing to send an empty " +
|
|
135
|
+
"password, because ZEP locks the account after 5 failed attempts.",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
120
139
|
this.cookies.clear();
|
|
121
140
|
this.clientsessid = null;
|
|
122
141
|
this.requesttoken = null;
|
|
@@ -131,9 +150,16 @@ export class ZepClient {
|
|
|
131
150
|
login: "Anmelden",
|
|
132
151
|
}).toString();
|
|
133
152
|
|
|
153
|
+
// Match the browser form POST exactly: same-origin form submissions carry
|
|
154
|
+
// Referer and Origin, and ZEP's login handler is the pickiest endpoint.
|
|
155
|
+
const loginUrl = `${this.root}/view/login.php`;
|
|
134
156
|
const res = await this.raw("/view/login.php", {
|
|
135
157
|
method: "POST",
|
|
136
|
-
headers: {
|
|
158
|
+
headers: {
|
|
159
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
160
|
+
referer: loginUrl,
|
|
161
|
+
origin: this.root,
|
|
162
|
+
},
|
|
137
163
|
body,
|
|
138
164
|
});
|
|
139
165
|
|
|
@@ -190,6 +216,22 @@ export class ZepClient {
|
|
|
190
216
|
return this.lastLogin;
|
|
191
217
|
}
|
|
192
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Reachability probe used by zep_doctor. Read-only: it fetches the login
|
|
221
|
+
* page and reports what came back. Never call it on a client that holds a
|
|
222
|
+
* live session - priming a new PHPSESSID would replace the working cookie.
|
|
223
|
+
*/
|
|
224
|
+
async ping(): Promise<ZepPing> {
|
|
225
|
+
const res = await this.raw("/view/login.php", { method: "GET" });
|
|
226
|
+
const body = await res.text();
|
|
227
|
+
const location = res.headers.get("location") ?? "";
|
|
228
|
+
return {
|
|
229
|
+
status: res.status,
|
|
230
|
+
loginPageOk: /id="login-form"/.test(body),
|
|
231
|
+
redirectedToApp: /index\.php/.test(location) && !/action=login/.test(location),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
193
235
|
/** Log in only when there is no usable session yet. */
|
|
194
236
|
async ensureLogin(): Promise<void> {
|
|
195
237
|
if (this.hasSession) return;
|
|
@@ -229,9 +271,9 @@ export class ZepClient {
|
|
|
229
271
|
const headers = new Headers(rest.headers ?? {});
|
|
230
272
|
headers.set("user-agent", USER_AGENT);
|
|
231
273
|
headers.set("accept-language", "de-DE,de;q=0.9,en;q=0.8");
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
274
|
+
// Deliberately no automatic X-Requested-With here: the browser only sends
|
|
275
|
+
// it on ajax.php calls, never on the login form POST. Sending it on the
|
|
276
|
+
// login made the request diverge from the real form.
|
|
235
277
|
if (this.cookies.size > 0) {
|
|
236
278
|
headers.set(
|
|
237
279
|
"cookie",
|
|
@@ -449,8 +491,8 @@ export class ZepClient {
|
|
|
449
491
|
letzterReiseOrt: "D-Office",
|
|
450
492
|
ortProjektrelevant: "1",
|
|
451
493
|
reise: "0",
|
|
452
|
-
startort: "
|
|
453
|
-
zielort: "
|
|
494
|
+
startort: "Beispielweg 1",
|
|
495
|
+
zielort: "Beispielort",
|
|
454
496
|
fahrzeug: "Firmenwagen",
|
|
455
497
|
km: "285",
|
|
456
498
|
mitfahrer: "1",
|
package/src/clients/zep-parse.ts
CHANGED
|
@@ -297,13 +297,51 @@ function resolveDayDate(dayLabel: string, kwDate: string): string | null {
|
|
|
297
297
|
return monday.toISOString().slice(0, 10);
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Label of a day row: `Do 17.09.`.
|
|
302
|
+
*
|
|
303
|
+
* The label cell also carries the meals/breaks icons, whose text content is
|
|
304
|
+
* literally `restaurant` / `local_cafe`; taking the plain text of the cell
|
|
305
|
+
* would render as `Do 17.09. restaurant`. Prefer the dedicated weekday and
|
|
306
|
+
* day/month divs and only fall back to text surgery.
|
|
307
|
+
*/
|
|
308
|
+
function dayRowLabel(cellHtml: string, joined: string): string {
|
|
309
|
+
const weekday = /class="modimidofrsaso"[^>]*>\s*([A-Za-z]{2})\s*</.exec(cellHtml)?.[1];
|
|
310
|
+
const dayMonth = /class="tag_monat"[^>]*>\s*([0-9]{2}\.[0-9]{2}\.)\s*</.exec(cellHtml)?.[1];
|
|
311
|
+
if (weekday && dayMonth) return `${weekday} ${dayMonth}`;
|
|
312
|
+
|
|
313
|
+
return joined
|
|
314
|
+
.split(/\s{2,}/)[0]!
|
|
315
|
+
.replace(/\s*(restaurant|local_cafe)\b.*$/i, "")
|
|
316
|
+
.trim();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Place of work of a booking row, or null when ZEP does not render it.
|
|
321
|
+
*
|
|
322
|
+
* The Ort cell is the row's last column (`von` + 9) and, unlike the other
|
|
323
|
+
* metadata columns, it carries no `data-columnid` of its own. ZEP only emits
|
|
324
|
+
* it when there is something to report, so an absent cell means the booking
|
|
325
|
+
* sits on the default place of work (`NULL`). A trailing `*` marks a
|
|
326
|
+
* deviation from that default and is not part of the value.
|
|
327
|
+
*/
|
|
328
|
+
function bookingOrt(flat: ReadonlyArray<string>, from: number): string | null {
|
|
329
|
+
return (flat[from + 9] ?? "").replace(/[\s*]+$/, "").trim() || null;
|
|
330
|
+
}
|
|
331
|
+
|
|
300
332
|
/**
|
|
301
333
|
* Parse the week table rendered into `#ProjektzeitTableDiv`.
|
|
302
334
|
*
|
|
303
|
-
* Row shapes observed on the live system:
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
335
|
+
* Row shapes observed on the live system (2026-09-17, ZEP v7.13.88):
|
|
336
|
+
*
|
|
337
|
+
* day, no bookings `["Do 17.09. restaurant", ""]`
|
|
338
|
+
* day, with bookings `["Do 17.09. restaurant", "", "editdelete", von, bis, Dauer, "", Projekt, Vorgang, Taet, fakturierbar, Bemerkung, Ort]`
|
|
339
|
+
* further bookings `["editdelete", von, bis, Dauer, "", Projekt, Vorgang, Taet, "payments", Bemerkung, Ort]`
|
|
340
|
+
* sum row `["Summe", "4,00", ...]`
|
|
341
|
+
*
|
|
342
|
+
* The day's FIRST booking is rendered inside the day row itself (the label
|
|
343
|
+
* cell then has `rowspan=N`), which is why the booking columns must be read
|
|
344
|
+
* relative to the first `HH:MM` cell rather than at a fixed offset.
|
|
307
345
|
*/
|
|
308
346
|
export function parseWeek(tableHtml: string, kwDate: string): ZepWeek {
|
|
309
347
|
const days: ZepWeekDay[] = [];
|
|
@@ -341,19 +379,22 @@ export function parseWeek(tableHtml: string, kwDate: string): ZepWeek {
|
|
|
341
379
|
// --- day header -------------------------------------------------------
|
|
342
380
|
const joined = flat.join(" ").trim();
|
|
343
381
|
const dayMatch = DAY_RE.exec(joined);
|
|
344
|
-
|
|
382
|
+
// A day row is identified by its `id="day_YYYY-MM-DD"` cell; the label
|
|
383
|
+
// text is only used as a fallback so that a markup change in the label
|
|
384
|
+
// cannot silently re-attach a day's bookings to the previous day.
|
|
385
|
+
const dayId =
|
|
386
|
+
/id="day_(\d{4}-\d{2}-\d{2})"/.exec(row.html) ??
|
|
387
|
+
/openMahlzeiten\s*\(\s*this\s*,\s*'(\d{4}-\d{2}-\d{2})'/.exec(row.html);
|
|
388
|
+
if (dayId || (dayMatch && cells.length <= 3)) {
|
|
345
389
|
flush();
|
|
346
|
-
const
|
|
347
|
-
/id="day_(\d{4}-\d{2}-\d{2})"/.exec(row.html) ??
|
|
348
|
-
/openMahlzeiten\s*\(\s*this\s*,\s*'(\d{4}-\d{2}-\d{2})'/.exec(row.html);
|
|
349
|
-
const label = joined.split(/\s{2,}/)[0] ?? joined;
|
|
390
|
+
const label = dayRowLabel(cells[0] ?? "", joined) || joined;
|
|
350
391
|
current = {
|
|
351
392
|
label,
|
|
352
|
-
date:
|
|
393
|
+
date: dayId?.[1] ?? resolveDayDate(label, kwDate),
|
|
353
394
|
bookings: [],
|
|
354
395
|
classes: row.classes,
|
|
355
396
|
};
|
|
356
|
-
continue
|
|
397
|
+
// deliberately no `continue`: this row may carry the day's first booking
|
|
357
398
|
}
|
|
358
399
|
|
|
359
400
|
if (!current) continue;
|
|
@@ -368,22 +409,26 @@ export function parseWeek(tableHtml: string, kwDate: string): ZepWeek {
|
|
|
368
409
|
}
|
|
369
410
|
|
|
370
411
|
// --- booking row ------------------------------------------------------
|
|
371
|
-
|
|
372
|
-
|
|
412
|
+
// Anchor on the `von` cell: cell 1 in a standalone booking row, cell 3
|
|
413
|
+
// behind the label in a day row that carries its first booking.
|
|
414
|
+
const from = flat.findIndex((c, index) => index > 0 && /^\d{2}:\d{2}$/.test(c));
|
|
415
|
+
if (from === -1) continue;
|
|
373
416
|
|
|
374
|
-
const objectId =
|
|
417
|
+
const objectId =
|
|
418
|
+
/objectId=?(\d+)/.exec(row.html)?.[1] ?? null;
|
|
375
419
|
current.bookings.push({
|
|
376
420
|
objectId,
|
|
377
421
|
dayLabel: current.label,
|
|
378
422
|
date: current.date,
|
|
379
|
-
from: flat[
|
|
380
|
-
to: flat[
|
|
381
|
-
duration: flat[
|
|
382
|
-
project: flat[
|
|
383
|
-
vorgang: flat[
|
|
384
|
-
taetigkeit: flat[
|
|
385
|
-
billable: /payments/i.test(flat[
|
|
386
|
-
comment: flat[
|
|
423
|
+
from: flat[from] ?? "",
|
|
424
|
+
to: flat[from + 1] ?? "",
|
|
425
|
+
duration: flat[from + 2] ?? "",
|
|
426
|
+
project: flat[from + 4] ?? "",
|
|
427
|
+
vorgang: flat[from + 5] ?? "",
|
|
428
|
+
taetigkeit: flat[from + 6] ?? "",
|
|
429
|
+
billable: /payments/i.test(flat[from + 7] ?? "") || /payments/i.test(row.html),
|
|
430
|
+
comment: flat[from + 8] ?? "",
|
|
431
|
+
ort: bookingOrt(flat, from),
|
|
387
432
|
});
|
|
388
433
|
}
|
|
389
434
|
|
package/src/config.ts
CHANGED
|
@@ -10,14 +10,20 @@
|
|
|
10
10
|
* on every call.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
16
|
|
|
17
17
|
import type { ZepAccount, ZepConfigFile, ZepSession } from "./types.ts";
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
/** Resolved on every call, so honouring $HOME works and tests can relocate it. */
|
|
20
|
+
function configFile(): string {
|
|
21
|
+
return join(homedir(), ".pi", "zep-config.json");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sessionFile(): string {
|
|
25
|
+
return join(homedir(), ".pi", "zep-session.json");
|
|
26
|
+
}
|
|
21
27
|
|
|
22
28
|
export const DEFAULT_BASE_URL = "https://zep-online.de";
|
|
23
29
|
export const DEFAULT_MANDANT = "zepneoimpulse";
|
|
@@ -28,6 +34,19 @@ let config: { profiles: Record<string, ZepAccount>; activeProfile: string | null
|
|
|
28
34
|
};
|
|
29
35
|
let sessions: Record<string, ZepSession> = {};
|
|
30
36
|
|
|
37
|
+
// mtimes of the last read, so refreshConfig() can tell whether the files
|
|
38
|
+
// changed on disk. -1 means "never read".
|
|
39
|
+
let configMtime = -1;
|
|
40
|
+
let sessionMtime = -1;
|
|
41
|
+
|
|
42
|
+
function mtimeOf(path: string): number {
|
|
43
|
+
try {
|
|
44
|
+
return statSync(path).mtimeMs;
|
|
45
|
+
} catch {
|
|
46
|
+
return -1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
function writePrivate(path: string, data: unknown): void {
|
|
32
51
|
const dir = dirname(path);
|
|
33
52
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -49,12 +68,31 @@ function readJson<T>(path: string, fallback: T): T {
|
|
|
49
68
|
}
|
|
50
69
|
|
|
51
70
|
export function loadConfig(): void {
|
|
52
|
-
const loaded = readJson<ZepConfigFile>(
|
|
71
|
+
const loaded = readJson<ZepConfigFile>(configFile(), { profiles: {}, activeProfile: null });
|
|
53
72
|
config = {
|
|
54
73
|
profiles: loaded?.profiles ?? {},
|
|
55
74
|
activeProfile: loaded?.activeProfile ?? null,
|
|
56
75
|
};
|
|
57
|
-
sessions = readJson<Record<string, ZepSession>>(
|
|
76
|
+
sessions = readJson<Record<string, ZepSession>>(sessionFile(), {});
|
|
77
|
+
configMtime = mtimeOf(configFile());
|
|
78
|
+
sessionMtime = mtimeOf(sessionFile());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Re-read the files when they changed on disk.
|
|
83
|
+
*
|
|
84
|
+
* pi loads an extension once per session, so without this a config file that
|
|
85
|
+
* the user wrote by hand (the documented alternative to zep_setup) is only
|
|
86
|
+
* picked up after a restart - the tools would keep reporting
|
|
87
|
+
* "No ZEP profile configured" while a perfectly valid file sits on disk.
|
|
88
|
+
*
|
|
89
|
+
* Cheap: two stat() calls in the common case where nothing changed.
|
|
90
|
+
*/
|
|
91
|
+
export function refreshConfig(): void {
|
|
92
|
+
const nextConfig = mtimeOf(configFile());
|
|
93
|
+
const nextSession = mtimeOf(sessionFile());
|
|
94
|
+
if (nextConfig === configMtime && nextSession === sessionMtime) return;
|
|
95
|
+
loadConfig();
|
|
58
96
|
}
|
|
59
97
|
|
|
60
98
|
export function getConfig(): ZepConfigFile {
|
|
@@ -102,14 +140,14 @@ export function getProfile(name?: string): ZepAccount {
|
|
|
102
140
|
export function saveProfile(name: string, account: ZepAccount): void {
|
|
103
141
|
config.profiles[name] = account;
|
|
104
142
|
if (!config.activeProfile) config.activeProfile = name;
|
|
105
|
-
writePrivate(
|
|
143
|
+
writePrivate(configFile(), config);
|
|
106
144
|
}
|
|
107
145
|
|
|
108
146
|
export function updateProfile(name: string, patch: Partial<ZepAccount>): void {
|
|
109
147
|
const existing = config.profiles[name];
|
|
110
148
|
if (!existing) return;
|
|
111
149
|
config.profiles[name] = { ...existing, ...patch };
|
|
112
|
-
writePrivate(
|
|
150
|
+
writePrivate(configFile(), config);
|
|
113
151
|
}
|
|
114
152
|
|
|
115
153
|
export function useProfile(name: string): void {
|
|
@@ -117,7 +155,7 @@ export function useProfile(name: string): void {
|
|
|
117
155
|
throw new Error(`ZEP profile "${name}" not found.`);
|
|
118
156
|
}
|
|
119
157
|
config.activeProfile = name;
|
|
120
|
-
writePrivate(
|
|
158
|
+
writePrivate(configFile(), config);
|
|
121
159
|
}
|
|
122
160
|
|
|
123
161
|
export function deleteProfile(name: string): void {
|
|
@@ -126,16 +164,16 @@ export function deleteProfile(name: string): void {
|
|
|
126
164
|
if (config.activeProfile === name) {
|
|
127
165
|
config.activeProfile = listProfiles()[0] ?? null;
|
|
128
166
|
}
|
|
129
|
-
writePrivate(
|
|
130
|
-
writePrivate(
|
|
167
|
+
writePrivate(configFile(), config);
|
|
168
|
+
writePrivate(sessionFile(), sessions);
|
|
131
169
|
}
|
|
132
170
|
|
|
133
171
|
export function configPath(): string {
|
|
134
|
-
return
|
|
172
|
+
return configFile();
|
|
135
173
|
}
|
|
136
174
|
|
|
137
175
|
export function sessionPath(): string {
|
|
138
|
-
return
|
|
176
|
+
return sessionFile();
|
|
139
177
|
}
|
|
140
178
|
|
|
141
179
|
// ------------------------------------------------------------------- sessions
|
|
@@ -146,10 +184,10 @@ export function getSession(profileName: string): ZepSession | undefined {
|
|
|
146
184
|
|
|
147
185
|
export function saveSession(profileName: string, session: ZepSession): void {
|
|
148
186
|
sessions[profileName] = session;
|
|
149
|
-
writePrivate(
|
|
187
|
+
writePrivate(sessionFile(), sessions);
|
|
150
188
|
}
|
|
151
189
|
|
|
152
190
|
export function clearSession(profileName: string): void {
|
|
153
191
|
delete sessions[profileName];
|
|
154
|
-
writePrivate(
|
|
192
|
+
writePrivate(sessionFile(), sessions);
|
|
155
193
|
}
|
|
@@ -65,7 +65,8 @@ export function formatWeek(week: ZepWeek): string {
|
|
|
65
65
|
lines.push(
|
|
66
66
|
` #${booking.objectId ?? "?"} ${booking.from}-${booking.to} (${booking.duration}) ` +
|
|
67
67
|
`${booking.project} / ${booking.vorgang} / ${booking.taetigkeit}` +
|
|
68
|
-
`${booking.billable ? " [billable]" : ""}
|
|
68
|
+
`${booking.billable ? " [billable]" : ""}` +
|
|
69
|
+
`${booking.ort ? ` @ ${booking.ort}` : ""}`,
|
|
69
70
|
);
|
|
70
71
|
if (booking.comment) lines.push(` ${booking.comment}`);
|
|
71
72
|
}
|
package/src/session.ts
CHANGED
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
* after 5 failed password attempts.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
clearSession,
|
|
11
|
+
getActiveProfileName,
|
|
12
|
+
getProfile,
|
|
13
|
+
getSession,
|
|
14
|
+
refreshConfig,
|
|
15
|
+
saveSession,
|
|
16
|
+
} from "./config.ts";
|
|
10
17
|
import { ZepClient, ZepError } from "./clients/zep-client.ts";
|
|
11
18
|
|
|
12
19
|
export interface ClientHandle {
|
|
@@ -17,6 +24,7 @@ export interface ClientHandle {
|
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
export async function createClient(profileName?: string): Promise<ClientHandle> {
|
|
27
|
+
refreshConfig();
|
|
20
28
|
const name = profileName ?? getActiveProfileName();
|
|
21
29
|
if (!name) {
|
|
22
30
|
throw new ZepError("No ZEP profile configured. Call zep_setup first.");
|
|
@@ -62,6 +70,7 @@ export async function createClient(profileName?: string): Promise<ClientHandle>
|
|
|
62
70
|
* Used by zep_login force=true.
|
|
63
71
|
*/
|
|
64
72
|
export async function loginProfile(profileName: string): Promise<ZepClient> {
|
|
73
|
+
refreshConfig();
|
|
65
74
|
const account = getProfile(profileName);
|
|
66
75
|
clearSession(profileName);
|
|
67
76
|
|
package/src/tools/zep-book.ts
CHANGED
|
@@ -64,13 +64,13 @@ export const ZepBookTool = {
|
|
|
64
64
|
bemerkung: Type.Optional(
|
|
65
65
|
Type.String({
|
|
66
66
|
description:
|
|
67
|
-
"Comment. Convention: include the ticket/story numbers (e.g. '
|
|
67
|
+
"Comment. Convention: include the ticket/story numbers (e.g. '4711 Ladeanzeige, 4712 Fach-HU-Nummer').",
|
|
68
68
|
}),
|
|
69
69
|
),
|
|
70
70
|
projekt: Type.Optional(
|
|
71
71
|
Type.String({
|
|
72
72
|
description:
|
|
73
|
-
"Project id (e.g. '413' for
|
|
73
|
+
"Project id (e.g. '413' for Contoso) or part of its label (e.g. 'Contoso'). Falls back to the project ZEP has selected for this user.",
|
|
74
74
|
}),
|
|
75
75
|
),
|
|
76
76
|
vorgang: Type.Optional(
|
|
@@ -85,7 +85,10 @@ export const ZepBookTool = {
|
|
|
85
85
|
}),
|
|
86
86
|
),
|
|
87
87
|
ort: Type.Optional(
|
|
88
|
-
Type.String({
|
|
88
|
+
Type.String({
|
|
89
|
+
description:
|
|
90
|
+
"Location code, e.g. 'D-Office' (office), 'D' (inland away) or 'D-3M'. The default 'NULL' is the first place of work (Erste Tätigkeitsstätte).",
|
|
91
|
+
}),
|
|
89
92
|
),
|
|
90
93
|
entries: Type.Optional(
|
|
91
94
|
Type.Array(
|
|
@@ -228,6 +231,10 @@ export const ZepBookTool = {
|
|
|
228
231
|
const comment = entry.bemerkung ?? params.bemerkung ?? "";
|
|
229
232
|
|
|
230
233
|
const vorgangLabel = scope.vorgaenge.find((v) => v.id === vorgangId)?.label;
|
|
234
|
+
// Always state the place of work that goes onto the wire: 'NULL' is
|
|
235
|
+
// the default (Erste Tätigkeitsstätte) and is exactly what a booking
|
|
236
|
+
// without an explicit ort gets, so it must not be invisible here.
|
|
237
|
+
const ortLabel = form.orte.find((o) => o.id === ort)?.label;
|
|
231
238
|
|
|
232
239
|
planned.push({
|
|
233
240
|
request: {
|
|
@@ -245,7 +252,8 @@ export const ZepBookTool = {
|
|
|
245
252
|
`${date} ${from}-${to} (${duration})\n` +
|
|
246
253
|
` project ${projectId}${scope.project ? ` "${scope.project.label}"` : ""}\n` +
|
|
247
254
|
` Vorgang ${vorgangId}${vorgangLabel ? ` "${vorgangLabel}"` : ""}\n` +
|
|
248
|
-
` Tätigkeit ${taetigkeit}
|
|
255
|
+
` Tätigkeit ${taetigkeit}, Ort ${ort}` +
|
|
256
|
+
`${ortLabel && ortLabel !== ort ? ` (${ortLabel})` : ""}` +
|
|
249
257
|
`${comment ? `\n "${comment}"` : ""}`,
|
|
250
258
|
});
|
|
251
259
|
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zep_doctor -- diagnose the ZEP setup without spending a login attempt.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: every login attempt costs one of only five tries before
|
|
5
|
+
* ZEP locks the account, and a rejected login says only "Benutzername oder
|
|
6
|
+
* Kennwort falsch" - it cannot distinguish a wrong userid from a wrong
|
|
7
|
+
* password, nor from a config file that carries a trailing space. This tool
|
|
8
|
+
* checks everything that can be checked for free, and performs the real
|
|
9
|
+
* credential check only when explicitly asked for.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, statSync } from "node:fs";
|
|
13
|
+
import { Type } from "typebox";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
configPath,
|
|
17
|
+
getActiveProfileName,
|
|
18
|
+
getProfile,
|
|
19
|
+
getSession,
|
|
20
|
+
listProfiles,
|
|
21
|
+
refreshConfig,
|
|
22
|
+
sessionPath,
|
|
23
|
+
} from "../config.ts";
|
|
24
|
+
import { ZepClient } from "../clients/zep-client.ts";
|
|
25
|
+
import { errorResult, textResult, type ToolResult } from "./shared.ts";
|
|
26
|
+
|
|
27
|
+
interface Check {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly status: "ok" | "warn" | "fail" | "skip";
|
|
30
|
+
readonly detail: string;
|
|
31
|
+
/** What to do about it, when it is not "ok". */
|
|
32
|
+
readonly hint?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ICON = { ok: "OK ", warn: "WARN", fail: "FAIL", skip: "SKIP" } as const;
|
|
36
|
+
|
|
37
|
+
function render(checks: ReadonlyArray<Check>): string {
|
|
38
|
+
return checks
|
|
39
|
+
.map((c) => {
|
|
40
|
+
const head = `${ICON[c.status]} ${c.name}: ${c.detail}`;
|
|
41
|
+
return c.hint ? `${head}\n -> ${c.hint}` : head;
|
|
42
|
+
})
|
|
43
|
+
.join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const ZepDoctorTool = {
|
|
47
|
+
name: "zep_doctor",
|
|
48
|
+
label: "ZEP Doctor",
|
|
49
|
+
description:
|
|
50
|
+
"Check the ZEP configuration and connectivity before booking: config file, permissions, profile fields, password hygiene (whitespace is the usual cause of 'Benutzername oder Kennwort falsch'), cached session, and reachability of the server. Uses no login attempt unless checkCredentials is set, and reports how many password tries are left.",
|
|
51
|
+
parameters: Type.Object({
|
|
52
|
+
profile: Type.Optional(
|
|
53
|
+
Type.String({ description: "ZEP profile to check. Defaults to the active profile." }),
|
|
54
|
+
),
|
|
55
|
+
checkCredentials: Type.Optional(
|
|
56
|
+
Type.Boolean({
|
|
57
|
+
description:
|
|
58
|
+
"Also perform ONE real login attempt to verify userid and password. This consumes one of the 5 tries before ZEP locks the account, so it is off by default.",
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
}),
|
|
62
|
+
|
|
63
|
+
async execute(
|
|
64
|
+
_toolCallId: string,
|
|
65
|
+
params: { profile?: string; checkCredentials?: boolean },
|
|
66
|
+
): Promise<ToolResult> {
|
|
67
|
+
// Re-read from disk: the doctor is exactly the tool you run right after
|
|
68
|
+
// editing the config by hand.
|
|
69
|
+
refreshConfig();
|
|
70
|
+
const checks: Check[] = [];
|
|
71
|
+
|
|
72
|
+
// ------------------------------------------------------------ config file
|
|
73
|
+
const path = configPath();
|
|
74
|
+
if (!existsSync(path)) {
|
|
75
|
+
checks.push({
|
|
76
|
+
name: "config file",
|
|
77
|
+
status: "fail",
|
|
78
|
+
detail: `${path} does not exist`,
|
|
79
|
+
hint: "Run zep_setup, or write the file by hand (profiles.<name>.userid/password, activeProfile).",
|
|
80
|
+
});
|
|
81
|
+
return errorResult(render(checks), { ok: false });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const mode = statSync(path).mode & 0o777;
|
|
85
|
+
checks.push({
|
|
86
|
+
name: "config file",
|
|
87
|
+
status: mode === 0o600 ? "ok" : "warn",
|
|
88
|
+
detail: `${path} (mode ${mode.toString(8)})`,
|
|
89
|
+
...(mode === 0o600
|
|
90
|
+
? {}
|
|
91
|
+
: { hint: `chmod 600 ${path} - the file holds a password in clear text.` }),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const profiles = listProfiles();
|
|
95
|
+
const name = params.profile ?? getActiveProfileName();
|
|
96
|
+
if (!name) {
|
|
97
|
+
checks.push({
|
|
98
|
+
name: "active profile",
|
|
99
|
+
status: "fail",
|
|
100
|
+
detail: `none set (known: ${profiles.join(", ") || "none"})`,
|
|
101
|
+
hint: 'Set "activeProfile" in the config file, or run zep_profile action=use.',
|
|
102
|
+
});
|
|
103
|
+
return errorResult(render(checks), { ok: false });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let account;
|
|
107
|
+
try {
|
|
108
|
+
account = getProfile(name);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
checks.push({
|
|
111
|
+
name: `profile "${name}"`,
|
|
112
|
+
status: "fail",
|
|
113
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
114
|
+
hint: `Known profiles: ${profiles.join(", ") || "none"}`,
|
|
115
|
+
});
|
|
116
|
+
return errorResult(render(checks), { ok: false });
|
|
117
|
+
}
|
|
118
|
+
checks.push({ name: "profile", status: "ok", detail: `"${name}" (active)` });
|
|
119
|
+
|
|
120
|
+
// --------------------------------------------------------------- fields
|
|
121
|
+
const missing = (["baseUrl", "mandant", "userid", "password"] as const).filter(
|
|
122
|
+
(field) => !account[field],
|
|
123
|
+
);
|
|
124
|
+
checks.push(
|
|
125
|
+
missing.length === 0
|
|
126
|
+
? { name: "profile fields", status: "ok", detail: "baseUrl, mandant, userid, password" }
|
|
127
|
+
: {
|
|
128
|
+
name: "profile fields",
|
|
129
|
+
status: "fail",
|
|
130
|
+
detail: `missing or empty: ${missing.join(", ")}`,
|
|
131
|
+
hint: 'Add the field to the profile in the config file, or re-run zep_setup.',
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
checks.push({
|
|
136
|
+
name: "target",
|
|
137
|
+
status: "ok",
|
|
138
|
+
detail: `${account.baseUrl}/${account.mandant} as ${account.userid}`,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ------------------------------------------------- password hygiene
|
|
142
|
+
const raw = account.password ?? "";
|
|
143
|
+
const hygiene = passwordShapeIssues(raw);
|
|
144
|
+
|
|
145
|
+
checks.push(
|
|
146
|
+
hygiene.length === 0
|
|
147
|
+
? { name: "password shape", status: "ok", detail: `${raw.length} characters, no whitespace` }
|
|
148
|
+
: {
|
|
149
|
+
name: "password shape",
|
|
150
|
+
status: "fail",
|
|
151
|
+
detail: hygiene.join("; "),
|
|
152
|
+
hint: "Fix the stored password. ZEP reports all of these as 'Benutzername oder Kennwort falsch'.",
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// --------------------------------------------------------- session cache
|
|
157
|
+
const cached = getSession(name);
|
|
158
|
+
let liveSession: ZepClient | null = null;
|
|
159
|
+
|
|
160
|
+
if (!cached) {
|
|
161
|
+
checks.push({
|
|
162
|
+
name: "cached session",
|
|
163
|
+
status: "skip",
|
|
164
|
+
detail: `${sessionPath()} has no entry for "${name}"`,
|
|
165
|
+
hint: "A login is needed once; after that the session is reused.",
|
|
166
|
+
});
|
|
167
|
+
} else {
|
|
168
|
+
const client = new ZepClient(account, {});
|
|
169
|
+
client.applySession(cached);
|
|
170
|
+
const alive = await client.probe();
|
|
171
|
+
checks.push({
|
|
172
|
+
name: "cached session",
|
|
173
|
+
status: alive ? "ok" : "warn",
|
|
174
|
+
detail: `CLIENTSESSID ${cached.clientsessid} saved ${cached.savedAt} - ${
|
|
175
|
+
alive ? "still accepted" : "no longer accepted"
|
|
176
|
+
}`,
|
|
177
|
+
...(alive ? {} : { hint: "It expired; the next call performs a fresh login." }),
|
|
178
|
+
});
|
|
179
|
+
if (alive) liveSession = client;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ------------------------------------------------------------ reachability
|
|
183
|
+
// A dedicated client: ping() primes a fresh PHPSESSID, which must not
|
|
184
|
+
// overwrite the one belonging to a live session.
|
|
185
|
+
try {
|
|
186
|
+
const ping = await new ZepClient(account, {}).ping();
|
|
187
|
+
checks.push({
|
|
188
|
+
name: "reachability",
|
|
189
|
+
status: ping.loginPageOk || ping.redirectedToApp ? "ok" : "warn",
|
|
190
|
+
detail: `GET /view/login.php -> HTTP ${ping.status}, login form ${
|
|
191
|
+
ping.loginPageOk ? "present" : "absent"
|
|
192
|
+
}${ping.redirectedToApp ? ", redirects into the app (already authenticated)" : ""}`,
|
|
193
|
+
...(ping.loginPageOk || ping.redirectedToApp
|
|
194
|
+
? {}
|
|
195
|
+
: { hint: "Unexpected response; a proxy or maintenance page may be in the way." }),
|
|
196
|
+
});
|
|
197
|
+
} catch (error) {
|
|
198
|
+
checks.push({
|
|
199
|
+
name: "reachability",
|
|
200
|
+
status: "fail",
|
|
201
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
202
|
+
hint: "Check network/proxy. Override baseUrl per profile if a mirror is used.",
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --------------------------------------------------------- live session
|
|
207
|
+
if (liveSession) {
|
|
208
|
+
try {
|
|
209
|
+
const { form } = await liveSession.loadForm();
|
|
210
|
+
checks.push({
|
|
211
|
+
name: "booking form",
|
|
212
|
+
status: "ok",
|
|
213
|
+
detail: `${form.projects.length} projects, ${form.vorgaenge.length} Vorgänge for the selected project`,
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
checks.push({
|
|
217
|
+
name: "booking form",
|
|
218
|
+
status: "fail",
|
|
219
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ------------------------------------------------------ credential check
|
|
225
|
+
if (params.checkCredentials) {
|
|
226
|
+
const client = new ZepClient(account, {});
|
|
227
|
+
try {
|
|
228
|
+
const result = await client.login();
|
|
229
|
+
checks.push({
|
|
230
|
+
name: "credential check",
|
|
231
|
+
status: result.ok ? "ok" : "fail",
|
|
232
|
+
detail: result.ok
|
|
233
|
+
? `login accepted (CLIENTSESSID ${result.clientsessid})`
|
|
234
|
+
: `${result.message ?? "rejected"}${
|
|
235
|
+
result.attemptsLeft !== undefined ? ` - ${result.attemptsLeft} tries left` : ""
|
|
236
|
+
}`,
|
|
237
|
+
...(result.ok
|
|
238
|
+
? {}
|
|
239
|
+
: {
|
|
240
|
+
hint:
|
|
241
|
+
"ZEP cannot tell 'wrong userid' from 'wrong password'. Verify both; do NOT retry blindly - the account locks after 5.",
|
|
242
|
+
}),
|
|
243
|
+
});
|
|
244
|
+
} catch (error) {
|
|
245
|
+
checks.push({
|
|
246
|
+
name: "credential check",
|
|
247
|
+
status: "fail",
|
|
248
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
checks.push({
|
|
253
|
+
name: "credential check",
|
|
254
|
+
status: "skip",
|
|
255
|
+
detail: "not attempted (costs one of 5 tries before lockout)",
|
|
256
|
+
hint: "Re-run with checkCredentials=true only when the stored userid/password are confirmed.",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const text = render(checks);
|
|
261
|
+
const failed = checks.some((c) => c.status === "fail");
|
|
262
|
+
const warnOrFail = failed || checks.some((c) => c.status === "warn");
|
|
263
|
+
|
|
264
|
+
const verdict = failed
|
|
265
|
+
? "\n\nVerdict: something must be fixed before booking will work."
|
|
266
|
+
: warnOrFail
|
|
267
|
+
? "\n\nVerdict: usable, with warnings above."
|
|
268
|
+
: "\n\nVerdict: everything checks out.";
|
|
269
|
+
|
|
270
|
+
return warnOrFail
|
|
271
|
+
? errorResult(text + verdict, { ok: !failed, checks })
|
|
272
|
+
: textResult(text + verdict, { ok: true, checks });
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Problems with a stored password that ZEP would all report as
|
|
278
|
+
* "Benutzername oder Kennwort falsch" - i.e. silently. Pure, so it is testable
|
|
279
|
+
* without a config file.
|
|
280
|
+
*/
|
|
281
|
+
export function passwordShapeIssues(raw: string): string[] {
|
|
282
|
+
const issues: string[] = [];
|
|
283
|
+
if (raw !== raw.trim()) issues.push("leading/trailing whitespace (copy-paste!)");
|
|
284
|
+
if (/\s/.test(raw.trim())) issues.push("contains an inner whitespace character");
|
|
285
|
+
if (raw === "undefined" || raw === "null") issues.push(`literally the string "${raw}"`);
|
|
286
|
+
if (raw.length < 6) issues.push(`suspiciously short (${raw.length} chars)`);
|
|
287
|
+
// `!` starts history expansion in an interactive bash. A password written
|
|
288
|
+
// through a shell command can silently lose everything from the `!` on -
|
|
289
|
+
// and ZEP then only says "wrong password".
|
|
290
|
+
if (raw.includes("!")) {
|
|
291
|
+
issues.push(
|
|
292
|
+
`contains "!" (${raw.length} chars) - in an interactive shell "!" starts history expansion, ` +
|
|
293
|
+
"so a password written via a command line may be truncated at that position",
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return issues;
|
|
297
|
+
}
|
package/src/tools/zep-profile.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getActiveProfileName,
|
|
11
11
|
listProfiles,
|
|
12
12
|
useProfile,
|
|
13
|
+
refreshConfig,
|
|
13
14
|
} from "../config.ts";
|
|
14
15
|
import { errorResult, textResult, type ToolResult } from "./shared.ts";
|
|
15
16
|
|
|
@@ -31,6 +32,7 @@ export const ZepProfileTool = {
|
|
|
31
32
|
_toolCallId: string,
|
|
32
33
|
params: { action?: string; name?: string },
|
|
33
34
|
): Promise<ToolResult> {
|
|
35
|
+
refreshConfig();
|
|
34
36
|
const action = params.action ?? "list";
|
|
35
37
|
try {
|
|
36
38
|
if (action === "list") {
|
package/src/tools/zep-status.ts
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
configPath,
|
|
9
|
+
getActiveProfileName,
|
|
10
|
+
listProfiles,
|
|
11
|
+
refreshConfig,
|
|
12
|
+
sessionPath,
|
|
13
|
+
} from "../config.ts";
|
|
8
14
|
import { ZepClient } from "../clients/zep-client.ts";
|
|
9
15
|
import { createClient, ZepError } from "../session.ts";
|
|
10
16
|
import { formatFormData } from "../formatting/formatters.ts";
|
|
@@ -35,6 +41,7 @@ export const ZepStatusTool = {
|
|
|
35
41
|
_toolCallId: string,
|
|
36
42
|
params: { profile?: string; probe?: boolean; relogin?: boolean },
|
|
37
43
|
): Promise<ToolResult> {
|
|
44
|
+
refreshConfig();
|
|
38
45
|
const lines: string[] = [];
|
|
39
46
|
const profiles = listProfiles();
|
|
40
47
|
|
package/src/types.ts
CHANGED
|
@@ -94,6 +94,13 @@ export interface ZepBooking {
|
|
|
94
94
|
readonly taetigkeit: string;
|
|
95
95
|
readonly billable: boolean;
|
|
96
96
|
readonly comment: string;
|
|
97
|
+
/**
|
|
98
|
+
* Place of work as the table renders it, e.g. `D-Office`.
|
|
99
|
+
*
|
|
100
|
+
* ZEP only draws this cell when there is something to say, so `null` means
|
|
101
|
+
* "no cell rendered" and therefore the default place of work (`NULL`).
|
|
102
|
+
*/
|
|
103
|
+
readonly ort: string | null;
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
export interface ZepWeekDay {
|