import { apiInitializer } from "discourse/lib/api"; import { debounce } from "@ember/runloop"; import { ajax } from "discourse/lib/ajax"; import I18n from "I18n"; const URL_REGEX = /^(https?:\/\/[^\s/$.?#][^\s]*)$/i; const DEBOUNCE_MS = 600; export default apiInitializer("1.8.0", (api) => { if (!api.container.lookup("site-settings:main").url_to_article_enabled) { return; } // ----------------------------------------------------------------------- // Intercept Discourse's native title URL lookup at the CLASS level. // Instance-patching is too late — Discourse's own titleChanged observer // may fire before ours. This wraps the prototype method so the flag wins // regardless of observer order. // ----------------------------------------------------------------------- api.modifyClass("model:composer", { pluginId: "url-to-article", _titleLookup() { if (this._urlToArticleSuppressLookup) return; return this._super(...arguments); }, }); // ----------------------------------------------------------------------- // Inject helper button + status banner into the composer // ----------------------------------------------------------------------- api.modifyClass("component:composer-editor", { pluginId: "url-to-article", didInsertElement() { this._super(...arguments); this._setupUrlToArticle(); }, willDestroyElement() { this._super(...arguments); this._teardownUrlToArticle(); }, _setupUrlToArticle() { const composer = this.get("composer"); if (!composer) return; this._titleObserver = () => this._onTitleChanged(); composer.addObserver("model.title", this, "_titleObserver"); // Intercept paste on the title input BEFORE Ember's data-binding fires, // so the suppression flag is set before Discourse's titleChanged observer runs. this._attachTitlePasteListener(); }, _attachTitlePasteListener() { const tryAttach = (attempts = 0) => { const input = document.querySelector("#reply-title"); if (input) { this._titlePasteHandler = (e) => { const text = ( e.clipboardData || window.clipboardData ).getData("text/plain").trim(); if (URL_REGEX.test(text)) { const model = this.get("composer.model"); if (model) model._urlToArticleSuppressLookup = true; } }; input.addEventListener("paste", this._titlePasteHandler, true); this._titleInputEl = input; } else if (attempts < 10) { setTimeout(() => tryAttach(attempts + 1), 200); } }; tryAttach(); }, _teardownUrlToArticle() { const composer = this.get("composer"); if (!composer) return; composer.removeObserver("model.title", this, "_titleObserver"); this._clearSuppression(); if (this._titleInputEl && this._titlePasteHandler) { this._titleInputEl.removeEventListener( "paste", this._titlePasteHandler, true ); } }, _onTitleChanged() { const title = this.get("composer.model.title") || ""; const match = title.trim().match(URL_REGEX); if (!match) { this._clearSuppression(); this._hideArticleBar(); return; } const url = match[1]; if (this._lastDetectedUrl === url) return; this._lastDetectedUrl = url; // Belt-and-suspenders: ensure suppression is set even if paste listener // missed it (e.g. URL typed manually rather than pasted). const model = this.get("composer.model"); if (model) model._urlToArticleSuppressLookup = true; const autoPopulate = api.container .lookup("site-settings:main") .url_to_article_auto_populate; if (autoPopulate) { debounce(this, "_fetchAndPopulate", url, DEBOUNCE_MS); } else { this._showArticleBar(url); } }, // ---- Suppression helpers ------------------------------------------ _clearSuppression() { const model = this.get("composer.model"); if (model) delete model._urlToArticleSuppressLookup; }, _triggerNativeLookup() { this._clearSuppression(); const model = this.get("composer.model"); if (model && typeof model._titleLookup === "function") { model._titleLookup(); } }, // ---- Bar UI ------------------------------------------------------- _showArticleBar(url) { this._hideArticleBar(); const bar = document.createElement("div"); bar.className = "url-to-article-bar"; bar.dataset.url = url; bar.innerHTML = ` 📄 ${I18n.t("url_to_article.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._lastDetectedUrl = null; this._triggerNativeLookup(); }); bar.querySelector(".url-to-article-dismiss").addEventListener("click", () => { this._hideArticleBar(); this._clearSuppression(); this._lastDetectedUrl = null; }); const toolbarEl = this.element.querySelector(".d-editor-container"); if (toolbarEl) { toolbarEl.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}`; }, // ---- 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 = I18n.t("url_to_article.fetching"); } this._setStatus(I18n.t("url_to_article.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._clearSuppression(); this._setStatus(I18n.t("url_to_article.success"), "success"); setTimeout(() => this._hideArticleBar(), 3000); } catch (err) { const msg = err.jqXHR?.responseJSON?.error || err.message || I18n.t("url_to_article.error_generic"); this._setStatus( `${I18n.t("url_to_article.error_prefix")} ${msg}`, "error" ); if (btn) { btn.disabled = false; btn.textContent = I18n.t("url_to_article.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(`> ${I18n.t("url_to_article.source_label")}: <${data.url}>`); lines.push(""); } else { lines.push(`> ${I18n.t("url_to_article.source_label")}: <${data.url}>`); lines.push(""); } if (data.description) { lines.push(`*${data.description}*`); lines.push(""); lines.push("---"); lines.push(""); } lines.push(data.markdown || ""); const body = lines.join("\n"); const currentTitle = composerModel.get("title") || ""; if (currentTitle.trim() === data.url || currentTitle.trim() === "") { composerModel.set("title", data.title || data.url); } composerModel.set("reply", body); }, }); });