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);
},
});
});