Kristjan ESPERANTO 965e935881
[linter] Review linter setup (#3783)
- [2b395b9f] Fix command to lint markdown in `CONTRIBUTING.md`
- [fed4c86c] Re-activate JSDoc ESLint plugin and fix linting issues (As
far as I remember, we deactivated it when we upgraded to ESLint 9
because it was not compatible with it. But its now.)
- [a3d2064b] Refactor ESLint config to use `defineConfig` and
`globalIgnores` ([these are like new
defaults](https://eslint.org/blog/2025/03/flat-config-extends-define-config-global-ignores/))
- [a3d2064b] Replace `eslint-plugin-import` with
`eslint-plugin-import-x`
(https://github.com/es-tooling/module-replacements/blob/main/docs/modules/eslint-plugin-import.md)
- [86a185b6] Switch Stylelint config to flat format and simplify
Stylelint scripts (like we already did for ESLint and prettier)
  - [f5a2c541] Fix some typos
2025-05-12 23:40:05 +02:00

442 lines
13 KiB
JavaScript

Module.register("newsfeed", {
// Default module config.
defaults: {
feeds: [
{
title: "New York Times",
url: "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml",
encoding: "UTF-8" //ISO-8859-1
}
],
showAsList: false,
showSourceTitle: true,
showPublishDate: true,
broadcastNewsFeeds: true,
broadcastNewsUpdates: true,
showDescription: false,
showTitleAsUrl: false,
wrapTitle: true,
wrapDescription: true,
truncDescription: true,
lengthDescription: 400,
hideLoading: false,
reloadInterval: 5 * 60 * 1000, // every 5 minutes
updateInterval: 10 * 1000,
animationSpeed: 2.5 * 1000,
maxNewsItems: 0, // 0 for unlimited
ignoreOldItems: false,
ignoreOlderThan: 24 * 60 * 60 * 1000, // 1 day
removeStartTags: "",
removeEndTags: "",
startTags: [],
endTags: [],
prohibitedWords: [],
scrollLength: 500,
logFeedWarnings: false,
dangerouslyDisableAutoEscaping: false
},
getUrlPrefix (item) {
if (item.useCorsProxy) {
return `${location.protocol}//${location.host}${config.basePath}cors?url=`;
} else {
return "";
}
},
// Define required scripts.
getScripts () {
return ["moment.js"];
},
//Define required styles.
getStyles () {
return ["newsfeed.css"];
},
// Define required translations.
getTranslations () {
// The translations for the default modules are defined in the core translation files.
// Therefore we can just return false. Otherwise we should have returned a dictionary.
// If you're trying to build your own module including translations, check out the documentation.
return false;
},
// Define start sequence.
start () {
Log.info(`Starting module: ${this.name}`);
// Set locale.
moment.locale(config.language);
this.newsItems = [];
this.loaded = false;
this.error = null;
this.activeItem = 0;
this.scrollPosition = 0;
this.registerFeeds();
this.isShowingDescription = this.config.showDescription;
},
// Override socket notification handler.
socketNotificationReceived (notification, payload) {
if (notification === "NEWS_ITEMS") {
this.generateFeed(payload);
if (!this.loaded) {
if (this.config.hideLoading) {
this.show();
}
this.scheduleUpdateInterval();
}
this.loaded = true;
this.error = null;
} else if (notification === "NEWSFEED_ERROR") {
this.error = this.translate(payload.error_type);
this.scheduleUpdateInterval();
}
},
//Override fetching of template name
getTemplate () {
if (this.config.feedUrl) {
return "oldconfig.njk";
} else if (this.config.showFullArticle) {
return "fullarticle.njk";
}
return "newsfeed.njk";
},
//Override template data and return whats used for the current template
getTemplateData () {
if (this.activeItem >= this.newsItems.length) {
this.activeItem = 0;
}
this.activeItemCount = this.newsItems.length;
// this.config.showFullArticle is a run-time configuration, triggered by optional notifications
if (this.config.showFullArticle) {
this.activeItemHash = this.newsItems[this.activeItem]?.hash;
return {
url: this.getActiveItemURL()
};
}
if (this.error) {
this.activeItemHash = undefined;
return {
error: this.error
};
}
if (this.newsItems.length === 0) {
this.activeItemHash = undefined;
return {
empty: true
};
}
const item = this.newsItems[this.activeItem];
this.activeItemHash = item.hash;
const items = this.newsItems.map(function (item) {
item.publishDate = moment(new Date(item.pubdate)).fromNow();
return item;
});
return {
loaded: true,
config: this.config,
sourceTitle: item.sourceTitle,
publishDate: moment(new Date(item.pubdate)).fromNow(),
title: item.title,
url: this.getActiveItemURL(),
description: item.description,
items: items
};
},
getActiveItemURL () {
const item = this.newsItems[this.activeItem];
if (item) {
return typeof item.url === "string" ? this.getUrlPrefix(item) + item.url : this.getUrlPrefix(item) + item.url.href;
} else {
return "";
}
},
/**
* Registers the feeds to be used by the backend.
*/
registerFeeds () {
for (let feed of this.config.feeds) {
this.sendSocketNotification("ADD_FEED", {
feed: feed,
config: this.config
});
}
},
/**
* Gets a feed property by name
* @param {object} feed A feed object.
* @param {string} property The name of the property.
* @returns {*} The value of the specified property for the feed.
*/
getFeedProperty (feed, property) {
let res = this.config[property];
const f = this.config.feeds.find((feedItem) => feedItem.url === feed);
if (f && f[property]) res = f[property];
return res;
},
/**
* Generate an ordered list of items for this configured module.
* @param {object} feeds An object with feeds returned by the node helper.
*/
generateFeed (feeds) {
let newsItems = [];
for (let feed in feeds) {
const feedItems = feeds[feed];
if (this.subscribedToFeed(feed)) {
for (let item of feedItems) {
item.sourceTitle = this.titleForFeed(feed);
if (!(this.getFeedProperty(feed, "ignoreOldItems") && Date.now() - new Date(item.pubdate) > this.getFeedProperty(feed, "ignoreOlderThan"))) {
newsItems.push(item);
}
}
}
}
newsItems.sort(function (a, b) {
const dateA = new Date(a.pubdate);
const dateB = new Date(b.pubdate);
return dateB - dateA;
});
if (this.config.maxNewsItems > 0) {
newsItems = newsItems.slice(0, this.config.maxNewsItems);
}
if (this.config.prohibitedWords.length > 0) {
newsItems = newsItems.filter(function (item) {
for (let word of this.config.prohibitedWords) {
if (item.title.toLowerCase().indexOf(word.toLowerCase()) > -1) {
return false;
}
}
return true;
}, this);
}
newsItems.forEach((item) => {
//Remove selected tags from the beginning of rss feed items (title or description)
if (this.config.removeStartTags === "title" || this.config.removeStartTags === "both") {
for (let startTag of this.config.startTags) {
if (item.title.slice(0, startTag.length) === startTag) {
item.title = item.title.slice(startTag.length, item.title.length);
}
}
}
if (this.config.removeStartTags === "description" || this.config.removeStartTags === "both") {
if (this.isShowingDescription) {
for (let startTag of this.config.startTags) {
if (item.description.slice(0, startTag.length) === startTag) {
item.description = item.description.slice(startTag.length, item.description.length);
}
}
}
}
//Remove selected tags from the end of rss feed items (title or description)
if (this.config.removeEndTags) {
for (let endTag of this.config.endTags) {
if (item.title.slice(-endTag.length) === endTag) {
item.title = item.title.slice(0, -endTag.length);
}
}
if (this.isShowingDescription) {
for (let endTag of this.config.endTags) {
if (item.description.slice(-endTag.length) === endTag) {
item.description = item.description.slice(0, -endTag.length);
}
}
}
}
});
// get updated news items and broadcast them
const updatedItems = [];
newsItems.forEach((value) => {
if (this.newsItems.findIndex((value1) => value1 === value) === -1) {
// Add item to updated items list
updatedItems.push(value);
}
});
// check if updated items exist, if so and if we should broadcast these updates, then lets do so
if (this.config.broadcastNewsUpdates && updatedItems.length > 0) {
this.sendNotification("NEWS_FEED_UPDATE", { items: updatedItems });
}
this.newsItems = newsItems;
},
/**
* Check if this module is configured to show this feed.
* @param {string} feedUrl Url of the feed to check.
* @returns {boolean} True if it is subscribed, false otherwise
*/
subscribedToFeed (feedUrl) {
for (let feed of this.config.feeds) {
if (feed.url === feedUrl) {
return true;
}
}
return false;
},
/**
* Returns title for the specific feed url.
* @param {string} feedUrl Url of the feed
* @returns {string} The title of the feed
*/
titleForFeed (feedUrl) {
for (let feed of this.config.feeds) {
if (feed.url === feedUrl) {
return feed.title || "";
}
}
return "";
},
/**
* Schedule visual update.
*/
scheduleUpdateInterval () {
this.updateDom(this.config.animationSpeed);
// Broadcast NewsFeed if needed
if (this.config.broadcastNewsFeeds) {
this.sendNotification("NEWS_FEED", { items: this.newsItems });
}
// #2638 Clear timer if it already exists
if (this.timer) clearInterval(this.timer);
this.timer = setInterval(() => {
/*
* When animations are enabled, don't update the DOM unless we are actually changing what we are displaying.
* (Animating from a headline to itself is unsightly.)
* Cases:
*
* Number of items | Number of items | Display
* at last update | right now | Behaviour
* ----------------------------------------------------
* 0 | 0 | do not update
* 0 | >0 | update
* 1 | 0 or >1 | update
* 1 | 1 | update only if item details (hash value) changed
* >1 | any | update
*
* (N.B. We set activeItemCount and activeItemHash in getTemplateData().)
*/
if (this.newsItems.length > 1 || this.newsItems.length !== this.activeItemCount || this.activeItemHash !== this.newsItems[0]?.hash) {
this.activeItem++; // this is OK if newsItems.Length==1; getTemplateData will wrap it around
this.updateDom(this.config.animationSpeed);
}
// Broadcast NewsFeed if needed
if (this.config.broadcastNewsFeeds) {
this.sendNotification("NEWS_FEED", { items: this.newsItems });
}
}, this.config.updateInterval);
},
resetDescrOrFullArticleAndTimer () {
this.isShowingDescription = this.config.showDescription;
this.config.showFullArticle = false;
this.scrollPosition = 0;
// reset bottom bar alignment
document.getElementsByClassName("region bottom bar")[0].classList.remove("newsfeed-fullarticle");
if (!this.timer) {
this.scheduleUpdateInterval();
}
},
notificationReceived (notification, payload, sender) {
const before = this.activeItem;
if (notification === "MODULE_DOM_CREATED" && this.config.hideLoading) {
this.hide();
} else if (notification === "ARTICLE_NEXT") {
this.activeItem++;
if (this.activeItem >= this.newsItems.length) {
this.activeItem = 0;
}
this.resetDescrOrFullArticleAndTimer();
Log.debug(`${this.name} - going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);
this.updateDom(100);
} else if (notification === "ARTICLE_PREVIOUS") {
this.activeItem--;
if (this.activeItem < 0) {
this.activeItem = this.newsItems.length - 1;
}
this.resetDescrOrFullArticleAndTimer();
Log.debug(`${this.name} - going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);
this.updateDom(100);
}
// if "more details" is received the first time: show article summary, on second time show full article
else if (notification === "ARTICLE_MORE_DETAILS") {
// full article is already showing, so scrolling down
if (this.config.showFullArticle === true) {
this.scrollPosition += this.config.scrollLength;
window.scrollTo(0, this.scrollPosition);
Log.debug(`${this.name} - scrolling down`);
Log.debug(`${this.name} - ARTICLE_MORE_DETAILS, scroll position: ${this.config.scrollLength}`);
} else {
this.showFullArticle();
}
} else if (notification === "ARTICLE_SCROLL_UP") {
if (this.config.showFullArticle === true) {
this.scrollPosition -= this.config.scrollLength;
window.scrollTo(0, this.scrollPosition);
Log.debug(`${this.name} - scrolling up`);
Log.debug(`${this.name} - ARTICLE_SCROLL_UP, scroll position: ${this.config.scrollLength}`);
}
} else if (notification === "ARTICLE_LESS_DETAILS") {
this.resetDescrOrFullArticleAndTimer();
Log.debug(`${this.name} - showing only article titles again`);
this.updateDom(100);
} else if (notification === "ARTICLE_TOGGLE_FULL") {
if (this.config.showFullArticle) {
this.activeItem++;
this.resetDescrOrFullArticleAndTimer();
} else {
this.showFullArticle();
}
} else if (notification === "ARTICLE_INFO_REQUEST") {
this.sendNotification("ARTICLE_INFO_RESPONSE", {
title: this.newsItems[this.activeItem].title,
source: this.newsItems[this.activeItem].sourceTitle,
date: this.newsItems[this.activeItem].pubdate,
desc: this.newsItems[this.activeItem].description,
url: this.getActiveItemURL()
});
}
},
showFullArticle () {
this.isShowingDescription = !this.isShowingDescription;
this.config.showFullArticle = !this.isShowingDescription;
// make bottom bar align to top to allow scrolling
if (this.config.showFullArticle === true) {
document.getElementsByClassName("region bottom bar")[0].classList.add("newsfeed-fullarticle");
}
clearInterval(this.timer);
this.timer = null;
Log.debug(`${this.name} - showing ${this.isShowingDescription ? "article description" : "full article"}`);
this.updateDom(100);
}
});