nothumanallowed 13.2.55 → 13.2.58
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/package.json +1 -1
- package/src/commands/ui.mjs +119 -4
- package/src/constants.mjs +1 -1
- package/src/services/github.mjs +19 -0
- package/src/services/google-calendar.mjs +6 -0
- package/src/services/mail-router.mjs +13 -0
- package/src/services/microsoft-calendar.mjs +7 -0
- package/src/services/web-ui.mjs +227 -57
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "13.2.
|
|
3
|
+
"version": "13.2.58",
|
|
4
4
|
"description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/commands/ui.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { loadConfig } from '../config.mjs';
|
|
|
18
18
|
import { detectMailProvider, hasMailProvider, getProviderStatus } from '../services/mail-router.mjs';
|
|
19
19
|
import { callLLM, callLLMStream, callAgent, parseAgentFile } from '../services/llm.mjs';
|
|
20
20
|
import { getUnreadImportant, getMessage, listMessages, sendEmail, createDraft } from '../services/mail-router.mjs';
|
|
21
|
-
import { getTodayEvents, getUpcomingEvents, createEvent, updateEvent, getEventsForDate } from '../services/mail-router.mjs';
|
|
21
|
+
import { getTodayEvents, getUpcomingEvents, createEvent, updateEvent, deleteEvent, getEventsForDate } from '../services/mail-router.mjs';
|
|
22
22
|
import {
|
|
23
23
|
getTasks,
|
|
24
24
|
addTask,
|
|
@@ -1027,6 +1027,70 @@ export async function cmdUI(args) {
|
|
|
1027
1027
|
return;
|
|
1028
1028
|
}
|
|
1029
1029
|
|
|
1030
|
+
// POST /api/calendar — create event
|
|
1031
|
+
if (method === 'POST' && pathname === '/api/calendar') {
|
|
1032
|
+
try {
|
|
1033
|
+
const body = await parseBody(req);
|
|
1034
|
+
if (!body.summary) { sendJSON(res, 400, { error: 'summary required' }); logRequest(method, pathname, 400, Date.now() - start); return; }
|
|
1035
|
+
const calendarId = body.calendarId || 'primary';
|
|
1036
|
+
const event = { summary: body.summary };
|
|
1037
|
+
if (body.description) event.description = body.description;
|
|
1038
|
+
if (body.location) event.location = body.location;
|
|
1039
|
+
if (body.allDay && body.date) {
|
|
1040
|
+
event.start = { date: body.date };
|
|
1041
|
+
event.end = { date: body.date };
|
|
1042
|
+
} else {
|
|
1043
|
+
const startDT = body.start || (body.date ? body.date + 'T09:00:00' : new Date().toISOString());
|
|
1044
|
+
const endDT = body.end || (body.date ? body.date + 'T10:00:00' : new Date(Date.now() + 3600000).toISOString());
|
|
1045
|
+
event.start = { dateTime: startDT };
|
|
1046
|
+
event.end = { dateTime: endDT };
|
|
1047
|
+
}
|
|
1048
|
+
const created = await createEvent(config, event, calendarId);
|
|
1049
|
+
sendJSON(res, 201, { event: created });
|
|
1050
|
+
} catch (e) {
|
|
1051
|
+
sendJSON(res, 500, { error: e.message });
|
|
1052
|
+
}
|
|
1053
|
+
logRequest(method, pathname, 201, Date.now() - start);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// PATCH /api/calendar/:calId/:eventId — update event
|
|
1058
|
+
const calPatchMatch = pathname.match(/^\/api\/calendar\/([^/]+)\/([^/]+)$/);
|
|
1059
|
+
if (method === 'PATCH' && calPatchMatch) {
|
|
1060
|
+
try {
|
|
1061
|
+
const calendarId = decodeURIComponent(calPatchMatch[1]);
|
|
1062
|
+
const eventId = decodeURIComponent(calPatchMatch[2]);
|
|
1063
|
+
const body = await parseBody(req);
|
|
1064
|
+
const patch = {};
|
|
1065
|
+
if (body.summary !== undefined) patch.summary = body.summary;
|
|
1066
|
+
if (body.description !== undefined) patch.description = body.description;
|
|
1067
|
+
if (body.location !== undefined) patch.location = body.location;
|
|
1068
|
+
if (body.start !== undefined) patch.start = { dateTime: body.start };
|
|
1069
|
+
if (body.end !== undefined) patch.end = { dateTime: body.end };
|
|
1070
|
+
const updated = await updateEvent(config, calendarId, eventId, patch);
|
|
1071
|
+
sendJSON(res, 200, { event: updated });
|
|
1072
|
+
} catch (e) {
|
|
1073
|
+
sendJSON(res, 500, { error: e.message });
|
|
1074
|
+
}
|
|
1075
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// DELETE /api/calendar/:calId/:eventId — delete event
|
|
1080
|
+
const calDeleteMatch = pathname.match(/^\/api\/calendar\/([^/]+)\/([^/]+)$/);
|
|
1081
|
+
if (method === 'DELETE' && calDeleteMatch) {
|
|
1082
|
+
try {
|
|
1083
|
+
const calendarId = decodeURIComponent(calDeleteMatch[1]);
|
|
1084
|
+
const eventId = decodeURIComponent(calDeleteMatch[2]);
|
|
1085
|
+
await deleteEvent(config, calendarId, eventId);
|
|
1086
|
+
sendJSON(res, 200, { ok: true });
|
|
1087
|
+
} catch (e) {
|
|
1088
|
+
sendJSON(res, 500, { error: e.message });
|
|
1089
|
+
}
|
|
1090
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1030
1094
|
// GET /api/tasks
|
|
1031
1095
|
if (method === 'GET' && pathname === '/api/tasks') {
|
|
1032
1096
|
const tasks = getTasks();
|
|
@@ -2390,11 +2454,26 @@ export async function cmdUI(args) {
|
|
|
2390
2454
|
}
|
|
2391
2455
|
|
|
2392
2456
|
// ── GitHub ───────────────────────────────────────────────────────
|
|
2457
|
+
if (method === 'GET' && pathname === '/api/github/repos') {
|
|
2458
|
+
try {
|
|
2459
|
+
const gh = await import('../services/github.mjs');
|
|
2460
|
+
const data = await gh.listUserRepos(config, 30);
|
|
2461
|
+
sendJSON(res, 200, data);
|
|
2462
|
+
} catch (e) {
|
|
2463
|
+
sendJSON(res, 200, { error: e.message, repos: [] });
|
|
2464
|
+
}
|
|
2465
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2393
2469
|
if (method === 'GET' && pathname === '/api/github') {
|
|
2394
2470
|
try {
|
|
2395
2471
|
const gh = await import('../services/github.mjs');
|
|
2396
|
-
const
|
|
2397
|
-
|
|
2472
|
+
const [notifData, repoData] = await Promise.all([
|
|
2473
|
+
gh.listNotificationsRaw(config, 15),
|
|
2474
|
+
gh.listUserRepos(config, 30).catch(() => null),
|
|
2475
|
+
]);
|
|
2476
|
+
sendJSON(res, 200, { notifications: notifData, user: repoData });
|
|
2398
2477
|
} catch (e) {
|
|
2399
2478
|
sendJSON(res, 200, { error: e.message, notifications: [] });
|
|
2400
2479
|
}
|
|
@@ -2525,7 +2604,7 @@ export async function cmdUI(args) {
|
|
|
2525
2604
|
const daysUntil = Math.ceil((thisYear - today) / 86400000);
|
|
2526
2605
|
if (daysUntil <= 90) {
|
|
2527
2606
|
const dateStr = thisYear.toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
|
|
2528
|
-
upcoming.push({ name: c.name, date: dateStr, daysUntil });
|
|
2607
|
+
upcoming.push({ name: c.name, date: dateStr, rawDate: c.birthday, daysUntil, contactId: c.resourceName });
|
|
2529
2608
|
}
|
|
2530
2609
|
}
|
|
2531
2610
|
upcoming.sort((a, b) => a.daysUntil - b.daysUntil);
|
|
@@ -2537,6 +2616,42 @@ export async function cmdUI(args) {
|
|
|
2537
2616
|
return;
|
|
2538
2617
|
}
|
|
2539
2618
|
|
|
2619
|
+
// POST /api/birthdays — create or update birthday on a contact
|
|
2620
|
+
if (method === 'POST' && pathname === '/api/birthdays') {
|
|
2621
|
+
try {
|
|
2622
|
+
const body = await parseBody(req);
|
|
2623
|
+
const gc = await import('../services/google-contacts.mjs');
|
|
2624
|
+
if (body.contactId) {
|
|
2625
|
+
// Update existing contact's birthday
|
|
2626
|
+
await gc.updateContact(config, body.contactId, { birthday: body.date });
|
|
2627
|
+
} else {
|
|
2628
|
+
// Create new contact with just name + birthday
|
|
2629
|
+
const created = await gc.createContact(config, { name: body.name });
|
|
2630
|
+
await gc.updateContact(config, created.resourceName, { birthday: body.date });
|
|
2631
|
+
}
|
|
2632
|
+
sendJSON(res, 200, { ok: true });
|
|
2633
|
+
} catch (e) {
|
|
2634
|
+
sendJSON(res, 500, { error: e.message });
|
|
2635
|
+
}
|
|
2636
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
// POST /api/birthdays/delete — clear birthday from a contact
|
|
2641
|
+
if (method === 'POST' && pathname === '/api/birthdays/delete') {
|
|
2642
|
+
try {
|
|
2643
|
+
const body = await parseBody(req);
|
|
2644
|
+
const gc = await import('../services/google-contacts.mjs');
|
|
2645
|
+
// Clear birthday by setting it to empty (remove field)
|
|
2646
|
+
await gc.updateContact(config, body.contactId, { birthday: '' });
|
|
2647
|
+
sendJSON(res, 200, { ok: true });
|
|
2648
|
+
} catch (e) {
|
|
2649
|
+
sendJSON(res, 500, { error: e.message });
|
|
2650
|
+
}
|
|
2651
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2540
2655
|
// ── Studio: plan workflow ────────────────────────────────────────
|
|
2541
2656
|
if (pathname === '/api/studio/plan' && method === 'POST') {
|
|
2542
2657
|
const body = await parseBody(req);
|
package/src/constants.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
|
|
|
5
5
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
6
|
const __dirname = path.dirname(__filename);
|
|
7
7
|
|
|
8
|
-
export const VERSION = '13.2.
|
|
8
|
+
export const VERSION = '13.2.58';
|
|
9
9
|
export const BASE_URL = 'https://nothumanallowed.com/cli';
|
|
10
10
|
export const API_BASE = 'https://nothumanallowed.com/api/v1';
|
|
11
11
|
|
package/src/services/github.mjs
CHANGED
|
@@ -166,6 +166,25 @@ export async function listPRsRaw(config, repo, state = 'open', maxResults = 15)
|
|
|
166
166
|
/**
|
|
167
167
|
* Mark all notifications as read.
|
|
168
168
|
*/
|
|
169
|
+
export async function listUserRepos(config, maxResults = 30) {
|
|
170
|
+
const data = await ghFetch(config, `/user/repos?sort=pushed&direction=desc&per_page=${maxResults}&affiliation=owner,collaborator`);
|
|
171
|
+
const user = await ghFetch(config, '/user');
|
|
172
|
+
return {
|
|
173
|
+
login: user.login,
|
|
174
|
+
name: user.name,
|
|
175
|
+
avatar: user.avatar_url,
|
|
176
|
+
repos: (Array.isArray(data) ? data : []).map(r => ({
|
|
177
|
+
full_name: r.full_name,
|
|
178
|
+
description: r.description || '',
|
|
179
|
+
language: r.language || '',
|
|
180
|
+
stars: r.stargazers_count || 0,
|
|
181
|
+
open_issues: r.open_issues_count || 0,
|
|
182
|
+
pushed: r.pushed_at ? r.pushed_at.slice(0, 10) : '',
|
|
183
|
+
private: r.private,
|
|
184
|
+
})),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
169
188
|
export async function markNotificationsRead(config) {
|
|
170
189
|
await ghFetch(config, '/notifications', {
|
|
171
190
|
method: 'PUT',
|
|
@@ -174,6 +174,12 @@ export async function updateEvent(config, calendarId, eventId, patch) {
|
|
|
174
174
|
});
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
export async function deleteEvent(config, calendarId, eventId) {
|
|
178
|
+
await calFetch(config, `/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`, {
|
|
179
|
+
method: 'DELETE',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
177
183
|
// ── Event Parser ───────────────────────────────────────────────────────────
|
|
178
184
|
|
|
179
185
|
function parseEvent(raw) {
|
|
@@ -281,6 +281,19 @@ export async function updateEvent(config, calendarId, eventId, patch) {
|
|
|
281
281
|
return gc.updateEvent(config, calendarId, eventId, patch);
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
export async function deleteEvent(config, calendarId, eventId) {
|
|
285
|
+
const provider = detectMailProvider(config);
|
|
286
|
+
if (!provider) throw new Error('No mail provider authenticated.');
|
|
287
|
+
|
|
288
|
+
if (provider === 'microsoft') {
|
|
289
|
+
const ms = await getMicrosoftCalendar();
|
|
290
|
+
return ms.deleteEvent(config, calendarId, eventId);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const gc = await getGoogleCalendar();
|
|
294
|
+
return gc.deleteEvent(config, calendarId, eventId);
|
|
295
|
+
}
|
|
296
|
+
|
|
284
297
|
/**
|
|
285
298
|
* List events for a date range.
|
|
286
299
|
*/
|
|
@@ -228,6 +228,13 @@ export async function updateEvent(config, calendarId, eventId, patch) {
|
|
|
228
228
|
});
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
export async function deleteEvent(config, calendarId, eventId) {
|
|
232
|
+
const calPath = calendarId === 'primary'
|
|
233
|
+
? `/calendar/events/${eventId}`
|
|
234
|
+
: `/calendars/${calendarId}/events/${eventId}`;
|
|
235
|
+
await graphFetch(config, calPath, { method: 'DELETE' });
|
|
236
|
+
}
|
|
237
|
+
|
|
231
238
|
// ── Event Parser ───────────────────────────────────────────────────────────
|
|
232
239
|
|
|
233
240
|
/**
|
package/src/services/web-ui.mjs
CHANGED
|
@@ -242,7 +242,7 @@ setInterval(updateClock,1000);updateClock();
|
|
|
242
242
|
|
|
243
243
|
// ---- API ----
|
|
244
244
|
function apiGet(p){return fetch(API+p).then(function(r){return r.ok?r.json():null}).catch(function(){return null})}
|
|
245
|
-
function apiPost(p,b){return fetch(API+p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}).then(function(r){
|
|
245
|
+
function apiPost(p,b,m){return fetch(API+p,{method:m||'POST',headers:{'Content-Type':'application/json'},body:b!=null?JSON.stringify(b):undefined}).then(function(r){if(!r.ok)return r.text().then(function(t){throw new Error(t||r.status)});return r.text().then(function(t){try{return JSON.parse(t)}catch(e){return null}})})}
|
|
246
246
|
function apiPatch(p){return fetch(API+p,{method:'PATCH'}).then(function(r){return r.ok?r.json():null}).catch(function(){return null})}
|
|
247
247
|
|
|
248
248
|
// ---- LOAD DATA ----
|
|
@@ -1184,75 +1184,180 @@ function openDayDetail(dateStr){
|
|
|
1184
1184
|
var evts=calEventsCache[dateStr]||[];
|
|
1185
1185
|
var dayLabel=new Date(dateStr+'T12:00:00').toLocaleDateString('en',{weekday:'long',month:'long',day:'numeric',year:'numeric'});
|
|
1186
1186
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
h+='<
|
|
1192
|
-
|
|
1193
|
-
evts.
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
h+='<div style="color:var(--
|
|
1202
|
-
x.
|
|
1203
|
-
|
|
1204
|
-
h+='<div style="
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
h+='<div style="
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1187
|
+
function buildDayHtml(){
|
|
1188
|
+
var h='<h2 style="color:var(--green);margin-bottom:4px">'+esc(dayLabel)+'</h2>';
|
|
1189
|
+
h+='<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">';
|
|
1190
|
+
h+='<span style="color:var(--dim);font-size:11px">'+dateStr+'</span>';
|
|
1191
|
+
h+='<button onclick="openEventForm(null,'+JSON.stringify(dateStr)+')" style="margin-left:auto;background:var(--green3);color:var(--bg);padding:5px 12px;border-radius:var(--r);font-size:12px;font-weight:700">+ Add Event</button>';
|
|
1192
|
+
h+='</div>';
|
|
1193
|
+
if(evts.length===0){
|
|
1194
|
+
h+='<div style="color:var(--dim);padding:20px;text-align:center">No events on this day</div>';
|
|
1195
|
+
} else {
|
|
1196
|
+
evts.forEach(function(x,idx){
|
|
1197
|
+
var timeStr=x.isAllDay?'All day':fmtTime(x.start)+' - '+fmtTime(x.end);
|
|
1198
|
+
var calId=x.calendarId||'primary';
|
|
1199
|
+
h+='<div style="border:1px solid var(--border);border-radius:6px;padding:12px;margin-bottom:10px;background:var(--bg3)">';
|
|
1200
|
+
h+='<div style="display:flex;align-items:flex-start;gap:6px;margin-bottom:4px">';
|
|
1201
|
+
h+='<div style="flex:1"><div style="color:var(--amber);font-weight:700;font-size:13px;margin-bottom:4px">'+esc(timeStr)+'</div>';
|
|
1202
|
+
h+='<div style="color:var(--bright);font-size:15px;font-weight:700;margin-bottom:6px">'+esc(x.summary)+'</div></div>';
|
|
1203
|
+
if(x.id){
|
|
1204
|
+
h+='<div style="display:flex;gap:4px;flex-shrink:0">';
|
|
1205
|
+
h+='<button onclick="openEventForm('+JSON.stringify({id:x.id,calId:calId,summary:x.summary,description:x.description||'',location:x.location||'',start:x.start,end:x.end,isAllDay:x.isAllDay})+','+JSON.stringify(dateStr)+')" style="background:var(--bg2);border:1px solid var(--border);color:var(--text);padding:3px 8px;border-radius:4px;font-size:11px">Edit</button>';
|
|
1206
|
+
h+='<button onclick="deleteCalEvent('+JSON.stringify(calId)+','+JSON.stringify(x.id)+','+JSON.stringify(dateStr)+')" style="background:var(--bg2);border:1px solid var(--red);color:var(--red);padding:3px 8px;border-radius:4px;font-size:11px">Delete</button>';
|
|
1207
|
+
h+='</div>';
|
|
1208
|
+
}
|
|
1209
|
+
h+='</div>';
|
|
1210
|
+
if(x.location)h+='<div style="color:var(--cyan);font-size:12px;margin-bottom:4px">Location: '+esc(x.location)+'</div>';
|
|
1211
|
+
if(x.organizer)h+='<div style="color:var(--dim);font-size:11px;margin-bottom:4px">Organizer: '+esc(x.organizer)+'</div>';
|
|
1212
|
+
if(x.attendees&&x.attendees.length>0){
|
|
1213
|
+
h+='<div style="color:var(--dim);font-size:11px;margin-bottom:4px">Attendees:</div>';
|
|
1214
|
+
x.attendees.forEach(function(a){
|
|
1215
|
+
var status=a.responseStatus==='accepted'?'var(--green)':a.responseStatus==='declined'?'var(--red)':'var(--dim)';
|
|
1216
|
+
h+='<div style="font-size:11px;color:'+status+';padding-left:8px">'+esc(a.name||a.email)+' ('+esc(a.responseStatus)+')</div>';
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
if(x.description){
|
|
1220
|
+
h+='<div style="border-top:1px solid var(--border);margin-top:8px;padding-top:8px;color:var(--text);font-size:12px;white-space:pre-wrap;word-wrap:break-word">'+esc(x.description)+'</div>';
|
|
1221
|
+
}
|
|
1222
|
+
if(x.hangoutLink){
|
|
1223
|
+
h+='<div style="margin-top:8px"><a href="'+esc(x.hangoutLink)+'" target="_blank" style="color:var(--cyan);font-size:12px;font-weight:700">Join Video Call</a></div>';
|
|
1224
|
+
}
|
|
1225
|
+
if(x.htmlLink){
|
|
1226
|
+
h+='<div style="margin-top:4px"><a href="'+esc(x.htmlLink)+'" target="_blank" style="color:var(--dim);font-size:10px">Open in Google Calendar</a></div>';
|
|
1227
|
+
}
|
|
1228
|
+
h+='</div>';
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
return h;
|
|
1218
1232
|
}
|
|
1219
1233
|
|
|
1220
|
-
// Use the agent modal for day detail (read-only mode)
|
|
1221
1234
|
selectedAgent=null;
|
|
1222
1235
|
agentChatHistory=[];
|
|
1223
1236
|
document.getElementById('modalName').textContent=dayLabel;
|
|
1224
1237
|
document.getElementById('modalAgentDesc').textContent='';
|
|
1225
|
-
// Show the day events in the messages area
|
|
1226
1238
|
var msgEl=document.getElementById('agentMessages');
|
|
1227
|
-
if(msgEl){msgEl.innerHTML='<div class="agent-chat__bubble agent-chat__bubble--agent md-body" style="width:100%;max-width:100%;box-sizing:border-box">'+
|
|
1228
|
-
// Hide input footer in read-only mode
|
|
1239
|
+
if(msgEl){msgEl.innerHTML='<div id="dayDetailBody" class="agent-chat__bubble agent-chat__bubble--agent md-body" style="width:100%;max-width:100%;box-sizing:border-box">'+buildDayHtml()+'</div>';}
|
|
1229
1240
|
var footer=document.querySelector('.agent-chat__footer');
|
|
1230
1241
|
if(footer)footer.style.display='none';
|
|
1231
1242
|
document.getElementById('agentModal').classList.add('modal-overlay--open');
|
|
1232
1243
|
}
|
|
1233
1244
|
|
|
1245
|
+
function refreshDayDetail(dateStr){
|
|
1246
|
+
delete calEventsCache[dateStr];
|
|
1247
|
+
apiGet('/api/calendar?date='+dateStr).then(function(r){
|
|
1248
|
+
calEventsCache[dateStr]=(r&&r.events)||[];
|
|
1249
|
+
openDayDetail(dateStr);
|
|
1250
|
+
renderCalendar(document.getElementById('content'));
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function deleteCalEvent(calId,eventId,dateStr){
|
|
1255
|
+
if(!confirm('Delete this event?'))return;
|
|
1256
|
+
apiPost('/api/calendar/'+encodeURIComponent(calId)+'/'+encodeURIComponent(eventId),null,'DELETE').then(function(){
|
|
1257
|
+
refreshDayDetail(dateStr);
|
|
1258
|
+
}).catch(function(e){alert('Error: '+e.message);});
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function openEventForm(evt,dateStr){
|
|
1262
|
+
var isEdit=evt&&evt.id;
|
|
1263
|
+
var defDate=dateStr||new Date().toISOString().split('T')[0];
|
|
1264
|
+
var defStart=evt&&evt.start?evt.start:defDate+'T09:00';
|
|
1265
|
+
var defEnd=evt&&evt.end?evt.end:defDate+'T10:00';
|
|
1266
|
+
if(defStart.length>16)defStart=defStart.slice(0,16);
|
|
1267
|
+
if(defEnd.length>16)defEnd=defEnd.slice(0,16);
|
|
1268
|
+
|
|
1269
|
+
var overlay=document.createElement('div');
|
|
1270
|
+
overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center';
|
|
1271
|
+
var card=document.createElement('div');
|
|
1272
|
+
card.style.cssText='background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:24px;width:420px;max-width:95vw;max-height:90vh;overflow-y:auto';
|
|
1273
|
+
card.innerHTML='<div style="font-size:16px;font-weight:700;color:var(--bright);margin-bottom:16px">'+(isEdit?'Edit Event':'New Event')+'</div>'+
|
|
1274
|
+
'<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:4px">Title *</label>'+
|
|
1275
|
+
'<input id="evtTitle" type="text" value="'+esc(evt&&evt.summary||'')+'" placeholder="Event title" style="width:100%;box-sizing:border-box;padding:8px 10px;margin-bottom:12px;font-size:13px">'+
|
|
1276
|
+
'<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:4px">Start</label>'+
|
|
1277
|
+
'<input id="evtStart" type="datetime-local" value="'+esc(defStart)+'" style="width:100%;box-sizing:border-box;padding:8px 10px;margin-bottom:12px;font-size:13px">'+
|
|
1278
|
+
'<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:4px">End</label>'+
|
|
1279
|
+
'<input id="evtEnd" type="datetime-local" value="'+esc(defEnd)+'" style="width:100%;box-sizing:border-box;padding:8px 10px;margin-bottom:12px;font-size:13px">'+
|
|
1280
|
+
'<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:4px">Location</label>'+
|
|
1281
|
+
'<input id="evtLoc" type="text" value="'+esc(evt&&evt.location||'')+'" placeholder="Optional" style="width:100%;box-sizing:border-box;padding:8px 10px;margin-bottom:12px;font-size:13px">'+
|
|
1282
|
+
'<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:4px">Description</label>'+
|
|
1283
|
+
'<textarea id="evtDesc" style="width:100%;box-sizing:border-box;padding:8px 10px;margin-bottom:16px;font-size:13px;height:70px;resize:vertical">'+esc(evt&&evt.description||'')+'</textarea>'+
|
|
1284
|
+
'<div style="display:flex;gap:8px;justify-content:flex-end">'+
|
|
1285
|
+
'<button id="evtCancelBtn" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:8px 18px;border-radius:var(--r);font-size:13px">Cancel</button>'+
|
|
1286
|
+
'<button id="evtSaveBtn" style="background:var(--green3);color:var(--bg);padding:8px 18px;border-radius:var(--r);font-size:13px;font-weight:700">'+(isEdit?'Save Changes':'Create Event')+'</button>'+
|
|
1287
|
+
'</div><div id="evtErr" style="color:var(--red);font-size:12px;margin-top:8px"></div>';
|
|
1288
|
+
overlay.appendChild(card);
|
|
1289
|
+
document.body.appendChild(overlay);
|
|
1290
|
+
card.querySelector('#evtCancelBtn').onclick=function(){document.body.removeChild(overlay);};
|
|
1291
|
+
overlay.onclick=function(e){if(e.target===overlay)document.body.removeChild(overlay);};
|
|
1292
|
+
card.querySelector('#evtSaveBtn').onclick=function(){
|
|
1293
|
+
var title=card.querySelector('#evtTitle').value.trim();
|
|
1294
|
+
if(!title){card.querySelector('#evtErr').textContent='Title is required';return;}
|
|
1295
|
+
var startVal=card.querySelector('#evtStart').value;
|
|
1296
|
+
var endVal=card.querySelector('#evtEnd').value;
|
|
1297
|
+
var loc=card.querySelector('#evtLoc').value.trim();
|
|
1298
|
+
var desc=card.querySelector('#evtDesc').value.trim();
|
|
1299
|
+
var btn=card.querySelector('#evtSaveBtn');
|
|
1300
|
+
btn.textContent='Saving...';btn.disabled=true;
|
|
1301
|
+
var promise;
|
|
1302
|
+
if(isEdit){
|
|
1303
|
+
var calId=evt.calId||'primary';
|
|
1304
|
+
var patch={summary:title,start:startVal,end:endVal};
|
|
1305
|
+
if(loc)patch.location=loc;
|
|
1306
|
+
if(desc)patch.description=desc;
|
|
1307
|
+
promise=apiPost('/api/calendar/'+encodeURIComponent(calId)+'/'+encodeURIComponent(evt.id),patch,'PATCH');
|
|
1308
|
+
} else {
|
|
1309
|
+
promise=apiPost('/api/calendar',{summary:title,start:startVal,end:endVal,location:loc,description:desc,date:dateStr});
|
|
1310
|
+
}
|
|
1311
|
+
promise.then(function(){
|
|
1312
|
+
document.body.removeChild(overlay);
|
|
1313
|
+
refreshDayDetail(dateStr);
|
|
1314
|
+
}).catch(function(e){
|
|
1315
|
+
card.querySelector('#evtErr').textContent='Error: '+(e.message||'Unknown error');
|
|
1316
|
+
btn.textContent=isEdit?'Save Changes':'Create Event';btn.disabled=false;
|
|
1317
|
+
});
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1234
1321
|
// ---- GITHUB ----
|
|
1235
1322
|
var ghData=null;var ghRepo='';
|
|
1236
1323
|
function renderGitHub(el){
|
|
1237
1324
|
function renderGhData(r){
|
|
1238
|
-
var
|
|
1325
|
+
var user=r.user||null;
|
|
1326
|
+
// Header: user profile + repo input
|
|
1327
|
+
var userHtml='';
|
|
1328
|
+
if(user&&user.login){
|
|
1329
|
+
userHtml='<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;padding:10px 14px;background:var(--bg2);border-radius:var(--r);border:1px solid var(--border)">'+(user.avatar?'<img src="'+esc(user.avatar)+'" style="width:36px;height:36px;border-radius:50%;object-fit:cover" alt="">':'')+'<div><div style="font-weight:700;font-size:13px;color:var(--green)">@'+esc(user.login)+'</div>'+(user.name?'<div style="font-size:11px;color:var(--dim)">'+esc(user.name)+'</div>':'')+'</div></div>';
|
|
1330
|
+
}
|
|
1331
|
+
var h=userHtml+'<div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap"><input type="text" id="ghRepo" placeholder="owner/repo" value="'+esc(ghRepo)+'" style="flex:1;min-width:180px;font-size:13px;padding:10px 14px" onkeydown="if(event.key===\\x27Enter\\x27)loadGhIssues()"><button onclick="loadGhIssues()" style="background:var(--green3);color:var(--bg);padding:8px 16px;border-radius:var(--r);font-weight:700;font-size:12px">Issues</button><button onclick="loadGhPRs()" style="background:var(--cyan);color:var(--bg);padding:8px 16px;border-radius:var(--r);font-weight:700;font-size:12px">PRs</button></div>';
|
|
1332
|
+
// My repos as clickable pills
|
|
1333
|
+
if(user&&user.repos&&user.repos.length>0){
|
|
1334
|
+
h+='<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">';
|
|
1335
|
+
user.repos.slice(0,12).forEach(function(repo){
|
|
1336
|
+
h+='<button onclick="ghRepo=\\x27'+esc(repo.full_name)+'\\x27;document.getElementById(\\x27ghRepo\\x27).value=\\x27'+esc(repo.full_name)+'\\x27;loadGhIssues()" style="background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:11px;color:var(--fg);cursor:pointer;white-space:nowrap" title="'+esc(repo.description)+(repo.open_issues?' | '+repo.open_issues+' open issues':'')+'">'+(repo.private?'🔒 ':'')+esc(repo.full_name)+(repo.open_issues?'<span style="color:var(--amber);margin-left:4px">'+repo.open_issues+'</span>':'')+'</button>';
|
|
1337
|
+
});
|
|
1338
|
+
h+='</div>';
|
|
1339
|
+
}
|
|
1340
|
+
// Notifications
|
|
1239
1341
|
var notifs=r.notifications||[];
|
|
1240
1342
|
if(notifs.length>0){
|
|
1241
1343
|
h+='<div style="display:flex;align-items:center;justify-content:space-between"><div class="section-title">Notifications ('+notifs.length+')</div><button onclick="ghMarkRead()" style="background:var(--bg3);color:var(--dim);border:1px solid var(--border);padding:4px 10px;border-radius:var(--r);font-size:10px;cursor:pointer">Mark all read</button></div>';
|
|
1242
1344
|
notifs.forEach(function(n){h+='<div class="card" style="padding:10px 14px;cursor:pointer" onclick="window.open(\\x27'+esc(n.url)+'\\x27,\\x27_blank\\x27)"><span style="color:var(--cyan);font-size:11px">'+esc(n.repo)+'</span> <span style="color:var(--dim);font-size:10px">['+esc(n.type)+']</span><div style="font-size:13px;margin-top:2px">'+esc(n.title)+'</div><div style="font-size:10px;color:var(--dim)">'+esc(n.reason)+' · '+esc(n.updated)+'</div></div>'});
|
|
1243
1345
|
}
|
|
1346
|
+
// Issues
|
|
1244
1347
|
if(r.issues&&r.issues.length>0){
|
|
1245
1348
|
h+='<div class="section-title">Issues — '+esc(r.repo||ghRepo)+'</div>';
|
|
1246
1349
|
r.issues.forEach(function(i){h+='<div class="card" style="padding:10px 14px;cursor:pointer" onclick="window.open(\\x27'+esc(i.url)+'\\x27,\\x27_blank\\x27)"><span style="color:var(--green);font-weight:700">#'+i.number+'</span> '+esc(i.title)+(i.assignee?' <span style="font-size:10px;color:var(--cyan)">→ '+esc(i.assignee)+'</span>':'')+(i.labels?'<span style="font-size:9px;color:var(--amber);margin-left:6px">['+esc(i.labels)+']</span>':'')+'<div style="font-size:10px;color:var(--dim)">'+esc(i.updated)+'</div></div>'});
|
|
1247
1350
|
}
|
|
1351
|
+
// PRs
|
|
1248
1352
|
if(r.prs&&r.prs.length>0){
|
|
1249
1353
|
h+='<div class="section-title">Pull Requests — '+esc(r.repo||ghRepo)+'</div>';
|
|
1250
1354
|
r.prs.forEach(function(p){h+='<div class="card" style="padding:10px 14px;cursor:pointer" onclick="window.open(\\x27'+esc(p.url)+'\\x27,\\x27_blank\\x27)"><span style="color:var(--cyan);font-weight:700">#'+p.number+'</span> '+esc(p.title)+' <span style="font-size:10px;color:var(--dim)">by '+esc(p.author)+'</span>'+(p.draft?'<span style="font-size:9px;color:var(--amber)"> DRAFT</span>':'')+'<div style="font-size:10px;color:var(--dim)">'+esc(p.updated)+'</div></div>'});
|
|
1251
1355
|
}
|
|
1252
|
-
if(!notifs.length&&!(r.issues&&r.issues.length)&&!(r.prs&&r.prs.length)){
|
|
1356
|
+
if(!notifs.length&&!(r.issues&&r.issues.length)&&!(r.prs&&r.prs.length)){
|
|
1357
|
+
h+='<div class="card" style="text-align:center;color:var(--dim);padding:20px">Click a repo above or type owner/repo and click Issues or PRs.</div>';
|
|
1358
|
+
}
|
|
1253
1359
|
el.innerHTML=h;
|
|
1254
1360
|
}
|
|
1255
|
-
// If ghData already loaded (e.g. after loadGhIssues/loadGhPRs), render directly without re-fetching
|
|
1256
1361
|
if(ghData){renderGhData(ghData);return;}
|
|
1257
1362
|
el.innerHTML='<div style="text-align:center;padding:40px"><div class="spinner"></div><div style="color:var(--dim)">Loading GitHub...</div></div>';
|
|
1258
1363
|
apiGet('/api/github').then(function(r){
|
|
@@ -1267,7 +1372,12 @@ function ghMarkRead(){apiPost('/api/github/mark-read',{}).then(function(){if(ghD
|
|
|
1267
1372
|
|
|
1268
1373
|
// ---- NOTION ----
|
|
1269
1374
|
function renderNotion(el){
|
|
1270
|
-
|
|
1375
|
+
apiGet('/api/notion/search?q=').then(function(r){
|
|
1376
|
+
var banner=r&&r.error?setupBanner('Notion','nha config set notion-token YOUR_INTEGRATION_TOKEN')+'<div style="color:var(--dim);font-size:12px;padding:8px 0">Get an Integration Token from notion.so/my-integrations → New integration → Internal → copy Secret</div>':'';
|
|
1377
|
+
el.innerHTML=banner+'<div style="display:flex;gap:8px;margin-bottom:16px"><input type="text" id="notionQuery" placeholder="Search Notion pages..." style="flex:1;font-size:13px;padding:10px 14px" onkeydown="if(event.key===\\x27Enter\\x27)searchNotion()"><button onclick="searchNotion()" style="background:var(--green3);color:var(--bg);padding:8px 16px;border-radius:var(--r);font-weight:700;font-size:12px">Search</button></div><div id="notionResults"></div>';
|
|
1378
|
+
}).catch(function(){
|
|
1379
|
+
el.innerHTML=setupBanner('Notion','nha config set notion-token YOUR_INTEGRATION_TOKEN')+'<div style="display:flex;gap:8px;margin-bottom:16px"><input type="text" id="notionQuery" placeholder="Search Notion pages..." style="flex:1;font-size:13px;padding:10px 14px"><button style="background:var(--green3);color:var(--bg);padding:8px 16px;border-radius:var(--r);font-weight:700;font-size:12px" disabled>Search</button></div>';
|
|
1380
|
+
});
|
|
1271
1381
|
}
|
|
1272
1382
|
function searchNotion(){
|
|
1273
1383
|
var q=document.getElementById('notionQuery');if(!q||!q.value.trim())return;
|
|
@@ -1289,11 +1399,12 @@ function loadNotionPage(id){
|
|
|
1289
1399
|
}
|
|
1290
1400
|
|
|
1291
1401
|
// ---- SLACK ----
|
|
1402
|
+
function setupBanner(service,cmd){return '<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--bg2);border:1px solid var(--border);border-left:3px solid var(--amber);border-radius:var(--r);margin-bottom:14px;font-size:12px"><span style="font-size:20px">🔒</span><div><div style="color:var(--fg);font-weight:600;margin-bottom:2px">'+esc(service)+' not configured</div><div style="color:var(--dim);font-family:var(--mono);font-size:11px">'+esc(cmd)+'</div></div></div>';}
|
|
1292
1403
|
var slackData=null;
|
|
1293
1404
|
function renderSlack(el){
|
|
1294
1405
|
el.innerHTML='<div style="text-align:center;padding:40px"><div class="spinner"></div><div style="color:var(--dim)">Loading Slack channels...</div></div>';
|
|
1295
1406
|
apiGet('/api/slack/channels').then(function(r){
|
|
1296
|
-
if(r&&r.error){el.innerHTML='
|
|
1407
|
+
if(r&&r.error){el.innerHTML=setupBanner('Slack','nha config set slack-token xoxb-YOUR_TOKEN')+'<div style="color:var(--dim);font-size:12px;padding:8px 0">Get a Bot Token from api.slack.com/apps → OAuth & Permissions → Bot Token Scopes: channels:read, channels:history, users:read</div>';return}
|
|
1297
1408
|
slackData=r;
|
|
1298
1409
|
var channels=r.channels||[];
|
|
1299
1410
|
var h='<div class="section-title">Channels ('+channels.length+')</div>';
|
|
@@ -1322,17 +1433,75 @@ function renderBirthdays(el){
|
|
|
1322
1433
|
apiGet('/api/birthdays').then(function(r){
|
|
1323
1434
|
if(r&&r.error){el.innerHTML='<div class="card" style="text-align:center;padding:30px"><div style="color:var(--dim)">'+esc(r.error)+'</div></div>';return}
|
|
1324
1435
|
var bdays=r.birthdays||[];
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
h+='<div class="card" style="padding:
|
|
1331
|
-
}
|
|
1436
|
+
var h='<div style="display:flex;align-items:center;margin-bottom:12px">';
|
|
1437
|
+
h+='<div class="section-title" style="margin:0;flex:1">Upcoming Birthdays</div>';
|
|
1438
|
+
h+='<button onclick="openBirthdayForm(null)" style="background:var(--green3);color:var(--bg);padding:5px 14px;border-radius:var(--r);font-size:12px;font-weight:700">+ Add Birthday</button>';
|
|
1439
|
+
h+='</div>';
|
|
1440
|
+
if(bdays.length===0){
|
|
1441
|
+
h+='<div class="card" style="text-align:center;padding:30px;color:var(--dim)">No upcoming birthdays found.<br><span style="font-size:11px">Add one above, or add birthdays to your Google Contacts.</span></div>';
|
|
1442
|
+
} else {
|
|
1443
|
+
bdays.forEach(function(b){
|
|
1444
|
+
var isToday=b.daysUntil===0;
|
|
1445
|
+
var label=isToday?'<span style="color:var(--red);font-weight:700">TODAY!</span>':b.daysUntil===1?'<span style="color:var(--amber)">Tomorrow</span>':'<span style="color:var(--dim)">in '+b.daysUntil+' days</span>';
|
|
1446
|
+
h+='<div class="card" style="padding:10px 14px;display:flex;align-items:center;gap:8px'+(isToday?';border-color:var(--red)':'')+'"><span style="font-size:18px">🎂</span><div style="flex:1"><div style="font-weight:700;font-size:13px">'+esc(b.name)+'</div><div style="font-size:11px;color:var(--dim)">'+esc(b.date)+'</div></div><div style="margin-right:8px">'+label+'</div>';
|
|
1447
|
+
if(b.contactId){
|
|
1448
|
+
h+='<button onclick="openBirthdayForm('+JSON.stringify({contactId:b.contactId,name:b.name,date:b.date})+')" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:3px 8px;border-radius:4px;font-size:11px">Edit</button>';
|
|
1449
|
+
h+='<button onclick="deleteBirthday('+JSON.stringify(b.contactId)+','+JSON.stringify(b.name)+')" style="background:var(--bg3);border:1px solid var(--red);color:var(--red);padding:3px 8px;border-radius:4px;font-size:11px;margin-left:4px">Delete</button>';
|
|
1450
|
+
}
|
|
1451
|
+
h+='</div>';
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1332
1454
|
el.innerHTML=h;
|
|
1333
1455
|
});
|
|
1334
1456
|
}
|
|
1335
1457
|
|
|
1458
|
+
function openBirthdayForm(b){
|
|
1459
|
+
var isEdit=b&&b.contactId;
|
|
1460
|
+
var overlay=document.createElement('div');
|
|
1461
|
+
overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center';
|
|
1462
|
+
var card=document.createElement('div');
|
|
1463
|
+
card.style.cssText='background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:24px;width:360px;max-width:95vw';
|
|
1464
|
+
card.innerHTML='<div style="font-size:16px;font-weight:700;color:var(--bright);margin-bottom:16px">'+(isEdit?'Edit Birthday':'Add Birthday')+'</div>'+
|
|
1465
|
+
'<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:4px">Name *</label>'+
|
|
1466
|
+
'<input id="bdayName" type="text" value="'+esc(b&&b.name||'')+'" placeholder="Contact name" style="width:100%;box-sizing:border-box;padding:8px 10px;margin-bottom:12px;font-size:13px">'+
|
|
1467
|
+
'<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:4px">Birthday (MM-DD or YYYY-MM-DD)</label>'+
|
|
1468
|
+
'<input id="bdayDate" type="text" value="'+esc(b&&b.date||'')+'" placeholder="e.g. 03-15 or 1990-03-15" style="width:100%;box-sizing:border-box;padding:8px 10px;margin-bottom:16px;font-size:13px">'+
|
|
1469
|
+
'<div style="font-size:11px;color:var(--dim);margin-bottom:16px">Birthday will be saved as a Google Calendar event on the specified date.</div>'+
|
|
1470
|
+
'<div style="display:flex;gap:8px;justify-content:flex-end">'+
|
|
1471
|
+
'<button id="bdayCancelBtn" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:8px 18px;border-radius:var(--r);font-size:13px">Cancel</button>'+
|
|
1472
|
+
'<button id="bdaySaveBtn" style="background:var(--green3);color:var(--bg);padding:8px 18px;border-radius:var(--r);font-size:13px;font-weight:700">'+(isEdit?'Save':'Add')+'</button>'+
|
|
1473
|
+
'</div><div id="bdayErr" style="color:var(--red);font-size:12px;margin-top:8px"></div>';
|
|
1474
|
+
overlay.appendChild(card);
|
|
1475
|
+
document.body.appendChild(overlay);
|
|
1476
|
+
card.querySelector('#bdayCancelBtn').onclick=function(){document.body.removeChild(overlay);};
|
|
1477
|
+
overlay.onclick=function(e){if(e.target===overlay)document.body.removeChild(overlay);};
|
|
1478
|
+
card.querySelector('#bdaySaveBtn').onclick=function(){
|
|
1479
|
+
var name=card.querySelector('#bdayName').value.trim();
|
|
1480
|
+
var date=card.querySelector('#bdayDate').value.trim();
|
|
1481
|
+
if(!name){card.querySelector('#bdayErr').textContent='Name is required';return;}
|
|
1482
|
+
if(!date){card.querySelector('#bdayErr').textContent='Date is required';return;}
|
|
1483
|
+
var btn=card.querySelector('#bdaySaveBtn');
|
|
1484
|
+
btn.textContent='Saving...';btn.disabled=true;
|
|
1485
|
+
// Parse date into a full date for the calendar event
|
|
1486
|
+
var fullDate=date;
|
|
1487
|
+
if(/^\d{2}-\d{2}$/.test(date))fullDate=new Date().getFullYear()+'-'+date;
|
|
1488
|
+
apiPost('/api/birthdays',{name:name,date:fullDate,contactId:isEdit?b.contactId:null,edit:isEdit}).then(function(){
|
|
1489
|
+
document.body.removeChild(overlay);
|
|
1490
|
+
renderBirthdays(document.getElementById('content'));
|
|
1491
|
+
}).catch(function(e){
|
|
1492
|
+
card.querySelector('#bdayErr').textContent='Error: '+(e.message||'Unknown error');
|
|
1493
|
+
btn.textContent=isEdit?'Save':'Add';btn.disabled=false;
|
|
1494
|
+
});
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function deleteBirthday(contactId,name){
|
|
1499
|
+
if(!confirm('Remove birthday for '+name+'?'))return;
|
|
1500
|
+
apiPost('/api/birthdays/delete',{contactId:contactId}).then(function(){
|
|
1501
|
+
renderBirthdays(document.getElementById('content'));
|
|
1502
|
+
}).catch(function(e){alert('Error: '+e.message);});
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1336
1505
|
// ---- AGENTS ----
|
|
1337
1506
|
var AGENT_DESCRIPTIONS = {
|
|
1338
1507
|
saber:'Security audits, OWASP Top 10, threat modeling, pentest planning',
|
|
@@ -2624,15 +2793,16 @@ function showToast(type, title, body, durationMs) {
|
|
|
2624
2793
|
var ws = null;
|
|
2625
2794
|
var wsReconnectTimer = null;
|
|
2626
2795
|
var wsRetryCount = 0;
|
|
2627
|
-
var
|
|
2796
|
+
var wsRetryDelay = 2000; // start at 2s, exponential backoff up to 30s
|
|
2628
2797
|
function connectWebSocket() {
|
|
2629
|
-
if (
|
|
2798
|
+
if (wsReconnectTimer) return; // already scheduled
|
|
2630
2799
|
try {
|
|
2631
2800
|
ws = new WebSocket('ws://' + window.location.host);
|
|
2632
2801
|
} catch(e) { return; }
|
|
2633
2802
|
|
|
2634
2803
|
ws.onopen = function() {
|
|
2635
2804
|
wsRetryCount = 0;
|
|
2805
|
+
wsRetryDelay = 2000;
|
|
2636
2806
|
var indicator = document.getElementById('wsIndicator');
|
|
2637
2807
|
if (indicator) { indicator.style.color = 'var(--green)'; indicator.title = 'Live updates: connected'; }
|
|
2638
2808
|
};
|
|
@@ -2646,15 +2816,15 @@ function connectWebSocket() {
|
|
|
2646
2816
|
|
|
2647
2817
|
ws.onclose = function() {
|
|
2648
2818
|
var indicator = document.getElementById('wsIndicator');
|
|
2649
|
-
if (indicator) { indicator.style.color = 'var(--dim)'; indicator.title = 'Live updates:
|
|
2819
|
+
if (indicator) { indicator.style.color = 'var(--dim)'; indicator.title = 'Live updates: reconnecting...'; }
|
|
2650
2820
|
ws = null;
|
|
2651
2821
|
wsRetryCount++;
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
}
|
|
2822
|
+
var delay = Math.min(wsRetryDelay * Math.pow(1.5, Math.min(wsRetryCount - 1, 6)), 30000);
|
|
2823
|
+
wsRetryDelay = delay;
|
|
2824
|
+
wsReconnectTimer = setTimeout(function() {
|
|
2825
|
+
wsReconnectTimer = null;
|
|
2826
|
+
connectWebSocket();
|
|
2827
|
+
}, delay);
|
|
2658
2828
|
};
|
|
2659
2829
|
|
|
2660
2830
|
ws.onerror = function() {
|