Repository navigation
Expand file tree
/
Copy pathcontent_script.js
More file actions
370 lines (324 loc) · 14.3 KB
/
Copy pathcontent_script.js
File metadata and controls
370 lines (324 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
"use strict";
const timestampToTime = (timestamp) => {
const d = new Date(timestamp * Math.pow(10, 13 - timestamp.length));
const weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const yyyy = d.getFullYear();
const mm = ("0" + (d.getMonth() + 1)).slice(-2);
const dd = ("0" + d.getDate()).slice(-2);
const hh = ("0" + d.getHours()).slice(-2);
const mi = ("0" + d.getMinutes()).slice(-2);
const ss = ("0" + d.getSeconds()).slice(-2);
return `${yyyy}-${mm}-${dd} ${weekday[d.getDay()]} ${hh}:${mi}:${ss}`;
};
const waitMs = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// Convert Slack's rich text DOM to Markdown.
// Confirmed selectors via Chrome DevTools inspection:
// inline code : <code class="c-mrkdwn__code">
// code block : <pre class="c-mrkdwn__pre">
// bullet list : <ul class="...p-rich_text_list__bullet..."> + <li data-stringify-indent="N">
// bold : <b data-stringify-type="bold">
// link : <a data-stringify-link="url">
// italic/strike: inferred from same data-stringify-type pattern
const domToMarkdown = (node) => {
if (node.nodeType === 3) return node.textContent; // text node
if (node.nodeType !== 1) return ''; // non-element
const tag = node.tagName.toLowerCase();
const cls = node.className || '';
// Inline: br — Slack uses <br aria-hidden="true">, so check before the
// aria-hidden filter below or line breaks would be silently dropped.
if (tag === 'br') return '\n';
// Skip buttons (expand triggers) and hidden elements
if (tag === 'button') return '';
if (node.getAttribute('hidden') !== null) return '';
if (node.getAttribute('aria-hidden') === 'true') return '';
const inner = () => [...node.childNodes].map(domToMarkdown).join('');
// Inline: image → Markdown image syntax
if (tag === 'img') {
const src = node.getAttribute('src') || '';
const alt = node.getAttribute('alt') || '';
return src ? `` : '';
}
// Inline: link (data-stringify-link holds the canonical URL)
if (tag === 'a') {
const href = node.getAttribute('data-stringify-link') || node.getAttribute('href') || '';
const text = inner();
return (href && href !== text) ? `[${text}](${href})` : text;
}
// Inline: bold — <b data-stringify-type="bold">
if (tag === 'b' || node.getAttribute('data-stringify-type') === 'bold') return `**${inner()}**`;
// Inline: italic — inferred: <i data-stringify-type="italic">
if (tag === 'i' || node.getAttribute('data-stringify-type') === 'italic') return `_${inner()}_`;
// Inline: strikethrough — inferred: <s data-stringify-type="strike">
if (tag === 's' || node.getAttribute('data-stringify-type') === 'strike') return `~~${inner()}~~`;
// Inline: code — <code class="c-mrkdwn__code">
if (tag === 'code' || cls.includes('c-mrkdwn__code')) return `\`${inner()}\``;
// Block: code block — <pre class="c-mrkdwn__pre">
// inner div "p-rich_text_block--no-overflow" is a pass-through wrapper
if (tag === 'pre' || cls.includes('c-mrkdwn__pre')) return `\`\`\`\n${inner()}\n\`\`\`\n`;
// Block: blockquote
if (tag === 'blockquote' || cls.includes('p-rich_text_quote')) {
return inner().trimEnd().split('\n').map(l => `> ${l}`).join('\n') + '\n';
}
// Block: list item — <li data-stringify-indent="N">
// indent level comes from data-stringify-indent attribute
if (tag === 'li') {
const indent = parseInt(node.getAttribute('data-stringify-indent') || '0') * 2;
const parentUl = node.parentElement;
const isOrdered = parentUl?.className?.includes('p-rich_text_list__ordered') || tag === 'ol';
return ' '.repeat(indent) + (isOrdered ? '1. ' : '- ') + inner().trim() + '\n';
}
// Block: paragraph / section — each section ends with a newline
if (cls.includes('p-rich_text_section') || tag === 'p') {
const text = inner();
return text ? text.trimEnd() + '\n' : '';
}
return inner();
};
// Quote a single-line string only when YAML semantics require it.
// Leaving plain text unquoted reduces token count for LLM context.
const yamlReserved = /^(?:true|false|null|~|yes|no|on|off|y|n)$/i;
const yamlNumberLike = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$|^[+-]?0x[0-9a-f]+$/i;
const yamlSpecialStart = /^[!&*\[\]{}|<>=,?:#~"'%@`\-]/;
const yamlNeedsQuoting = (s) => {
if (s === '') return true;
if (s !== s.trim()) return true; // leading/trailing space
if (yamlSpecialStart.test(s)) return true; // forbidden first char
if (yamlReserved.test(s)) return true; // would parse as bool/null
if (yamlNumberLike.test(s)) return true; // would parse as number
if (/:(?:\s|$)/.test(s)) return true; // ": " inside ends a key
if (/\s#/.test(s)) return true; // " #" starts a comment
if (/[\t\x00-\x1F]/.test(s)) return true; // control char (incl. tab)
return false;
};
const toYamlScalar = (str) => {
const trimmed = str.replace(/\n+$/, '');
if (trimmed.includes('\n')) {
return '|\n' + trimmed.split('\n').map(l => ' ' + l).join('\n');
}
if (yamlNeedsQuoting(trimmed)) {
return '"' + trimmed.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
return trimmed;
};
const formatAsYaml = (messages) => {
return messages.map(({ date, channel, sender, message }) => [
`- date: ${toYamlScalar(date)}`,
` channel: ${toYamlScalar(channel)}`,
` sender: ${toYamlScalar(sender)}`,
` message: ${toYamlScalar(message)}`,
].join('\n')).join('\n\n');
};
const waitForSearchResult = () => {
const observeFunc = () => {
const groups = document.querySelectorAll(".c-message_group");
const allHaveTimestamp = [...groups].every(g => {
const ts = g.querySelector(".c-timestamp");
return ts && ts.getAttribute("data-ts");
});
const el = document.querySelector(".c-search_message__content");
return (el && allHaveTimestamp) ? el : null;
};
return new Promise((resolve) => {
const el = observeFunc();
if (el) { resolve(el); return; }
new MutationObserver((_, observer) => {
const el = observeFunc();
if (el) { resolve(el); observer.disconnect(); }
}).observe(document.documentElement, { childList: true, subtree: true });
});
};
// Read the "N results" indicator. Class name is hashed (resultCounts__xxx)
// so we match by prefix. Returns null if not found or unparseable.
const getTotalResultCount = () => {
const els = document.querySelectorAll('[class*="resultCounts"]');
for (const el of els) {
const m = el.textContent.match(/(\d+)\s*(?:results?|件|ヒット)/i);
if (m) return parseInt(m[1], 10);
}
return null;
};
// Wait for pagination state and content to actually swap after a click.
// We watch the page indicator change AND the first visible message's
// timestamp change AND every message group having a timestamp.
const waitForPageSwap = (oldPage, oldFirstTs) => {
return new Promise((resolve) => {
const ready = () => {
const w = document.querySelector('.c-pagination_wrapper');
const cur = w?.getAttribute('data-qa-current-page');
if (!cur || cur === oldPage) return false;
const groups = document.querySelectorAll('.c-message_group');
if (groups.length === 0) return false;
const newTs = groups[0].querySelector('.c-timestamp')?.getAttribute('data-ts');
if (newTs && newTs === oldFirstTs) return false;
return [...groups].every(g => {
const ts = g.querySelector('.c-timestamp');
return ts && ts.getAttribute('data-ts');
});
};
if (ready()) { resolve(); return; }
const observer = new MutationObserver(() => {
if (ready()) { observer.disconnect(); resolve(); }
});
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
setTimeout(() => { observer.disconnect(); resolve(); }, 5000);
});
};
// Walk the pagination back to page 1. With many pages, Slack hides the
// page-1 button (only a window around the current page is shown), so we
// hop via the smallest-numbered visible page button each step. When no
// numbered button below the current page is visible (e.g. on the very
// first page of the visible window), fall back to the "Previous page"
// arrow. Bounded loop so we never spin forever.
const goToFirstPage = async () => {
for (let i = 0; i < 50; i++) {
const wrapper = document.querySelector('.c-pagination_wrapper');
if (!wrapper) return; // single-page result, nothing to do
const current = wrapper.getAttribute('data-qa-current-page');
if (!current || current === '1') return;
const currentNum = parseInt(current, 10);
const visiblePagesBelow = [...wrapper.querySelectorAll('[data-qa^="c-pagination_page_btn_"]')]
.map(b => ({
btn: b,
n: parseInt(b.getAttribute('data-qa').replace('c-pagination_page_btn_', ''), 10),
}))
.filter(x => Number.isFinite(x.n) && x.n < currentNum)
.sort((a, b) => a.n - b.n);
let target;
if (visiblePagesBelow.length > 0) {
target = visiblePagesBelow[0].btn; // jump to smallest visible
} else {
target = wrapper.querySelector('[data-qa="c-pagination_back_btn"]');
if (!target || target.getAttribute('aria-disabled') === 'true') return;
}
const oldFirstTs = document.querySelector('.c-message_group .c-timestamp')?.getAttribute('data-ts');
target.click();
await waitForPageSwap(current, oldFirstTs);
}
};
const expandShowMore = async () => {
const outerBtns = [...document.querySelectorAll('[data-qa="search_expand"]')]
.filter(el => el.offsetParent !== null);
if (outerBtns.length > 0) {
outerBtns.forEach(btn => btn.click());
await waitMs(500);
}
const innerBtns = [...document.querySelectorAll('.c-rich_text_expand_button')]
.filter(el => el.offsetParent !== null);
if (innerBtns.length > 0) {
innerBtns.forEach(btn => btn.click());
await waitMs(500);
}
};
const collectMessagesFromPage = (messagePack) => {
return new Promise((resolve) => {
messagePack.messagePushed = false;
document.querySelectorAll('[role="document"]').forEach((group) => {
const tsAttr = group.querySelector(".c-timestamp")?.getAttribute("data-ts")?.split(".")[0];
if (!tsAttr) return;
const datetime = timestampToTime(tsAttr);
const channelNameEl = group.querySelector('[data-qa="inline_channel_entity__name"]');
const channelName = channelNameEl ? channelNameEl.textContent : "DirectMessage";
const sender = group.querySelector(".c-message__sender_button")?.textContent ?? "";
const msgEl = group.querySelector('[data-qa="message-text"]');
const attachEl = group.querySelector(".c-search_message__attachments");
const messageText = msgEl ? domToMarkdown(msgEl).trim() : "";
const attachmentText = attachEl ? domToMarkdown(attachEl).trim() : "";
// File attachments: aria-label has filename, message_file_link has href.
// Strip Slack's tracking query params (origin_team/origin_channel) — the
// base URL still resolves and we save tokens.
const fileLines = [...group.querySelectorAll('.c-search_message__file_container')]
.map(container => {
const fileEl = container.querySelector('[data-qa="search_result_file"]');
const linkEl = container.querySelector('[data-qa="message_file_link"]');
const filename = fileEl?.getAttribute('aria-label') || 'file';
const url = (linkEl?.getAttribute('href') || '').split('?')[0];
if (!url) return '';
const isImage = container.querySelector('.p-file_thumbnail__container--image') !== null;
return isImage ? `` : `[${filename}](${url})`;
})
.filter(Boolean)
.join('\n');
const trimmedMessage = [messageText, attachmentText, fileLines]
.filter(Boolean)
.join('\n')
.trim();
const key = `${datetime}\t${channelName}\t${sender}\t${trimmedMessage}`;
if (messagePack.messageSet.has(key)) return;
messagePack.messages.push({ date: datetime, channel: channelName, sender, message: trimmedMessage });
messagePack.messagePushed = true;
messagePack.messageSet.add(key);
group.scrollIntoView();
});
resolve(messagePack);
});
};
const clickNextPage = (messagePack) => {
messagePack.hasNextPage = false;
const arrowBtns = document.querySelectorAll(".c-pagination__arrow_btn");
if (!arrowBtns.length) return Promise.resolve(messagePack);
const nextBtn = [...arrowBtns].find(e =>
["Next page", "次のページ"].includes(e.getAttribute("aria-label"))
);
if (!nextBtn) return Promise.resolve(messagePack);
messagePack.hasNextPage = nextBtn.getAttribute("aria-disabled") === "false";
if (messagePack.hasNextPage) nextBtn.click();
return Promise.resolve(messagePack);
};
const checkPageLimit = (messagePack) => {
if (!document.querySelector(".c-search_message__content")) {
messagePack.hasNextPage = false;
}
return Promise.resolve(messagePack);
};
const runExport = async (messagePack) => {
if (!messagePack.hasNextPage) {
const yaml = formatAsYaml(messagePack.messages);
chrome.runtime.sendMessage({
action: "exportComplete",
data: yaml,
count: messagePack.messages.length,
bytes: new Blob([yaml]).size,
});
return;
}
await waitForSearchResult();
do {
await waitMs(800);
await expandShowMore();
await collectMessagesFromPage(messagePack);
} while (messagePack.messagePushed);
chrome.runtime.sendMessage({
action: "progress",
count: messagePack.messages.length,
total: messagePack.total,
});
await clickNextPage(messagePack);
await waitMs(600);
await checkPageLimit(messagePack);
await runExport(messagePack);
};
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.action === "startExport") {
(async () => {
await goToFirstPage();
await waitForSearchResult();
const total = getTotalResultCount();
await runExport({
messages: [],
messageSet: new Set(),
messagePushed: false,
hasNextPage: true,
total,
});
})();
sendResponse({ status: "started" });
return true;
}
if (message.action === "checkSearchPage") {
const hasResults = !!document.querySelector('.c-search_message__content');
const totalCount = getTotalResultCount();
sendResponse({ hasResults, totalCount });
return false;
}
return true;
});