@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,78 @@
1
+ /**
2
+ * zep_projects -- discover bookable projects, Vorgänge and Tätigkeiten.
3
+ */
4
+
5
+ import { Type } from "typebox";
6
+
7
+ import { createClient } from "../session.ts";
8
+ import { formatFormData } from "../formatting/formatters.ts";
9
+ import { errorResult, textResult, type ToolResult } from "./shared.ts";
10
+
11
+ export const ZepProjectsTool = {
12
+ name: "zep_projects",
13
+ label: "ZEP Projects",
14
+ description:
15
+ "List the ZEP projects available for time booking, plus the Vorgänge (work packages) and Tätigkeiten of the selected project. Use it to resolve ids before calling zep_book. Pass 'projekt' to make ZEP re-render the form for a specific project so its Vorgänge can be read.",
16
+ parameters: Type.Object({
17
+ profile: Type.Optional(
18
+ Type.String({ description: "ZEP profile to use. Defaults to the active profile." }),
19
+ ),
20
+ projekt: Type.Optional(
21
+ Type.String({
22
+ description:
23
+ "Project id (e.g. '413') or part of its label. ZEP re-renders the booking form for this project so its Vorgänge become visible.",
24
+ }),
25
+ ),
26
+ }),
27
+
28
+ async execute(
29
+ _toolCallId: string,
30
+ params: { profile?: string; projekt?: string },
31
+ ): Promise<ToolResult> {
32
+ try {
33
+ const { client, profileName } = await createClient(params.profile);
34
+ const { form } = await client.loadForm();
35
+
36
+ if (!params.projekt) {
37
+ return textResult(formatFormData(form), {
38
+ profile: profileName,
39
+ projects: form.projects,
40
+ selected: form.selected,
41
+ });
42
+ }
43
+
44
+ const resolved = resolveProject(form, params.projekt);
45
+ if (!resolved) return errorResult(`No project matches "${params.projekt}".`);
46
+
47
+ const refreshed = await client.refreshFormForProject(resolved.id);
48
+ const merged = {
49
+ ...form,
50
+ vorgaenge: refreshed.vorgaenge.length ? refreshed.vorgaenge : form.vorgaenge,
51
+ taetigkeiten: refreshed.taetigkeiten.length ? refreshed.taetigkeiten : form.taetigkeiten,
52
+ selected: { ...form.selected, projektId: resolved.id },
53
+ };
54
+
55
+ return textResult(`${formatFormData(merged)}\n\n(resolved "${params.projekt}" -> ${resolved.id} ${resolved.label})`, {
56
+ profile: profileName,
57
+ project: resolved,
58
+ vorgaenge: merged.vorgaenge,
59
+ });
60
+ } catch (error) {
61
+ return errorResult(error instanceof Error ? error.message : String(error));
62
+ }
63
+ },
64
+ };
65
+
66
+ export function resolveProject(
67
+ form: { projects: ReadonlyArray<{ id: string; label: string }> },
68
+ reference: string,
69
+ ): { id: string; label: string } | null {
70
+ const needle = reference.trim().toLowerCase();
71
+ const byId = form.projects.find((p) => p.id === reference.trim());
72
+ if (byId) return byId;
73
+ const exact = form.projects.find((p) => p.label.toLowerCase() === needle);
74
+ if (exact) return exact;
75
+ const partial = form.projects.filter((p) => p.label.toLowerCase().includes(needle));
76
+ if (partial.length >= 1) return partial[0]!;
77
+ return null;
78
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * zep_setup -- store the ZEP account (userid + password + Mandant).
3
+ */
4
+
5
+ import { Type } from "typebox";
6
+
7
+ import { DEFAULT_BASE_URL, DEFAULT_MANDANT, getProfile, saveProfile, useProfile } from "../config.ts";
8
+ import type { SetupParams, ZepAccount } from "../types.ts";
9
+ import { errorResult, textResult, type ToolResult } from "./shared.ts";
10
+
11
+ export const ZepSetupTool = {
12
+ name: "zep_setup",
13
+ label: "ZEP Setup",
14
+ description:
15
+ "Configure a ZEP (zep-online.de) account for time tracking: userid, password and Mandant. Call this before any other zep_* tool. Credentials are stored in ~/.pi/zep-config.json (file mode 0600).",
16
+ parameters: Type.Object({
17
+ name: Type.String({
18
+ description: "Profile name, e.g. 'work'. Use a short, memorable name.",
19
+ }),
20
+ userid: Type.String({
21
+ description: "ZEP user id as used in the ZEP login form, e.g. 'patrick.weppelmann'.",
22
+ }),
23
+ password: Type.String({
24
+ description:
25
+ "ZEP password. WARNING: ZEP locks the account after 5 failed login attempts, so make sure this is correct.",
26
+ }),
27
+ mandant: Type.Optional(
28
+ Type.String({
29
+ description: `Mandant / tenant path segment in the ZEP URL, e.g. '${DEFAULT_MANDANT}'. Defaults to '${DEFAULT_MANDANT}'.`,
30
+ }),
31
+ ),
32
+ baseUrl: Type.Optional(
33
+ Type.String({
34
+ description: `ZEP origin, defaults to '${DEFAULT_BASE_URL}'.`,
35
+ }),
36
+ ),
37
+ }),
38
+
39
+ async execute(_toolCallId: string, params: SetupParams): Promise<ToolResult> {
40
+ try {
41
+ const existing = (() => {
42
+ try {
43
+ return getProfile(params.name);
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ })();
48
+
49
+ const account: ZepAccount = {
50
+ name: params.name,
51
+ baseUrl: (params.baseUrl ?? existing?.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""),
52
+ mandant: (params.mandant ?? existing?.mandant ?? DEFAULT_MANDANT).replace(/^\/+|\/+$/g, ""),
53
+ userid: params.userid,
54
+ password: params.password,
55
+ ...(existing?.lastProjektId ? { lastProjektId: existing.lastProjektId } : {}),
56
+ ...(existing?.lastVorgangId ? { lastVorgangId: existing.lastVorgangId } : {}),
57
+ ...(existing?.lastTaetigkeit ? { lastTaetigkeit: existing.lastTaetigkeit } : {}),
58
+ };
59
+
60
+ saveProfile(params.name, account);
61
+ useProfile(params.name);
62
+
63
+ return textResult(
64
+ `ZEP profile "${params.name}" saved and set as active.\n` +
65
+ ` URL: ${account.baseUrl}/${account.mandant}\n` +
66
+ ` User: ${account.userid}\n` +
67
+ `\nNext: run zep_status to verify the login, then zep_week / zep_book.`,
68
+ { profile: params.name, mandant: account.mandant },
69
+ );
70
+ } catch (error) {
71
+ return errorResult(error instanceof Error ? error.message : String(error));
72
+ }
73
+ },
74
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * zep_status -- report configuration, session state and a successful login.
3
+ */
4
+
5
+ import { Type } from "typebox";
6
+
7
+ import { configPath, getActiveProfileName, listProfiles, sessionPath } from "../config.ts";
8
+ import { ZepClient } from "../clients/zep-client.ts";
9
+ import { createClient, ZepError } from "../session.ts";
10
+ import { formatFormData } from "../formatting/formatters.ts";
11
+ import { errorResult, textResult, type ToolResult } from "./shared.ts";
12
+
13
+ export const ZepStatusTool = {
14
+ name: "zep_status",
15
+ label: "ZEP Status",
16
+ description:
17
+ "Show the configured ZEP profiles and verify the live session by logging in and reading the booking form. Use the 'probe' flag to also confirm that the Projektzeiten page is reachable.",
18
+ parameters: Type.Object({
19
+ profile: Type.Optional(
20
+ Type.String({ description: "ZEP profile to use. Defaults to the active profile." }),
21
+ ),
22
+ probe: Type.Optional(
23
+ Type.Boolean({
24
+ description: "Also load the booking form (project list) to prove the session works.",
25
+ }),
26
+ ),
27
+ relogin: Type.Optional(
28
+ Type.Boolean({
29
+ description: "Discard any cached session and log in from scratch.",
30
+ }),
31
+ ),
32
+ }),
33
+
34
+ async execute(
35
+ _toolCallId: string,
36
+ params: { profile?: string; probe?: boolean; relogin?: boolean },
37
+ ): Promise<ToolResult> {
38
+ const lines: string[] = [];
39
+ const profiles = listProfiles();
40
+
41
+ lines.push(`Config file: ${configPath()}`);
42
+ lines.push(`Session file: ${sessionPath()}`);
43
+ lines.push(`Profiles: ${profiles.length ? profiles.join(", ") : "(none)"}`);
44
+ lines.push(`Active: ${getActiveProfileName() ?? "(none)"}`);
45
+ lines.push("");
46
+
47
+ if (profiles.length === 0) {
48
+ lines.push("No ZEP profile configured yet. Call zep_setup with userid, password and mandant.");
49
+ return textResult(lines.join("\n"), { profiles: 0 });
50
+ }
51
+
52
+ try {
53
+ if (params.relogin) {
54
+ const { getActiveProfileName: active, getProfile, saveSession } = await import("../config.ts");
55
+ const name = params.profile ?? active()!;
56
+ const account = getProfile(name);
57
+ const client = new ZepClient(account, { onSession: (s) => saveSession(name, s) });
58
+ const result = await client.login();
59
+ if (!result.ok) {
60
+ lines.push(`Login FAILED: ${result.message}`);
61
+ if (result.attemptsLeft !== undefined) {
62
+ lines.push(`Attempts left before lockout: ${result.attemptsLeft}`);
63
+ }
64
+ return errorResult(lines.join("\n"));
65
+ }
66
+ lines.push(`Login OK (fresh). CLIENTSESSID=${result.clientsessid}`);
67
+ lines.push(`Request token: ${client.token ?? "(none)"}`);
68
+ if (params.probe) {
69
+ const { form } = await client.loadForm();
70
+ lines.push("");
71
+ lines.push(formatFormData(form, "Projekte"));
72
+ }
73
+ return textResult(lines.join("\n"), { ok: true, freshLogin: true });
74
+ }
75
+
76
+ const handle = await createClient(params.profile);
77
+ lines.push(`Session for "${handle.profileName}": OK (${handle.sessionSource})`);
78
+ lines.push(`CLIENTSESSID: ${handle.client.sessionId}`);
79
+ lines.push(`Request token: ${handle.client.token ?? "(none)"}`);
80
+
81
+ if (params.probe) {
82
+ const { form } = await handle.client.loadForm();
83
+ lines.push("");
84
+ lines.push(formatFormData(form, "Projekte"));
85
+ } else {
86
+ const alive = await handle.client.probe();
87
+ lines.push(`Week read: ${alive ? "OK" : "FAILED"}`);
88
+ }
89
+
90
+ return textResult(lines.join("\n"), {
91
+ ok: true,
92
+ sessionSource: handle.sessionSource,
93
+ });
94
+ } catch (error) {
95
+ const message = error instanceof Error ? error.message : String(error);
96
+ if (error instanceof ZepError && error.attemptsLeft !== undefined) {
97
+ lines.push(`Attempts left before lockout: ${error.attemptsLeft}`);
98
+ }
99
+ lines.push(message);
100
+ return errorResult(lines.join("\n"));
101
+ }
102
+ },
103
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * zep_week -- read the Projektzeiten week overview.
3
+ */
4
+
5
+ import { Type } from "typebox";
6
+
7
+ import { createClient } from "../session.ts";
8
+ import { formatWeek } from "../formatting/formatters.ts";
9
+ import { errorResult, textResult, toIsoDate, type ToolResult } from "./shared.ts";
10
+
11
+ export const ZepWeekTool = {
12
+ name: "zep_week",
13
+ label: "ZEP Week",
14
+ description:
15
+ "Show the ZEP Projektzeiten week overview: per day the bookings with id, times, duration, project, Vorgang, Tätigkeit and comment, plus the daily sum and whether the day is under-booked. Read-only.",
16
+ parameters: Type.Object({
17
+ profile: Type.Optional(
18
+ Type.String({ description: "ZEP profile to use. Defaults to the active profile." }),
19
+ ),
20
+ date: Type.Optional(
21
+ Type.String({
22
+ description:
23
+ "Any date inside the wanted ISO week (YYYY-MM-DD or DD.MM.YYYY). Defaults to the current week.",
24
+ }),
25
+ ),
26
+ }),
27
+
28
+ async execute(
29
+ _toolCallId: string,
30
+ params: { profile?: string; date?: string },
31
+ ): Promise<ToolResult> {
32
+ try {
33
+ const iso = toIsoDate(params.date);
34
+ const { client, profileName } = await createClient(params.profile);
35
+ const week = await client.week(iso);
36
+ return textResult(formatWeek(week), {
37
+ profile: profileName,
38
+ kwDate: week.kwDate,
39
+ days: week.days.length,
40
+ });
41
+ } catch (error) {
42
+ return errorResult(error instanceof Error ? error.message : String(error));
43
+ }
44
+ },
45
+ };
package/src/types.ts ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Data types for the pi ZEP extension.
3
+ *
4
+ * Everything here is a plain immutable-shaped interface: no behaviour,
5
+ * no classes. I/O lives in clients/, formatting in formatting/.
6
+ */
7
+
8
+ // ---------------------------------------------------------------- configuration
9
+
10
+ /**
11
+ * One ZEP account/mandant ("Mandant" = the tenant path segment, e.g. `zepneoimpulse`).
12
+ */
13
+ export interface ZepAccount {
14
+ readonly name: string;
15
+ /** Origin of the ZEP installation, e.g. `https://zep-online.de`. */
16
+ readonly baseUrl: string;
17
+ /** Mandant / client path segment, e.g. `zepneoimpulse`. */
18
+ readonly mandant: string;
19
+ /** ZEP user id as used on the login form, e.g. `patrick.weppelmann`. */
20
+ readonly userid: string;
21
+ readonly password: string;
22
+ /** Last used booking selection, so repeat bookings need fewer parameters. */
23
+ readonly lastProjektId?: string;
24
+ readonly lastVorgangId?: string;
25
+ readonly lastTaetigkeit?: string;
26
+ }
27
+
28
+ export interface ZepConfigFile {
29
+ readonly profiles: Record<string, ZepAccount>;
30
+ readonly activeProfile: string | null;
31
+ }
32
+
33
+ /**
34
+ * Cached server-side session. ZEP binds a session to `CLIENTSESSID`
35
+ * (URL parameter) plus a `PHPSESSID` cookie - both are needed.
36
+ */
37
+ export interface ZepSession {
38
+ readonly clientsessid: string;
39
+ /** CSRF-ish token; rotates on every server response. */
40
+ readonly requesttoken: string | null;
41
+ readonly cookies: Record<string, string>;
42
+ readonly savedAt: string;
43
+ }
44
+
45
+ /** Result of a login attempt. */
46
+ export interface LoginResult {
47
+ readonly ok: boolean;
48
+ readonly clientsessid?: string;
49
+ readonly message?: string;
50
+ /** Remaining password attempts before ZEP locks the account. */
51
+ readonly attemptsLeft?: number;
52
+ }
53
+
54
+ // ---------------------------------------------------------------- domain
55
+
56
+ export interface ZepOption {
57
+ readonly id: string;
58
+ readonly label: string;
59
+ readonly selected?: boolean;
60
+ readonly disabled?: boolean;
61
+ }
62
+
63
+ export interface ZepFormData {
64
+ /** Project options from the booking form (#projektId). */
65
+ readonly projects: ReadonlyArray<ZepOption>;
66
+ /** Vorgang (sub-project / work package) options for the currently selected project. */
67
+ readonly vorgaenge: ReadonlyArray<ZepOption>;
68
+ /** Activity options (#taetigkeit). */
69
+ readonly taetigkeiten: ReadonlyArray<ZepOption>;
70
+ /** Location options (#ort). */
71
+ readonly orte: ReadonlyArray<ZepOption>;
72
+ /** Currently selected ids. */
73
+ readonly selected: {
74
+ readonly projektId: string | null;
75
+ readonly vorgangId: string | null;
76
+ readonly taetigkeit: string | null;
77
+ readonly ort: string | null;
78
+ };
79
+ }
80
+
81
+ /** One row of the week overview table. */
82
+ export interface ZepBooking {
83
+ /** Server-side id, needed to edit/delete the row. */
84
+ readonly objectId: string | null;
85
+ /** e.g. `Do 17.09.` */
86
+ readonly dayLabel: string;
87
+ /** ISO date when known, e.g. `2026-09-17`. */
88
+ readonly date: string | null;
89
+ readonly from: string;
90
+ readonly to: string;
91
+ readonly duration: string;
92
+ readonly project: string;
93
+ readonly vorgang: string;
94
+ readonly taetigkeit: string;
95
+ readonly billable: boolean;
96
+ readonly comment: string;
97
+ }
98
+
99
+ export interface ZepWeekDay {
100
+ readonly dayLabel: string;
101
+ readonly date: string | null;
102
+ readonly bookings: ReadonlyArray<ZepBooking>;
103
+ /** Sum line: total duration, e.g. `8,00`. */
104
+ readonly total: string | null;
105
+ /** `payments x,00` marker when the day is billable. */
106
+ readonly billableTotal: string | null;
107
+ /** True when the day has no booking at all and no target hours. */
108
+ readonly empty: boolean;
109
+ /** True when the day is under-booked (`pzminuszeit`). */
110
+ readonly underBooked: boolean;
111
+ /** True when the day lies in the future. */
112
+ readonly future: boolean;
113
+ /** True for today. */
114
+ readonly today: boolean;
115
+ }
116
+
117
+ export interface ZepWeek {
118
+ /** Monday of the shown week, ISO date. */
119
+ readonly kwDate: string;
120
+ readonly days: ReadonlyArray<ZepWeekDay>;
121
+ }
122
+
123
+ /** A booking request to send to ZEP. */
124
+ export interface ZepBookingRequest {
125
+ readonly date: string;
126
+ readonly from: string;
127
+ readonly to: string;
128
+ readonly duration: string;
129
+ readonly projektId: string;
130
+ readonly vorgangId: string;
131
+ readonly taetigkeit: string;
132
+ readonly comment: string;
133
+ readonly ort?: string;
134
+ }
135
+
136
+ export interface ZepSaveResult {
137
+ readonly ok: boolean;
138
+ /** Server error text, e.g. the "Plan Stunden überschritten" notice. */
139
+ readonly error?: string;
140
+ readonly message?: string;
141
+ }
142
+
143
+ // ---------------------------------------------------------------- tool params
144
+
145
+ export interface SetupParams {
146
+ name: string;
147
+ userid: string;
148
+ password: string;
149
+ mandant?: string;
150
+ baseUrl?: string;
151
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "nodenext",
5
+ "moduleResolution": "nodenext",
6
+ "lib": ["ES2023"],
7
+ "types": ["node"],
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "exactOptionalPropertyTypes": false,
11
+ "allowImportingTsExtensions": true,
12
+ "noEmit": true,
13
+ "skipLibCheck": true,
14
+ "verbatimModuleSyntax": true,
15
+ "resolveJsonModule": true
16
+ },
17
+ "include": ["index.ts", "src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts", "sentinel.config.ts"]
18
+ }