create-pmos 0.204.0 → 0.205.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-capture-test.js +206 -0
- package/workspace/extension/README.md +22 -7
- package/workspace/extension/capture.js +114 -0
- package/workspace/extension/manifest.json +49 -8
- package/workspace/extension/popup.css +28 -0
- package/workspace/extension/popup.html +15 -0
- package/workspace/extension/popup.js +195 -0
- package/workspace/guide/report-design.md +15 -1
- package/workspace/lib/extension-cli.js +6 -4
- package/workspace/lib/report-design.js +62 -4
- package/workspace/lib/routes-report-design.js +33 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-boot.js +2 -1
- package/workspace/public/pm/c3-capture.css +23 -0
- package/workspace/public/pm/c3-capture.js +152 -0
- package/workspace/public/pm/c3-design-studio.js +17 -5
- package/workspace/public/pm/c3-shell.js +2 -0
- package/workspace/skills/report-design.skill +1 -0
- package/workspace/test-lists/report-design-studio-ui.json +3 -3
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/* popup.js — the PMOS button's popup (popup.html): "Add this page to a report design".
|
|
2
|
+
* PMOS draws the popup: the page is one frame, ${address}/c3.html?capture=1&pop=1&…, which lists the reports and has the Add button. The extension never posts to PMOS
|
|
3
|
+
* itself, because every PMOS write needs a press on a PMOS page (a nonce and an Origin check). So when the frame asks (postMessage { pmosCapture: 'request' }), this
|
|
4
|
+
* page takes the open tab and posts it back to the frame ({ pmosCapture: 'data', … }), and the frame uploads it with its own press. Both ways, the message must
|
|
5
|
+
* come from the other window and from the PMOS origin; the extension posts to the PMOS origin only.
|
|
6
|
+
* Google Slides and Google Docs → the PDF, fetched here with the user's own Google cookies (host permission for https://docs.google.com/*)
|
|
7
|
+
* any other page → pictures of the whole page, scrolled top to bottom: at most 8, JPEG, 650 ms apart (Chrome's capture rate limit), scroll put back after
|
|
8
|
+
* When nothing answers at the address, this page says so and offers Try again. Nothing else is fetched. */
|
|
9
|
+
(function () {
|
|
10
|
+
'use strict';
|
|
11
|
+
var A = window.PMOS_ADDRESS;
|
|
12
|
+
var C = window.PMOS_CAPTURE;
|
|
13
|
+
var CHECK_MS = 4000; // how long to wait for PMOS to answer
|
|
14
|
+
var READY_MS = 8000; // how long to wait for the frame to say it is drawn
|
|
15
|
+
var SETTLE_MS = 300; // after a scroll, before the picture
|
|
16
|
+
var msg = document.getElementById('msg');
|
|
17
|
+
var frame = null;
|
|
18
|
+
var frameOrigin = '';
|
|
19
|
+
var readyTimer = null;
|
|
20
|
+
var busy = false;
|
|
21
|
+
var current = null; // { tab, kind, address }
|
|
22
|
+
|
|
23
|
+
function readAddress(done) {
|
|
24
|
+
var fallback = A.DEFAULT_ADDRESS;
|
|
25
|
+
try {
|
|
26
|
+
var o = {}; o[A.KEY] = fallback;
|
|
27
|
+
chrome.storage.sync.get(o, function (v) { done(A.normalizeAddress(v && v[A.KEY]) || fallback); });
|
|
28
|
+
} catch (e) { done(fallback); }
|
|
29
|
+
}
|
|
30
|
+
function getTab(done) {
|
|
31
|
+
try { chrome.tabs.query({ active: true, currentWindow: true }, function (t) { done((t && t[0]) || null); }); } catch (e) { done(null); }
|
|
32
|
+
}
|
|
33
|
+
/** Resolves when something answers at the address (an opaque no-cors reply is enough), rejects when nothing listens. */
|
|
34
|
+
function answers(addr) {
|
|
35
|
+
var ctl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
36
|
+
var t = ctl ? setTimeout(function () { ctl.abort(); }, CHECK_MS) : null;
|
|
37
|
+
return fetch(addr + '/c3.html', { mode: 'no-cors', cache: 'no-store', signal: ctl ? ctl.signal : undefined })
|
|
38
|
+
.then(function (r) { if (t) clearTimeout(t); return r; }, function (e) { if (t) clearTimeout(t); throw e; });
|
|
39
|
+
}
|
|
40
|
+
var sleep = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); };
|
|
41
|
+
|
|
42
|
+
/* ── the popup's own states ── */
|
|
43
|
+
function head(host) {
|
|
44
|
+
var h = document.createElement('div'); h.className = 'head';
|
|
45
|
+
var b = document.createElement('b'); b.textContent = 'PMOS'; h.appendChild(b);
|
|
46
|
+
var s = document.createElement('span'); s.textContent = 'Add this page to a report design'; h.appendChild(s);
|
|
47
|
+
host.appendChild(h);
|
|
48
|
+
}
|
|
49
|
+
function show(text, withRetry, extra) {
|
|
50
|
+
if (frame) { frame.remove(); frame = null; }
|
|
51
|
+
clearTimeout(readyTimer);
|
|
52
|
+
msg.textContent = '';
|
|
53
|
+
msg.className = 'msg';
|
|
54
|
+
if (!msg.parentNode) document.body.appendChild(msg);
|
|
55
|
+
head(msg);
|
|
56
|
+
var p = document.createElement('p'); p.textContent = text; msg.appendChild(p);
|
|
57
|
+
if (extra) msg.appendChild(extra);
|
|
58
|
+
if (withRetry) {
|
|
59
|
+
var b = document.createElement('button'); b.type = 'button'; b.textContent = 'Try again'; b.setAttribute('data-act', 'retry');
|
|
60
|
+
b.addEventListener('click', function () { start(); });
|
|
61
|
+
msg.appendChild(b);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function showDown(addr) {
|
|
65
|
+
var a = document.createElement('a'); a.href = '#'; a.textContent = 'Set the address';
|
|
66
|
+
a.addEventListener('click', function (e) { e.preventDefault(); try { chrome.runtime.openOptionsPage(); } catch (x) { /* no options page */ } });
|
|
67
|
+
show('PMOS is not answering at ' + addr + '. Start PMOS, then try again.', true, a);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* ── the frame ── */
|
|
71
|
+
function post(m) {
|
|
72
|
+
if (!frame || !frame.contentWindow || !C.validOutgoing(m)) return false;
|
|
73
|
+
frame.contentWindow.postMessage(m, frameOrigin);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
function showFrame(tab, kind, addr, screens) {
|
|
77
|
+
var q = new URLSearchParams({ capture: '1', pop: '1', pkind: kind, ptitle: C.sampleName(tab.title, tab.url), purl: tab.url || '' });
|
|
78
|
+
if (screens) q.set('pscreens', String(screens));
|
|
79
|
+
frame = document.createElement('iframe');
|
|
80
|
+
frame.title = 'PMOS: add this page to a report design';
|
|
81
|
+
frame.src = addr + '/c3.html?' + q.toString();
|
|
82
|
+
frameOrigin = new URL(addr).origin;
|
|
83
|
+
if (msg.parentNode) msg.remove();
|
|
84
|
+
document.body.appendChild(frame);
|
|
85
|
+
// an older PMOS answers but has no capture page: it never says ready
|
|
86
|
+
readyTimer = setTimeout(function () { showDown(addr); }, READY_MS);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ── what is taken ── */
|
|
90
|
+
var toBase64 = function (buf) {
|
|
91
|
+
var bytes = new Uint8Array(buf); var s = '';
|
|
92
|
+
for (var i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
|
93
|
+
return btoa(s);
|
|
94
|
+
};
|
|
95
|
+
var run = function (tabId, func, args) {
|
|
96
|
+
return chrome.scripting.executeScript({ target: { tabId: tabId }, func: func, args: args || [] }).then(function (r) { return r && r[0] ? r[0].result : undefined; });
|
|
97
|
+
};
|
|
98
|
+
var measure = function () { var d = document.documentElement; var b = document.body; return { h: Math.max(d ? d.scrollHeight : 0, b ? b.scrollHeight : 0), vh: window.innerHeight, y: window.scrollY }; };
|
|
99
|
+
var scrollTo = function (y) { window.scrollTo({ top: y, left: 0, behavior: 'instant' }); return window.scrollY; };
|
|
100
|
+
|
|
101
|
+
function takePdf(tab, kind) {
|
|
102
|
+
var url = C.exportUrl(tab.url);
|
|
103
|
+
post({ pmosCapture: 'progress', text: 'Getting the PDF from Google', step: 1, of: 3 });
|
|
104
|
+
return fetch(url, { credentials: 'include', cache: 'no-store', redirect: 'follow' }).then(function (r) {
|
|
105
|
+
if (!r.ok) throw new Error('Google did not give the PDF (' + r.status + '). Open the file in Chrome while signed in to Google, then try again.');
|
|
106
|
+
return r.arrayBuffer();
|
|
107
|
+
}, function () { throw new Error('Chrome could not reach Google to get the PDF. Check the connection, then try again.'); }).then(function (buf) {
|
|
108
|
+
var bytes = new Uint8Array(buf);
|
|
109
|
+
if (!(bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46)) throw new Error('Google did not give a PDF. Sign in to Google in this Chrome, make sure you can open the file, then try again.');
|
|
110
|
+
if (bytes.length > C.MAX_BYTES) throw new Error('The PDF is over 20 MB. PMOS keeps a sample up to 20 MB. Add a smaller copy.');
|
|
111
|
+
post({ pmosCapture: 'progress', text: 'Reading the PDF', step: 2, of: 3 });
|
|
112
|
+
var pages = C.pdfPageCount(bytes);
|
|
113
|
+
return { pmosCapture: 'data', kind: 'pdf', name: C.sampleName(tab.title, tab.url), url: tab.url, pages: pages || undefined, data: toBase64(buf) };
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function takePictures(tab) {
|
|
118
|
+
var tabId = tab.id; var windowId = tab.windowId;
|
|
119
|
+
var start = null; var pics = []; var bytes = 0; var last = 0;
|
|
120
|
+
return run(tabId, measure).then(function (m) {
|
|
121
|
+
if (!m || !m.vh) throw new Error('Chrome does not let PMOS read this page.');
|
|
122
|
+
start = m.y || 0;
|
|
123
|
+
var plan = C.scrollPlan(m.h, m.vh, C.MAX_PICTURES);
|
|
124
|
+
var n = plan.offsets.length;
|
|
125
|
+
var chain = Promise.resolve();
|
|
126
|
+
plan.offsets.forEach(function (y, i) {
|
|
127
|
+
chain = chain.then(function () {
|
|
128
|
+
post({ pmosCapture: 'progress', text: 'Picture ' + (i + 1) + ' of ' + n + (i ? ', scrolling down' : ''), step: i + 1, of: n + 1 });
|
|
129
|
+
return run(tabId, scrollTo, [y]);
|
|
130
|
+
}).then(function () {
|
|
131
|
+
var wait = Math.max(SETTLE_MS, C.MIN_GAP_MS - (Date.now() - last));
|
|
132
|
+
return sleep(wait);
|
|
133
|
+
}).then(function () {
|
|
134
|
+
last = Date.now();
|
|
135
|
+
return chrome.tabs.captureVisibleTab(windowId, { format: 'jpeg', quality: 80 });
|
|
136
|
+
}).then(function (dataUrl) {
|
|
137
|
+
var b64 = String(dataUrl || '').replace(/^data:[^,]*,/, '');
|
|
138
|
+
if (!b64) throw new Error('Chrome did not give a picture of the page.');
|
|
139
|
+
bytes += C.base64Bytes(b64);
|
|
140
|
+
if (bytes > C.MAX_BYTES) throw new Error('The pictures are over 20 MB. PMOS keeps a sample up to 20 MB. Open a shorter page, or add it as a PDF.');
|
|
141
|
+
pics.push(b64);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
return chain;
|
|
145
|
+
}).then(function () {
|
|
146
|
+
return { pmosCapture: 'data', kind: 'pictures', name: C.sampleName(tab.title, tab.url), url: tab.url, pictures: pics };
|
|
147
|
+
}).then(function (out) { return restore(tabId, start).then(function () { return out; }); },
|
|
148
|
+
function (e) { return restore(tabId, start).then(function () { throw e; }); });
|
|
149
|
+
}
|
|
150
|
+
function restore(tabId, y) { return y == null ? Promise.resolve() : run(tabId, scrollTo, [y]).catch(function () { /* the page went away */ }); }
|
|
151
|
+
|
|
152
|
+
function capture() {
|
|
153
|
+
if (busy || !current) return;
|
|
154
|
+
busy = true;
|
|
155
|
+
var tab = current.tab;
|
|
156
|
+
var job = C.exportUrl(tab.url) ? takePdf(tab, current.kind) : takePictures(tab);
|
|
157
|
+
job.then(function (out) {
|
|
158
|
+
post({ pmosCapture: 'progress', text: 'Sending it to PMOS', step: out.kind === 'pdf' ? 3 : (out.pictures.length + 1), of: out.kind === 'pdf' ? 3 : (out.pictures.length + 1) });
|
|
159
|
+
if (!post(out)) throw new Error('The page could not be sent to PMOS.');
|
|
160
|
+
}).catch(function (e) {
|
|
161
|
+
post({ pmosCapture: 'error', error: String((e && e.message) || 'The page could not be taken.') });
|
|
162
|
+
}).then(function () { busy = false; });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
window.addEventListener('message', function (e) {
|
|
166
|
+
var m = C.fromFrame(e, frame && frame.contentWindow, frameOrigin);
|
|
167
|
+
if (!m) return;
|
|
168
|
+
if (m.pmosCapture === 'ready') { clearTimeout(readyTimer); return; }
|
|
169
|
+
if (m.pmosCapture === 'resize') { if (frame) frame.style.height = m.height + 'px'; return; }
|
|
170
|
+
if (m.pmosCapture === 'close') { window.close(); return; }
|
|
171
|
+
if (m.pmosCapture === 'open') {
|
|
172
|
+
try { chrome.tabs.create({ url: frameOrigin + '/c3.html?studio=' + encodeURIComponent(m.report) }); } catch (x) { /* no tabs */ }
|
|
173
|
+
window.close();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (m.pmosCapture === 'request') capture();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
function start() {
|
|
180
|
+
busy = false;
|
|
181
|
+
readAddress(function (addr) {
|
|
182
|
+
getTab(function (tab) {
|
|
183
|
+
var kind = tab ? C.classifyUrl(tab.url) : 'blocked';
|
|
184
|
+
if (!tab || kind === 'blocked') { show('Chrome does not let PMOS read this page. Open a web page, such as a Google Slides deck or any site, then press the PMOS button.', false); return; }
|
|
185
|
+
current = { tab: tab, kind: kind, address: addr };
|
|
186
|
+
var go = function (screens) {
|
|
187
|
+
answers(addr).then(function () { showFrame(tab, C.pageKind(tab.url), addr, screens); }, function () { showDown(addr); });
|
|
188
|
+
};
|
|
189
|
+
if (kind !== 'other') { go(0); return; }
|
|
190
|
+
run(tab.id, measure).then(function (m) { go(m && m.vh ? C.scrollPlan(m.h, m.vh, 1000).total : 0); }, function () { go(0); });
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
start();
|
|
195
|
+
})();
|
|
@@ -22,7 +22,7 @@ Use one of these two buttons:
|
|
|
22
22
|
|
|
23
23
|
1. Select **Deck** or **Document** at the top of the studio.
|
|
24
24
|
2. Write what the report is for and who reads it. For example: "This goes to the progress session every other Thursday. The decisions must stand out."
|
|
25
|
-
3. Press **Add a sample** to give PMOS a report that people liked. You can upload a file, paste a link, or pick a report document that PMOS has. Each sample shows what PMOS took from it. PMOS reads a link from Google Slides, Docs or Sheets, or from Confluence, through your own connectors. It saves the words and the structure (slide titles, order, sections and tables), and it changes nothing in the file. It does not get the look. To match the look, also add the file as a PDF or a screenshot. For Google Slides, choose **File**, **Download**, **PDF document (.pdf)** in Slides, then add that file. If your connectors cannot read the link, the sample says so. Any other link is kept by its address, and PMOS cannot open it. PMOS sees the pages of a PDF or an image, so it can follow their look. From a .pptx or a .docx, PMOS gets only the words.
|
|
25
|
+
3. Press **Add a sample** to give PMOS a report that people liked. You can upload a file, paste a link, add a page from Chrome (see below), or pick a report document that PMOS has. Each sample shows what PMOS took from it. PMOS reads a link from Google Slides, Docs or Sheets, or from Confluence, through your own connectors. It saves the words and the structure (slide titles, order, sections and tables), and it changes nothing in the file. It does not get the look. To match the look, also add the file as a PDF or a screenshot. For Google Slides, choose **File**, **Download**, **PDF document (.pdf)** in Slides, then add that file. If your connectors cannot read the link, the sample says so. Any other link is kept by its address, and PMOS cannot open it. PMOS sees the pages of a PDF or an image, so it can follow their look. From a .pptx or a .docx, PMOS gets only the words.
|
|
26
26
|
4. Press **Make the next version**. PMOS makes a version from your words and this cycle's data. This takes a short time. You can leave the studio. The studio shows when the version is ready.
|
|
27
27
|
5. Read the version in the preview. Write what to change and press **Make the next version** again. Each turn makes a new version: D1, D2, D3 and so on.
|
|
28
28
|
|
|
@@ -30,6 +30,20 @@ To set the width of the studio, use **Half**, **3/4** or **Whole** at the top ri
|
|
|
30
30
|
|
|
31
31
|
If a turn fails, the studio shows why. Nothing changes. Try the turn again.
|
|
32
32
|
|
|
33
|
+
|
|
34
|
+
### Add a page from Chrome
|
|
35
|
+
|
|
36
|
+
You can add the page you are looking at in Chrome, on any website, as a sample. This needs the PMOS extension in Chrome. Run `pmos extension` in a terminal for the steps to load it.
|
|
37
|
+
|
|
38
|
+
1. In Chrome, open the page, for example a Google Slides deck, a Google Doc or a Confluence page.
|
|
39
|
+
2. Press the **PMOS** button in the Chrome toolbar. The popup says "Add this page to a report design".
|
|
40
|
+
3. Choose the report in the **Report** list. It starts on the report whose design studio you opened last.
|
|
41
|
+
4. Press **Add as a sample**. Keep the popup open while it saves. When it says "Added to" and the name of the report, press **Open the studio**.
|
|
42
|
+
|
|
43
|
+
A Google Slides deck or a Google Doc comes to PMOS as a PDF, so PMOS can follow its look. The sample reads "From Chrome, PDF, 12 slides", or "pages" for a document. Chrome asks Google for the PDF with your own Google sign-in. Any other page comes as pictures of the whole page, scrolled top to bottom, at most 8 pictures. The sample reads "From Chrome, 3 page pictures". PMOS reads every picture, and follows its look as it does for a PDF. A sample is at most 20 MB. If the page is bigger, the popup says so: open a shorter page, or add a smaller copy.
|
|
44
|
+
|
|
45
|
+
The sample shows in a design studio that is already open, in a few seconds. If the popup says "PMOS is not answering", start PMOS and press **Try again**. Nothing leaves your computer: the popup sends the page only to your own PMOS.
|
|
46
|
+
|
|
33
47
|
### Point at a part
|
|
34
48
|
|
|
35
49
|
Keep **Point and say** on. Click a part of the preview and write what to change there. A numbered marker shows the spot. Your next turn sends the markers with your words.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/* extension-cli.js — `pmos extension`: where the Chrome extension
|
|
2
|
+
/* extension-cli.js — `pmos extension`: where the PMOS Chrome extension is (the Meet panel and the button that adds a page to a report design), and how to load it (initiatives c3-meet-extension, report-design-studio).
|
|
3
3
|
*
|
|
4
4
|
* pmos extension print the absolute path of workspace/extension and the steps to load it in Chrome
|
|
5
5
|
*
|
|
@@ -10,20 +10,22 @@ const path = require('path');
|
|
|
10
10
|
|
|
11
11
|
const COMMANDS = { extension: [] };
|
|
12
12
|
const FOLDER = path.resolve(__dirname, '..', 'extension');
|
|
13
|
-
const USAGE = ['usage: pmos extension — the Chrome extension
|
|
13
|
+
const USAGE = ['usage: pmos extension — the Chrome extension: the meeting companion over a Google Meet call, and a button that adds a page to a report design'];
|
|
14
14
|
|
|
15
15
|
/** The lines `pmos extension` prints for a folder. Pure. */
|
|
16
16
|
function lines(folder, exists = true) {
|
|
17
17
|
const out = [];
|
|
18
18
|
if (!exists) { out.push(`✗ the extension folder is not here: ${folder}`, ' Reinstall the workspace with `pmos upgrade`.'); return out; }
|
|
19
|
-
out.push('PMOS for
|
|
19
|
+
out.push('PMOS for Chrome', '', ` folder ${folder}`, '',
|
|
20
20
|
'Load it in three steps:',
|
|
21
21
|
' 1. In Chrome, open chrome://extensions',
|
|
22
22
|
' 2. Turn on Developer mode (top right)',
|
|
23
23
|
' 3. Press Load unpacked, then pick the folder above',
|
|
24
24
|
'',
|
|
25
25
|
'PMOS is looked for at http://localhost:7433. If pmos printed another port, open the extension\'s Options and set that address.',
|
|
26
|
-
'Then open a Google Meet call: the panel shows at the bottom right.'
|
|
26
|
+
'Then open a Google Meet call: the panel shows at the bottom right.',
|
|
27
|
+
'To add a page to a report design, open any page and press the PMOS button in the toolbar. Choose the report, then Add as a sample.',
|
|
28
|
+
'After an update, press the reload arrow on the extension\'s card in chrome://extensions.');
|
|
27
29
|
return out;
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -47,7 +47,8 @@ const issues = require('./issues.js');
|
|
|
47
47
|
|
|
48
48
|
const EVENTS = Object.freeze(['describe', 'sample', 'fetched', 'took', 'turn', 'version', 'accept', 'revert', 'error', 'cycle-decision']);
|
|
49
49
|
const FORMATS = Object.freeze(['deck', 'doc']);
|
|
50
|
-
const SAMPLE_KINDS = Object.freeze(['pdf', 'pptx', 'docx', 'image', 'link', 'pmos-doc']);
|
|
50
|
+
const SAMPLE_KINDS = Object.freeze(['pdf', 'pptx', 'docx', 'image', 'pictures', 'link', 'pmos-doc']);
|
|
51
|
+
const MAX_PICTURES = 12;
|
|
51
52
|
const MAX_SAMPLE_BYTES = 20 * 1024 * 1024;
|
|
52
53
|
const MAX_THUMB_BYTES = 256 * 1024;
|
|
53
54
|
const MAX_TEXT = 4000;
|
|
@@ -118,7 +119,8 @@ function fold(events) {
|
|
|
118
119
|
if (FORMATS.includes(l.format)) st.format = l.format;
|
|
119
120
|
st.log.push({ at: l.at, who: 'you', kind: 'describe', text: str(l.text) });
|
|
120
121
|
} else if (l.ev === 'sample') {
|
|
121
|
-
st.samples.set(l.id, { id: l.id, name: str(l.name), kind: l.kind, source: str(l.source), ext: str(l.ext), took: null, readable: l.readable !== false, at: l.at
|
|
122
|
+
st.samples.set(l.id, { id: l.id, name: str(l.name), kind: l.kind, source: str(l.source), ext: str(l.ext), took: null, readable: l.readable !== false, at: l.at,
|
|
123
|
+
...(l.via === 'chrome' ? { via: 'chrome' } : {}), ...(Number.isInteger(l.count) ? { count: l.count } : {}), ...(Number.isInteger(l.pages) ? { pages: l.pages } : {}) });
|
|
122
124
|
st.log.push({ at: l.at, who: 'you', kind: 'sample', text: str(l.name), sample: l.id });
|
|
123
125
|
} else if (l.ev === 'fetched') {
|
|
124
126
|
const s = st.samples.get(l.id);
|
|
@@ -220,6 +222,32 @@ function addSample(root, cadence, input, { actor, now } = {}) {
|
|
|
220
222
|
writeAtomic(path.join(p.samples, `${id}.md`), text);
|
|
221
223
|
const fm = /^---\r?\n[\s\S]*?\btitle:\s*["']?([^\n"']+)/.exec(text);
|
|
222
224
|
rec = { id, name: oneLine(input.name || (fm ? fm[1] : rel.split('/').slice(-2).join(' ')), 120), kind: 'pmos-doc', source: rel, ext: 'md', readable: true, thumb: null, text_file: null };
|
|
225
|
+
} else if (str(input.kind) === 'pictures' || Array.isArray(input.pictures)) {
|
|
226
|
+
// pictures of a whole page, scrolled top to bottom, from the Chrome extension (the page frames PMOS; see extension/popup.js)
|
|
227
|
+
const list = Array.isArray(input.pictures) ? input.pictures : null;
|
|
228
|
+
if (str(input.kind) !== 'pictures' || !list || !list.length) return refuse(400, 'pictures needs kind "pictures" and a list of pictures (base64 JPEG or PNG)');
|
|
229
|
+
if (list.length > MAX_PICTURES) return refuse(400, `at most ${MAX_PICTURES} pictures in one sample`);
|
|
230
|
+
const bufs = [];
|
|
231
|
+
let total = 0;
|
|
232
|
+
for (const [i, raw] of list.entries()) {
|
|
233
|
+
if (typeof raw !== 'string' || !raw) return refuse(400, `picture ${i + 1} is empty`);
|
|
234
|
+
const b64 = raw.replace(/^data:[^,]*,/, '');
|
|
235
|
+
total += Math.floor(b64.length * 3 / 4);
|
|
236
|
+
if (total > MAX_SAMPLE_BYTES + 4 * list.length) return refuse(413, 'a sample is at most 20 MB');
|
|
237
|
+
const buf = Buffer.from(b64, 'base64');
|
|
238
|
+
const s = buf.length ? sniff(buf) : null;
|
|
239
|
+
if (!s || s.kind !== 'image') return refuse(415, `picture ${i + 1} must be a PNG or a JPG`);
|
|
240
|
+
bufs.push({ buf, ext: s.ext });
|
|
241
|
+
}
|
|
242
|
+
if (bufs.reduce((n, b) => n + b.buf.length, 0) > MAX_SAMPLE_BYTES) return refuse(413, 'a sample is at most 20 MB');
|
|
243
|
+
fs.mkdirSync(p.samples, { recursive: true });
|
|
244
|
+
const files = bufs.map((b, i) => { const f = `${id}-${i + 1}.${b.ext}`; writeAtomic(path.join(p.samples, f), b.buf); return f; });
|
|
245
|
+
const first = bufs[0];
|
|
246
|
+
const thumb = first.buf.length <= MAX_THUMB_BYTES ? `data:image/${first.ext === 'jpg' ? 'jpeg' : 'png'};base64,${first.buf.toString('base64')}` : null;
|
|
247
|
+
const src = typeof input.source_url === 'string' ? input.source_url.trim() : '';
|
|
248
|
+
const u = parseUrl(src);
|
|
249
|
+
const source = u && /^https?:$/.test(u.protocol) && src.length <= 2000 ? src : 'upload';
|
|
250
|
+
rec = { id, name: oneLine(input.name || 'Page pictures', 120), kind: 'pictures', source, ext: first.ext, count: files.length, files, via: 'chrome', bytes: bufs.reduce((n, b) => n + b.buf.length, 0), readable: true, thumb, text_file: null };
|
|
223
251
|
} else {
|
|
224
252
|
const kind = str(input.kind);
|
|
225
253
|
if (!['pdf', 'pptx', 'docx', 'image'].includes(kind)) return refuse(400, 'kind must be pdf, pptx, docx or image (or send a link, or a report doc)');
|
|
@@ -244,19 +272,42 @@ function addSample(root, cadence, input, { actor, now } = {}) {
|
|
|
244
272
|
try { thumb = ooxmlThumb(buf); } catch { thumb = null; }
|
|
245
273
|
}
|
|
246
274
|
rec = { id, name: oneLine(input.name || `${kind} sample`, 120), kind, source: 'upload', ext, bytes: buf.length, readable, thumb, text_file: textFile };
|
|
275
|
+
if (kind === 'pdf' && input.from === 'chrome') {
|
|
276
|
+
// a PDF the Chrome extension fetched: where it came from and how many pages it has
|
|
277
|
+
const src = typeof input.source_url === 'string' ? input.source_url.trim() : '';
|
|
278
|
+
const u = parseUrl(src);
|
|
279
|
+
if (u && /^https?:$/.test(u.protocol) && src.length <= 2000) rec.source = src;
|
|
280
|
+
rec.via = 'chrome';
|
|
281
|
+
const n = Number(input.pages);
|
|
282
|
+
if (Number.isInteger(n) && n > 0 && n < 5000) rec.pages = n;
|
|
283
|
+
}
|
|
247
284
|
}
|
|
248
285
|
writeAtomic(path.join(p.samples, `${id}.json`), JSON.stringify({ ...rec, took: null, added_at: iso(now) }, null, 2) + '\n');
|
|
249
|
-
append(root, cadence, { ev: 'sample', id, name: rec.name, kind: rec.kind, source: rec.source, ext: rec.ext, readable: rec.readable
|
|
286
|
+
append(root, cadence, { ev: 'sample', id, name: rec.name, kind: rec.kind, source: rec.source, ext: rec.ext, readable: rec.readable,
|
|
287
|
+
...(rec.via ? { via: rec.via } : {}), ...(rec.count ? { count: rec.count } : {}), ...(rec.pages ? { pages: rec.pages } : {}) }, { actor, now });
|
|
250
288
|
return { ok: true, sample: sampleOut(rec) };
|
|
251
289
|
}
|
|
252
290
|
function sampleOut(s, { fetching } = {}) {
|
|
253
291
|
const out = { id: s.id, name: s.name, kind: s.kind, source: s.source, took: s.took || null, thumb: typeof s.thumb === 'string' && s.thumb.startsWith('data:image/') ? s.thumb : null, readable: s.readable !== false };
|
|
292
|
+
if (s.via === 'chrome') {
|
|
293
|
+
out.via = 'chrome';
|
|
294
|
+
out.line = chromeLine(s);
|
|
295
|
+
if (s.count) out.count = s.count;
|
|
296
|
+
if (s.pages) out.pages = s.pages;
|
|
297
|
+
}
|
|
254
298
|
if (s.kind === 'link') {
|
|
255
299
|
if (fetching) { out.fetching = true; out.hint = 'PMOS is reading this link with your connectors.'; }
|
|
256
300
|
else out.hint = linkHint(s.source, { fetched: !!(s.text_file && s.fetched), reason: s.fetch_reason });
|
|
257
301
|
}
|
|
258
302
|
return out;
|
|
259
303
|
}
|
|
304
|
+
/** The kind line of a sample the Chrome extension added: 'From Chrome, PDF, 12 slides' | 'From Chrome, PDF, 3 pages' | 'From Chrome, 3 page pictures'. */
|
|
305
|
+
function chromeLine(s) {
|
|
306
|
+
if (s.kind === 'pictures') { const n = s.count || 0; return n ? `From Chrome, ${n} page ${n === 1 ? 'picture' : 'pictures'}` : 'From Chrome, page pictures'; }
|
|
307
|
+
const g = googleOf(parseUrl(s.source));
|
|
308
|
+
const n = s.pages || 0;
|
|
309
|
+
return n ? `From Chrome, PDF, ${n} ${g && g[2] === 'Slides' ? (n === 1 ? 'slide' : 'slides') : (n === 1 ? 'page' : 'pages')}` : 'From Chrome, PDF';
|
|
310
|
+
}
|
|
260
311
|
/* Google files: name them for what they are, and say which download PMOS can read */
|
|
261
312
|
// a PDF, because the job sees its pages; from a .pptx or .docx it gets the words only, not the look
|
|
262
313
|
const GOOGLE = [[/^\/presentation\//, 'Google Slides deck', 'Slides'], [/^\/document\//, 'Google Docs document', 'Docs'], [/^\/spreadsheets\//, 'Google Sheets sheet', 'Sheets']];
|
|
@@ -309,6 +360,13 @@ function recordTook(root, cadence, id, took, readable, { now } = {}) {
|
|
|
309
360
|
/** The file the job reads for a sample: the pdf or image itself, the extracted words of a .pptx/.docx, the copy of a report doc, the text copy of a fetched link; null for any other link. */
|
|
310
361
|
function sampleReadPath(root, cadence, s) {
|
|
311
362
|
const p = paths(root, cadence);
|
|
363
|
+
if (s.kind === 'pictures') {
|
|
364
|
+
// every picture of the page, in order (samples/<id>-1.jpg, <id>-2.jpg, …); null when none is there
|
|
365
|
+
let names = [];
|
|
366
|
+
try { names = fs.readdirSync(p.samples).filter((f) => f.startsWith(`${s.id}-`) && /^\d+\.(jpg|png)$/.test(f.slice(s.id.length + 1))); } catch { names = []; }
|
|
367
|
+
names.sort((a, b) => parseInt(a.slice(s.id.length + 1), 10) - parseInt(b.slice(s.id.length + 1), 10));
|
|
368
|
+
return names.length ? names.map((f) => path.join(p.samples, f)) : null;
|
|
369
|
+
}
|
|
312
370
|
if (s.kind === 'link') { const f = path.join(p.samples, `${s.id}.txt`); return s.fetched && fs.existsSync(f) ? f : null; }
|
|
313
371
|
if (s.kind === 'pptx' || s.kind === 'docx') { const f = path.join(p.samples, `${s.id}.txt`); return fs.existsSync(f) ? f : null; }
|
|
314
372
|
const f = path.join(p.samples, `${s.id}.${s.ext}`);
|
|
@@ -352,7 +410,7 @@ function writeBrief(root, cadence, setup, { now } = {}) {
|
|
|
352
410
|
'## Description', '', st.description ? st.description : 'No description was given.', '', '## Samples', ''];
|
|
353
411
|
const samples = [...st.samples.values()];
|
|
354
412
|
if (!samples.length) lines.push('No samples.');
|
|
355
|
-
for (const s of samples) lines.push(`- ${s.name} (${s.kind}${s.kind === 'link' || s.kind === 'pmos-doc' ? ', ' + s.source : ''}): ${s.took ? 'PMOS took ' + s.took : (s.readable ? 'nothing taken yet' : 'could not be read')}`);
|
|
413
|
+
for (const s of samples) lines.push(`- ${s.name} (${s.kind}${s.kind === 'link' || s.kind === 'pmos-doc' ? ', ' + s.source : ''}${s.via === 'chrome' ? ', from Chrome' : ''}): ${s.took ? 'PMOS took ' + s.took : (s.readable ? 'nothing taken yet' : 'could not be read')}`);
|
|
356
414
|
lines.push('', '## Reference outline', '');
|
|
357
415
|
for (const o of (Array.isArray(meta.outline) ? meta.outline : [])) lines.push(`- ${o.id} (${o.kind || 'slide'}): ${o.title || ''}`);
|
|
358
416
|
const sec = (h, list) => { lines.push('', `## ${h}`, ''); if (!list || !list.length) lines.push('None written yet.'); else for (const x of list) lines.push(`- ${x}`); };
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* GET /api/report-design/version?ws&id&n → text/html, the sanitized render of D<n>
|
|
6
6
|
* POST /api/report-design/describe { ws, id, text, format? }
|
|
7
7
|
* POST /api/report-design/sample { ws, id, name, kind, data } | { ws, id, link, name? } | { ws, id, doc, name? } → { ok, sample }
|
|
8
|
+
* from Chrome: { kind: 'pdf', data, from: 'chrome', source_url?, pages? } | { kind: 'pictures', pictures: [base64 JPEG or PNG, …], source_url?, name, from: 'chrome' } (the field is `from`: pm-guard reserves `via` for the writer; the record keeps via: 'chrome')
|
|
9
|
+
* GET /api/report-design/last?ws → { ok, id, name, at } the report whose design studio was opened last (the Chrome popup's default report); id null when none
|
|
8
10
|
* POST /api/report-design/turn { ws, id, text, points: [{ n, slide, x, y, text }] } → { ok, running } ONE job per report; 409 while one runs
|
|
9
11
|
* POST /api/report-design/accept { ws, id, n } → { ok, design, brief, redraw } "This is it": writes brief.md and redraws the current
|
|
10
12
|
* cycle in the saved design (redraw: { path, slot, started, reason? }; a sent cycle keeps its look)
|
|
@@ -101,6 +103,11 @@ function promptFor(skill, { mode, root, cadence, setup, st, turnText, points, cy
|
|
|
101
103
|
lines.push(samples.length ? '- Samples:' : '- Samples: none');
|
|
102
104
|
for (const s of samples) {
|
|
103
105
|
const f = D.sampleReadPath(root, cadence, s);
|
|
106
|
+
if (s.kind === 'pictures') {
|
|
107
|
+
const fs_ = Array.isArray(f) ? f : [];
|
|
108
|
+
lines.push(` - ${s.id}${s.took ? '' : ' (NEW)'} "${s.name}", pictures of a whole web page from Chrome, top to bottom, ${fs_.length ? `${fs_.length} in all: read every one, in this order, and follow the look closely: ${fs_.join(', ')}` : 'its files could not be read'}`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
104
111
|
const where = s.kind === 'link' ? (f ? `a link: ${s.source}. PMOS read its words and structure through the PM's connectors: read ${f}. It has the words and the order, not the look` : `a link: ${s.source} (you have no web tool)`) : f ? `read ${f}` : 'its file could not be read';
|
|
105
112
|
lines.push(` - ${s.id}${s.took ? '' : ' (NEW)'} "${s.name}", ${s.kind}: ${where}`);
|
|
106
113
|
}
|
|
@@ -352,6 +359,26 @@ function sweep(ctx) {
|
|
|
352
359
|
}
|
|
353
360
|
|
|
354
361
|
// ── the Edit log line ───────────────────────────────────────────────────────────────────────────────────────
|
|
362
|
+
/* The report whose design studio was opened last, for each workspace: <runtime>/report-design-last.json { [ws]: { id, at } }. A read of a design is an open of its studio. */
|
|
363
|
+
function lastFile(ctx) { return ctx.runtimeDir ? path.join(ctx.runtimeDir, 'report-design-last.json') : null; }
|
|
364
|
+
function lastEntry(ctx, w) {
|
|
365
|
+
const f = lastFile(ctx);
|
|
366
|
+
if (!f) return null;
|
|
367
|
+
try { const o = JSON.parse(fs.readFileSync(f, 'utf8')); const e = o && o[w.id]; return e && D.CADENCE_RE.test(String(e.id)) ? { id: e.id, at: typeof e.at === 'string' ? e.at : null } : null; } catch { return null; }
|
|
368
|
+
}
|
|
369
|
+
const lastStudio = (ctx, w) => { const e = lastEntry(ctx, w); return e ? e.id : null; };
|
|
370
|
+
function rememberStudio(ctx, w, id) {
|
|
371
|
+
const f = lastFile(ctx);
|
|
372
|
+
if (!f || lastStudio(ctx, w) === id) return false;
|
|
373
|
+
try {
|
|
374
|
+
let o = {};
|
|
375
|
+
try { o = JSON.parse(fs.readFileSync(f, 'utf8')) || {}; } catch { o = {}; }
|
|
376
|
+
o[w.id] = { id, at: new Date().toISOString() };
|
|
377
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
378
|
+
fs.writeFileSync(f, JSON.stringify(o) + '\n');
|
|
379
|
+
return true;
|
|
380
|
+
} catch { return false; }
|
|
381
|
+
}
|
|
355
382
|
function editLog(ctx, row) {
|
|
356
383
|
if (!ctx.runtimeDir) return false;
|
|
357
384
|
try {
|
|
@@ -430,8 +457,14 @@ async function handle(req, res, url, ctx) {
|
|
|
430
457
|
return sendHtml(res, D.sanitize(html));
|
|
431
458
|
}
|
|
432
459
|
try { settleStale(w.root, id); } catch { /* a read never fails on this */ }
|
|
460
|
+
rememberStudio(ctx, w, id);
|
|
433
461
|
return send({ status: 200, body: { ok: true, setup, design: D.designAnswer(w.root, id, designOpts(w.root, id)) } });
|
|
434
462
|
}
|
|
463
|
+
if (sub === 'last') {
|
|
464
|
+
const e = lastEntry(ctx, w);
|
|
465
|
+
const setup = e ? setupOf(w.root, e.id) : null;
|
|
466
|
+
return send({ status: 200, body: { ok: true, id: setup ? e.id : null, name: setup ? setup.name : null, at: setup ? e.at : null } });
|
|
467
|
+
}
|
|
435
468
|
if (sub === 'cycle' || sub === 'cycle-html') {
|
|
436
469
|
const d = reportDoc(w, text(url.searchParams.get('path'))); if (d.error) return send(d.error);
|
|
437
470
|
if (sub === 'cycle') return send({ status: 200, body: cycleAnswer(w, d.c) });
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.205.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.205.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -6,5 +6,6 @@
|
|
|
6
6
|
const narrow = typeof matchMedia === 'function' && matchMedia('(max-width:640px)').matches;
|
|
7
7
|
let desk = false;
|
|
8
8
|
try { desk = localStorage.getItem('pmos.c3.testDesk') === 'desk'; } catch { /* no storage */ }
|
|
9
|
-
if (
|
|
9
|
+
if (new URLSearchParams(location.search).get('capture') === '1') import('./c3-capture.js'); // the PMOS Chrome extension's popup page: a small page on its own, at any width
|
|
10
|
+
else if (narrow && !desk) import('./c3-phone-boot.js');
|
|
10
11
|
else import('./c3-shell.js');
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/* pm/c3-capture.css — the page the PMOS Chrome extension frames in its popup (c3-capture.js). Mockup 42-reportdeck.css .mk-cx-pop and friends. */
|
|
2
|
+
.c3.c3-cap { min-height: 0; padding: 8px; background: var(--background); }
|
|
3
|
+
.c3 .cap-pop { display: grid; gap: 10px; padding: 12px; box-sizing: border-box; }
|
|
4
|
+
.c3 .cap-head { display: flex; gap: 8px; align-items: center; font-size: var(--fs-3); font-weight: 600; }
|
|
5
|
+
.c3 .cap-head b { font-size: var(--fs-1); color: var(--acc); }
|
|
6
|
+
.c3 .cap-head span { flex: 1; min-width: 0; }
|
|
7
|
+
.c3 .cap-what { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 10px; align-items: start; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--background); }
|
|
8
|
+
.c3 .cap-what b { display: flex; gap: 6px; align-items: center; font-size: var(--fs-2); overflow-wrap: anywhere; }
|
|
9
|
+
.c3 .cap-what .mono { font-size: var(--fs-1); overflow-wrap: anywhere; }
|
|
10
|
+
.c3 .cap-thumb { display: grid; gap: 3px; align-content: start; width: 56px; aspect-ratio: 16 / 9; padding: 4px; box-sizing: border-box; border: 1px solid var(--slide-border); border-radius: 6px; background: var(--slide-bg); }
|
|
11
|
+
.c3 .cap-thumb i { display: block; height: 3px; background: var(--slide-track); }
|
|
12
|
+
.c3 .cap-thumb.deck i:first-child { height: 6px; background: var(--slide-brand); }
|
|
13
|
+
.c3 .cap-thumb.page { aspect-ratio: 3 / 4; width: 40px; justify-self: center; }
|
|
14
|
+
.c3 .cap-f { display: grid; gap: 4px; }
|
|
15
|
+
.c3 .cap-f select { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--background); color: var(--foreground); font: inherit; font-size: var(--fs-3); }
|
|
16
|
+
.c3 .cap-lbl { font-size: var(--fs-1); }
|
|
17
|
+
.c3 .cap-line { font-size: var(--fs-2); line-height: 1.5; }
|
|
18
|
+
.c3 .cap-step { font-size: var(--fs-1); }
|
|
19
|
+
.c3 .cap-acts { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
20
|
+
.c3 .cap-msg { display: flex; gap: 8px; align-items: flex-start; margin: 0; font-size: var(--fs-3); line-height: 1.5; }
|
|
21
|
+
.c3 .cap-msg .ic { flex: 0 0 auto; margin-top: 4px; }
|
|
22
|
+
.c3 .cap-prog { height: 6px; border-radius: 6px; background: var(--muted); overflow: hidden; }
|
|
23
|
+
.c3 .cap-prog i { display: block; width: var(--rp-w, 6%); height: 100%; background: var(--acc); }
|