import { apiInitializer } from "discourse/lib/api"; import { ajax } from "discourse/lib/ajax"; const URL_REGEX = /^(https?:\/\/[^\s/$.?#][^\s]*)$/i; const STRINGS = { bar_label: "URL detected — import as article?", fetch_button: "Import Article", onebox_button: "Use Onebox", dismiss: "Dismiss", fetching: "Fetching…", success: "Article imported!", error_generic: "Unknown error", error_prefix: "Error:", source_label: "Source", retry_button: "Retry", }; export default apiInitializer("1.8.0", (api) => { if (!api.container.lookup("service:site-settings").bookmark_url_enabled) { return; } let titleInputEl = null; let titlePasteHandler = null; let pendingUrl = null; // ---- Bar UI ------------------------------------------------------- function showArticleBar(url) { hideArticleBar(); const bar = document.createElement("div"); bar.className = "bookmark-url-bar"; bar.innerHTML = ` 📄 ${STRINGS.bar_label} `; bar.querySelector(".bookmark-url-btn").addEventListener("click", () => { fetchAndPopulate(url); }); bar.querySelector(".bookmark-url-onebox-btn").addEventListener("click", () => { hideArticleBar(); commitUrlToModel(); }); bar.querySelector(".bookmark-url-dismiss").addEventListener("click", () => { hideArticleBar(); pendingUrl = null; }); const container = document.querySelector(".d-editor-container"); if (container) { container.parentElement.insertBefore(bar, container); } else { document .querySelector(".composer-fields") ?.insertAdjacentElement("afterbegin", bar); } } function hideArticleBar() { document .querySelectorAll(".bookmark-url-bar") .forEach((el) => el.remove()); } function setStatus(message, type = "info") { const bar = document.querySelector(".bookmark-url-bar"); if (!bar) return; let status = bar.querySelector(".bookmark-url-status"); if (!status) { status = document.createElement("span"); status.className = "bookmark-url-status"; bar.appendChild(status); } status.textContent = message; status.className = `bookmark-url-status bookmark-url-status--${type}`; } // Release the URL into Ember's data-binding so Discourse handles it normally function commitUrlToModel() { const input = titleInputEl; const url = pendingUrl; if (!input || !url) return; input.value = url; input.dispatchEvent(new Event("input", { bubbles: true })); pendingUrl = null; } // ---- Fetch & populate --------------------------------------------- async function fetchAndPopulate(url) { const bar = document.querySelector(".bookmark-url-bar"); const btn = bar?.querySelector(".bookmark-url-btn"); if (btn) { btn.disabled = true; btn.textContent = STRINGS.fetching; } setStatus(STRINGS.fetching, "info"); try { const data = await ajax("/bookmark-url/extract", { type: "POST", data: { url }, }); if (data.error) throw new Error(data.error); populateComposer(data); setStatus(STRINGS.success, "success"); setTimeout(() => hideArticleBar(), 3000); } catch (err) { const msg = err.jqXHR?.responseJSON?.error || err.message || STRINGS.error_generic; setStatus(`${STRINGS.error_prefix} ${msg}`, "error"); if (btn) { btn.disabled = false; btn.textContent = STRINGS.retry_button; } } } function populateComposer(data) { const composerService = api.container.lookup("service:composer"); const composerModel = composerService?.model; if (!composerModel) return; const lines = []; const siteName = data.site_name ? `**${data.site_name}**` : ""; const byline = data.byline ? ` — *${data.byline}*` : ""; if (siteName || byline) { lines.push(`> ${siteName}${byline}`); lines.push(`> ${STRINGS.source_label}: <${data.url}>`); lines.push(""); } else { lines.push(`> ${STRINGS.source_label}: <${data.url}>`); lines.push(""); } if (data.description) { lines.push(`*${data.description}*`); lines.push(""); lines.push("---"); lines.push(""); } lines.push(data.markdown || ""); composerModel.set("title", data.title || pendingUrl || ""); composerModel.set("reply", lines.join("\n")); pendingUrl = null; if (titleInputEl) { titleInputEl.value = composerModel.get("title"); } } // ---- Title paste interception ------------------------------------- // capture:true so our handler runs before Discourse's, then // preventDefault + stopImmediatePropagation so Discourse never sees // the paste event — no title lookup, no onebox race. function attachTitlePasteListener(attempts = 0) { const input = document.querySelector("#reply-title"); if (input) { titleInputEl = input; titlePasteHandler = (e) => { const text = (e.clipboardData || window.clipboardData) .getData("text/plain") .trim(); if (!URL_REGEX.test(text)) return; e.preventDefault(); e.stopImmediatePropagation(); // Show URL in the field visually without going through Ember binding input.value = text; pendingUrl = text; showArticleBar(text); }; input.addEventListener("paste", titlePasteHandler, true); } else if (attempts < 15) { setTimeout(() => attachTitlePasteListener(attempts + 1), 200); } } function detachTitlePasteListener() { if (titleInputEl && titlePasteHandler) { titleInputEl.removeEventListener("paste", titlePasteHandler, true); } titleInputEl = null; titlePasteHandler = null; pendingUrl = null; hideArticleBar(); } // ---- Composer lifecycle ------------------------------------------- api.onAppEvent("composer:opened", () => { attachTitlePasteListener(); }); api.onAppEvent("composer:closed", () => { detachTitlePasteListener(); }); });