kalvium-worklog 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.
- package/README.md +124 -0
- package/dist/capture-token.d.ts +5 -0
- package/dist/capture-token.js +128 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +209 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.js +75 -0
- package/dist/daily-submit.d.ts +2 -0
- package/dist/daily-submit.js +9 -0
- package/dist/discover-position.d.ts +5 -0
- package/dist/discover-position.js +121 -0
- package/dist/generate-webapp.d.ts +4 -0
- package/dist/generate-webapp.js +61 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +7 -0
- package/dist/postinstall.d.ts +10 -0
- package/dist/postinstall.js +73 -0
- package/dist/refresh.d.ts +7 -0
- package/dist/refresh.js +42 -0
- package/dist/scheduler.d.ts +15 -0
- package/dist/scheduler.js +200 -0
- package/dist/submit.d.ts +26 -0
- package/dist/submit.js +196 -0
- package/package.json +51 -0
- package/templates/worklog_standalone.html +423 -0
- package/templates/worklog_template.html +387 -0
package/dist/submit.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { getWorklogApi, log } from "./config.js";
|
|
2
|
+
import { refreshToken } from "./refresh.js";
|
|
3
|
+
import { captureToken } from "./capture-token.js";
|
|
4
|
+
export const STATUS_OPTIONS = {
|
|
5
|
+
on_site: "Working on-site (Company location)",
|
|
6
|
+
remote: "Working Remotely (Not in the Kalvium environment)",
|
|
7
|
+
classroom: "Working out of the Kalvium environment (Classroom)",
|
|
8
|
+
holiday: "Today was a company Holiday",
|
|
9
|
+
leave: "Took an approved leave from work",
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Submit worklog with auto-relogin retry.
|
|
13
|
+
* If the refresh token has expired, automatically opens a browser
|
|
14
|
+
* for re-login and retries the submission.
|
|
15
|
+
*/
|
|
16
|
+
export async function submitWorklog(options = {}) {
|
|
17
|
+
const text = options.text ?? "working";
|
|
18
|
+
const statusKey = options.status ?? "on_site";
|
|
19
|
+
const dryRun = options.dryRun ?? false;
|
|
20
|
+
const worklogApi = getWorklogApi();
|
|
21
|
+
if (!worklogApi) {
|
|
22
|
+
return {
|
|
23
|
+
success: false,
|
|
24
|
+
message: "No config found. Run `kalvium-worklog discover` first.",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const statusLabel = STATUS_OPTIONS[statusKey] ?? STATUS_OPTIONS.on_site;
|
|
28
|
+
const body = JSON.stringify({
|
|
29
|
+
title: statusLabel,
|
|
30
|
+
description: statusLabel,
|
|
31
|
+
category: statusKey,
|
|
32
|
+
timeSpent: 0,
|
|
33
|
+
priorityLevel: "medium",
|
|
34
|
+
blockers: "",
|
|
35
|
+
worklogs: JSON.stringify({ content: `<p>${text}</p>` }),
|
|
36
|
+
});
|
|
37
|
+
if (dryRun) {
|
|
38
|
+
console.log(`[DRY RUN] Would submit to ${worklogApi}`);
|
|
39
|
+
console.log(` text='${text}', status=${statusKey}`);
|
|
40
|
+
console.log(` Body: ${body}`);
|
|
41
|
+
return { success: true, message: "Dry run" };
|
|
42
|
+
}
|
|
43
|
+
// Try up to 2 times: first with existing token, then after re-login
|
|
44
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
45
|
+
if (attempt > 0) {
|
|
46
|
+
console.log(`Retry attempt ${attempt + 1} after re-login...`);
|
|
47
|
+
}
|
|
48
|
+
// Step 1: Refresh token
|
|
49
|
+
const refreshResult = await refreshToken();
|
|
50
|
+
if (!refreshResult.success || !refreshResult.accessToken) {
|
|
51
|
+
if (attempt === 0) {
|
|
52
|
+
console.log("Token expired. Auto re-logging in via browser...");
|
|
53
|
+
const reloginOk = await captureToken();
|
|
54
|
+
if (reloginOk)
|
|
55
|
+
continue;
|
|
56
|
+
return { success: false, message: "Re-login failed" };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
success: false,
|
|
60
|
+
message: refreshResult.error ?? "Token refresh failed",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// Step 2: Submit worklog
|
|
64
|
+
console.log(`Submitting worklog: '${text}'...`);
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(worklogApi, {
|
|
67
|
+
method: "PUT",
|
|
68
|
+
headers: {
|
|
69
|
+
authorization: `Bearer ${refreshResult.accessToken}`,
|
|
70
|
+
"content-type": "application/json",
|
|
71
|
+
origin: "https://kalvium.community",
|
|
72
|
+
referer: "https://kalvium.community/",
|
|
73
|
+
},
|
|
74
|
+
body,
|
|
75
|
+
});
|
|
76
|
+
const data = await res.json();
|
|
77
|
+
if (res.ok && data.worklogId) {
|
|
78
|
+
console.log(`SUCCESS! Worklog ID: ${data.worklogId}`);
|
|
79
|
+
console.log(` Status: ${data.status}`);
|
|
80
|
+
console.log(` Submitted at: ${data.submittedAt}`);
|
|
81
|
+
return {
|
|
82
|
+
success: true,
|
|
83
|
+
message: "Submitted",
|
|
84
|
+
worklogId: data.worklogId,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// 401/403 = token invalid — try re-login
|
|
88
|
+
if ((res.status === 401 || res.status === 403) && attempt === 0) {
|
|
89
|
+
console.log(`Got HTTP ${res.status}. Token may be invalid. Trying re-login...`);
|
|
90
|
+
const reloginOk = await captureToken();
|
|
91
|
+
if (reloginOk)
|
|
92
|
+
continue;
|
|
93
|
+
return {
|
|
94
|
+
success: false,
|
|
95
|
+
message: `HTTP ${res.status}: ${JSON.stringify(data).slice(0, 200)}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// 404 = already submitted today
|
|
99
|
+
if (res.status === 404) {
|
|
100
|
+
console.log("Already submitted today (no pending worklog).");
|
|
101
|
+
return { success: true, message: "Already submitted" };
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
success: false,
|
|
105
|
+
message: `HTTP ${res.status}: ${JSON.stringify(data).slice(0, 300)}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
return { success: false, message: String(e) };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { success: false, message: "Max retries exceeded" };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Check recent worklog status with auto-relogin retry.
|
|
116
|
+
*/
|
|
117
|
+
export async function checkStatus() {
|
|
118
|
+
const worklogApi = getWorklogApi();
|
|
119
|
+
if (!worklogApi) {
|
|
120
|
+
console.log("ERROR: No config found. Run `kalvium-worklog discover` first.");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
124
|
+
const refreshResult = await refreshToken();
|
|
125
|
+
if (!refreshResult.success || !refreshResult.accessToken) {
|
|
126
|
+
if (attempt === 0) {
|
|
127
|
+
console.log("Token expired. Auto re-logging in via browser...");
|
|
128
|
+
const reloginOk = await captureToken();
|
|
129
|
+
if (reloginOk)
|
|
130
|
+
continue;
|
|
131
|
+
console.log("ERROR: Re-login failed.");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
console.log(`ERROR: ${refreshResult.error}`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(worklogApi, {
|
|
139
|
+
headers: { authorization: `Bearer ${refreshResult.accessToken}` },
|
|
140
|
+
});
|
|
141
|
+
if (res.status === 401 || res.status === 403) {
|
|
142
|
+
if (attempt === 0) {
|
|
143
|
+
console.log("Token rejected. Trying re-login...");
|
|
144
|
+
const reloginOk = await captureToken();
|
|
145
|
+
if (reloginOk)
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
console.log("ERROR: Could not fetch status.");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const worklogs = await res.json();
|
|
152
|
+
if (!Array.isArray(worklogs)) {
|
|
153
|
+
console.log("ERROR: Invalid response");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
157
|
+
for (const w of worklogs.slice(0, 5)) {
|
|
158
|
+
const date = (w.worklogDate ?? "").slice(0, 10);
|
|
159
|
+
const status = w.status ?? "?";
|
|
160
|
+
let content = "";
|
|
161
|
+
if (w.worklogs) {
|
|
162
|
+
content = (w.worklogs.content ?? "")
|
|
163
|
+
.replace(/<\/?p>/g, "")
|
|
164
|
+
.replace(/<br>/g, " ");
|
|
165
|
+
}
|
|
166
|
+
const marker = date === today ? " <-- TODAY" : "";
|
|
167
|
+
console.log(` ${date} | ${status.padEnd(8)} | ${content}${marker}`);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
console.log(`ERROR: ${e}`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Daily auto-submit (used by scheduler).
|
|
179
|
+
* Skips weekends. Logs to file.
|
|
180
|
+
*/
|
|
181
|
+
export async function dailySubmit() {
|
|
182
|
+
const day = new Date().getDay();
|
|
183
|
+
if (day === 0 || day === 6) {
|
|
184
|
+
log("[INFO] Weekend - skipping");
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
log("[INFO] Starting daily worklog submission...");
|
|
188
|
+
const result = await submitWorklog({ text: "working", status: "on_site" });
|
|
189
|
+
if (result.success) {
|
|
190
|
+
log(`[INFO] SUCCESS: ${result.message}`);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
log(`[ERROR] ${result.message}`);
|
|
194
|
+
}
|
|
195
|
+
return result.success;
|
|
196
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kalvium-worklog",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Auto-submit your daily Kalvium worklog without opening the website",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"kalvium",
|
|
7
|
+
"worklog",
|
|
8
|
+
"automation",
|
|
9
|
+
"internship",
|
|
10
|
+
"daily-report"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/kp/kalvium-worklog.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/kp/kalvium-worklog#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/kp/kalvium-worklog/issues"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "dist/index.js",
|
|
24
|
+
"types": "dist/index.d.ts",
|
|
25
|
+
"bin": {
|
|
26
|
+
"kalvium-worklog": "dist/cli.js",
|
|
27
|
+
"kworklog": "dist/cli.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"templates"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc",
|
|
35
|
+
"dev": "tsc --watch",
|
|
36
|
+
"prepublishOnly": "npm run build",
|
|
37
|
+
"clean": "rm -rf dist",
|
|
38
|
+
"postinstall": "node dist/postinstall.js"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"commander": "^12.1.0",
|
|
42
|
+
"playwright": "^1.48.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^22.0.0",
|
|
46
|
+
"typescript": "^5.6.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
|
6
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
7
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
8
|
+
<meta name="apple-mobile-web-app-title" content="Worklog">
|
|
9
|
+
<meta name="theme-color" content="#0f172a">
|
|
10
|
+
<title>Kalvium Worklog</title>
|
|
11
|
+
<style>
|
|
12
|
+
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
|
|
13
|
+
body {
|
|
14
|
+
font-family: -apple-system, sans-serif;
|
|
15
|
+
background: #0f172a; color: #f8fafc;
|
|
16
|
+
min-height: 100vh; display: flex; flex-direction: column;
|
|
17
|
+
align-items: center; justify-content: center; padding: 20px;
|
|
18
|
+
-webkit-user-select: none; user-select: none;
|
|
19
|
+
}
|
|
20
|
+
.card {
|
|
21
|
+
background: #1e293b; border-radius: 20px; padding: 28px 22px;
|
|
22
|
+
width: 100%; max-width: 380px; box-shadow: 0 10px 40px rgba(0,0,0,0.4);
|
|
23
|
+
}
|
|
24
|
+
.header { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
|
|
25
|
+
.logo { width: 36px; height: 36px; border-radius: 10px; background: #3b82f6;
|
|
26
|
+
display: flex; align-items: center; justify-content: center;
|
|
27
|
+
font-weight: 800; font-size: 18px; color: white; flex-shrink: 0; }
|
|
28
|
+
h1 { font-size: 20px; font-weight: 700; }
|
|
29
|
+
.sub { color: #64748b; font-size: 13px; margin-bottom: 20px; }
|
|
30
|
+
textarea {
|
|
31
|
+
width: 100%; min-height: 80px; border-radius: 12px;
|
|
32
|
+
border: 2px solid #334155; background: #0f172a; color: #f8fafc;
|
|
33
|
+
padding: 14px; font-size: 16px; resize: none; margin-bottom: 14px;
|
|
34
|
+
-webkit-user-select: text; user-select: text; font-family: inherit;
|
|
35
|
+
}
|
|
36
|
+
textarea:focus { outline: none; border-color: #3b82f6; }
|
|
37
|
+
.btn {
|
|
38
|
+
width: 100%; padding: 15px; border-radius: 12px; border: none;
|
|
39
|
+
font-size: 16px; font-weight: 600; cursor: pointer; transition: 0.15s;
|
|
40
|
+
-webkit-user-select: none; user-select: none;
|
|
41
|
+
}
|
|
42
|
+
.btn-submit { background: #3b82f6; color: white; margin-bottom: 8px; }
|
|
43
|
+
.btn-submit:active { transform: scale(0.97); background: #2563eb; }
|
|
44
|
+
.btn-submit:disabled { opacity: 0.4; }
|
|
45
|
+
.btn-status { background: #334155; color: #94a3b8; font-size: 14px; }
|
|
46
|
+
.btn-status:active { transform: scale(0.97); }
|
|
47
|
+
.btn-setup { background: transparent; color: #64748b; font-size: 13px;
|
|
48
|
+
margin-top: 12px; text-decoration: underline; }
|
|
49
|
+
#result {
|
|
50
|
+
margin-top: 14px; padding: 12px 14px; border-radius: 10px;
|
|
51
|
+
font-size: 14px; display: none; line-height: 1.4;
|
|
52
|
+
}
|
|
53
|
+
.success { background: #064e3b; color: #6ee7b7; }
|
|
54
|
+
.error { background: #7f1d1d; color: #fca5a5; }
|
|
55
|
+
.loading { background: #1e293b; color: #94a3b8; }
|
|
56
|
+
.spinner { display: inline-block; width: 14px; height: 14px;
|
|
57
|
+
border: 2px solid #64748b; border-top-color: #f8fafc;
|
|
58
|
+
border-radius: 50%; animation: spin 0.8s linear infinite;
|
|
59
|
+
vertical-align: middle; margin-right: 6px; }
|
|
60
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
61
|
+
.setup-panel { display: none; margin-top: 16px; }
|
|
62
|
+
.setup-panel textarea { min-height: 120px; font-size: 12px; }
|
|
63
|
+
.token-status { font-size: 12px; color: #64748b; text-align: center; margin-top: 10px; }
|
|
64
|
+
.setup-tabs { display: flex; gap: 8px; margin-bottom: 12px; }
|
|
65
|
+
.tab-btn { flex: 1; padding: 10px; border-radius: 8px; border: none;
|
|
66
|
+
font-size: 13px; cursor: pointer; background: #0f172a; color: #64748b; }
|
|
67
|
+
.tab-btn.active { background: #3b82f6; color: white; }
|
|
68
|
+
.tab-content { display: none; }
|
|
69
|
+
.tab-content.active { display: block; }
|
|
70
|
+
.hint { font-size: 12px; color: #64748b; margin-top: 8px; line-height: 1.5; }
|
|
71
|
+
</style>
|
|
72
|
+
</head>
|
|
73
|
+
<body>
|
|
74
|
+
<div class="card">
|
|
75
|
+
<div class="header">
|
|
76
|
+
<div class="logo">K</div>
|
|
77
|
+
<div>
|
|
78
|
+
<h1>Kalvium Worklog</h1>
|
|
79
|
+
<div class="sub" id="dateDisplay"></div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<textarea id="text" placeholder="What did you worked on today?">working</textarea>
|
|
84
|
+
|
|
85
|
+
<button class="btn btn-submit" id="submitBtn" onclick="submitWorklog()">Submit Worklog</button>
|
|
86
|
+
<button class="btn btn-status" onclick="checkStatus()">Check Status</button>
|
|
87
|
+
<button class="btn btn-setup" onclick="toggleSetup()">Setup / Token</button>
|
|
88
|
+
|
|
89
|
+
<div class="setup-panel" id="setupPanel">
|
|
90
|
+
<div class="setup-tabs">
|
|
91
|
+
<button class="tab-btn active" onclick="switchTab('paste')">Paste Token</button>
|
|
92
|
+
<button class="tab-btn" onclick="switchTab('url')">From URL</button>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="tab-content active" id="tab-paste">
|
|
96
|
+
<p class="sub" style="margin-bottom:10px">Paste token JSON from computer setup:</p>
|
|
97
|
+
<textarea id="tokenInput" placeholder='{"refresh_token":"eyJ...","access_token":"eyJ..."}'></textarea>
|
|
98
|
+
<button class="btn btn-submit" onclick="saveToken()" style="margin-top:8px">Save Token</button>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div class="tab-content" id="tab-url">
|
|
102
|
+
<p class="sub" style="margin-bottom:10px">If you opened a link with a token, it will be loaded automatically.</p>
|
|
103
|
+
<p class="hint" id="urlStatus">No token found in URL.</p>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div class="token-status" id="tokenStatus"></div>
|
|
108
|
+
<div id="result"></div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<script>
|
|
112
|
+
// ─── Config ──────────────────────────────────────────────────────────────
|
|
113
|
+
const KEYCLOAK_URL = "https://auth.kalvium.community/auth/realms/kalvium/protocol/openid-connect/token";
|
|
114
|
+
const CLIENT_ID = "login_client";
|
|
115
|
+
const WORK_STATUS = "Working on-site (Company location)";
|
|
116
|
+
const CATEGORY = "on_site";
|
|
117
|
+
const API_BASE = "https://student-api.kalvium.community/api/internships/worklogs";
|
|
118
|
+
|
|
119
|
+
// ─── JWT decode (extract position_id from token sub claim) ───────────────
|
|
120
|
+
function decodeJwt(token) {
|
|
121
|
+
try {
|
|
122
|
+
const payload = token.split('.')[1];
|
|
123
|
+
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
|
124
|
+
return JSON.parse(decoded);
|
|
125
|
+
} catch (e) { return null; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getPositionId() {
|
|
129
|
+
const tokens = getTokenData();
|
|
130
|
+
if (!tokens || !tokens.access_token) return null;
|
|
131
|
+
const payload = decodeJwt(tokens.access_token);
|
|
132
|
+
return payload ? payload.sub : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getWorklogApi() {
|
|
136
|
+
const posId = getPositionId();
|
|
137
|
+
return posId ? `${API_BASE}/${posId}` : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Token Management ────────────────────────────────────────────────────
|
|
141
|
+
function getTokenData() {
|
|
142
|
+
try { return JSON.parse(localStorage.getItem("kalvium_tokens")); }
|
|
143
|
+
catch { return null; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function saveTokenData(data) {
|
|
147
|
+
localStorage.setItem("kalvium_tokens", JSON.stringify(data));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let pendingAction = null;
|
|
151
|
+
|
|
152
|
+
async function refreshAccessToken() {
|
|
153
|
+
const tokens = getTokenData();
|
|
154
|
+
if (!tokens || !tokens.refresh_token) {
|
|
155
|
+
const err = new Error("No token stored");
|
|
156
|
+
err.code = "NO_TOKEN";
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const body = new URLSearchParams({
|
|
161
|
+
grant_type: "refresh_token",
|
|
162
|
+
client_id: CLIENT_ID,
|
|
163
|
+
refresh_token: tokens.refresh_token
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const res = await fetch(KEYCLOAK_URL, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
169
|
+
body: body
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const data = await res.json();
|
|
173
|
+
if (data.error) {
|
|
174
|
+
const err = new Error(`Token expired (${data.error})`);
|
|
175
|
+
err.code = "TOKEN_EXPIRED";
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
saveTokenData(data);
|
|
180
|
+
updateTokenStatus();
|
|
181
|
+
return data.access_token;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function showTokenExpiredUI(originalError) {
|
|
185
|
+
const result = document.getElementById("result");
|
|
186
|
+
result.style.display = "block";
|
|
187
|
+
result.className = "error";
|
|
188
|
+
result.innerHTML = `
|
|
189
|
+
✗ ${originalError.message}<br><br>
|
|
190
|
+
<b>Token expired.</b> Get a new token from your computer:<br>
|
|
191
|
+
1. Run <code>python ~/.kalvium/capture_token.py</code> on your computer<br>
|
|
192
|
+
2. Copy the token JSON<br>
|
|
193
|
+
3. Tap "Setup / Token" below and paste it<br>
|
|
194
|
+
4. Your action will retry automatically
|
|
195
|
+
`;
|
|
196
|
+
document.getElementById("setupPanel").style.display = "block";
|
|
197
|
+
document.getElementById("tokenInput").focus();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Submit Worklog ──────────────────────────────────────────────────────
|
|
201
|
+
async function submitWorklog() {
|
|
202
|
+
const text = document.getElementById("text").value.trim();
|
|
203
|
+
if (!text) return alert("Enter some text first");
|
|
204
|
+
|
|
205
|
+
pendingAction = { type: 'submit', text: text };
|
|
206
|
+
await doSubmit(text);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function doSubmit(text) {
|
|
210
|
+
const btn = document.getElementById("submitBtn");
|
|
211
|
+
const result = document.getElementById("result");
|
|
212
|
+
btn.disabled = true;
|
|
213
|
+
result.style.display = "block";
|
|
214
|
+
result.className = "loading";
|
|
215
|
+
result.innerHTML = '<span class="spinner"></span>Refreshing token...';
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const accessToken = await refreshAccessToken();
|
|
219
|
+
const worklogApi = getWorklogApi();
|
|
220
|
+
if (!worklogApi) throw new Error("Could not determine position ID from token");
|
|
221
|
+
|
|
222
|
+
result.innerHTML = '<span class="spinner"></span>Submitting worklog...';
|
|
223
|
+
|
|
224
|
+
const body = JSON.stringify({
|
|
225
|
+
title: WORK_STATUS, description: WORK_STATUS, category: CATEGORY,
|
|
226
|
+
timeSpent: 0, priorityLevel: "medium", blockers: "",
|
|
227
|
+
worklogs: JSON.stringify({ content: `<p>${text}</p>` })
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const res = await fetch(worklogApi, {
|
|
231
|
+
method: "PUT",
|
|
232
|
+
headers: {
|
|
233
|
+
"authorization": `Bearer ${accessToken}`,
|
|
234
|
+
"content-type": "application/json",
|
|
235
|
+
"origin": "https://kalvium.community",
|
|
236
|
+
"referer": "https://kalvium.community/"
|
|
237
|
+
},
|
|
238
|
+
body: body
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const data = await res.json();
|
|
242
|
+
if (res.ok && data.worklogId) {
|
|
243
|
+
result.className = "success";
|
|
244
|
+
result.innerHTML = `✓ Submitted!<br>ID: ${data.worklogId} | Status: ${data.status}`;
|
|
245
|
+
pendingAction = null;
|
|
246
|
+
} else if (res.status === 401 || res.status === 403) {
|
|
247
|
+
const err = new Error("Token rejected by server");
|
|
248
|
+
err.code = "TOKEN_EXPIRED";
|
|
249
|
+
throw err;
|
|
250
|
+
} else if (res.status === 404) {
|
|
251
|
+
result.className = "success";
|
|
252
|
+
result.innerHTML = "✓ Already submitted today!";
|
|
253
|
+
pendingAction = null;
|
|
254
|
+
} else {
|
|
255
|
+
result.className = "error";
|
|
256
|
+
result.innerHTML = `✗ ${data.message || "Failed"}`;
|
|
257
|
+
}
|
|
258
|
+
} catch (e) {
|
|
259
|
+
if (e.code === "TOKEN_EXPIRED" || e.code === "NO_TOKEN") {
|
|
260
|
+
showTokenExpiredUI(e);
|
|
261
|
+
} else {
|
|
262
|
+
result.className = "error";
|
|
263
|
+
result.innerHTML = `✗ ${e.message}`;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
btn.disabled = false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── Check Status ────────────────────────────────────────────────────────
|
|
270
|
+
async function checkStatus() {
|
|
271
|
+
pendingAction = { type: 'status' };
|
|
272
|
+
await doCheckStatus();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function doCheckStatus() {
|
|
276
|
+
const result = document.getElementById("result");
|
|
277
|
+
result.style.display = "block";
|
|
278
|
+
result.className = "loading";
|
|
279
|
+
result.innerHTML = '<span class="spinner"></span>Checking...';
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const accessToken = await refreshAccessToken();
|
|
283
|
+
const worklogApi = getWorklogApi();
|
|
284
|
+
if (!worklogApi) throw new Error("Could not determine position ID from token");
|
|
285
|
+
|
|
286
|
+
const res = await fetch(worklogApi, {
|
|
287
|
+
headers: { "authorization": `Bearer ${accessToken}` }
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (res.status === 401 || res.status === 403) {
|
|
291
|
+
const err = new Error("Token rejected by server");
|
|
292
|
+
err.code = "TOKEN_EXPIRED";
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const worklogs = await res.json();
|
|
297
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
298
|
+
|
|
299
|
+
let html = "";
|
|
300
|
+
for (const w of worklogs.slice(0, 5)) {
|
|
301
|
+
const date = (w.worklogDate || "").slice(0, 10);
|
|
302
|
+
const status = w.status || "?";
|
|
303
|
+
let content = "";
|
|
304
|
+
if (w.worklogs) content = (w.worklogs.content || "").replace(/<\/?p>/g, "");
|
|
305
|
+
const marker = date === today ? " ← TODAY" : "";
|
|
306
|
+
html += `${date} | ${status} | ${content}${marker}<br>`;
|
|
307
|
+
}
|
|
308
|
+
result.className = "success";
|
|
309
|
+
result.innerHTML = html;
|
|
310
|
+
pendingAction = null;
|
|
311
|
+
} catch (e) {
|
|
312
|
+
if (e.code === "TOKEN_EXPIRED" || e.code === "NO_TOKEN") {
|
|
313
|
+
showTokenExpiredUI(e);
|
|
314
|
+
} else {
|
|
315
|
+
result.className = "error";
|
|
316
|
+
result.innerHTML = `✗ ${e.message}`;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── Setup ───────────────────────────────────────────────────────────────
|
|
322
|
+
function toggleSetup() {
|
|
323
|
+
document.getElementById("setupPanel").style.display =
|
|
324
|
+
document.getElementById("setupPanel").style.display === "block" ? "none" : "block";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function switchTab(tab) {
|
|
328
|
+
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
|
329
|
+
document.querySelectorAll(".tab-content").forEach(c => c.classList.remove("active"));
|
|
330
|
+
event.target.classList.add("active");
|
|
331
|
+
document.getElementById("tab-" + tab).classList.add("active");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function saveToken() {
|
|
335
|
+
const input = document.getElementById("tokenInput").value.trim();
|
|
336
|
+
try {
|
|
337
|
+
const data = JSON.parse(input);
|
|
338
|
+
if (data.refresh_token) {
|
|
339
|
+
saveTokenData(data);
|
|
340
|
+
updateTokenStatus();
|
|
341
|
+
document.getElementById("tokenInput").value = "";
|
|
342
|
+
document.getElementById("setupPanel").style.display = "none";
|
|
343
|
+
|
|
344
|
+
// Auto-retry the pending action if there was one
|
|
345
|
+
if (pendingAction) {
|
|
346
|
+
if (pendingAction.type === 'submit') {
|
|
347
|
+
alert("Token saved! Retrying your submission...");
|
|
348
|
+
doSubmit(pendingAction.text);
|
|
349
|
+
} else if (pendingAction.type === 'status') {
|
|
350
|
+
alert("Token saved! Retrying status check...");
|
|
351
|
+
doCheckStatus();
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
alert("Token saved! You can now submit worklogs.");
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
alert("Invalid token JSON — no refresh_token found");
|
|
358
|
+
}
|
|
359
|
+
} catch (e) {
|
|
360
|
+
alert("Invalid JSON: " + e.message);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function updateTokenStatus() {
|
|
365
|
+
const tokens = getTokenData();
|
|
366
|
+
const el = document.getElementById("tokenStatus");
|
|
367
|
+
if (tokens && tokens.refresh_token) {
|
|
368
|
+
const posId = getPositionId();
|
|
369
|
+
el.textContent = `✓ Token stored${posId ? ' (ID: ' + posId.slice(0,8) + '...)' : ''}`;
|
|
370
|
+
el.style.color = "#6ee7b7";
|
|
371
|
+
} else {
|
|
372
|
+
el.textContent = "⚠ No token — tap Setup to add";
|
|
373
|
+
el.style.color = "#fca5a5";
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ─── URL token loading (for hosted version) ──────────────────────────────
|
|
378
|
+
function loadTokenFromUrl() {
|
|
379
|
+
// Check URL hash for token (e.g., #token=base64encodedjson)
|
|
380
|
+
const hash = window.location.hash;
|
|
381
|
+
if (hash.startsWith("#token=")) {
|
|
382
|
+
try {
|
|
383
|
+
const encoded = hash.substring(7);
|
|
384
|
+
const decoded = atob(encoded);
|
|
385
|
+
const data = JSON.parse(decoded);
|
|
386
|
+
if (data.refresh_token) {
|
|
387
|
+
saveTokenData(data);
|
|
388
|
+
updateTokenStatus();
|
|
389
|
+
document.getElementById("urlStatus").textContent = "✓ Token loaded from URL!";
|
|
390
|
+
document.getElementById("urlStatus").style.color = "#6ee7b7";
|
|
391
|
+
// Clean the URL
|
|
392
|
+
history.replaceState(null, "", window.location.pathname);
|
|
393
|
+
}
|
|
394
|
+
} catch (e) {
|
|
395
|
+
document.getElementById("urlStatus").textContent = "✗ Invalid token in URL";
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// Also check query param
|
|
399
|
+
const params = new URLSearchParams(window.location.search);
|
|
400
|
+
const tokenParam = params.get("token");
|
|
401
|
+
if (tokenParam) {
|
|
402
|
+
try {
|
|
403
|
+
const data = JSON.parse(atob(tokenParam));
|
|
404
|
+
if (data.refresh_token) {
|
|
405
|
+
saveTokenData(data);
|
|
406
|
+
updateTokenStatus();
|
|
407
|
+
document.getElementById("urlStatus").textContent = "✓ Token loaded from URL!";
|
|
408
|
+
document.getElementById("urlStatus").style.color = "#6ee7b7";
|
|
409
|
+
history.replaceState(null, "", window.location.pathname);
|
|
410
|
+
}
|
|
411
|
+
} catch (e) {}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ─── Init ────────────────────────────────────────────────────────────────
|
|
416
|
+
document.getElementById("dateDisplay").textContent = new Date().toLocaleDateString("en-US", {
|
|
417
|
+
weekday: "long", year: "numeric", month: "long", day: "numeric"
|
|
418
|
+
});
|
|
419
|
+
loadTokenFromUrl();
|
|
420
|
+
updateTokenStatus();
|
|
421
|
+
</script>
|
|
422
|
+
</body>
|
|
423
|
+
</html>
|