moved extension files into subdirectory
This commit is contained in:
591
wwcompanion-extension/popup.js
Normal file
591
wwcompanion-extension/popup.js
Normal file
@@ -0,0 +1,591 @@
|
||||
const extractBtn = document.getElementById("extractBtn");
|
||||
const analyzeBtn = document.getElementById("analyzeBtn");
|
||||
const abortBtn = document.getElementById("abortBtn");
|
||||
const extractRunBtn = document.getElementById("extractRunBtn");
|
||||
const stopRow = document.getElementById("stopRow");
|
||||
const buttonRow = document.querySelector(".button-row");
|
||||
const taskSelect = document.getElementById("taskSelect");
|
||||
const outputEl = document.getElementById("output");
|
||||
const statusEl = document.getElementById("status");
|
||||
const postingCountEl = document.getElementById("postingCount");
|
||||
const promptCountEl = document.getElementById("promptCount");
|
||||
const settingsBtn = document.getElementById("settingsBtn");
|
||||
const copyRenderedBtn = document.getElementById("copyRenderedBtn");
|
||||
const copyRawBtn = document.getElementById("copyRawBtn");
|
||||
const clearOutputBtn = document.getElementById("clearOutputBtn");
|
||||
|
||||
const OUTPUT_STORAGE_KEY = "lastOutput";
|
||||
const AUTO_RUN_KEY = "autoRunDefaultTask";
|
||||
|
||||
const state = {
|
||||
postingText: "",
|
||||
tasks: [],
|
||||
port: null,
|
||||
isAnalyzing: false,
|
||||
outputRaw: "",
|
||||
autoRunPending: false
|
||||
};
|
||||
|
||||
function getStorage(keys) {
|
||||
return new Promise((resolve) => chrome.storage.local.get(keys, resolve));
|
||||
}
|
||||
|
||||
function buildUserMessage(resume, task, posting) {
|
||||
return [
|
||||
"=== RESUME ===",
|
||||
resume || "",
|
||||
"",
|
||||
"=== TASK ===",
|
||||
task || "",
|
||||
"",
|
||||
"=== JOB POSTING ===",
|
||||
posting || ""
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeAttribute(text) {
|
||||
return text.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function sanitizeUrl(url) {
|
||||
const trimmed = url.trim().replace(/&/g, "&");
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return "";
|
||||
}
|
||||
|
||||
function applyInline(text) {
|
||||
if (!text) return "";
|
||||
const codeSpans = [];
|
||||
let output = text.replace(/`([^`]+)`/g, (_match, code) => {
|
||||
const id = codeSpans.length;
|
||||
codeSpans.push(code);
|
||||
return `@@CODESPAN${id}@@`;
|
||||
});
|
||||
|
||||
output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, url) => {
|
||||
const safeUrl = sanitizeUrl(url);
|
||||
if (!safeUrl) return label;
|
||||
return `<a href="${escapeAttribute(
|
||||
safeUrl
|
||||
)}" target="_blank" rel="noreferrer">${label}</a>`;
|
||||
});
|
||||
|
||||
output = output.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
output = output.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
output = output.replace(/_([^_]+)_/g, "<em>$1</em>");
|
||||
|
||||
output = output.replace(/@@CODESPAN(\d+)@@/g, (_match, id) => {
|
||||
const code = codeSpans[Number(id)] || "";
|
||||
return `<code>${code}</code>`;
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderMarkdown(rawText) {
|
||||
const { text, blocks } = (() => {
|
||||
const escaped = escapeHtml(rawText || "");
|
||||
const codeBlocks = [];
|
||||
const replaced = escaped.replace(/```([\s\S]*?)```/g, (_match, code) => {
|
||||
let content = code;
|
||||
if (content.startsWith("\n")) content = content.slice(1);
|
||||
const firstLine = content.split("\n")[0] || "";
|
||||
if (/^[a-z0-9+.#-]+$/i.test(firstLine.trim()) && content.includes("\n")) {
|
||||
content = content.split("\n").slice(1).join("\n");
|
||||
}
|
||||
const id = codeBlocks.length;
|
||||
codeBlocks.push(content);
|
||||
return `@@CODEBLOCK${id}@@`;
|
||||
});
|
||||
return { text: replaced, blocks: codeBlocks };
|
||||
})();
|
||||
|
||||
const lines = text.split(/\r?\n/);
|
||||
const result = [];
|
||||
let paragraph = [];
|
||||
let listType = null;
|
||||
let inBlockquote = false;
|
||||
let quoteLines = [];
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (!paragraph.length) return;
|
||||
result.push(`<p>${applyInline(paragraph.join("<br>"))}</p>`);
|
||||
paragraph = [];
|
||||
};
|
||||
|
||||
const closeList = () => {
|
||||
if (!listType) return;
|
||||
result.push(`</${listType}>`);
|
||||
listType = null;
|
||||
};
|
||||
|
||||
const openList = (type) => {
|
||||
if (listType === type) return;
|
||||
if (listType) result.push(`</${listType}>`);
|
||||
listType = type;
|
||||
result.push(`<${type}>`);
|
||||
};
|
||||
|
||||
const closeBlockquote = () => {
|
||||
if (!inBlockquote) return;
|
||||
result.push(`<blockquote>${applyInline(quoteLines.join("<br>"))}</blockquote>`);
|
||||
inBlockquote = false;
|
||||
quoteLines = [];
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
const isQuoteLine = /^\s*>\s?/.test(line);
|
||||
|
||||
if (trimmed === "") {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
closeBlockquote();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockquote && !isQuoteLine) {
|
||||
closeBlockquote();
|
||||
}
|
||||
|
||||
if (/^@@CODEBLOCK\d+@@$/.test(trimmed)) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
closeBlockquote();
|
||||
result.push(trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (headingMatch) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
closeBlockquote();
|
||||
const level = headingMatch[1].length;
|
||||
result.push(`<h${level}>${applyInline(headingMatch[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^(\s*[-*_])\1{2,}\s*$/.test(line)) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
closeBlockquote();
|
||||
result.push("<hr>");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isQuoteLine) {
|
||||
if (!inBlockquote) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
inBlockquote = true;
|
||||
quoteLines = [];
|
||||
}
|
||||
quoteLines.push(line.replace(/^\s*>\s?/, ""));
|
||||
continue;
|
||||
}
|
||||
|
||||
const unorderedMatch = line.match(/^[-*+]\s+(.+)$/);
|
||||
if (unorderedMatch) {
|
||||
flushParagraph();
|
||||
closeBlockquote();
|
||||
openList("ul");
|
||||
result.push(`<li>${applyInline(unorderedMatch[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedMatch = line.match(/^\d+\.\s+(.+)$/);
|
||||
if (orderedMatch) {
|
||||
flushParagraph();
|
||||
closeBlockquote();
|
||||
openList("ol");
|
||||
result.push(`<li>${applyInline(orderedMatch[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
paragraph.push(line);
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
closeList();
|
||||
closeBlockquote();
|
||||
|
||||
return result
|
||||
.join("\n")
|
||||
.replace(/@@CODEBLOCK(\d+)@@/g, (_match, id) => {
|
||||
const code = blocks[Number(id)] || "";
|
||||
return `<pre><code>${code}</code></pre>`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderOutput() {
|
||||
outputEl.innerHTML = renderMarkdown(state.outputRaw);
|
||||
outputEl.scrollTop = outputEl.scrollHeight;
|
||||
}
|
||||
|
||||
function persistOutputNow() {
|
||||
return chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: state.outputRaw });
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
statusEl.textContent = message;
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
const value = theme || "system";
|
||||
document.documentElement.dataset.theme = value;
|
||||
}
|
||||
|
||||
function setAnalyzing(isAnalyzing) {
|
||||
state.isAnalyzing = isAnalyzing;
|
||||
analyzeBtn.disabled = isAnalyzing;
|
||||
abortBtn.disabled = !isAnalyzing;
|
||||
extractBtn.disabled = isAnalyzing;
|
||||
extractRunBtn.disabled = isAnalyzing;
|
||||
if (buttonRow && stopRow) {
|
||||
buttonRow.classList.toggle("hidden", isAnalyzing);
|
||||
stopRow.classList.toggle("hidden", !isAnalyzing);
|
||||
}
|
||||
updateTaskSelectState();
|
||||
}
|
||||
|
||||
function updatePostingCount() {
|
||||
postingCountEl.textContent = `Posting: ${state.postingText.length} chars`;
|
||||
}
|
||||
|
||||
function updatePromptCount(count) {
|
||||
promptCountEl.textContent = `Prompt: ${count} chars`;
|
||||
}
|
||||
|
||||
function renderTasks(tasks) {
|
||||
state.tasks = tasks;
|
||||
taskSelect.innerHTML = "";
|
||||
|
||||
if (!tasks.length) {
|
||||
const option = document.createElement("option");
|
||||
option.textContent = "No tasks configured";
|
||||
option.value = "";
|
||||
taskSelect.appendChild(option);
|
||||
updateTaskSelectState();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
const option = document.createElement("option");
|
||||
option.value = task.id;
|
||||
option.textContent = task.name || "Untitled task";
|
||||
taskSelect.appendChild(option);
|
||||
}
|
||||
updateTaskSelectState();
|
||||
}
|
||||
|
||||
function updateTaskSelectState() {
|
||||
const hasTasks = state.tasks.length > 0;
|
||||
taskSelect.disabled = state.isAnalyzing || !hasTasks;
|
||||
}
|
||||
|
||||
function isWaterlooWorksUrl(url) {
|
||||
try {
|
||||
return new URL(url).hostname === "waterlooworks.uwaterloo.ca";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sendToActiveTab(message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const tab = tabs[0];
|
||||
if (!tab?.id) {
|
||||
reject(new Error("No active tab found."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isWaterlooWorksUrl(tab.url || "")) {
|
||||
reject(new Error("Open waterlooworks.uwaterloo.ca to use this."));
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.tabs.sendMessage(tab.id, message, (response) => {
|
||||
const error = chrome.runtime.lastError;
|
||||
if (error) {
|
||||
const msg =
|
||||
error.message && error.message.includes("Receiving end does not exist")
|
||||
? "Couldn't reach the page. Try refreshing WaterlooWorks and retry."
|
||||
: error.message;
|
||||
reject(new Error(msg));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ensurePort() {
|
||||
if (state.port) return state.port;
|
||||
|
||||
const port = chrome.runtime.connect({ name: "analysis" });
|
||||
port.onMessage.addListener((message) => {
|
||||
if (message?.type === "DELTA") {
|
||||
state.outputRaw += message.text;
|
||||
renderOutput();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.type === "SYNC") {
|
||||
state.outputRaw = message.text || "";
|
||||
renderOutput();
|
||||
if (message.streaming) {
|
||||
setAnalyzing(true);
|
||||
setStatus("Analyzing...");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.type === "DONE") {
|
||||
setAnalyzing(false);
|
||||
setStatus("Done");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.type === "ABORTED") {
|
||||
setAnalyzing(false);
|
||||
setStatus("Aborted.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.type === "ERROR") {
|
||||
setAnalyzing(false);
|
||||
setStatus(message.message || "Error during analysis.");
|
||||
}
|
||||
});
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
state.port = null;
|
||||
setAnalyzing(false);
|
||||
});
|
||||
|
||||
state.port = port;
|
||||
return port;
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
const { tasks = [] } = await getStorage(["tasks"]);
|
||||
renderTasks(tasks);
|
||||
maybeRunDefaultTask();
|
||||
}
|
||||
|
||||
async function loadTheme() {
|
||||
const { theme = "system" } = await getStorage(["theme"]);
|
||||
applyTheme(theme);
|
||||
}
|
||||
|
||||
async function handleExtract() {
|
||||
setStatus("Extracting...");
|
||||
try {
|
||||
const response = await sendToActiveTab({ type: "EXTRACT_POSTING" });
|
||||
if (!response?.ok) {
|
||||
setStatus(response?.error || "No posting detected.");
|
||||
return false;
|
||||
}
|
||||
|
||||
state.postingText = response.sanitized || "";
|
||||
updatePostingCount();
|
||||
updatePromptCount(0);
|
||||
setStatus("Posting extracted.");
|
||||
return true;
|
||||
} catch (error) {
|
||||
setStatus(error.message || "Unable to extract posting.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnalyze() {
|
||||
if (!state.postingText) {
|
||||
setStatus("Extract a job posting first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
const tab = tabs[0];
|
||||
if (!tab?.url || !isWaterlooWorksUrl(tab.url)) {
|
||||
setStatus("Open waterlooworks.uwaterloo.ca to run tasks.");
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = taskSelect.value;
|
||||
const task = state.tasks.find((item) => item.id === taskId);
|
||||
if (!task) {
|
||||
setStatus("Select a task prompt.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { apiKey, apiBaseUrl, apiKeyHeader, apiKeyPrefix, model, systemPrompt, resume } =
|
||||
await getStorage([
|
||||
"apiKey",
|
||||
"apiBaseUrl",
|
||||
"apiKeyHeader",
|
||||
"apiKeyPrefix",
|
||||
"model",
|
||||
"systemPrompt",
|
||||
"resume"
|
||||
]);
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
setStatus("Set an API base URL in Settings.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (apiKeyHeader && !apiKey) {
|
||||
setStatus("Add your API key in Settings.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
setStatus("Set a model name in Settings.");
|
||||
return;
|
||||
}
|
||||
|
||||
const promptText = buildUserMessage(resume || "", task.text || "", state.postingText);
|
||||
updatePromptCount(promptText.length);
|
||||
|
||||
state.outputRaw = "";
|
||||
renderOutput();
|
||||
setAnalyzing(true);
|
||||
setStatus("Analyzing...");
|
||||
|
||||
const port = ensurePort();
|
||||
port.postMessage({
|
||||
type: "START_ANALYSIS",
|
||||
payload: {
|
||||
apiKey,
|
||||
apiBaseUrl,
|
||||
apiKeyHeader,
|
||||
apiKeyPrefix,
|
||||
model,
|
||||
systemPrompt: systemPrompt || "",
|
||||
resume: resume || "",
|
||||
taskText: task.text || "",
|
||||
postingText: state.postingText,
|
||||
tabId: tab.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleExtractAndAnalyze() {
|
||||
const extracted = await handleExtract();
|
||||
if (!extracted) return;
|
||||
await handleAnalyze();
|
||||
}
|
||||
|
||||
function handleAbort() {
|
||||
if (!state.port) return;
|
||||
state.port.postMessage({ type: "ABORT_ANALYSIS" });
|
||||
setAnalyzing(false);
|
||||
setStatus("Aborted.");
|
||||
}
|
||||
|
||||
async function handleClearOutput() {
|
||||
state.outputRaw = "";
|
||||
renderOutput();
|
||||
await persistOutputNow();
|
||||
setStatus("Output cleared.");
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text, label) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setStatus(`${label} copied.`);
|
||||
} catch (error) {
|
||||
setStatus(`Unable to copy ${label.toLowerCase()}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyRendered() {
|
||||
const text = outputEl.innerText || "";
|
||||
if (!text.trim()) {
|
||||
setStatus("Nothing to copy.");
|
||||
return;
|
||||
}
|
||||
void copyTextToClipboard(text, "Output");
|
||||
}
|
||||
|
||||
function handleCopyRaw() {
|
||||
const text = state.outputRaw || "";
|
||||
if (!text.trim()) {
|
||||
setStatus("Nothing to copy.");
|
||||
return;
|
||||
}
|
||||
void copyTextToClipboard(text, "Markdown");
|
||||
}
|
||||
|
||||
extractBtn.addEventListener("click", handleExtract);
|
||||
analyzeBtn.addEventListener("click", handleAnalyze);
|
||||
extractRunBtn.addEventListener("click", handleExtractAndAnalyze);
|
||||
abortBtn.addEventListener("click", handleAbort);
|
||||
settingsBtn.addEventListener("click", () => chrome.runtime.openOptionsPage());
|
||||
copyRenderedBtn.addEventListener("click", handleCopyRendered);
|
||||
copyRawBtn.addEventListener("click", handleCopyRaw);
|
||||
clearOutputBtn.addEventListener("click", () => void handleClearOutput());
|
||||
|
||||
updatePostingCount();
|
||||
updatePromptCount(0);
|
||||
renderOutput();
|
||||
setAnalyzing(false);
|
||||
loadTasks();
|
||||
loadTheme();
|
||||
|
||||
async function loadSavedOutput() {
|
||||
const stored = await getStorage([OUTPUT_STORAGE_KEY]);
|
||||
state.outputRaw = stored[OUTPUT_STORAGE_KEY] || "";
|
||||
renderOutput();
|
||||
}
|
||||
|
||||
async function loadAutoRunRequest() {
|
||||
const stored = await getStorage([AUTO_RUN_KEY]);
|
||||
if (stored[AUTO_RUN_KEY]) {
|
||||
state.autoRunPending = true;
|
||||
await chrome.storage.local.remove(AUTO_RUN_KEY);
|
||||
}
|
||||
maybeRunDefaultTask();
|
||||
}
|
||||
|
||||
function maybeRunDefaultTask() {
|
||||
if (!state.autoRunPending) return;
|
||||
if (state.isAnalyzing) return;
|
||||
if (!state.tasks.length) return;
|
||||
taskSelect.value = state.tasks[0].id;
|
||||
state.autoRunPending = false;
|
||||
void handleExtractAndAnalyze();
|
||||
}
|
||||
|
||||
loadSavedOutput();
|
||||
loadAutoRunRequest();
|
||||
ensurePort();
|
||||
|
||||
chrome.storage.onChanged.addListener((changes) => {
|
||||
if (changes[AUTO_RUN_KEY]?.newValue) {
|
||||
state.autoRunPending = true;
|
||||
void chrome.storage.local.remove(AUTO_RUN_KEY);
|
||||
maybeRunDefaultTask();
|
||||
}
|
||||
|
||||
if (changes[OUTPUT_STORAGE_KEY]?.newValue !== undefined) {
|
||||
if (!state.isAnalyzing || !state.port) {
|
||||
state.outputRaw = changes[OUTPUT_STORAGE_KEY].newValue || "";
|
||||
renderOutput();
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.theme) {
|
||||
applyTheme(changes.theme.newValue || "system");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user