init commit

This commit is contained in:
2026-03-18 11:10:07 -04:00
commit b1ef516348
8 changed files with 730 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
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");
},
_onTitleChanged() {
const title = this.get("composer.model.title") || "";
const match = title.trim().match(URL_REGEX);
if (!match) {
this._hideArticleBar();
return;
}
const url = match[1];
if (this._lastDetectedUrl === url) return; // Same URL — no-op
this._lastDetectedUrl = url;
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);
}
},
// ---- 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-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-dismiss").addEventListener("click", () => {
this._hideArticleBar();
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._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);
},
});
});