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.
@@ -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 for Google Meet is, and how to load it (initiative c3-meet-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 that shows the meeting companion over a Google Meet call'];
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 Google Meet (Chrome)', '', ` folder ${folder}`, '',
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 }, { actor, now });
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.204.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.204.0",
9
+ "version": "0.205.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.204.0",
3
+ "version": "0.205.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -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 (narrow && !desk) import('./c3-phone-boot.js');
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); }