240 lines
7.7 KiB
JavaScript
240 lines
7.7 KiB
JavaScript
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;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Inject a 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() {
|
|
// Watch the title field — it lives outside the composer-editor DOM,
|
|
// so we observe via the composer model's `title` property.
|
|
const composer = this.get("composer");
|
|
if (!composer) return;
|
|
|
|
this._titleObserver = () => this._onTitleChanged();
|
|
composer.addObserver("model.title", this, "_titleObserver");
|
|
},
|
|
|
|
_teardownUrlToArticle() {
|
|
const composer = this.get("composer");
|
|
if (!composer) return;
|
|
composer.removeObserver("model.title", this, "_titleObserver");
|
|
this._restoreNativeTitleLookup();
|
|
},
|
|
|
|
_onTitleChanged() {
|
|
const title = this.get("composer.model.title") || "";
|
|
const match = title.trim().match(URL_REGEX);
|
|
|
|
if (!match) {
|
|
this._restoreNativeTitleLookup();
|
|
this._hideArticleBar();
|
|
return;
|
|
}
|
|
|
|
const url = match[1];
|
|
|
|
if (this._lastDetectedUrl === url) return; // Same URL — no-op
|
|
this._lastDetectedUrl = url;
|
|
|
|
// Suppress Discourse's native title lookup so it doesn't race our bar
|
|
this._suppressNativeTitleLookup();
|
|
|
|
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);
|
|
}
|
|
},
|
|
|
|
// ---- Native title-lookup suppression ------------------------------
|
|
|
|
_suppressNativeTitleLookup() {
|
|
const model = this.get("composer.model");
|
|
if (!model || this._originalTitleLookup) return;
|
|
if (typeof model._titleLookup === "function") {
|
|
this._originalTitleLookup = model._titleLookup.bind(model);
|
|
model._titleLookup = () => {};
|
|
}
|
|
},
|
|
|
|
_restoreNativeTitleLookup() {
|
|
const model = this.get("composer.model");
|
|
if (!model || !this._originalTitleLookup) return;
|
|
model._titleLookup = this._originalTitleLookup;
|
|
this._originalTitleLookup = null;
|
|
},
|
|
|
|
_triggerNativeTitleLookup() {
|
|
this._restoreNativeTitleLookup();
|
|
const model = this.get("composer.model");
|
|
if (model && typeof model._titleLookup === "function") {
|
|
model._titleLookup();
|
|
}
|
|
},
|
|
|
|
// ---- Bar UI -------------------------------------------------------
|
|
|
|
_showArticleBar(url) {
|
|
this._hideArticleBar(); // remove any existing bar first
|
|
|
|
const bar = document.createElement("div");
|
|
bar.className = "url-to-article-bar";
|
|
bar.dataset.url = url;
|
|
bar.innerHTML = `
|
|
<span class="url-to-article-icon">📄</span>
|
|
<span class="url-to-article-label">${I18n.t("url_to_article.bar_label")}</span>
|
|
<button class="btn btn-small btn-primary url-to-article-btn">
|
|
${I18n.t("url_to_article.fetch_button")}
|
|
</button>
|
|
<button class="btn btn-small btn-default url-to-article-onebox-btn">
|
|
${I18n.t("url_to_article.onebox_button")}
|
|
</button>
|
|
<button class="btn btn-small btn-flat url-to-article-dismiss" aria-label="${I18n.t("url_to_article.dismiss")}">✕</button>
|
|
`;
|
|
|
|
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._triggerNativeTitleLookup();
|
|
});
|
|
|
|
bar.querySelector(".url-to-article-dismiss").addEventListener("click", () => {
|
|
this._hideArticleBar();
|
|
this._restoreNativeTitleLookup();
|
|
this._lastDetectedUrl = null; // Allow re-detection if title changes
|
|
});
|
|
|
|
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._restoreNativeTitleLookup();
|
|
this._setStatus(I18n.t("url_to_article.success"), "success");
|
|
|
|
// Auto-hide bar after 3 seconds on 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;
|
|
|
|
// Build the article body in Markdown
|
|
const lines = [];
|
|
|
|
// Attribution header
|
|
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");
|
|
|
|
// Only set title if it's still the raw URL (avoid overwriting edited titles)
|
|
const currentTitle = composerModel.get("title") || "";
|
|
if (currentTitle.trim() === data.url || currentTitle.trim() === "") {
|
|
composerModel.set("title", data.title || data.url);
|
|
}
|
|
|
|
composerModel.set("reply", body);
|
|
},
|
|
});
|
|
});
|