kaizen-agent 1.31.0 → 1.31.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/browser.ts +14 -12
- package/cli/sources.test.ts +19 -0
- package/cli/sources.ts +17 -9
- package/package.json +1 -1
- package/web/board.html +34 -19
package/cli/browser.ts
CHANGED
|
@@ -219,7 +219,8 @@ export async function readPage(page: Page, url: string, platform: string, max =
|
|
|
219
219
|
for (let i = 0, still = 0; i < 12 && seen.size < max && still < 3; i++) {
|
|
220
220
|
const before = seen.size;
|
|
221
221
|
for (const p of await page.js<any[]>(COLLECT(re)) ?? []) if (!seen.has(p.link) && onPlatform(p.link)) seen.set(p.link, p);
|
|
222
|
-
|
|
222
|
+
// A page that renders its posts late is not empty: stop early only once some arrived.
|
|
223
|
+
still = seen.size && seen.size === before ? still + 1 : 0;
|
|
223
224
|
await page.js("window.scrollBy(0, window.innerHeight * 1.5)");
|
|
224
225
|
await Bun.sleep(1500);
|
|
225
226
|
}
|
|
@@ -251,27 +252,29 @@ export function postLink(notes = ""): string | null {
|
|
|
251
252
|
return null;
|
|
252
253
|
}
|
|
253
254
|
|
|
254
|
-
// Where each platform takes a reply on a post's own page:
|
|
255
|
-
//
|
|
256
|
-
// for
|
|
257
|
-
// dialog the composer opened, when there is one.
|
|
255
|
+
// Where each platform takes a reply on a post's own page: the box, and the button that
|
|
256
|
+
// sends it. Each is a selector, and for buttons known only by their words, a pattern
|
|
257
|
+
// for those words.
|
|
258
258
|
type Step = [css: string, words?: string];
|
|
259
|
-
const REPLY: Record<string, {
|
|
259
|
+
const REPLY: Record<string, { box: Step; send: Step }> = {
|
|
260
260
|
x: { box: ['[data-testid="tweetTextarea_0"]'], send: ['[data-testid="tweetButtonInline"],[data-testid="tweetButton"]'] },
|
|
261
|
-
threads: {
|
|
261
|
+
threads: { box: ['[contenteditable="true"]'], send: ['[role="button"]:has(svg[aria-label="Reply"])'] },
|
|
262
262
|
instagram: { box: ['textarea[aria-label*="comment" i],textarea[placeholder*="comment" i]'], send: ['[role="button"],button', "^Post$"] },
|
|
263
263
|
tiktok: { box: ['[data-e2e="comment-input"] [contenteditable="true"],[data-e2e="comment-input"] textarea'], send: ['[data-e2e="comment-post"]'] },
|
|
264
264
|
};
|
|
265
265
|
|
|
266
|
-
// Runs in the page: the first shown element a step names, looked for
|
|
267
|
-
//
|
|
268
|
-
// and marks it as the box. False when there is none.
|
|
266
|
+
// Runs in the page: the first shown element a step names, looked for nearest the
|
|
267
|
+
// marked box first (its own composer), then in the last open dialog, then the whole
|
|
268
|
+
// page. Clicks it, or focuses it and marks it as the box. False when there is none.
|
|
269
269
|
const FIND = (s: Step, act: "click" | "focus") => `(() => {
|
|
270
270
|
const words = ${JSON.stringify(s[1] ?? "")};
|
|
271
271
|
const fits = (e) => e.getClientRects().length && e.getAttribute("aria-disabled") !== "true" && !e.disabled
|
|
272
272
|
&& (!words || new RegExp(words, "i").test((e.innerText || e.getAttribute("aria-label") || "").trim()));
|
|
273
|
+
const pick = (r) => [...r.querySelectorAll(${JSON.stringify(s[0])})].find(fits);
|
|
274
|
+
let near = document.querySelector("[data-kaizen-box]");
|
|
275
|
+
while (near && !pick(near)) near = near.parentElement;
|
|
273
276
|
const dialog = [...document.querySelectorAll('[role="dialog"]')].filter((d) => d.getClientRects().length).at(-1);
|
|
274
|
-
const el = [dialog, document].filter(Boolean).map(
|
|
277
|
+
const el = [near, dialog, document].filter(Boolean).map(pick).find(Boolean);
|
|
275
278
|
if (!el) return false;
|
|
276
279
|
${act === "click" ? "(el.closest('[role=\"button\"],button') || el).click();" : "el.focus(); document.querySelectorAll('[data-kaizen-box]').forEach((e) => e.removeAttribute('data-kaizen-box')); el.setAttribute('data-kaizen-box', '');"}
|
|
277
280
|
return true;
|
|
@@ -296,7 +299,6 @@ export async function replyOn(page: Page, link: string, platform: string, text:
|
|
|
296
299
|
await page.go(link);
|
|
297
300
|
if (!(await here()) || (await page.js<boolean>(WALL))) return { ok: false, note: signInNote(platform) };
|
|
298
301
|
if (await page.js<boolean>(CHECK)) return { ok: false, note: checkNote(platform) };
|
|
299
|
-
if (how.open && (await page.js<boolean>(FIND(how.open, "click")))) await Bun.sleep(2000);
|
|
300
302
|
if (!(await page.js<boolean>(FIND(how.box, "focus"))))
|
|
301
303
|
return { ok: false, note: (await page.js<boolean>(OFFERS)) ? signInNote(platform) : `Could not find the reply box on ${name}. Nothing was posted.` };
|
|
302
304
|
await page.call("Input.insertText", { text });
|
package/cli/sources.test.ts
CHANGED
|
@@ -688,3 +688,22 @@ test("a reply is stamped once on its icebox item; a second is refused", () => {
|
|
|
688
688
|
expect(markReplied(state, "a1")).toBe("already replied");
|
|
689
689
|
} finally { rmSync(state, { recursive: true, force: true }); }
|
|
690
690
|
});
|
|
691
|
+
|
|
692
|
+
test("a GitHub issues link is read through the API: open issues, no pull requests", async () => {
|
|
693
|
+
const api = "https://api.github.com/repos/hfadhlullah/kaizen/issues?state=open&per_page=50";
|
|
694
|
+
expect(exportUrl("https://github.com/hfadhlullah/kaizen/issues")).toEqual({ url: api, kind: "github" });
|
|
695
|
+
expect(exportUrl("https://github.com/hfadhlullah/kaizen/issues/?q=is%3Aopen").url).toBe(api);
|
|
696
|
+
expect(exportUrl("https://github.com/hfadhlullah/kaizen/issues/6").kind).toBe("text");
|
|
697
|
+
expect(exportUrl("https://github.com/hfadhlullah/kaizen").kind).toBe("text");
|
|
698
|
+
const body = JSON.stringify([
|
|
699
|
+
{ number: 7, title: "TikTok source shows no posts", html_url: "https://github.com/o/r/issues/7", body: "x" },
|
|
700
|
+
{ number: 8, title: "Bump deps", html_url: "https://github.com/o/r/pull/8", pull_request: {} },
|
|
701
|
+
]);
|
|
702
|
+
expect(split("github", body)).toEqual([{ text: "TikTok source shows no posts", notes: "#7 · https://github.com/o/r/issues/7" }]);
|
|
703
|
+
let accept = "";
|
|
704
|
+
const json = async (_u: string, i: RequestInit) => { accept = new Headers(i.headers).get("accept")!; return new Response(body, { headers: { "content-type": "application/json; charset=utf-8" } }); };
|
|
705
|
+
expect((await fetchText(api, json, pub, true)).ok).toBe(true);
|
|
706
|
+
expect(accept).toBe("application/json");
|
|
707
|
+
// JSON only when asked: a plain source answering JSON is still refused.
|
|
708
|
+
expect(await fetchText(api, json, pub)).toEqual({ ok: false, note: UNREADABLE });
|
|
709
|
+
});
|
package/cli/sources.ts
CHANGED
|
@@ -285,9 +285,13 @@ export async function resolvesPublic(host: string, lookup: Lookup = dnsLookup):
|
|
|
285
285
|
}
|
|
286
286
|
|
|
287
287
|
// Google serves a sheet or a doc as plain text from its export address, to anyone
|
|
288
|
-
// the share setting allows.
|
|
289
|
-
|
|
288
|
+
// the share setting allows. A GitHub repo's issues page is HTML; its open issues
|
|
289
|
+
// come from the public API instead. Anything else is fetched as given.
|
|
290
|
+
export type Kind = "sheet" | "doc" | "text" | "github";
|
|
291
|
+
export function exportUrl(raw: string): { url: string; kind: Kind } {
|
|
290
292
|
const u = new URL(raw);
|
|
293
|
+
const gh = u.hostname.replace(/^www\./, "") === "github.com" && /^\/([\w.-]+)\/([\w.-]+)\/issues\/?$/.exec(u.pathname);
|
|
294
|
+
if (gh) return { url: `https://api.github.com/repos/${gh[1]}/${gh[2]}/issues?state=open&per_page=50`, kind: "github" };
|
|
291
295
|
if (u.hostname === "docs.google.com") {
|
|
292
296
|
// Signed in to several accounts, Google puts /u/<n> after the first segment.
|
|
293
297
|
const pub = /^\/spreadsheets(?:\/u\/\d+)?\/d\/e\/([\w-]+)\/pub/.exec(u.pathname);
|
|
@@ -312,7 +316,7 @@ type Fetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
|
312
316
|
|
|
313
317
|
// No cookie, no authorization, no redirect followed unchecked: every hop goes
|
|
314
318
|
// through the same guard as the first address.
|
|
315
|
-
export async function fetchText(url: string, doFetch: Fetch = fetch, lookup: Lookup = dnsLookup): Promise<{ ok: true; body: string } | { ok: false; note: string }> {
|
|
319
|
+
export async function fetchText(url: string, doFetch: Fetch = fetch, lookup: Lookup = dnsLookup, json = false): Promise<{ ok: true; body: string } | { ok: false; note: string }> {
|
|
316
320
|
const signal = AbortSignal.timeout(15000);
|
|
317
321
|
for (let hop = 0; hop <= 5; hop++) {
|
|
318
322
|
const why = checkUrl(url);
|
|
@@ -322,7 +326,7 @@ export async function fetchText(url: string, doFetch: Fetch = fetch, lookup: Loo
|
|
|
322
326
|
const pub = await resolvesPublic(host, lookup);
|
|
323
327
|
if (pub === null) return { ok: false, note: hop ? "The link redirected to an address that could not be found." : "The link's address could not be found. Check it for a typo." };
|
|
324
328
|
if (!pub) return { ok: false, note: "Refused: not a public address." };
|
|
325
|
-
const res = await doFetch(url, { redirect: "manual", credentials: "omit", signal, headers: { accept: "text/plain, text/*" } });
|
|
329
|
+
const res = await doFetch(url, { redirect: "manual", credentials: "omit", signal, headers: { accept: json ? "application/json" : "text/plain, text/*" } });
|
|
326
330
|
if (res.status >= 300 && res.status < 400) {
|
|
327
331
|
const to = res.headers.get("location");
|
|
328
332
|
if (!to) return { ok: false, note: `Redirect with nowhere to go (HTTP ${res.status}).` };
|
|
@@ -332,7 +336,7 @@ export async function fetchText(url: string, doFetch: Fetch = fetch, lookup: Loo
|
|
|
332
336
|
if (res.status === 401 || res.status === 403) return { ok: false, note: SIGN_IN };
|
|
333
337
|
if (!res.ok) return { ok: false, note: `The link answered HTTP ${res.status}.` };
|
|
334
338
|
const type = (res.headers.get("content-type") ?? "").toLowerCase();
|
|
335
|
-
if (!type.startsWith("text/") || type.startsWith("text/html")) return { ok: false, note: UNREADABLE };
|
|
339
|
+
if (json ? !type.startsWith("application/json") : !type.startsWith("text/") || type.startsWith("text/html")) return { ok: false, note: UNREADABLE };
|
|
336
340
|
// Read no further than the cap; a cut-off last line is dropped, not gathered half.
|
|
337
341
|
const chunks: Uint8Array[] = [];
|
|
338
342
|
let size = 0;
|
|
@@ -358,11 +362,15 @@ const DONE = /^(done|finish|finished)$/i;
|
|
|
358
362
|
|
|
359
363
|
type Item = { text: string; notes?: string };
|
|
360
364
|
|
|
361
|
-
export const split = (kind:
|
|
365
|
+
export const split = (kind: Kind, body: string): Item[] => sift(kind, body).items;
|
|
362
366
|
|
|
363
367
|
// The requests, and how many rows were left out for their status. A row left out is
|
|
364
368
|
// never handed to ingest, so it is not marked seen: reopened, it is gathered then.
|
|
365
|
-
export function sift(kind:
|
|
369
|
+
export function sift(kind: Kind, body: string): { items: Item[]; done: number } {
|
|
370
|
+
// Pull requests come back from the issues API too; they are not requests. The notes
|
|
371
|
+
// are the number and link only, so an edited body does not make the issue new.
|
|
372
|
+
if (kind === "github") return { items: (JSON.parse(body) as any[]).filter((i) => !i.pull_request && typeof i.title === "string")
|
|
373
|
+
.map((i) => ({ text: i.title, notes: `#${i.number} · ${i.html_url}` })), done: 0 };
|
|
366
374
|
const rows = body.split(/\r?\n/);
|
|
367
375
|
if (kind !== "sheet") return { items: rows.filter((r) => r.trim()).map((text) => ({ text })), done: 0 };
|
|
368
376
|
const cells = rows.map((r) => r.split("\t").map((c) => c.trim()));
|
|
@@ -425,10 +433,10 @@ export async function gatherSource(state: string, url: string, doFetch?: Fetch,
|
|
|
425
433
|
}
|
|
426
434
|
try {
|
|
427
435
|
const target = exportUrl(url);
|
|
428
|
-
const got = await fetchText(target.url, doFetch, lookup);
|
|
436
|
+
const got = await fetchText(target.url, doFetch, lookup, target.kind === "github");
|
|
429
437
|
if (got.ok) {
|
|
430
438
|
const { items, done } = sift(target.kind, got.body);
|
|
431
|
-
return record(state, url, ingest(state, url, items), got.body.split("\n").filter((r) => r.trim()).length, done);
|
|
439
|
+
return record(state, url, ingest(state, url, items), target.kind === "github" ? undefined : got.body.split("\n").filter((r) => r.trim()).length, done);
|
|
432
440
|
}
|
|
433
441
|
const last = { at: new Date().toISOString(), ok: false, added: 0, note: got.note };
|
|
434
442
|
setLast(state, url, last);
|
package/package.json
CHANGED
package/web/board.html
CHANGED
|
@@ -207,12 +207,12 @@ aside.wide{width:min(960px,100%)}#pbody .term{height:100%;min-height:320px;backg
|
|
|
207
207
|
.scrim.on{display:block}
|
|
208
208
|
aside{position:fixed;top:0;right:0;bottom:0;z-index:11;width:520px;max-width:100%;background:var(--bg-2);border-left:1px solid var(--line);box-shadow:var(--shadow-2);display:flex;flex-direction:column;transform:translateX(100%);visibility:hidden;transition:transform .18s,visibility .18s}
|
|
209
209
|
aside.open{transform:none;visibility:visible}
|
|
210
|
-
/* Settings and the Icebox are pages: the same panel, docked into the app column,
|
|
210
|
+
/* Settings and the Icebox are pages: the same panel, docked into the app column, full width like the board */
|
|
211
211
|
body.paged #board,body.paged .ftr,body.paged .scrim{display:none}
|
|
212
212
|
/* The Icebox keeps the project bar, without the board's title and New idea; Settings has none */
|
|
213
213
|
body.paged:not(.ice) .hdr,body.ice .hdr h1{display:none}body.ice #newtop{visibility:hidden}
|
|
214
214
|
aside.docked{position:static;z-index:auto;flex:1;min-height:0;width:auto;transform:none;visibility:visible;transition:none;box-shadow:none;border:0;background:none}
|
|
215
|
-
aside.docked>header,aside.docked>.body{padding-inline:
|
|
215
|
+
aside.docked>header,aside.docked>.body{padding-inline:28px}
|
|
216
216
|
aside.docked>header{border-bottom:0;padding-top:28px;padding-bottom:12px}
|
|
217
217
|
aside.docked>header b{font-size:20px}
|
|
218
218
|
aside.docked #pclose{display:none}
|
|
@@ -371,8 +371,11 @@ body:not(.paged) .src-sec,body:not(.paged) .src-scrim{display:none}
|
|
|
371
371
|
.ice-form{display:grid;gap:6px;padding-top:8px;border-top:1px solid var(--line)}
|
|
372
372
|
.ice-form .sel{width:100%;max-width:none}
|
|
373
373
|
.ice-form textarea.sel{height:auto;min-height:64px;font:inherit;font-size:13px;resize:vertical}
|
|
374
|
-
.ice-form-acts{display:flex;align-items:center;gap:8px}
|
|
375
|
-
.ice-form-acts .hint{flex:1;margin:0;font-size:11px}
|
|
374
|
+
.ice-form-acts{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
|
375
|
+
.ice-form-acts .hint{flex:1 1 200px;margin:0;font-size:11px}
|
|
376
|
+
.ice-form-acts .sel{width:auto;flex:none}
|
|
377
|
+
.ice-form-acts .end{flex:1}
|
|
378
|
+
.ice-form-acts .btn{white-space:nowrap}
|
|
376
379
|
.ice-notes{font-size:12px;color:var(--dim);line-height:1.45;overflow-wrap:anywhere;white-space:pre-wrap}
|
|
377
380
|
.src-empty{padding:26px 16px;text-align:center;background:var(--bg);border:1px dashed var(--line);border-radius:var(--r);margin:12px 0 16px}
|
|
378
381
|
.src-empty-ico{width:36px;height:36px;margin:0 auto 10px;color:var(--dim);display:flex;align-items:center;justify-content:center}
|
|
@@ -382,7 +385,8 @@ body:not(.paged) .src-sec,body:not(.paged) .src-scrim{display:none}
|
|
|
382
385
|
.src-empty-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;margin-top:12px;text-align:left}
|
|
383
386
|
.src-empty-item{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r-s);padding:8px 10px;font-size:11px;color:var(--dim);line-height:1.4}
|
|
384
387
|
.src-empty-item strong{display:block;color:var(--ink);font-weight:500;margin-bottom:2px}
|
|
385
|
-
.src-add{display:
|
|
388
|
+
.src-add[hidden]{display:none}.src-hda{display:flex;align-items:center;gap:6px}
|
|
389
|
+
.src-add{display:grid;gap:12px;margin-bottom:16px;padding:16px;background:var(--bg);border:1px solid var(--line);border-radius:var(--r)}
|
|
386
390
|
.src-add h4{margin:0;font-size:13px;font-weight:600;color:var(--ink)}
|
|
387
391
|
.src-add .two{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
|
388
392
|
.src-add .two.plat{grid-template-columns:140px 1fr}
|
|
@@ -1086,11 +1090,18 @@ function progress(src,prev){
|
|
|
1086
1090
|
const groups=rows=>{const m=new Map();for(const r of rows){if(!m.has(r.group))m.set(r.group,[]);m.get(r.group).push(r)}return [...m]};
|
|
1087
1091
|
// The search survives the re-render each save does; the gear button starts it empty.
|
|
1088
1092
|
let setQ="";
|
|
1093
|
+
// Settings and the Icebox fill the same panel after a fetch: a fill only lands if no other view
|
|
1094
|
+
// or fill started since, or a slow answer paints one page under the other's title.
|
|
1095
|
+
let pageGen=0;
|
|
1089
1096
|
async function openSettings(){
|
|
1097
|
+
if(view!=="settings")return;const gen=++pageGen;
|
|
1090
1098
|
stopDictation(true);
|
|
1099
|
+
// Coming from another page or a card, its body goes now, not when the fetch answers.
|
|
1100
|
+
if(!$("panel").classList.contains("open")||$("ptitle").textContent!=="Settings"){$("pbody").innerHTML=`<p class="snone">Loading…</p>`;$("psub").textContent=""}
|
|
1091
1101
|
termStop();openKey=null;runDoc=null;$("pterm").hidden=true;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent="Settings";$("ptabs").innerHTML="";$("pfoot").innerHTML="";
|
|
1092
1102
|
const cur=document.documentElement.dataset.theme||"system";
|
|
1093
1103
|
const cfg=await fetch("/settings").then(r=>r.json()).catch(()=>null);
|
|
1104
|
+
if(gen!==pageGen)return;
|
|
1094
1105
|
$("psub").textContent="Theme applies to this browser. Everything else is saved to your kaizen settings.";$("psub").title=cfg?cfg.file:"";
|
|
1095
1106
|
// One control per row: a switch for on/off, one segment per value for a small number,
|
|
1096
1107
|
// a select for a choice. The row's label names the control; its help sits under it.
|
|
@@ -1133,7 +1144,7 @@ async function openSettings(){
|
|
|
1133
1144
|
// ---- views: the board, or Settings and the Icebox as pages in the docked panel, each with its own address
|
|
1134
1145
|
let view="board";
|
|
1135
1146
|
const markView=v=>document.querySelectorAll(".rail a.it").forEach(a=>a.dataset.view===v?a.setAttribute("aria-current","page"):a.removeAttribute("aria-current"));
|
|
1136
|
-
function go(v,push){if(v==="icebox")v="sources";if(!["settings","sources"].includes(v))v="board";const was=view;view=v;markView(v);document.body.classList.toggle("paged",v!=="board");document.body.classList.toggle("ice",v==="sources");
|
|
1147
|
+
function go(v,push){pageGen++;if(v==="icebox")v="sources";if(!["settings","sources"].includes(v))v="board";const was=view;view=v;markView(v);document.body.classList.toggle("paged",v!=="board");document.body.classList.toggle("ice",v==="sources");
|
|
1137
1148
|
if(push&&v!==was)history.pushState(null,"",v==="board"?location.pathname:"?view="+(v==="sources"?"icebox":v));
|
|
1138
1149
|
const p=$("panel");
|
|
1139
1150
|
if(v==="board"){if(was==="board")return;
|
|
@@ -1160,11 +1171,12 @@ const SRC="Icebox";
|
|
|
1160
1171
|
let iceReply=null;const iceStyle={};
|
|
1161
1172
|
const rStyle=dir=>{if(!iceStyle[dir])try{iceStyle[dir]=localStorage.getItem("kz-reply-style:"+dir)}catch(e){}return iceStyle[dir]||"casual"};
|
|
1162
1173
|
const STYLES={casual:"Casual",fun:"Fun",helpful:"Helpful",promo:"Promo"};
|
|
1163
|
-
let iceForm=null,iceCur=null,iceSrc="",iceCat="",srcKind="link",srcShow=null,iceBusy=false,iceNav=null;const iceSel=new Set();
|
|
1174
|
+
let iceForm=null,iceCur=null,iceSrc="",iceCat="",srcKind="link",srcShow=null,srcAdd=false,iceBusy=false,iceNav=null;const iceSel=new Set();
|
|
1164
1175
|
const PLAT={x:"X",tiktok:"TikTok",instagram:"Instagram",threads:"Threads"};
|
|
1165
1176
|
const srcOpen=()=>$("panel").classList.contains("open")&&$("ptitle").textContent===SRC;
|
|
1166
1177
|
const ago=at=>{const m=Math.round((Date.now()-Date.parse(at))/60000);return m<1?"just now":m<60?m+" min ago":m<1440?Math.round(m/60)+" h ago":Math.round(m/1440)+" d ago"};
|
|
1167
1178
|
async function openSources(){
|
|
1179
|
+
if(view!=="sources")return;const gen=++pageGen;
|
|
1168
1180
|
stopDictation(true);
|
|
1169
1181
|
// What is half typed survives a refresh from the server.
|
|
1170
1182
|
const typed=srcOpen()?[$("src-url")?.value||"",$("src-label")?.value||"",document.activeElement?.id,$("src-dir")?.value,$("src-cat")?.value||"",$("src-plat")?.value,$("src-q")?.value||""]:["",""];
|
|
@@ -1176,11 +1188,12 @@ async function openSources(){
|
|
|
1176
1188
|
const iceDraft=srcOpen()&&$("ice-title")?[$("ice-title").dataset.k,$("ice-title").value,$("ice-details").value]:null;
|
|
1177
1189
|
// And a category typed on a row but not yet saved.
|
|
1178
1190
|
const cats=new Map(srcOpen()?[...$("pbody").querySelectorAll("[data-ct]")].map(m=>[m.dataset.k,m.value]):[]);
|
|
1191
|
+
if(!srcOpen())$("pbody").innerHTML=`<p class="snone">Loading…</p>`;
|
|
1179
1192
|
termStop();openKey=null;runDoc=null;$("pterm").hidden=true;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=SRC;$("ptabs").innerHTML="";$("pfoot").innerHTML="";
|
|
1180
1193
|
$("psub").textContent="Requests gathered from your sources wait here until you promote them to Ideas or dismiss them; nothing starts on its own.";$("psub").title="";
|
|
1181
1194
|
const dir=all?null:S.dir,q=dir?"?dir="+encodeURIComponent(dir):"";
|
|
1182
1195
|
const d=await fetch("/sources"+q).then(r=>r.json()).catch(()=>null);
|
|
1183
|
-
if(
|
|
1196
|
+
if(gen!==pageGen||!srcOpen())return;
|
|
1184
1197
|
const host=u=>{try{return new URL(u).hostname}catch{return u}};
|
|
1185
1198
|
// The favicon of the site a link is on, asked of that site itself (Google's own static host for
|
|
1186
1199
|
// Sheets and Docs, whose site icon is generic): nothing but the host reaches anyone. A social
|
|
@@ -1203,7 +1216,8 @@ async function openSources(){
|
|
|
1203
1216
|
const fails=(d&&d.failed||[]).map(f=>`<p style="color:var(--dim)">The sources of ${esc(f)} could not be read.</p>`).join("");
|
|
1204
1217
|
const emptyState=`<div class="src-empty"><div class="src-empty-ico">${ico("link")}</div><h4>No request sources yet</h4><p>Connect shared links to gather requests into the icebox, where you choose which become ideas. Nothing runs automatically until you start it.</p><div class="src-empty-grid"><div class="src-empty-item"><strong>Google Sheets</strong>Public sheets with idea or task rows. Rows marked Done are skipped.</div><div class="src-empty-item"><strong>Google Docs</strong>Shared public docs with items or notes written line by line.</div><div class="src-empty-item"><strong>Public links</strong>TSV, CSV, or raw text links containing requests.</div><div class="src-empty-item"><strong>Social media</strong>A keyword, #hashtag or @account on X, TikTok, Instagram or Threads, read by your agent.</div></div></div>`;
|
|
1205
1218
|
const social=srcKind==="social";
|
|
1206
|
-
const
|
|
1219
|
+
const addOn=!list.length||srcAdd||[typed[0],typed[1],typed[4],typed[6]].some(Boolean);
|
|
1220
|
+
const addBox=`<div class="src-add" id="src-addbox"${addOn?"":" hidden"}><div class="hd"><h4>Add a source</h4><div class="src-kind" role="group" aria-label="Kind of source"><button id="src-k-link" data-kind="link" aria-pressed="${!social}">Link</button><button id="src-k-social" data-kind="social" aria-pressed="${social}">Social</button></div></div><label for="src-url"${social?" hidden":""}><span>Link</span><input class="sel" id="src-url" placeholder="https://docs.google.com/spreadsheets/d/… or any public link" spellcheck="false" autocomplete="off"></label><div class="two plat"${social?"":" hidden"}><label for="src-plat"><span>Platform</span><select class="sel" id="src-plat">${Object.entries(PLAT).map(([k,v])=>`<option value="${k}">${v}</option>`).join("")}</select></label><label for="src-q"><span>Follow</span><input class="sel" id="src-q" placeholder="keyword, #hashtag or @account" maxlength="100" spellcheck="false" autocomplete="off"></label></div><div class="two"><label for="src-label"><span>Label <i>optional</i></span><input class="sel" id="src-label" placeholder="${social?"e.g. Mentions on X":"e.g. Feedback Q4"}"></label><label for="src-cat"><span>Category <i>optional</i></span><input class="sel" id="src-cat" placeholder="${social?"e.g. Social":"e.g. Bugs"}" maxlength="40"></label></div><label for="src-dir"><span>Project</span><select class="sel" id="src-dir">${dir?"":`<option value="">Choose project…</option>`}${opts(dir)}</select></label><div class="acts"><span class="hint">${social?(chatOn?"Your ChatGPT plan reads public posts when you press Gather on the source.":"Kaizen signs in nowhere. Your agent reads public posts when you press Gather on the source."):"Public links only. For private docs, run /kaizen gather."}</span><button class="btn pri" id="src-add">Add source</button></div></div>`;
|
|
1207
1221
|
// The inbox: one row per request, by project when showing all, then by category,
|
|
1208
1222
|
// narrowed to one source by its chip. Checked, open and cursor items are kept by key.
|
|
1209
1223
|
const srcName=it=>{const s=list.find(x=>x.dir===it.dir&&x.url===it.source);return s?s.label||host(s.url):host(it.source)};
|
|
@@ -1236,14 +1250,14 @@ async function openSources(){
|
|
|
1236
1250
|
const bulk=iceSel.size?`<div class="ice-bulk" role="toolbar" aria-label="Selected requests"><span>${iceSel.size} selected</span><span class="end"></span><button class="btn sm" id="ice-bp" title="Each becomes its own idea, titled as gathered (p)">${ico("plus")} Promote</button><button class="btn ghost sm" id="ice-bd" title="Dismiss (d)">${ico("x")} Dismiss</button><button class="btn ghost sm" id="ice-bc" title="Clear selection (Esc)">Clear</button></div>`:"";
|
|
1237
1251
|
// The open item's form: a clean title for the idea's card and the person's own details;
|
|
1238
1252
|
// what was gathered is added to the idea's notes by the server.
|
|
1239
|
-
const form=it=>`<div class="ice-form"><label for="ice-title" class="hint" style="margin:0">Idea title</label><input class="sel" id="ice-title" data-k="${esc(iceKey(it))}" maxlength="200" value="${esc(it.text)}" aria-label="Idea title"><label for="ice-details" class="hint" style="margin:0">Details
|
|
1253
|
+
const form=it=>`<div class="ice-form"><label for="ice-title" class="hint" style="margin:0">Idea title</label><input class="sel" id="ice-title" data-k="${esc(iceKey(it))}" maxlength="200" value="${esc(it.text)}" aria-label="Idea title"><label for="ice-details" class="hint" style="margin:0">Details</label><textarea class="sel" id="ice-details" rows="3" placeholder="What, for whom, why" aria-label="Details"></textarea><div class="ice-form-acts"><span class="hint">Source goes into the notes.</span><button class="btn ghost sm" id="ice-cancel">Cancel</button><button class="btn pri sm" id="ice-ok">Promote</button></div></div>`;
|
|
1240
1254
|
// A gathered post can be answered: Reply drafts, the box is the approval, Send reply posts that text.
|
|
1241
1255
|
const replyBox=it=>{if(!it.post)return"";if(it.replied)return`<span class="hint" style="margin:0">You replied to this post ${ago(it.replied)}.</span>`;
|
|
1242
1256
|
const r=iceReply&&iceReply.k===iceKey(it)?iceReply:null;
|
|
1243
1257
|
const style=d=>`<select class="sel" id="ice-rst" aria-label="Reply style" title="Promo names kaizen; Helpful may mention it when it fits; Casual and Fun never do"${d?" disabled":""}>${Object.entries(STYLES).map(([v,l])=>`<option value="${v}"${v===rStyle(it.dir)?" selected":""}>${l}</option>`).join("")}</select>`;
|
|
1244
|
-
if(!r)return`<div class="ice-form-acts"><span class="hint">
|
|
1245
|
-
if(r.wait)return`<div class="ice-form-acts"><span class="hint">
|
|
1246
|
-
return`<div class="ice-form"><label for="ice-reply" class="hint" style="margin:0">Your reply${r.note?` · ${esc(r.note)}`:""}</label><textarea class="sel" id="ice-reply" rows="3" maxlength="2000" aria-label="Your reply"${r.busy||r.again?" disabled":""}>${esc(r.text)}</textarea><
|
|
1258
|
+
if(!r)return`<div class="ice-form-acts"><span class="hint">You review it before it posts.</span>${G.connected?style(false):""}<button class="btn sm" id="ice-rp">Reply</button></div>`;
|
|
1259
|
+
if(r.wait)return`<div class="ice-form-acts"><span class="hint">Drafting…</span></div>`;
|
|
1260
|
+
return`<div class="ice-form"><label for="ice-reply" class="hint" style="margin:0">Your reply${r.note?` · ${esc(r.note)}`:""}</label><textarea class="sel" id="ice-reply" rows="3" maxlength="2000" aria-label="Your reply"${r.busy||r.again?" disabled":""}>${esc(r.text)}</textarea><span class="hint" id="ice-rn" style="margin:0">${r.again?"Writing another…":`${r.text.length} chars`}</span><div class="ice-form-acts"><span class="end"></span>${G.connected?`${style(r.busy||r.again)}<button class="btn ghost sm" id="ice-rg"${r.busy||r.again?" disabled":""}>Regenerate</button>`:""}<button class="btn ghost sm" id="ice-rc"${r.busy?" disabled":""}>Cancel</button><button class="btn pri sm" id="ice-rs"${r.busy||r.again||!r.text.trim()?" disabled":""}>${r.busy?"Sending…":"Send reply"}</button></div></div>`};
|
|
1247
1261
|
const row=i=>{const it=ice[i],k=iceKey(it),open=iceForm===k;
|
|
1248
1262
|
return `<div class="ice-row${iceCur===k?" cur":""}${iceSel.has(k)?" sel":""}" data-i="${i}" data-k="${esc(k)}"><input type="checkbox" id="ice-ck-${i}" data-ck="${i}"${iceSel.has(k)?" checked":""} aria-label="Select: ${esc(it.text)}"><button class="ice-t" id="ice-o-${i}" data-o="${i}" aria-expanded="${open}" title="${esc(it.text)}">${esc(it.text)}</button><span class="ice-src" title="${esc(srcName(it))}">${fav(it.source,"link",list.find(x=>x.dir===it.dir&&x.url===it.source)?.social)}<span class="nm">${esc(many?it.project||srcName(it):srcName(it))}</span></span>${it.replied?`<span class="chip" title="Replied ${esc(ago(it.replied))}">replied</span>`:""}<span class="ice-age">${ago(it.at)}</span><span class="ice-ra"><button class="btn ghost sm" id="ice-pr-${i}" data-pr="${i}" title="Promote to Ideas as gathered (p)" aria-label="Promote to Ideas: ${esc(it.text)}">${ico("plus")}</button><button class="btn ghost sm" id="ice-ds-${i}" data-ds="${i}" title="Dismiss (d)" aria-label="Dismiss: ${esc(it.text)}">${ico("x")}</button></span></div>`
|
|
1249
1263
|
+(open?`<div class="ice-open">${it.notes?`<div class="ice-notes">${esc(it.notes)}</div>`:""}<span class="hint" style="margin:0">From ${esc(srcName(it))} · ${ago(it.at)}${it.post?` · <a href="${esc(it.post)}" target="_blank" rel="noopener">open post</a>`:""}</span>${replyBox(it)}${form(it)}</div>`:"")};
|
|
@@ -1252,7 +1266,7 @@ async function openSources(){
|
|
|
1252
1266
|
// The sources, in the right-hand drawer: shut until asked for, open by itself while there
|
|
1253
1267
|
// are none until closed. Slid in only when opened; a rebuild while open shows it in place.
|
|
1254
1268
|
const drawer=srcShow??!list.length,slide=drawer&&!$("src-sec")?.classList.contains("open");
|
|
1255
|
-
const sec=`<div class="src-scrim${drawer&&!slide?" on":""}" id="src-scrim"></div><aside class="src-sec${drawer&&!slide?" open":""}" id="src-sec" aria-label="Sources"${drawer?"":" inert"}><header><div class="t"><b>Sources</b><div class="sub">${d&&d.every&&d.every!=="off"?`Auto-check every ${esc(d.every)}`:"Auto-check off"} · <a id="src-to-settings" role="button" tabindex="0">Settings</a> · ${G.connected?`ChatGPT${G.email?" "+esc(G.email):""} <a id="cg-out" role="button" tabindex="0">Disconnect</a>`:`<a id="cg-in" role="button" tabindex="0">Connect ChatGPT</a>`}</div></div><button class="ib" id="src-x" aria-label="Close sources" title="Close">${ico("close")}</button></header><div class="body">${list.length?`<div class="src-list">${cards}</div>`:emptyState}
|
|
1269
|
+
const sec=`<div class="src-scrim${drawer&&!slide?" on":""}" id="src-scrim"></div><aside class="src-sec${drawer&&!slide?" open":""}" id="src-sec" aria-label="Sources"${drawer?"":" inert"}><header><div class="t"><b>Sources</b><div class="sub">${d&&d.every&&d.every!=="off"?`Auto-check every ${esc(d.every)}`:"Auto-check off"} · <a id="src-to-settings" role="button" tabindex="0">Settings</a> · ${G.connected?`ChatGPT${G.email?" "+esc(G.email):""} <a id="cg-out" role="button" tabindex="0">Disconnect</a>`:`<a id="cg-in" role="button" tabindex="0">Connect ChatGPT</a>`}</div></div><div class="src-hda">${list.length?`<button class="btn sm" id="src-new" aria-expanded="${addOn}" aria-controls="src-addbox">${ico("plus")} Add</button>`:""}<button class="ib" id="src-x" aria-label="Close sources" title="Close">${ico("close")}</button></div></header><div class="body">${addBox}${list.length?`<div class="src-list">${cards}</div>`:emptyState}</div></aside>`;
|
|
1256
1270
|
// First visit: offer the ChatGPT plan for social sources, or keep the agent.
|
|
1257
1271
|
const onb=G.use==="ask"&&!G.connected?`<div class="cg-onb"><b>Gather social posts with your ChatGPT plan</b><p>Connect ChatGPT Plus or Pro and Gather reads X, TikTok, Instagram and Threads sources right here, counted against your plan, with no agent to open. Skip it and Gather keeps using your agent. You can change this in Settings.</p><div class="acts"><button class="btn ghost" id="cg-skip">Skip for now</button><button class="btn pri" id="cg-go">Continue with ChatGPT</button></div></div>`:"";
|
|
1258
1272
|
$("pbody").innerHTML=d&&d.error?`<p style="color:var(--dim)">${esc(d.error)}</p>`:!d?`<p style="color:var(--dim)">The icebox could not be read.</p>`:onb+bar+bulk+fails+inbox+sec;
|
|
@@ -1268,7 +1282,8 @@ async function openSources(){
|
|
|
1268
1282
|
if($("cg-out"))$("cg-out").onclick=async()=>{if(!confirm("Disconnect ChatGPT? Gather goes back to your agent."))return;if((await post("/chatgpt",{op:"disconnect"})).ok){toast("ChatGPT disconnected");openSources()}};
|
|
1269
1283
|
if(slide){$("src-sec").offsetWidth;$("src-sec").classList.add("open");$("src-scrim").classList.add("on")}
|
|
1270
1284
|
const srcShut=()=>{srcShow=false;$("src-sec").classList.remove("open");$("src-scrim").classList.remove("on");$("src-sec").inert=true;$("src-tog").setAttribute("aria-expanded","false");$("src-tog").focus()};
|
|
1271
|
-
$("src-x").onclick=$("src-scrim").onclick=srcShut
|
|
1285
|
+
$("src-x").onclick=$("src-scrim").onclick=srcShut;
|
|
1286
|
+
if($("src-new"))$("src-new").onclick=()=>{const b=$("src-addbox");srcAdd=b.hidden;b.hidden=!srcAdd;$("src-new").setAttribute("aria-expanded",srcAdd);if(srcAdd)$(srcKind==="social"?"src-q":"src-url").focus()};$("src-sec").onkeydown=e=>{if(e.key==="Escape"){e.stopPropagation();srcShut()}};
|
|
1272
1287
|
$("src-tog").onclick=()=>{if($("src-sec").classList.contains("open"))return srcShut();srcShow=true;openSources().then(()=>srcShow&&$("src-url")?.focus())};
|
|
1273
1288
|
if($("ice-fs"))$("ice-fs").onchange=e=>{iceSrc=e.target.value;openSources()};
|
|
1274
1289
|
if($("ice-fc"))$("ice-fc").onchange=e=>{iceCat=e.target.value;openSources()};
|
|
@@ -1313,14 +1328,14 @@ async function openSources(){
|
|
|
1313
1328
|
if($("ice-rp"))$("ice-rp").onclick=async()=>{const it=open1,k=iceKey(it);iceReply={k,text:"",wait:true};openSources();
|
|
1314
1329
|
const j=await post("/reply",{op:"draft",dir:it.dir,id:it.id,style:rStyle(it.dir)});if(!iceReply||iceReply.k!==k)return;
|
|
1315
1330
|
iceReply={k,text:j.text||"",note:j.ok?j.note||"":"",wait:false};openSources().then(()=>$("ice-reply")?.focus())};
|
|
1316
|
-
if($("ice-reply")){const ta=$("ice-reply");ta.oninput=()=>{iceReply.text=ta.value;$("ice-rn").textContent=`${ta.value.length} characters · nothing is posted until you press Send reply`;$("ice-rs").disabled=!ta.value.trim()};
|
|
1317
|
-
ta.onkeydown=e=>{if(e.key==="Escape"){e.stopPropagation();$("ice-rc").click()}};
|
|
1318
|
-
$("ice-rc").onclick=()=>{iceReply=null;openSources()};
|
|
1319
1331
|
if($("ice-rst"))$("ice-rst").onchange=e=>{const d=open1.dir;iceStyle[d]=e.target.value;try{localStorage.setItem("kz-reply-style:"+d,iceStyle[d])}catch(e){}};
|
|
1320
1332
|
// Another draft in the picked style, told to differ from the one in the box; the box keeps its text until it lands.
|
|
1321
1333
|
if($("ice-rg"))$("ice-rg").onclick=async()=>{const it=open1,r=iceReply;r.again=true;openSources();
|
|
1322
1334
|
const j=await post("/reply",{op:"draft",dir:it.dir,id:it.id,style:rStyle(it.dir),avoid:r.text});if(iceReply!==r)return;
|
|
1323
1335
|
r.again=false;if(j.text)r.text=j.text;r.note=j.ok?j.note||"":"";openSources().then(()=>$("ice-reply")?.focus())};
|
|
1336
|
+
if($("ice-reply")){const ta=$("ice-reply");ta.oninput=()=>{iceReply.text=ta.value;$("ice-rn").textContent=`${ta.value.length} chars`;$("ice-rs").disabled=!ta.value.trim()};
|
|
1337
|
+
ta.onkeydown=e=>{if(e.key==="Escape"){e.stopPropagation();$("ice-rc").click()}};
|
|
1338
|
+
$("ice-rc").onclick=()=>{iceReply=null;openSources()};
|
|
1324
1339
|
$("ice-rs").onclick=async()=>{const it=open1,r=iceReply;r.busy=true;openSources();
|
|
1325
1340
|
const j=await post("/reply",{op:"send",dir:it.dir,id:it.id,text:r.text});
|
|
1326
1341
|
if(j.ok){iceReply=null;toast("Reply posted")}else if(j.sent)iceReply=null;else r.busy=false;
|
|
@@ -1369,7 +1384,7 @@ async function openSources(){
|
|
|
1369
1384
|
else if(j.ok){$("src-url").value=$("src-label").value=$("src-cat").value="";const g=await post("/sources",{op:"gather",dir:to,url}),r=g.results&&g.results[0];
|
|
1370
1385
|
toast(r?(r.ok?`Source added: ${r.note} in the icebox`:`Source added, but: ${r.note}`):"Source added");load()}
|
|
1371
1386
|
else toast(j.error||"Could not add source");
|
|
1372
|
-
if(srcOpen())openSources()})()};
|
|
1387
|
+
if(j.ok)srcAdd=false;if(srcOpen())openSources()})()};
|
|
1373
1388
|
$("src-url").onkeydown=$("src-q").onkeydown=$("src-label").onkeydown=$("src-cat").onkeydown=e=>{if(e.key==="Enter")$("src-add").click()};
|
|
1374
1389
|
}
|
|
1375
1390
|
if($("src-go"))$("src-go").onclick=()=>{$("src-go").disabled=true;$("src-go").innerHTML=ico("refresh")+" Gathering…";
|