@patimweb/pi-zep 1.0.0

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.
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Parsers for ZEP's HTML and AJAX-JSON responses.
3
+ *
4
+ * ZEP is a classic server-rendered PHP app: everything the client needs
5
+ * arrives either as HTML fragments or as a small JSON envelope that carries
6
+ * `{type:"schnipsel", target, data:"<html>"}` entries plus a fresh
7
+ * `requesttoken`. There is no DOM in Node, so these are deliberately
8
+ * tolerant regex parsers rather than a full HTML parser.
9
+ */
10
+
11
+ import type { ZepBooking, ZepOption, ZepWeek, ZepWeekDay } from "../types.ts";
12
+
13
+ /** Decode the handful of entities ZEP actually uses in labels. */
14
+ export function decodeEntities(input: string): string {
15
+ return input
16
+ .replace(/&nbsp;/g, " ")
17
+ .replace(/&amp;/g, "&")
18
+ .replace(/&lt;/g, "<")
19
+ .replace(/&gt;/g, ">")
20
+ .replace(/&quot;/g, '"')
21
+ .replace(/&#0?39;/g, "'")
22
+ .replace(/&auml;/g, "ä")
23
+ .replace(/&ouml;/g, "ö")
24
+ .replace(/&uuml;/g, "ü")
25
+ .replace(/&Auml;/g, "Ä")
26
+ .replace(/&Ouml;/g, "Ö")
27
+ .replace(/&Uuml;/g, "Ü")
28
+ .replace(/&szlig;/g, "ß")
29
+ .replace(/&euro;/g, "€")
30
+ .replace(/&#(\d+);/g, (_m, code: string) => String.fromCodePoint(Number(code)))
31
+ .replace(/&#x([0-9a-f]+);/gi, (_m, code: string) => String.fromCodePoint(parseInt(code, 16)));
32
+ }
33
+
34
+ function stripTags(html: string): string {
35
+ return decodeEntities(
36
+ html
37
+ .replace(/<br\s*\/?>/gi, " ")
38
+ .replace(/<[^>]*>/g, " ")
39
+ .replace(/\s+/g, " "),
40
+ ).trim();
41
+ }
42
+
43
+ /** The per-page CSRF-ish token. ZEP rotates it with every response. */
44
+ export function extractRequestToken(html: string): string | null {
45
+ const m = /var\s+requesttoken\s*=\s*'([^']+)'/.exec(html);
46
+ return m?.[1] ?? null;
47
+ }
48
+
49
+ /** `CLIENTSESSID` is the URL-carried half of the ZEP session. */
50
+ export function extractClientSessionId(text: string): string | null {
51
+ const m = /CLIENTSESSID=([A-Za-z0-9]+)/.exec(text);
52
+ return m?.[1] ?? null;
53
+ }
54
+
55
+ /**
56
+ * ZEP's generic server-side error envelope. `msg=555001`, `555003` and
57
+ * `555009` all mean "your session/token is not valid any more" and the
58
+ * server logs the session out as a side effect.
59
+ */
60
+ export function detectSessionLoss(body: string): string | null {
61
+ // ZEP emits non-ASCII as \uXXXX in its JSON, so match on the ASCII prefix
62
+ // rather than on "ungültig" itself.
63
+ const noToken = /Session ist ung/i.test(body);
64
+ const logoutForward = /forwardTo\('login\.php[^']*action=logout/i.test(body);
65
+ if (noToken || logoutForward) {
66
+ const msg = /msg=(\d+)/.exec(body)?.[1];
67
+ return msg ? `session invalid (msg=${msg})` : "session invalid";
68
+ }
69
+ return null;
70
+ }
71
+
72
+ function escapeRegExp(value: string): string {
73
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
74
+ }
75
+
76
+ /**
77
+ * Read the `<option>` list of a `<select id="...">`.
78
+ *
79
+ * Works for both the full page and the `formrefresh` HTML fragment.
80
+ */
81
+ export function parseSelect(html: string, selectId: string): ZepOption[] {
82
+ const open = new RegExp(
83
+ `<select[^>]*\\bid\\s*=\\s*["']${escapeRegExp(selectId)}["'][^>]*>`,
84
+ "i",
85
+ ).exec(html);
86
+ if (!open) return [];
87
+
88
+ const rest = html.slice(open.index + open[0].length);
89
+ const closeIdx = rest.toLowerCase().indexOf("</select>");
90
+ const body = closeIdx === -1 ? rest : rest.slice(0, closeIdx);
91
+
92
+ const options: ZepOption[] = [];
93
+ const optionRe = /<option\b([^>]*)>([\s\S]*?)<\/option>/gi;
94
+ let m: RegExpExecArray | null;
95
+ while ((m = optionRe.exec(body)) !== null) {
96
+ const attrs = m[1] ?? "";
97
+ const rawLabel = m[2] ?? "";
98
+ const valueMatch = /\bvalue\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrs);
99
+ const value = valueMatch?.[1] ?? valueMatch?.[2] ?? valueMatch?.[3] ?? "";
100
+ options.push({
101
+ id: decodeEntities(value),
102
+ label: stripTags(rawLabel),
103
+ selected: /\bselected\b/i.test(attrs),
104
+ disabled: /\bdisabled\b/i.test(attrs),
105
+ });
106
+ }
107
+ return options;
108
+ }
109
+
110
+ /** Currently selected value of a `<select id="...">`. */
111
+ export function selectedValue(options: ReadonlyArray<ZepOption>): string | null {
112
+ return options.find((o) => o.selected)?.id ?? null;
113
+ }
114
+
115
+ /**
116
+ * The AJAX JSON envelope. Every string value that looks like markup is a
117
+ * DOM snippet destined for a target element.
118
+ */
119
+ export interface ZepAjaxSnippet {
120
+ readonly target: string | null;
121
+ readonly data: string;
122
+ }
123
+
124
+ export interface ZepAjaxEnvelope {
125
+ readonly requesttoken: string | null;
126
+ readonly error: string | null;
127
+ readonly javascript: string | null;
128
+ readonly snippets: ReadonlyArray<ZepAjaxSnippet>;
129
+ readonly raw: unknown;
130
+ }
131
+
132
+ export function parseAjaxEnvelope(body: string): ZepAjaxEnvelope {
133
+ let parsed: unknown;
134
+ try {
135
+ parsed = JSON.parse(body);
136
+ } catch {
137
+ throw new Error(`ZEP returned a non-JSON response: ${body.slice(0, 300)}`);
138
+ }
139
+
140
+ const root = parsed as Record<string, unknown>;
141
+ const snippets: ZepAjaxSnippet[] = [];
142
+ for (const value of Object.values(root)) {
143
+ if (!value || typeof value !== "object") continue;
144
+ const entry = value as Record<string, unknown>;
145
+ if (typeof entry.data !== "string") continue;
146
+ if (!entry.data.includes("<")) continue;
147
+ snippets.push({
148
+ target: typeof entry.target === "string" ? entry.target : null,
149
+ data: entry.data,
150
+ });
151
+ }
152
+
153
+ return {
154
+ requesttoken: typeof root.requesttoken === "string" ? root.requesttoken : null,
155
+ error: typeof root.error === "string" ? root.error : null,
156
+ javascript: typeof root.javascript === "string" ? root.javascript : null,
157
+ snippets,
158
+ raw: parsed,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Pull the human readable message out of the `zepalert(...)` calls ZEP
164
+ * embeds in its JSON responses.
165
+ */
166
+ export function extractAlertMessage(javascript: string | null): string | null {
167
+ if (!javascript) return null;
168
+ const m = /zepalert\(\s*(['"])([\s\S]*?)\1/.exec(javascript);
169
+ if (!m) return null;
170
+ const text = decodeEntities(
171
+ (m[2] ?? "")
172
+ .replace(/<br\s*\/?>/gi, " ")
173
+ .replace(/<[^>]*>/g, " ")
174
+ .replace(/\\\//g, "/")
175
+ .replace(/\\'/g, "'")
176
+ .replace(/\s+/g, " "),
177
+ ).trim();
178
+ return text || null;
179
+ }
180
+
181
+ // ------------------------------------------------------------- week overview
182
+
183
+ export interface ZepAlert {
184
+ /** bootstrap alert kind: success, danger, warning or info */
185
+ readonly kind: string;
186
+ readonly message: string;
187
+ }
188
+
189
+ /**
190
+ * Read the message ZEP renders into a form message slot.
191
+ *
192
+ * This is where real rejections arrive. A failed booking answers with
193
+ * `error: "error"` and NO `javascript` at all - the German text (plan hours
194
+ * exceeded, Tätigkeit not allowed for the Vorgang, …) lives inside a
195
+ * `schnipsel` whose data is
196
+ * `<div class="card-alert alert alert-danger">…<div class="alert-message">…</div>`
197
+ * so a client that only looks at `javascript` reports a useless
198
+ * "unspecified error".
199
+ */
200
+ export function extractSnippetAlert(
201
+ snippets: ReadonlyArray<ZepAjaxSnippet>,
202
+ ): ZepAlert | null {
203
+ for (const snippet of snippets) {
204
+ const idx = snippet.data.search(/alert-message/i);
205
+ if (idx === -1) continue;
206
+
207
+ const prefix = snippet.data.slice(0, idx);
208
+ const kind = /alert-(success|danger|warning|info)/i.exec(prefix)?.[1]?.toLowerCase() ?? "info";
209
+
210
+ const body = snippet.data.slice(idx);
211
+ const close = body.search(/<\/div>/i);
212
+ const raw = close === -1 ? body : body.slice(0, close);
213
+ const message = decodeEntities(
214
+ raw
215
+ .replace(/^[^>]*>/, "")
216
+ .replace(/<br\s*\/?>/gi, " ")
217
+ .replace(/<[^>]*>/g, " ")
218
+ .replace(/\s+/g, " "),
219
+ ).trim();
220
+
221
+ if (message) return { kind, message };
222
+ }
223
+ return null;
224
+ }
225
+
226
+ /**
227
+ * Pick the week-table snippet out of an ajax envelope.
228
+ *
229
+ * The setKw response carries no `target` at all, so the table has to be
230
+ * recognised by its content. The save response, by contrast, ships a
231
+ * calendar snippet targeting `managerdiv_ProjektzeitMgr` - which must not be
232
+ * mistaken for the week table.
233
+ */
234
+ export function pickWeekTableSnippet(
235
+ snippets: ReadonlyArray<ZepAjaxSnippet>,
236
+ ): ZepAjaxSnippet | null {
237
+ const isWeekTable = (data: string) =>
238
+ /pz_kwnav/.test(data) || /id="objectId\d+"/.test(data) || /class="tag_\d/.test(data);
239
+
240
+ return (
241
+ snippets.find((s) => /ProjektzeitTable/i.test(s.target ?? "") && isWeekTable(s.data)) ??
242
+ snippets.find((s) => isWeekTable(s.data)) ??
243
+ [...snippets].sort((a, b) => b.data.length - a.data.length)[0] ??
244
+ null
245
+ );
246
+ }
247
+
248
+ interface RawRow {
249
+ readonly html: string;
250
+ readonly classes: string;
251
+ }
252
+
253
+ function splitRows(tableHtml: string): RawRow[] {
254
+ const rows: RawRow[] = [];
255
+ const rowRe = /<tr\b([^>]*)>([\s\S]*?)<\/tr>/gi;
256
+ let m: RegExpExecArray | null;
257
+ while ((m = rowRe.exec(tableHtml)) !== null) {
258
+ rows.push({ classes: m[1] ?? "", html: m[2] ?? "" });
259
+ }
260
+ return rows;
261
+ }
262
+
263
+ function splitCells(rowHtml: string): string[] {
264
+ const cells: string[] = [];
265
+ const cellRe = /<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]>/gi;
266
+ let m: RegExpExecArray | null;
267
+ while ((m = cellRe.exec(rowHtml)) !== null) cells.push(m[1] ?? "");
268
+ return cells;
269
+ }
270
+
271
+ const DAY_RE = /^\s*(Mo|Di|Mi|Do|Fr|Sa|So)\s+(\d{2}\.\d{2}\.)/;
272
+
273
+ const WEEKDAY_INDEX: Record<string, number> = { Mo: 0, Di: 1, Mi: 2, Do: 3, Fr: 4, Sa: 5, So: 6 };
274
+
275
+ /**
276
+ * Work out the ISO date of a day row from its `Do 17.09.` label when the
277
+ * markup does not spell the date out (ZEP embeds it in the meals-icon
278
+ * onclick, which can change between versions).
279
+ */
280
+ function resolveDayDate(dayLabel: string, kwDate: string): string | null {
281
+ const m = DAY_RE.exec(dayLabel);
282
+ if (!m) return null;
283
+ const offset = WEEKDAY_INDEX[m[1]!];
284
+ if (offset === undefined) return null;
285
+
286
+ const [dd, mm] = (m[2] ?? "").replace(/\.$/, "").split(".");
287
+ if (!dd || !mm) return null;
288
+
289
+ const monday = new Date(`${kwDate}T00:00:00Z`);
290
+ if (Number.isNaN(monday.getTime())) return null;
291
+ monday.setUTCDate(monday.getUTCDate() + offset);
292
+
293
+ // Handle a week that straddles the turn of the year.
294
+ if (monday.getUTCMonth() + 1 !== Number(mm)) {
295
+ monday.setUTCFullYear(monday.getUTCFullYear() + (Number(mm) === 1 ? 1 : -1));
296
+ }
297
+ return monday.toISOString().slice(0, 10);
298
+ }
299
+
300
+ /**
301
+ * Parse the week table rendered into `#ProjektzeitTableDiv`.
302
+ *
303
+ * Row shapes observed on the live system:
304
+ * day row `["Do 17.09. restaurant", ...]` (icons = meals/breaks)
305
+ * booking row `["editdelete", von, bis, Dauer, "", Projekt, Vorgang, Taet, "payments", Bemerkung]`
306
+ * sum row `["Summe", "8,00", "payments 8,00"]`
307
+ */
308
+ export function parseWeek(tableHtml: string, kwDate: string): ZepWeek {
309
+ const days: ZepWeekDay[] = [];
310
+ let current: {
311
+ label: string;
312
+ date: string | null;
313
+ bookings: ZepBooking[];
314
+ classes: string;
315
+ } | null = null;
316
+ let currentTotal: string | null = null;
317
+ let currentBillable: string | null = null;
318
+
319
+ const flush = (): void => {
320
+ if (!current) return;
321
+ days.push({
322
+ dayLabel: current.label,
323
+ date: current.date,
324
+ bookings: current.bookings,
325
+ total: currentTotal,
326
+ billableTotal: currentBillable,
327
+ empty: current.bookings.length === 0 && !currentTotal,
328
+ underBooked: /pzminuszeit/.test(current.classes),
329
+ future: /zukunft/.test(current.classes),
330
+ today: /\btoday\b/.test(current.classes),
331
+ });
332
+ current = null;
333
+ currentTotal = null;
334
+ currentBillable = null;
335
+ };
336
+
337
+ for (const row of splitRows(tableHtml)) {
338
+ const cells = splitCells(row.html);
339
+ const flat = cells.map((c) => stripTags(c));
340
+
341
+ // --- day header -------------------------------------------------------
342
+ const joined = flat.join(" ").trim();
343
+ const dayMatch = DAY_RE.exec(joined);
344
+ if (dayMatch && cells.length <= 3) {
345
+ flush();
346
+ const iso =
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;
350
+ current = {
351
+ label,
352
+ date: iso?.[1] ?? resolveDayDate(label, kwDate),
353
+ bookings: [],
354
+ classes: row.classes,
355
+ };
356
+ continue;
357
+ }
358
+
359
+ if (!current) continue;
360
+
361
+ // --- sum row ----------------------------------------------------------
362
+ if (/^Summe\b/i.test(joined)) {
363
+ const numbers = flat.filter((c) => /^\d+[.,]\d+$/.test(c));
364
+ currentTotal = numbers[0] ?? null;
365
+ const pay = flat.find((c) => /payments/i.test(c));
366
+ currentBillable = pay ? (pay.match(/(\d+[.,]\d+)/)?.[1] ?? null) : null;
367
+ continue;
368
+ }
369
+
370
+ // --- booking row ------------------------------------------------------
371
+ const from = /^\d{2}:\d{2}$/.test(flat[1] ?? "");
372
+ if (!from) continue;
373
+
374
+ const objectId = /objectId=(\d+)/.exec(row.html)?.[1] ?? null;
375
+ current.bookings.push({
376
+ objectId,
377
+ dayLabel: current.label,
378
+ date: current.date,
379
+ from: flat[1] ?? "",
380
+ to: flat[2] ?? "",
381
+ duration: flat[3] ?? "",
382
+ project: flat[5] ?? "",
383
+ vorgang: flat[6] ?? "",
384
+ taetigkeit: flat[7] ?? "",
385
+ billable: /payments/i.test(flat[8] ?? "") || /payments/i.test(row.html),
386
+ comment: flat[9] ?? "",
387
+ });
388
+ }
389
+
390
+ flush();
391
+ return { kwDate, days };
392
+ }
393
+
394
+ /** Monday of the ISO week containing `date`. */
395
+ export function mondayOf(date: Date): string {
396
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
397
+ const day = d.getUTCDay(); // 0 = Sunday
398
+ const diff = day === 0 ? -6 : 1 - day;
399
+ d.setUTCDate(d.getUTCDate() + diff);
400
+ return d.toISOString().slice(0, 10);
401
+ }
402
+
403
+ /** `HH:MM` -> minutes since midnight. Returns null when unparseable. */
404
+ export function timeToMinutes(time: string): number | null {
405
+ const m = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
406
+ if (!m) return null;
407
+ const hours = Number(m[1]);
408
+ const minutes = Number(m[2]);
409
+ if (hours > 23 || minutes > 59) return null;
410
+ return hours * 60 + minutes;
411
+ }
412
+
413
+ /** Duration between two `HH:MM` values as `HH:MM`, or null when invalid. */
414
+ export function durationBetween(from: string, to: string): string | null {
415
+ const a = timeToMinutes(from);
416
+ const b = timeToMinutes(to);
417
+ if (a === null || b === null) return null;
418
+ const diff = b - a;
419
+ if (diff <= 0) return null;
420
+ const h = Math.floor(diff / 60);
421
+ const min = diff % 60;
422
+ return `${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
423
+ }
package/src/config.ts ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Configuration and session persistence.
3
+ *
4
+ * Two files, both owner-readable only:
5
+ * ~/.pi/zep-config.json - accounts (userid + password)
6
+ * ~/.pi/zep-session.json - cached session per profile (CLIENTSESSID + cookies)
7
+ *
8
+ * The session cache matters: ZEP locks an account after 5 failed password
9
+ * attempts, so re-using a live session is much safer than logging in again
10
+ * on every call.
11
+ */
12
+
13
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { dirname, join } from "node:path";
16
+
17
+ import type { ZepAccount, ZepConfigFile, ZepSession } from "./types.ts";
18
+
19
+ const CONFIG_PATH = join(homedir(), ".pi", "zep-config.json");
20
+ const SESSION_PATH = join(homedir(), ".pi", "zep-session.json");
21
+
22
+ export const DEFAULT_BASE_URL = "https://zep-online.de";
23
+ export const DEFAULT_MANDANT = "zepneoimpulse";
24
+
25
+ let config: { profiles: Record<string, ZepAccount>; activeProfile: string | null } = {
26
+ profiles: {},
27
+ activeProfile: null,
28
+ };
29
+ let sessions: Record<string, ZepSession> = {};
30
+
31
+ function writePrivate(path: string, data: unknown): void {
32
+ const dir = dirname(path);
33
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
34
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
35
+ try {
36
+ chmodSync(path, 0o600);
37
+ } catch {
38
+ // best effort - Windows and some filesystems do not support chmod
39
+ }
40
+ }
41
+
42
+ function readJson<T>(path: string, fallback: T): T {
43
+ try {
44
+ if (!existsSync(path)) return fallback;
45
+ return JSON.parse(readFileSync(path, "utf8")) as T;
46
+ } catch {
47
+ return fallback;
48
+ }
49
+ }
50
+
51
+ export function loadConfig(): void {
52
+ const loaded = readJson<ZepConfigFile>(CONFIG_PATH, { profiles: {}, activeProfile: null });
53
+ config = {
54
+ profiles: loaded?.profiles ?? {},
55
+ activeProfile: loaded?.activeProfile ?? null,
56
+ };
57
+ sessions = readJson<Record<string, ZepSession>>(SESSION_PATH, {});
58
+ }
59
+
60
+ export function getConfig(): ZepConfigFile {
61
+ return config;
62
+ }
63
+
64
+ export function listProfiles(): string[] {
65
+ return Object.keys(config.profiles);
66
+ }
67
+
68
+ export function getActiveProfileName(): string | null {
69
+ return config.activeProfile;
70
+ }
71
+
72
+ export function getProfile(name?: string): ZepAccount {
73
+ const key = name ?? config.activeProfile;
74
+ if (!key) {
75
+ throw new Error(
76
+ "No ZEP profile configured. Call zep_setup first (userid, password, mandant).",
77
+ );
78
+ }
79
+ const profile = config.profiles[key];
80
+ if (!profile) {
81
+ const known = listProfiles();
82
+ throw new Error(
83
+ known.length
84
+ ? `ZEP profile "${key}" not found. Known profiles: ${known.join(", ")}`
85
+ : "No ZEP profile configured. Call zep_setup first.",
86
+ );
87
+ }
88
+
89
+ // A hand-written ~/.pi/zep-config.json may leave out the derived fields, so
90
+ // fill them in rather than crashing on `undefined.replace`.
91
+ const baseUrl = (profile.baseUrl ?? DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL;
92
+ const mandant = (profile.mandant ?? DEFAULT_MANDANT).trim() || DEFAULT_MANDANT;
93
+
94
+ return {
95
+ ...profile,
96
+ name: profile.name ?? key,
97
+ baseUrl: baseUrl.replace(/\/+$/, ""),
98
+ mandant: mandant.replace(/^\/+|\/+$/g, ""),
99
+ };
100
+ }
101
+
102
+ export function saveProfile(name: string, account: ZepAccount): void {
103
+ config.profiles[name] = account;
104
+ if (!config.activeProfile) config.activeProfile = name;
105
+ writePrivate(CONFIG_PATH, config);
106
+ }
107
+
108
+ export function updateProfile(name: string, patch: Partial<ZepAccount>): void {
109
+ const existing = config.profiles[name];
110
+ if (!existing) return;
111
+ config.profiles[name] = { ...existing, ...patch };
112
+ writePrivate(CONFIG_PATH, config);
113
+ }
114
+
115
+ export function useProfile(name: string): void {
116
+ if (!config.profiles[name]) {
117
+ throw new Error(`ZEP profile "${name}" not found.`);
118
+ }
119
+ config.activeProfile = name;
120
+ writePrivate(CONFIG_PATH, config);
121
+ }
122
+
123
+ export function deleteProfile(name: string): void {
124
+ delete config.profiles[name];
125
+ delete sessions[name];
126
+ if (config.activeProfile === name) {
127
+ config.activeProfile = listProfiles()[0] ?? null;
128
+ }
129
+ writePrivate(CONFIG_PATH, config);
130
+ writePrivate(SESSION_PATH, sessions);
131
+ }
132
+
133
+ export function configPath(): string {
134
+ return CONFIG_PATH;
135
+ }
136
+
137
+ export function sessionPath(): string {
138
+ return SESSION_PATH;
139
+ }
140
+
141
+ // ------------------------------------------------------------------- sessions
142
+
143
+ export function getSession(profileName: string): ZepSession | undefined {
144
+ return sessions[profileName];
145
+ }
146
+
147
+ export function saveSession(profileName: string, session: ZepSession): void {
148
+ sessions[profileName] = session;
149
+ writePrivate(SESSION_PATH, sessions);
150
+ }
151
+
152
+ export function clearSession(profileName: string): void {
153
+ delete sessions[profileName];
154
+ writePrivate(SESSION_PATH, sessions);
155
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Pure functions that turn ZEP domain data into text for the model/user.
3
+ */
4
+
5
+ import type { ZepFormData, ZepSaveResult, ZepWeek } from "../types.ts";
6
+
7
+ function pad(value: string, width: number): string {
8
+ return value.length >= width ? value : value + " ".repeat(width - value.length);
9
+ }
10
+
11
+ export function formatFormData(form: ZepFormData, heading = "Projekte"): string {
12
+ const lines: string[] = [];
13
+ lines.push(`${heading} (${form.projects.length}):`);
14
+ for (const project of form.projects) {
15
+ const marker = project.id === form.selected.projektId ? "*" : " ";
16
+ lines.push(` ${marker} ${project.id.padStart(4)} ${project.label}`);
17
+ }
18
+
19
+ lines.push("");
20
+ lines.push(
21
+ form.selected.projektId
22
+ ? `Vorgänge of project ${form.selected.projektId} (${form.vorgaenge.length}):`
23
+ : `Vorgänge (${form.vorgaenge.length}):`,
24
+ );
25
+ for (const vorgang of form.vorgaenge) {
26
+ const marker = vorgang.id === form.selected.vorgangId ? "*" : " ";
27
+ lines.push(` ${marker} ${vorgang.id.padStart(5)} ${vorgang.label}`);
28
+ }
29
+
30
+ lines.push("");
31
+ lines.push(`Tätigkeiten: ${form.taetigkeiten.map((t) => `${t.id}=${t.label}`).join(", ") || "(none)"}`);
32
+ lines.push(`Orte: ${form.orte.map((o) => `${o.id}=${o.label}`).join(", ") || "(none)"}`);
33
+ lines.push("");
34
+ lines.push(
35
+ `Current selection: projekt=${form.selected.projektId ?? "-"} vorgang=${form.selected.vorgangId ?? "-"} taetigkeit=${form.selected.taetigkeit ?? "-"}`,
36
+ );
37
+ return lines.join("\n");
38
+ }
39
+
40
+ export function formatWeek(week: ZepWeek): string {
41
+ const lines: string[] = [];
42
+ lines.push(`Projektzeiten week of ${week.kwDate}:`);
43
+ lines.push("");
44
+
45
+ if (week.days.length === 0) {
46
+ lines.push("(no rows returned - the session may have expired)");
47
+ return lines.join("\n");
48
+ }
49
+
50
+ for (const day of week.days) {
51
+ const flags = [
52
+ day.today ? "today" : "",
53
+ day.future ? "future" : "",
54
+ day.underBooked ? "UNDER-BOOKED" : "",
55
+ ]
56
+ .filter(Boolean)
57
+ .join(", ");
58
+
59
+ lines.push(`${day.dayLabel}${day.date ? ` (${day.date})` : ""}${flags ? ` [${flags}]` : ""}`);
60
+
61
+ if (day.bookings.length === 0) {
62
+ lines.push(" (no bookings)");
63
+ }
64
+ for (const booking of day.bookings) {
65
+ lines.push(
66
+ ` #${booking.objectId ?? "?"} ${booking.from}-${booking.to} (${booking.duration}) ` +
67
+ `${booking.project} / ${booking.vorgang} / ${booking.taetigkeit}` +
68
+ `${booking.billable ? " [billable]" : ""}`,
69
+ );
70
+ if (booking.comment) lines.push(` ${booking.comment}`);
71
+ }
72
+ if (day.total) {
73
+ lines.push(` Summe: ${day.total}${day.billableTotal ? ` (payments ${day.billableTotal})` : ""}`);
74
+ }
75
+ lines.push("");
76
+ }
77
+ return lines.join("\n").trimEnd();
78
+ }
79
+
80
+ export function formatSaveResult(result: ZepSaveResult, what = "Booking"): string {
81
+ if (result.ok) return `${what} OK: ${result.message ?? "saved"}`;
82
+ return `${what} REJECTED: ${result.error ?? "unknown error"}`;
83
+ }
84
+
85
+ export function formatMoneyTable(headers: ReadonlyArray<string>, rows: ReadonlyArray<ReadonlyArray<string>>): string {
86
+ const widths = headers.map((h, i) =>
87
+ Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)),
88
+ );
89
+ const line = (cells: ReadonlyArray<string>) =>
90
+ cells.map((c, i) => pad(c ?? "", widths[i] ?? 0)).join(" ").trimEnd();
91
+ return [line(headers), widths.map((w) => "-".repeat(w)).join(" "), ...rows.map(line)].join("\n");
92
+ }