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("site-settings:main").url_to_article_enabled) { return; } api.modifyClass("component:composer-editor", { pluginId: "url-to-article", didInsertElement() { this._super(...arguments); this._attachTitlePasteListener(); }, willDestroyElement() { this._super(...arguments); if (this._titleInputEl && this._titlePasteHandler) { this._titleInputEl.removeEventListener( "paste", this._titlePasteHandler, true ); } }, // ---- 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. _attachTitlePasteListener(attempts = 0) { const input = document.querySelector("#reply-title"); if (input) { this._titleInputEl = input; this._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; this._pendingUrl = text; this._showArticleBar(text); }; input.addEventListener("paste", this._titlePasteHandler, true); } else if (attempts < 15) { setTimeout(() => this._attachTitlePasteListener(attempts + 1), 200); } }, // ---- Bar UI ------------------------------------------------------- _showArticleBar(url) { this._hideArticleBar(); const bar = document.createElement("div"); bar.className = "url-to-article-bar"; bar.innerHTML = ` 📄 ${STRINGS.bar_label} `; bar.querySelector(".url-to-article-btn").addEventListener("click", () => { this._fetchAndPopulate(url); }); bar.querySelector(".url-to-article-onebox-btn").addEventListener("click", () => { this._hideArticleBar(); this._commitUrlToModel(); }); bar.querySelector(".url-to-article-dismiss").addEventListener("click", () => { this._hideArticleBar(); this._pendingUrl = null; }); const container = this.element.querySelector(".d-editor-container"); if (container) { container.insertAdjacentElement("afterbegin", bar); } }, _hideArticleBar() { this.element ?.querySelectorAll(".url-to-article-bar") .forEach((el) => el.remove()); }, _setStatus(message, type = "info") { const bar = this.element?.querySelector(".url-to-article-bar"); if (!bar) return; let status = bar.querySelector(".url-to-article-status"); if (!status) { status = document.createElement("span"); status.className = "url-to-article-status"; bar.appendChild(status); } status.textContent = message; status.className = `url-to-article-status url-to-article-status--${type}`; }, // Release the URL into Ember's data-binding so Discourse handles it normally _commitUrlToModel() { const input = this._titleInputEl; const url = this._pendingUrl; if (!input || !url) return; input.value = url; input.dispatchEvent(new Event("input", { bubbles: true })); this._pendingUrl = null; }, // ---- Fetch & populate --------------------------------------------- async _fetchAndPopulate(url) { const bar = this.element?.querySelector(".url-to-article-bar"); const btn = bar?.querySelector(".url-to-article-btn"); if (btn) { btn.disabled = true; btn.textContent = STRINGS.fetching; } this._setStatus(STRINGS.fetching, "info"); try { const data = await ajax("/url-to-article/extract", { type: "POST", data: { url }, }); if (data.error) throw new Error(data.error); this._populateComposer(data); this._setStatus(STRINGS.success, "success"); setTimeout(() => this._hideArticleBar(), 3000); } catch (err) { const msg = err.jqXHR?.responseJSON?.error || err.message || STRINGS.error_generic; this._setStatus(`${STRINGS.error_prefix} ${msg}`, "error"); if (btn) { btn.disabled = false; btn.textContent = STRINGS.retry_button; } } }, _populateComposer(data) { const composerModel = this.get("composer.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 || this._pendingUrl || ""); composerModel.set("reply", lines.join("\n")); this._pendingUrl = null; if (this._titleInputEl) { this._titleInputEl.value = composerModel.get("title"); } }, }); });