Files

214 lines
6.6 KiB
JavaScript
Raw Permalink Normal View History

2026-03-18 11:10:07 -04:00
import { apiInitializer } from "discourse/lib/api";
import { ajax } from "discourse/lib/ajax";
const URL_REGEX = /^(https?:\/\/[^\s/$.?#][^\s]*)$/i;
2026-03-18 13:23:48 -04:00
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",
};
2026-03-18 11:10:07 -04:00
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);
2026-03-18 12:59:09 -04:00
this._attachTitlePasteListener();
2026-03-18 11:10:07 -04:00
},
willDestroyElement() {
this._super(...arguments);
if (this._titleInputEl && this._titlePasteHandler) {
this._titleInputEl.removeEventListener(
"paste",
this._titlePasteHandler,
true
);
}
2026-03-18 11:10:07 -04:00
},
2026-03-18 12:59:09 -04:00
// ---- Title paste interception -------------------------------------
2026-03-18 13:23:48 -04:00
// capture:true so our handler runs before Discourse's, then
2026-03-18 12:59:09 -04:00
// preventDefault + stopImmediatePropagation so Discourse never sees
2026-03-18 13:23:48 -04:00
// the paste event — no title lookup, no onebox race.
2026-03-18 12:59:09 -04:00
_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);
2026-03-18 11:48:57 -04:00
}
},
2026-03-18 11:10:07 -04:00
// ---- Bar UI -------------------------------------------------------
_showArticleBar(url) {
this._hideArticleBar();
2026-03-18 11:10:07 -04:00
const bar = document.createElement("div");
bar.className = "url-to-article-bar";
bar.innerHTML = `
<span class="url-to-article-icon">📄</span>
2026-03-18 13:23:48 -04:00
<span class="url-to-article-label">${STRINGS.bar_label}</span>
2026-03-18 11:10:07 -04:00
<button class="btn btn-small btn-primary url-to-article-btn">
2026-03-18 13:23:48 -04:00
${STRINGS.fetch_button}
2026-03-18 11:10:07 -04:00
</button>
2026-03-18 11:48:57 -04:00
<button class="btn btn-small btn-default url-to-article-onebox-btn">
2026-03-18 13:23:48 -04:00
${STRINGS.onebox_button}
2026-03-18 11:48:57 -04:00
</button>
2026-03-18 12:59:09 -04:00
<button class="btn btn-small btn-flat url-to-article-dismiss"
2026-03-18 13:23:48 -04:00
aria-label="${STRINGS.dismiss}"></button>
2026-03-18 11:10:07 -04:00
`;
bar.querySelector(".url-to-article-btn").addEventListener("click", () => {
this._fetchAndPopulate(url);
});
2026-03-18 11:48:57 -04:00
bar.querySelector(".url-to-article-onebox-btn").addEventListener("click", () => {
this._hideArticleBar();
2026-03-18 12:59:09 -04:00
this._commitUrlToModel();
2026-03-18 11:48:57 -04:00
});
2026-03-18 11:10:07 -04:00
bar.querySelector(".url-to-article-dismiss").addEventListener("click", () => {
this._hideArticleBar();
2026-03-18 12:59:09 -04:00
this._pendingUrl = null;
2026-03-18 11:10:07 -04:00
});
2026-03-18 12:59:09 -04:00
const container = this.element.querySelector(".d-editor-container");
if (container) {
2026-03-18 13:36:31 -04:00
container.parentElement.insertBefore(bar, container);
} else {
this.element.insertAdjacentElement("afterbegin", bar);
2026-03-18 11:10:07 -04:00
}
},
_hideArticleBar() {
this.element
?.querySelectorAll(".url-to-article-bar")
.forEach((el) => el.remove());
2026-03-18 11:10:07 -04:00
},
_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}`;
},
2026-03-18 12:59:09 -04:00
// 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;
},
2026-03-18 11:10:07 -04:00
// ---- 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;
2026-03-18 13:23:48 -04:00
btn.textContent = STRINGS.fetching;
2026-03-18 11:10:07 -04:00
}
2026-03-18 13:23:48 -04:00
this._setStatus(STRINGS.fetching, "info");
2026-03-18 11:10:07 -04:00
try {
const data = await ajax("/url-to-article/extract", {
type: "POST",
data: { url },
});
2026-03-18 12:59:09 -04:00
if (data.error) throw new Error(data.error);
2026-03-18 11:10:07 -04:00
this._populateComposer(data);
2026-03-18 13:23:48 -04:00
this._setStatus(STRINGS.success, "success");
2026-03-18 11:10:07 -04:00
setTimeout(() => this._hideArticleBar(), 3000);
} catch (err) {
const msg =
err.jqXHR?.responseJSON?.error ||
err.message ||
2026-03-18 13:23:48 -04:00
STRINGS.error_generic;
this._setStatus(`${STRINGS.error_prefix} ${msg}`, "error");
2026-03-18 11:10:07 -04:00
if (btn) {
btn.disabled = false;
2026-03-18 13:23:48 -04:00
btn.textContent = STRINGS.retry_button;
2026-03-18 11:10:07 -04:00
}
}
},
_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}`);
2026-03-18 13:23:48 -04:00
lines.push(`> ${STRINGS.source_label}: <${data.url}>`);
2026-03-18 11:10:07 -04:00
lines.push("");
} else {
2026-03-18 13:23:48 -04:00
lines.push(`> ${STRINGS.source_label}: <${data.url}>`);
2026-03-18 11:10:07 -04:00
lines.push("");
}
if (data.description) {
lines.push(`*${data.description}*`);
lines.push("");
lines.push("---");
lines.push("");
}
lines.push(data.markdown || "");
2026-03-18 12:59:09 -04:00
composerModel.set("title", data.title || this._pendingUrl || "");
composerModel.set("reply", lines.join("\n"));
this._pendingUrl = null;
2026-03-18 11:10:07 -04:00
2026-03-18 12:59:09 -04:00
if (this._titleInputEl) {
this._titleInputEl.value = composerModel.get("title");
2026-03-18 11:10:07 -04:00
}
},
});
});